更新图标及场景序列化系统
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
283
packages/editor-core/src/Services/SceneManagerService.ts
Normal file
283
packages/editor-core/src/Services/SceneManagerService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user