feat: 预制体系统与架构改进 (#303)
* feat(prefab): 实现预制体系统和编辑器 UX 改进 ## 预制体系统 - 新增 PrefabSerializer: 预制体序列化/反序列化 - 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改 - 新增 PrefabService: 预制体核心服务 - 新增 PrefabLoader: 预制体资产加载器 - 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink ## 预制体编辑模式 - 支持双击 .prefab 文件进入编辑模式 - 预制体编辑模式工具栏 (保存/退出) - 预制体实例指示器和操作菜单 ## 编辑器 UX 改进 - SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航 - 支持双击实体名称内联编辑 - 删除实体时显示子节点数量警告 - 右键菜单添加重命名/复制选项及快捷键提示 - 布局持久化和重置功能 ## Bug 修复 - 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题 - 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见 - 修复 Inspector 资源字段高度不正确问题 * feat(editor): 改进编辑器 UX 交互体验 - ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计 - SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮 - PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理 - EntityInspector: 组件折叠状态持久化、属性搜索清除按钮 - Viewport: 变换操作实时数值显示 - 国际化: 添加相关文本 (en/zh) * fix(build): 修复 Web 构建资产加载和编辑器 UX 改进 构建系统修复: - 修复 asset-catalog.json 字段名不匹配 (entries vs assets) - 修复 BrowserFileSystemService 支持两种目录格式 - 修复 bundle 策略检测逻辑 (空对象判断) - 修复 module.json 中 assetExtensions 声明和类型推断 行为树修复: - 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath - 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree) 编辑器 UX 改进: - 构建完成对话框添加"打开文件夹"按钮 - 构建完成对话框样式优化 (圆形图标背景、按钮布局) - SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列) - SceneHierarchy 隐藏滚动条 错误追踪: - 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log) - 添加 append_to_log Tauri 命令 * feat(render): 修复 UI 渲染和点击特效系统 ## UI 渲染修复 - 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数 - 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap - Web 运行时添加 assetPathResolver 支持 GUID 解析 - UIInteractableComponent.blockEvents 默认值改为 false ## 点击特效系统 - 新增 ClickFxComponent 和 ClickFxSystem - 支持在点击位置播放粒子效果 - 支持多种触发模式和粒子轮换 ## Camera 系统重构 - CameraSystem 从 ecs-engine-bindgen 移至 camera 包 - 新增 CameraManager 统一管理相机 ## 编辑器改进 - 改进属性面板 UI 交互 - 粒子编辑器面板优化 - Transform 命令系统 * feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层 - 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay) - 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性 - 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染 - 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer - 更新粒子编辑器面板支持新的排序属性 - 优化 UI 渲染系统使用新的排序层级 * feat(ci): 集成 SignPath 代码签名服务 - 添加 SignPath 自动签名工作流(Windows) - 配置 release-editor.yml 支持代码签名 - 将构建改为草稿模式,等待签名完成后发布 - 添加证书文件到 .gitignore 防止泄露 * fix(asset): 修复 Web 构建资产路径解析和全局单例移除 ## 资产路径修复 - 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接 - BrowserPathResolver 支持两种模式: - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser) - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建) - BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理 ## 架构改进 - 移除全局单例 - 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入 - 移除 globalPathResolver 导出,改用 PathResolutionService - 移除 globalPathResolutionService 导出 - ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖 - EngineService 使用 new AssetManager() 替代全局实例 ## 新增服务 - PathResolutionService: 统一路径解析接口 - RuntimeModeService: 运行时模式查询服务 - SerializationContext: EntityRef 序列化上下文 ## 其他改进 - 完善 ServiceToken 注释说明本地定义的意图 - 导出 BrowserPathResolveMode 类型 * fix(build): 添加 world-streaming composite 设置修复类型检查 * fix(build): 移除 world-streaming 引用避免 composite 冲突 * fix(build): 将 const enum 改为 enum 兼容 isolatedModules * fix(build): 添加缺失的 IAssetManager 导入
This commit is contained in:
@@ -165,6 +165,33 @@ export class CommandManager {
|
||||
const batchCommand = new BatchCommand(commands);
|
||||
this.execute(batchCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令推入撤销栈但不执行
|
||||
* Push command to undo stack without executing
|
||||
*
|
||||
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
|
||||
* Used for operations that have already been performed (like drag transforms),
|
||||
* only need to record to history
|
||||
*/
|
||||
pushWithoutExecute(command: ICommand): void {
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { Entity, Component, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ComponentRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 添加组件命令
|
||||
*
|
||||
* 自动添加缺失的依赖组件(通过 @ECSComponent requires 选项声明)
|
||||
* Automatically adds missing dependency components (declared via @ECSComponent requires option)
|
||||
*/
|
||||
export class AddComponentCommand extends BaseCommand {
|
||||
private component: Component | null = null;
|
||||
/** 自动添加的依赖组件(用于撤销时一并移除) | Auto-added dependencies (for undo removal) */
|
||||
private autoAddedDependencies: Component[] = [];
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
@@ -18,9 +24,12 @@ export class AddComponentCommand extends BaseCommand {
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 先添加缺失的依赖组件 | Add missing dependencies first
|
||||
this.addMissingDependencies();
|
||||
|
||||
this.component = new this.ComponentClass();
|
||||
|
||||
// 应用初始数据
|
||||
// 应用初始数据 | Apply initial data
|
||||
if (this.initialData) {
|
||||
for (const [key, value] of Object.entries(this.initialData)) {
|
||||
(this.component as any)[key] = value;
|
||||
@@ -35,20 +44,90 @@ export class AddComponentCommand extends BaseCommand {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缺失的依赖组件
|
||||
* Add missing dependency components
|
||||
*/
|
||||
private addMissingDependencies(): void {
|
||||
const dependencies = getComponentDependencies(this.ComponentClass);
|
||||
|
||||
if (!dependencies || dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRegistry = Core.services.tryResolve(ComponentRegistry) as ComponentRegistry | null;
|
||||
if (!componentRegistry) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const depName of dependencies) {
|
||||
// 检查实体是否已有该依赖组件 | Check if entity already has this dependency
|
||||
const depInfo = componentRegistry.getComponent(depName);
|
||||
|
||||
if (!depInfo?.type) {
|
||||
console.warn(`Dependency component not found in registry: ${depName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const DepClass = depInfo.type;
|
||||
|
||||
// 使用名称检查而非类引用,因为打包可能导致同一个类有多个副本
|
||||
// Use name-based check instead of class reference, as bundling may create multiple copies of the same class
|
||||
const foundByName = this.entity.components.find(c => c.constructor.name === DepClass.name);
|
||||
|
||||
if (foundByName) {
|
||||
// 组件已存在(通过名称匹配),跳过添加
|
||||
// Component already exists (matched by name), skip adding
|
||||
continue;
|
||||
}
|
||||
|
||||
// 自动添加依赖组件 | Auto-add dependency component
|
||||
const depComponent = new DepClass();
|
||||
this.entity.addComponent(depComponent);
|
||||
this.autoAddedDependencies.push(depComponent);
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: depComponent,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.component) return;
|
||||
|
||||
// 先移除主组件 | Remove main component first
|
||||
this.entity.removeComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: this.ComponentClass.name
|
||||
componentType: getComponentTypeName(this.ComponentClass)
|
||||
});
|
||||
|
||||
// 移除自动添加的依赖组件(逆序) | Remove auto-added dependencies (reverse order)
|
||||
for (let i = this.autoAddedDependencies.length - 1; i >= 0; i--) {
|
||||
const dep = this.autoAddedDependencies[i];
|
||||
if (dep) {
|
||||
this.entity.removeComponent(dep);
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: dep.constructor.name,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
this.autoAddedDependencies = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `添加组件: ${this.ComponentClass.name}`;
|
||||
const mainName = getComponentTypeName(this.ComponentClass);
|
||||
if (this.autoAddedDependencies.length > 0) {
|
||||
const depNames = this.autoAddedDependencies.map(d => d.constructor.name).join(', ');
|
||||
return `添加组件: ${mainName} (+ 依赖: ${depNames})`;
|
||||
}
|
||||
return `添加组件: ${mainName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { ICommand } from './ICommand';
|
||||
export { BaseCommand } from './BaseCommand';
|
||||
export { CommandManager } from './CommandManager';
|
||||
export { TransformCommand, type TransformState, type TransformOperationType } from './transform/TransformCommand';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 应用预制体命令
|
||||
* Apply prefab command
|
||||
*
|
||||
* 将预制体实例的修改应用到源预制体文件。
|
||||
* Applies modifications from a prefab instance to the source prefab file.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 应用预制体命令
|
||||
* Apply prefab command
|
||||
*/
|
||||
export class ApplyPrefabCommand extends BaseCommand {
|
||||
private previousModifiedProperties: string[] = [];
|
||||
private previousOriginalValues: Record<string, unknown> = {};
|
||||
private success: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// 保存当前状态用于撤销 | Save current state for undo
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
this.previousModifiedProperties = [...comp.modifiedProperties];
|
||||
this.previousOriginalValues = { ...comp.originalValues };
|
||||
}
|
||||
|
||||
// 执行应用操作 | Execute apply operation
|
||||
this.success = await this.prefabService.applyToPrefab(this.entity);
|
||||
|
||||
if (!this.success) {
|
||||
throw new Error('Failed to apply changes to prefab');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复修改状态 | Restore modification state
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
comp.modifiedProperties = this.previousModifiedProperties;
|
||||
comp.originalValues = this.previousOriginalValues;
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `应用修改到预制体: ${prefabName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 断开预制体链接命令
|
||||
* Break prefab link command
|
||||
*
|
||||
* 断开实体与源预制体的关联,使其成为普通实体。
|
||||
* Breaks the link between an entity and its source prefab, making it a regular entity.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent, Core } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 保存的预制体实例组件状态
|
||||
* Saved prefab instance component state
|
||||
*/
|
||||
interface PrefabInstanceState {
|
||||
entityId: number;
|
||||
sourcePrefabGuid: string;
|
||||
sourcePrefabPath: string;
|
||||
isRoot: boolean;
|
||||
rootInstanceEntityId: number | null;
|
||||
modifiedProperties: string[];
|
||||
originalValues: Record<string, unknown>;
|
||||
instantiatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开预制体链接命令
|
||||
* Break prefab link command
|
||||
*/
|
||||
export class BreakPrefabLinkCommand extends BaseCommand {
|
||||
private removedStates: PrefabInstanceState[] = [];
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 保存所有将被移除的组件状态 | Save all component states that will be removed
|
||||
this.removedStates = [];
|
||||
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
throw new Error('Entity is not a prefab instance');
|
||||
}
|
||||
|
||||
// 保存根实体的状态 | Save root entity state
|
||||
this.saveComponentState(this.entity);
|
||||
|
||||
// 如果是根节点,也保存所有子实体的状态
|
||||
// If it's root, also save all children's state
|
||||
if (comp.isRoot) {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
scene.entities.forEach((e) => {
|
||||
if (e.id === this.entity.id) return;
|
||||
const childComp = e.getComponent(PrefabInstanceComponent);
|
||||
if (childComp && childComp.rootInstanceEntityId === this.entity.id) {
|
||||
this.saveComponentState(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 执行断开链接操作 | Execute break link operation
|
||||
this.prefabService.breakPrefabLink(this.entity);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复所有被移除的组件 | Restore all removed components
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
for (const state of this.removedStates) {
|
||||
const entity = scene.findEntityById(state.entityId);
|
||||
if (!entity) continue;
|
||||
|
||||
// 创建并恢复组件 | Create and restore component
|
||||
const comp = new PrefabInstanceComponent(
|
||||
state.sourcePrefabGuid,
|
||||
state.sourcePrefabPath,
|
||||
state.isRoot
|
||||
);
|
||||
comp.rootInstanceEntityId = state.rootInstanceEntityId;
|
||||
comp.modifiedProperties = state.modifiedProperties;
|
||||
comp.originalValues = state.originalValues;
|
||||
comp.instantiatedAt = state.instantiatedAt;
|
||||
|
||||
entity.addComponent(comp);
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('prefab:link:restored', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const state = this.removedStates.find(s => s.entityId === this.entity.id);
|
||||
const prefabName = state?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `断开预制体链接: ${prefabName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存实体的预制体实例组件状态
|
||||
* Save entity's prefab instance component state
|
||||
*/
|
||||
private saveComponentState(entity: Entity): void {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) return;
|
||||
|
||||
this.removedStates.push({
|
||||
entityId: entity.id,
|
||||
sourcePrefabGuid: comp.sourcePrefabGuid,
|
||||
sourcePrefabPath: comp.sourcePrefabPath,
|
||||
isRoot: comp.isRoot,
|
||||
rootInstanceEntityId: comp.rootInstanceEntityId,
|
||||
modifiedProperties: [...comp.modifiedProperties],
|
||||
originalValues: { ...comp.originalValues },
|
||||
instantiatedAt: comp.instantiatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 创建预制体命令
|
||||
* Create prefab command
|
||||
*
|
||||
* 从选中的实体创建预制体资产并保存到文件系统。
|
||||
* Creates a prefab asset from the selected entity and saves it to the file system.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { PrefabData } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, IFileAPI, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建预制体命令选项
|
||||
* Create prefab command options
|
||||
*/
|
||||
export interface CreatePrefabOptions {
|
||||
/** 预制体名称 | Prefab name */
|
||||
name: string;
|
||||
/** 保存路径(不包含文件名) | Save path (without filename) */
|
||||
savePath?: string;
|
||||
/** 预制体描述 | Prefab description */
|
||||
description?: string;
|
||||
/** 预制体标签 | Prefab tags */
|
||||
tags?: string[];
|
||||
/** 是否包含子实体 | Whether to include child entities */
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预制体命令
|
||||
* Create prefab command
|
||||
*/
|
||||
export class CreatePrefabCommand extends BaseCommand {
|
||||
private savedFilePath: string | null = null;
|
||||
private savedGuid: string | null = null;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private fileAPI: IFileAPI,
|
||||
private projectService: ProjectService | undefined,
|
||||
private assetRegistry: AssetRegistryService | null,
|
||||
private sourceEntity: Entity,
|
||||
private options: CreatePrefabOptions
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化 | Scene not initialized');
|
||||
}
|
||||
|
||||
// 获取层级系统 | Get hierarchy system
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
|
||||
// 创建预制体数据 | Create prefab data
|
||||
const prefabData = PrefabSerializer.createPrefab(
|
||||
this.sourceEntity,
|
||||
{
|
||||
name: this.options.name,
|
||||
description: this.options.description,
|
||||
tags: this.options.tags,
|
||||
includeChildren: this.options.includeChildren ?? true
|
||||
},
|
||||
hierarchySystem ?? undefined
|
||||
);
|
||||
|
||||
// 序列化为 JSON | Serialize to JSON
|
||||
const prefabJson = PrefabSerializer.serialize(prefabData, true);
|
||||
|
||||
// 确定保存路径 | Determine save path
|
||||
let savePath = this.options.savePath;
|
||||
if (!savePath && this.projectService?.isProjectOpen()) {
|
||||
// 默认保存到项目的 prefabs 目录 | Default save to project's prefabs directory
|
||||
const currentProject = this.projectService.getCurrentProject();
|
||||
if (currentProject) {
|
||||
const projectRoot = currentProject.path;
|
||||
const sep = projectRoot.includes('\\') ? '\\' : '/';
|
||||
savePath = `${projectRoot}${sep}assets${sep}prefabs`;
|
||||
// 确保目录存在 | Ensure directory exists
|
||||
await this.fileAPI.createDirectory(savePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建完整文件路径 | Build complete file path
|
||||
let fullPath: string | null = null;
|
||||
if (savePath) {
|
||||
const sep = savePath.includes('\\') ? '\\' : '/';
|
||||
fullPath = `${savePath}${sep}${this.options.name}.prefab`;
|
||||
} else {
|
||||
// 打开保存对话框 | Open save dialog
|
||||
fullPath = await this.fileAPI.saveSceneDialog(`${this.options.name}.prefab`);
|
||||
}
|
||||
|
||||
if (!fullPath) {
|
||||
throw new Error('保存被取消 | Save cancelled');
|
||||
}
|
||||
|
||||
// 确保扩展名正确 | Ensure correct extension
|
||||
if (!fullPath.endsWith('.prefab')) {
|
||||
fullPath += '.prefab';
|
||||
}
|
||||
|
||||
// 保存文件 | Save file
|
||||
await this.fileAPI.writeFileContent(fullPath, prefabJson);
|
||||
this.savedFilePath = fullPath;
|
||||
|
||||
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
|
||||
if (this.assetRegistry) {
|
||||
const guid = await this.assetRegistry.registerAsset(fullPath);
|
||||
this.savedGuid = guid;
|
||||
console.log(`[CreatePrefabCommand] Registered prefab asset with GUID: ${guid}`);
|
||||
}
|
||||
|
||||
// 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:created', {
|
||||
path: fullPath,
|
||||
guid: this.savedGuid,
|
||||
name: this.options.name,
|
||||
sourceEntityId: this.sourceEntity.id,
|
||||
sourceEntityName: this.sourceEntity.name
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 预制体创建是一个文件系统操作,撤销意味着删除文件
|
||||
// Prefab creation is a file system operation, undo means deleting the file
|
||||
// 但为了安全,我们不自动删除文件,只是清除引用
|
||||
// But for safety, we don't auto-delete the file, just clear the reference
|
||||
this.savedFilePath = null;
|
||||
|
||||
// TODO: 如果需要完整撤销,可以实现文件删除
|
||||
// TODO: If full undo is needed, implement file deletion
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建预制体: ${this.options.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存的文件路径
|
||||
* Get saved file path
|
||||
*/
|
||||
getSavedFilePath(): string | null {
|
||||
return this.savedFilePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 实例化预制体命令
|
||||
* Instantiate prefab command
|
||||
*
|
||||
* 从预制体资产创建实体实例。
|
||||
* Creates an entity instance from a prefab asset.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 实例化预制体命令选项
|
||||
* Instantiate prefab command options
|
||||
*/
|
||||
export interface InstantiatePrefabOptions {
|
||||
/** 父实体 | Parent entity */
|
||||
parent?: Entity;
|
||||
/** 实例名称(可选,默认使用预制体名称) | Instance name (optional, defaults to prefab name) */
|
||||
name?: string;
|
||||
/** 位置覆盖 | Position override */
|
||||
position?: { x: number; y: number };
|
||||
/** 是否追踪为预制体实例 | Whether to track as prefab instance */
|
||||
trackInstance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化预制体命令
|
||||
* Instantiate prefab command
|
||||
*/
|
||||
export class InstantiatePrefabCommand extends BaseCommand {
|
||||
private createdEntity: Entity | null = null;
|
||||
private createdEntityIds: number[] = [];
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private prefabData: PrefabData,
|
||||
private options: InstantiatePrefabOptions = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化 | Scene not initialized');
|
||||
}
|
||||
|
||||
// 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// We need to cast it to Map<string, ComponentType>
|
||||
const componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
this.createdEntity = PrefabSerializer.instantiate(
|
||||
this.prefabData,
|
||||
scene,
|
||||
componentRegistry,
|
||||
{
|
||||
parentId: this.options.parent?.id,
|
||||
name: this.options.name,
|
||||
position: this.options.position,
|
||||
trackInstance: this.options.trackInstance ?? true
|
||||
}
|
||||
);
|
||||
|
||||
// 收集所有创建的实体 ID(用于撤销) | Collect all created entity IDs (for undo)
|
||||
this.collectEntityIds(this.createdEntity);
|
||||
|
||||
// 更新 EntityStore | Update EntityStore
|
||||
this.entityStore.syncFromScene();
|
||||
|
||||
// 选中创建的实体 | Select created entity
|
||||
this.entityStore.selectEntity(this.createdEntity);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('entity:added', { entity: this.createdEntity });
|
||||
this.messageHub.publish('prefab:instantiated', {
|
||||
entity: this.createdEntity,
|
||||
prefabName: this.prefabData.metadata.name,
|
||||
prefabGuid: this.prefabData.metadata.guid
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdEntity) return;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 移除所有创建的实体 | Remove all created entities
|
||||
for (const entityId of this.createdEntityIds) {
|
||||
const entity = scene.findEntityById(entityId);
|
||||
if (entity) {
|
||||
scene.entities.remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 EntityStore | Update EntityStore
|
||||
this.entityStore.syncFromScene();
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('entity:removed', { entityId: this.createdEntity.id });
|
||||
|
||||
this.createdEntity = null;
|
||||
this.createdEntityIds = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const name = this.options.name || this.prefabData.metadata.name;
|
||||
return `实例化预制体: ${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建的根实体
|
||||
* Get created root entity
|
||||
*/
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.createdEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集实体 ID
|
||||
* Recursively collect entity IDs
|
||||
*/
|
||||
private collectEntityIds(entity: Entity): void {
|
||||
this.createdEntityIds.push(entity.id);
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (hierarchySystem) {
|
||||
const children = hierarchySystem.getChildren(entity);
|
||||
for (const child of children) {
|
||||
this.collectEntityIds(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 还原预制体实例命令
|
||||
* Revert prefab instance command
|
||||
*
|
||||
* 将预制体实例还原为源预制体的状态。
|
||||
* Reverts a prefab instance to the state of the source prefab.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 组件快照
|
||||
* Component snapshot
|
||||
*/
|
||||
interface ComponentSnapshot {
|
||||
typeName: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原预制体实例命令
|
||||
* Revert prefab instance command
|
||||
*/
|
||||
export class RevertPrefabCommand extends BaseCommand {
|
||||
private previousSnapshots: ComponentSnapshot[] = [];
|
||||
private previousModifiedProperties: string[] = [];
|
||||
private previousOriginalValues: Record<string, unknown> = {};
|
||||
private success: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// 保存当前状态用于撤销 | Save current state for undo
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
this.previousModifiedProperties = [...comp.modifiedProperties];
|
||||
this.previousOriginalValues = { ...comp.originalValues };
|
||||
|
||||
// 保存所有修改的属性当前值 | Save current values of all modified properties
|
||||
this.previousSnapshots = [];
|
||||
for (const key of comp.modifiedProperties) {
|
||||
const [componentType, ...pathParts] = key.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
for (const compInstance of this.entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
const value = this.getNestedValue(compInstance, propertyPath);
|
||||
this.previousSnapshots.push({
|
||||
typeName: key,
|
||||
data: { value: this.deepClone(value) }
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行还原操作 | Execute revert operation
|
||||
this.success = await this.prefabService.revertInstance(this.entity);
|
||||
|
||||
if (!this.success) {
|
||||
throw new Error('Failed to revert prefab instance');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复修改的属性值 | Restore modified property values
|
||||
for (const snapshot of this.previousSnapshots) {
|
||||
const [componentType, ...pathParts] = snapshot.typeName.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
for (const compInstance of this.entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
this.setNestedValue(compInstance, propertyPath, snapshot.data.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复修改状态 | Restore modification state
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
comp.modifiedProperties = this.previousModifiedProperties;
|
||||
comp.originalValues = this.previousOriginalValues;
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `还原预制体实例: ${prefabName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取嵌套属性值
|
||||
* Get nested property value
|
||||
*/
|
||||
private getNestedValue(obj: any, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置嵌套属性值
|
||||
* Set nested property value
|
||||
*/
|
||||
private setNestedValue(obj: any, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
if (current[key] === null || current[key] === undefined) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝值
|
||||
* Deep clone value
|
||||
*/
|
||||
private deepClone(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
14
packages/editor-app/src/application/commands/prefab/index.ts
Normal file
14
packages/editor-app/src/application/commands/prefab/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 预制体命令导出
|
||||
* Prefab commands export
|
||||
*/
|
||||
|
||||
export { CreatePrefabCommand } from './CreatePrefabCommand';
|
||||
export type { CreatePrefabOptions } from './CreatePrefabCommand';
|
||||
|
||||
export { InstantiatePrefabCommand } from './InstantiatePrefabCommand';
|
||||
export type { InstantiatePrefabOptions } from './InstantiatePrefabCommand';
|
||||
|
||||
export { ApplyPrefabCommand } from './ApplyPrefabCommand';
|
||||
export { RevertPrefabCommand } from './RevertPrefabCommand';
|
||||
export { BreakPrefabLinkCommand } from './BreakPrefabLinkCommand';
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
/**
|
||||
* Transform 状态快照
|
||||
* Transform state snapshot
|
||||
*/
|
||||
export interface TransformState {
|
||||
// TransformComponent
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
positionZ?: number;
|
||||
rotationX?: number;
|
||||
rotationY?: number;
|
||||
rotationZ?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
scaleZ?: number;
|
||||
// UITransformComponent
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
uiScaleX?: number;
|
||||
uiScaleY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变换操作类型
|
||||
* Transform operation type
|
||||
*/
|
||||
export type TransformOperationType = 'move' | 'rotate' | 'scale';
|
||||
|
||||
/**
|
||||
* 变换命令
|
||||
* Transform command for undo/redo support
|
||||
*/
|
||||
export class TransformCommand extends BaseCommand {
|
||||
private readonly componentType: 'transform' | 'uiTransform';
|
||||
private readonly timestamp: number;
|
||||
|
||||
constructor(
|
||||
private readonly messageHub: MessageHub,
|
||||
private readonly entity: Entity,
|
||||
private readonly component: Component,
|
||||
private readonly operationType: TransformOperationType,
|
||||
private readonly oldState: TransformState,
|
||||
private newState: TransformState
|
||||
) {
|
||||
super();
|
||||
this.componentType = component instanceof TransformComponent ? 'transform' : 'uiTransform';
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.applyState(this.newState);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.applyState(this.oldState);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const opNames: Record<TransformOperationType, string> = {
|
||||
move: '移动',
|
||||
rotate: '旋转',
|
||||
scale: '缩放'
|
||||
};
|
||||
return `${opNames[this.operationType]} ${this.entity.name || 'Entity'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以与另一个命令合并
|
||||
* 只有相同实体、相同操作类型、且在短时间内的命令可以合并
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof TransformCommand)) return false;
|
||||
|
||||
// 相同实体、相同组件、相同操作类型
|
||||
if (this.entity !== other.entity) return false;
|
||||
if (this.component !== other.component) return false;
|
||||
if (this.operationType !== other.operationType) return false;
|
||||
|
||||
// 时间间隔小于 500ms 才能合并(连续拖动)
|
||||
const timeDiff = other.timestamp - this.timestamp;
|
||||
return timeDiff < 500;
|
||||
}
|
||||
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof TransformCommand)) {
|
||||
throw new Error('无法合并不同类型的命令');
|
||||
}
|
||||
|
||||
// 保留原始 oldState,使用新命令的 newState
|
||||
return new TransformCommand(
|
||||
this.messageHub,
|
||||
this.entity,
|
||||
this.component,
|
||||
this.operationType,
|
||||
this.oldState,
|
||||
other.newState
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用变换状态
|
||||
* Apply transform state
|
||||
*/
|
||||
private applyState(state: TransformState): void {
|
||||
if (this.componentType === 'transform') {
|
||||
const transform = this.component as TransformComponent;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
} else {
|
||||
const uiTransform = this.component as UITransformComponent;
|
||||
if (state.x !== undefined) uiTransform.x = state.x;
|
||||
if (state.y !== undefined) uiTransform.y = state.y;
|
||||
if (state.rotation !== undefined) uiTransform.rotation = state.rotation;
|
||||
if (state.uiScaleX !== undefined) uiTransform.scaleX = state.uiScaleX;
|
||||
if (state.uiScaleY !== undefined) uiTransform.scaleY = state.uiScaleY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知属性变更
|
||||
* Notify property change
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
const propertyName = this.operationType === 'move'
|
||||
? (this.componentType === 'transform' ? 'position' : 'x')
|
||||
: this.operationType === 'rotate'
|
||||
? 'rotation'
|
||||
: (this.componentType === 'transform' ? 'scale' : 'scaleX');
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName,
|
||||
value: this.componentType === 'transform'
|
||||
? (this.component as TransformComponent)[propertyName as keyof TransformComponent]
|
||||
: (this.component as UITransformComponent)[propertyName as keyof UITransformComponent]
|
||||
});
|
||||
|
||||
// 通知 Inspector 刷新 | Notify Inspector to refresh
|
||||
this.messageHub.publish('entity:select', { entityId: this.entity.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 TransformComponent 捕获状态
|
||||
* Capture state from TransformComponent
|
||||
*/
|
||||
static captureTransformState(transform: TransformComponent): TransformState {
|
||||
return {
|
||||
positionX: transform.position.x,
|
||||
positionY: transform.position.y,
|
||||
positionZ: transform.position.z,
|
||||
rotationX: transform.rotation.x,
|
||||
rotationY: transform.rotation.y,
|
||||
rotationZ: transform.rotation.z,
|
||||
scaleX: transform.scale.x,
|
||||
scaleY: transform.scale.y,
|
||||
scaleZ: transform.scale.z
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 UITransformComponent 捕获状态
|
||||
* Capture state from UITransformComponent
|
||||
*/
|
||||
static captureUITransformState(uiTransform: UITransformComponent): TransformState {
|
||||
return {
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation,
|
||||
uiScaleX: uiTransform.scaleX,
|
||||
uiScaleY: uiTransform.scaleY
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user