更新图标及场景序列化系统

This commit is contained in:
YHH
2025-10-17 18:13:31 +08:00
parent 2ce7dad8d8
commit b826bbc4c7
74 changed files with 1382 additions and 721 deletions

View File

@@ -1,140 +0,0 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable, Component } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
import type { ComponentFileInfo } from './ComponentDiscoveryService';
import { ComponentRegistry } from './ComponentRegistry';
const logger = createLogger('ComponentLoaderService');
export interface LoadedComponentInfo {
fileInfo: ComponentFileInfo;
componentClass: typeof Component;
loadedAt: number;
}
@Injectable()
export class ComponentLoaderService implements IService {
private loadedComponents: Map<string, LoadedComponentInfo> = new Map();
private messageHub: MessageHub;
private componentRegistry: ComponentRegistry;
constructor(messageHub: MessageHub, componentRegistry: ComponentRegistry) {
this.messageHub = messageHub;
this.componentRegistry = componentRegistry;
}
public async loadComponents(
componentInfos: ComponentFileInfo[],
modulePathTransform?: (filePath: string) => string
): Promise<LoadedComponentInfo[]> {
const loadedComponents: LoadedComponentInfo[] = [];
for (const componentInfo of componentInfos) {
try {
const loadedComponent = await this.loadComponent(componentInfo, modulePathTransform);
if (loadedComponent) {
loadedComponents.push(loadedComponent);
}
} catch (error) {
logger.error(`Failed to load component: ${componentInfo.fileName}`, error);
}
}
await this.messageHub.publish('components:loaded', {
count: loadedComponents.length,
components: loadedComponents
});
return loadedComponents;
}
public async loadComponent(
componentInfo: ComponentFileInfo,
modulePathTransform?: (filePath: string) => string
): Promise<LoadedComponentInfo | null> {
try {
if (!componentInfo.className) {
logger.warn(`No class name found for component: ${componentInfo.fileName}`);
return null;
}
let componentClass: typeof Component | undefined;
if (modulePathTransform) {
const modulePath = modulePathTransform(componentInfo.path);
try {
const module = await import(/* @vite-ignore */ modulePath);
componentClass = module[componentInfo.className] || module.default;
if (!componentClass) {
logger.warn(`Component class ${componentInfo.className} not found in module exports`);
logger.warn(`Available exports: ${Object.keys(module).join(', ')}`);
}
} catch (error) {
logger.error(`Failed to import component module: ${modulePath}`, error);
}
}
this.componentRegistry.register({
name: componentInfo.className,
type: componentClass as any,
category: componentInfo.className.includes('Transform') ? 'Transform' :
componentInfo.className.includes('Render') || componentInfo.className.includes('Sprite') ? 'Rendering' :
componentInfo.className.includes('Physics') || componentInfo.className.includes('RigidBody') ? 'Physics' :
'Custom',
description: `Component from ${componentInfo.fileName}`,
metadata: {
path: componentInfo.path,
fileName: componentInfo.fileName
}
});
const loadedInfo: LoadedComponentInfo = {
fileInfo: componentInfo,
componentClass: (componentClass || Component) as any,
loadedAt: Date.now()
};
this.loadedComponents.set(componentInfo.path, loadedInfo);
return loadedInfo;
} catch (error) {
logger.error(`Failed to load component: ${componentInfo.fileName}`, error);
return null;
}
}
public getLoadedComponents(): LoadedComponentInfo[] {
return Array.from(this.loadedComponents.values());
}
public unloadComponent(filePath: string): boolean {
const loadedComponent = this.loadedComponents.get(filePath);
if (!loadedComponent || !loadedComponent.fileInfo.className) {
return false;
}
this.componentRegistry.unregister(loadedComponent.fileInfo.className);
this.loadedComponents.delete(filePath);
return true;
}
public clearLoadedComponents(): void {
for (const [filePath] of this.loadedComponents) {
this.unloadComponent(filePath);
}
}
private convertToModulePath(filePath: string): string {
return filePath;
}
public dispose(): void {
this.clearLoadedComponents();
}
}

View File

@@ -1,7 +1,8 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
import { createLogger, Scene } from '@esengine/ecs-framework';
import { MessageHub } from './MessageHub';
import type { IFileAPI } from '../Types/IFileAPI';
const logger = createLogger('ProjectService');
@@ -19,6 +20,8 @@ export interface ProjectConfig {
componentsPath?: string;
componentPattern?: string;
buildOutput?: string;
scenesPath?: string;
defaultScene?: string;
}
@Injectable()
@@ -26,9 +29,55 @@ export class ProjectService implements IService {
private currentProject: ProjectInfo | null = null;
private projectConfig: ProjectConfig | null = null;
private messageHub: MessageHub;
private fileAPI: IFileAPI;
constructor(messageHub: MessageHub) {
constructor(messageHub: MessageHub, fileAPI: IFileAPI) {
this.messageHub = messageHub;
this.fileAPI = fileAPI;
}
public async createProject(projectPath: string): Promise<void> {
try {
const sep = projectPath.includes('\\') ? '\\' : '/';
const configPath = `${projectPath}${sep}ecs-editor.config.json`;
const configExists = await this.fileAPI.pathExists(configPath);
if (configExists) {
throw new Error('ECS project already exists in this directory');
}
const config: ProjectConfig = {
projectType: 'cocos',
componentsPath: 'components',
componentPattern: '**/*.ts',
buildOutput: 'temp/editor-components',
scenesPath: 'ecs-scenes',
defaultScene: 'main.ecs'
};
await this.fileAPI.writeFileContent(configPath, JSON.stringify(config, null, 2));
const scenesPath = `${projectPath}${sep}${config.scenesPath}`;
await this.fileAPI.createDirectory(scenesPath);
const defaultScenePath = `${scenesPath}${sep}${config.defaultScene}`;
const emptyScene = new Scene();
const sceneData = emptyScene.serialize({
format: 'json',
pretty: true,
includeMetadata: true
}) as string;
await this.fileAPI.writeFileContent(defaultScenePath, sceneData);
await this.messageHub.publish('project:created', {
path: projectPath
});
logger.info('Project created', { path: projectPath });
} catch (error) {
logger.error('Failed to create project', error);
throw error;
}
}
public async openProject(projectPath: string): Promise<void> {
@@ -91,6 +140,28 @@ export class ProjectService implements IService {
return `${this.currentProject.path}${sep}${this.projectConfig.componentsPath}`;
}
public getScenesPath(): string | null {
if (!this.currentProject) {
return null;
}
const scenesPath = this.projectConfig?.scenesPath || 'assets/scenes';
const sep = this.currentProject.path.includes('\\') ? '\\' : '/';
return `${this.currentProject.path}${sep}${scenesPath}`;
}
public getDefaultScenePath(): string | null {
if (!this.currentProject) {
return null;
}
const scenesPath = this.getScenesPath();
if (!scenesPath) {
return null;
}
const defaultScene = this.projectConfig?.defaultScene || 'main.scene';
const sep = this.currentProject.path.includes('\\') ? '\\' : '/';
return `${scenesPath}${sep}${defaultScene}`;
}
private async validateProject(projectPath: string): Promise<ProjectInfo> {
const projectName = projectPath.split(/[\\/]/).pop() || 'Unknown Project';
@@ -118,7 +189,9 @@ export class ProjectService implements IService {
projectType: 'cocos',
componentsPath: '',
componentPattern: '**/*.ts',
buildOutput: 'temp/editor-components'
buildOutput: 'temp/editor-components',
scenesPath: 'ecs-scenes',
defaultScene: 'main.ecs'
};
}

View File

@@ -0,0 +1,283 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable, Core, createLogger, SceneSerializer, Scene } from '@esengine/ecs-framework';
import type { MessageHub } from './MessageHub';
import type { IFileAPI } from '../Types/IFileAPI';
import type { ProjectService } from './ProjectService';
const logger = createLogger('SceneManagerService');
export interface SceneState {
currentScenePath: string | null;
sceneName: string;
isModified: boolean;
isSaved: boolean;
}
@Injectable()
export class SceneManagerService implements IService {
private sceneState: SceneState = {
currentScenePath: null,
sceneName: 'Untitled',
isModified: false,
isSaved: false
};
private unsubscribeHandlers: Array<() => void> = [];
constructor(
private messageHub: MessageHub,
private fileAPI: IFileAPI,
private projectService?: ProjectService
) {
this.setupAutoModificationTracking();
logger.info('SceneManagerService initialized');
}
public async newScene(): Promise<void> {
if (!await this.canClose()) {
return;
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
scene.entities.removeAllEntities();
const systems = [...scene.systems];
for (const system of systems) {
scene.removeEntityProcessor(system);
}
this.sceneState = {
currentScenePath: null,
sceneName: 'Untitled',
isModified: false,
isSaved: false
};
await this.messageHub.publish('scene:new', {});
logger.info('New scene created');
}
public async openScene(filePath?: string): Promise<void> {
if (!await this.canClose()) {
return;
}
let path: string | null | undefined = filePath;
if (!path) {
path = await this.fileAPI.openSceneDialog();
if (!path) {
return;
}
}
try {
const jsonData = await this.fileAPI.readFileContent(path);
const validation = SceneSerializer.validate(jsonData);
if (!validation.valid) {
throw new Error(`场景文件损坏: ${validation.errors?.join(', ')}`);
}
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
scene.deserialize(jsonData, {
strategy: 'replace'
});
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
const sceneName = fileName.replace('.ecs', '');
this.sceneState = {
currentScenePath: path,
sceneName,
isModified: false,
isSaved: true
};
await this.messageHub.publish('scene:loaded', {
path,
sceneName,
isModified: false,
isSaved: true
});
logger.info(`Scene loaded: ${path}`);
} catch (error) {
logger.error('Failed to load scene:', error);
throw error;
}
}
public async saveScene(): Promise<void> {
if (!this.sceneState.currentScenePath) {
await this.saveSceneAs();
return;
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
const jsonData = scene.serialize({
format: 'json',
pretty: true,
includeMetadata: true
}) as string;
await this.fileAPI.saveProject(this.sceneState.currentScenePath, jsonData);
this.sceneState.isModified = false;
this.sceneState.isSaved = true;
await this.messageHub.publish('scene:saved', {
path: this.sceneState.currentScenePath
});
logger.info(`Scene saved: ${this.sceneState.currentScenePath}`);
} catch (error) {
logger.error('Failed to save scene:', error);
throw error;
}
}
public async saveSceneAs(filePath?: string): Promise<void> {
let path: string | null | undefined = filePath;
if (!path) {
let defaultName = this.sceneState.sceneName || 'Untitled';
if (this.projectService?.isProjectOpen()) {
const scenesPath = this.projectService.getScenesPath();
if (scenesPath) {
const sep = scenesPath.includes('\\') ? '\\' : '/';
defaultName = `${scenesPath}${sep}${defaultName}`;
}
}
path = await this.fileAPI.saveSceneDialog(defaultName);
if (!path) {
return;
}
}
if (!path.endsWith('.ecs')) {
path += '.ecs';
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
const jsonData = scene.serialize({
format: 'json',
pretty: true,
includeMetadata: true
}) as string;
await this.fileAPI.saveProject(path, jsonData);
const fileName = path.split(/[/\\]/).pop() || 'Untitled';
const sceneName = fileName.replace('.ecs', '');
this.sceneState = {
currentScenePath: path,
sceneName,
isModified: false,
isSaved: true
};
await this.messageHub.publish('scene:saved', { path });
logger.info(`Scene saved as: ${path}`);
} catch (error) {
logger.error('Failed to save scene as:', error);
throw error;
}
}
public async exportScene(filePath?: string): Promise<void> {
let path: string | null | undefined = filePath;
if (!path) {
let defaultName = (this.sceneState.sceneName || 'Untitled') + '.ecs.bin';
if (this.projectService?.isProjectOpen()) {
const scenesPath = this.projectService.getScenesPath();
if (scenesPath) {
const sep = scenesPath.includes('\\') ? '\\' : '/';
defaultName = `${scenesPath}${sep}${defaultName}`;
}
}
path = await this.fileAPI.saveSceneDialog(defaultName);
if (!path) {
return;
}
}
if (!path.endsWith('.ecs.bin')) {
path += '.ecs.bin';
}
try {
const scene = Core.scene as Scene | null;
if (!scene) {
throw new Error('No active scene');
}
const binaryData = scene.serialize({
format: 'binary'
}) as Uint8Array;
await this.fileAPI.exportBinary(binaryData, path);
await this.messageHub.publish('scene:exported', { path });
logger.info(`Scene exported: ${path}`);
} catch (error) {
logger.error('Failed to export scene:', error);
throw error;
}
}
public getSceneState(): SceneState {
return { ...this.sceneState };
}
public markAsModified(): void {
if (!this.sceneState.isModified) {
this.sceneState.isModified = true;
this.messageHub.publishSync('scene:modified', {});
logger.debug('Scene marked as modified');
}
}
public async canClose(): Promise<boolean> {
if (!this.sceneState.isModified) {
return true;
}
return true;
}
private setupAutoModificationTracking(): void {
const unsubscribeEntityAdded = this.messageHub.subscribe('entity:added', () => {
this.markAsModified();
});
const unsubscribeEntityRemoved = this.messageHub.subscribe('entity:removed', () => {
this.markAsModified();
});
this.unsubscribeHandlers.push(unsubscribeEntityAdded, unsubscribeEntityRemoved);
logger.debug('Auto modification tracking setup complete');
}
public dispose(): void {
for (const unsubscribe of this.unsubscribeHandlers) {
unsubscribe();
}
this.unsubscribeHandlers = [];
logger.info('SceneManagerService disposed');
}
}

View File

@@ -0,0 +1,61 @@
/**
* 文件 API 接口
*
* 定义编辑器与文件系统交互的抽象接口
* 具体实现由上层应用提供(如 TauriFileAPI
*/
export interface IFileAPI {
/**
* 打开场景文件选择对话框
* @returns 用户选择的文件路径,取消则返回 null
*/
openSceneDialog(): Promise<string | null>;
/**
* 打开保存场景对话框
* @param defaultName 默认文件名
* @returns 用户选择的文件路径,取消则返回 null
*/
saveSceneDialog(defaultName?: string): Promise<string | null>;
/**
* 读取文件内容
* @param path 文件路径
* @returns 文件内容(文本格式)
*/
readFileContent(path: string): Promise<string>;
/**
* 保存项目文件
* @param path 保存路径
* @param data 文件内容(文本格式)
*/
saveProject(path: string, data: string): Promise<void>;
/**
* 导出二进制文件
* @param data 二进制数据
* @param path 保存路径
*/
exportBinary(data: Uint8Array, path: string): Promise<void>;
/**
* 创建目录
* @param path 目录路径
*/
createDirectory(path: string): Promise<void>;
/**
* 写入文件内容
* @param path 文件路径
* @param content 文件内容
*/
writeFileContent(path: string, content: string): Promise<void>;
/**
* 检查路径是否存在
* @param path 文件或目录路径
* @returns 路径是否存在
*/
pathExists(path: string): Promise<boolean>;
}

View File

@@ -16,8 +16,9 @@ export * from './Services/LocaleService';
export * from './Services/PropertyMetadata';
export * from './Services/ProjectService';
export * from './Services/ComponentDiscoveryService';
export * from './Services/ComponentLoaderService';
export * from './Services/LogService';
export * from './Services/SettingsRegistry';
export * from './Services/SceneManagerService';
export * from './Types/UITypes';
export * from './Types/IFileAPI';