Feature/render pipeline (#232)

* refactor(engine): 重构2D渲染管线坐标系统

* feat(engine): 完善2D渲染管线和编辑器视口功能

* feat(editor): 实现Viewport变换工具系统

* feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示

* feat(editor): 实现Run on Device移动预览功能

* feat(editor): 添加组件属性控制和依赖关系系统

* feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器

* feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm

* feat(ci): 迁移项目到pnpm并修复CI构建问题

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 迁移CI工作流到pnpm并添加WASM构建支持

* chore: 移除 network 相关包

* chore: 移除 network 相关包
This commit is contained in:
YHH
2025-11-23 14:49:37 +08:00
committed by GitHub
parent b15cbab313
commit a3f7cc38b1
247 changed files with 33561 additions and 52047 deletions

View File

@@ -225,7 +225,6 @@ function App() {
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
services.messageHub.subscribe('ui:openWindow', (data: any) => {
console.log('[App] Received ui:openWindow:', data);
const { windowId } = data;
if (windowId === 'profiler') {
@@ -504,18 +503,14 @@ function App() {
};
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
console.log('[App] handleOpenSceneByPath called with:', scenePath);
if (!sceneManager) {
console.error('SceneManagerService not available');
return;
}
try {
console.log('[App] Opening scene:', scenePath);
await sceneManager.openScene(scenePath);
const sceneState = sceneManager.getSceneState();
console.log('[App] Scene opened, state:', sceneState);
setStatus(locale === 'zh' ? `已打开场景: ${sceneState.sceneName}` : `Scene opened: ${sceneState.sceneName}`);
} catch (error) {
console.error('Failed to open scene:', error);
@@ -615,38 +610,30 @@ function App() {
const handleReloadPlugins = async () => {
if (currentProjectPath && pluginManager) {
try {
console.log('[App] Starting plugin hot reload...');
// 1. 关闭所有动态面板
console.log('[App] Closing all dynamic panels');
setActiveDynamicPanels([]);
// 2. 清空当前面板列表(强制卸载插件面板组件)
console.log('[App] Clearing plugin panels');
setPanels((prev) => prev.filter((p) =>
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
));
// 3. 等待React完成卸载
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// 4. 卸载所有项目插件清理UIRegistry、调用uninstall
console.log('[App] Unloading all project plugins');
await pluginLoader.unloadProjectPlugins(pluginManager);
// 5. 等待卸载完成
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// 6. 重新加载插件
console.log('[App] Reloading project plugins');
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
// 7. 触发面板重新渲染
console.log('[App] Triggering panel re-render');
setPluginUpdateTrigger((prev) => prev + 1);
showToast(locale === 'zh' ? '插件已重新加载' : 'Plugins reloaded', 'success');
console.log('[App] Plugin hot reload completed');
} catch (error) {
console.error('Failed to reload plugins:', error);
showToast(locale === 'zh' ? '重新加载插件失败' : 'Failed to reload plugins', 'error');
@@ -690,7 +677,7 @@ function App() {
{
id: 'viewport',
title: locale === 'zh' ? '视口' : 'Viewport',
content: <Viewport locale={locale} />,
content: <Viewport locale={locale} messageHub={messageHub} />,
closable: false
},
{
@@ -765,8 +752,6 @@ function App() {
};
});
console.log('[App] Loading plugin panels:', pluginPanels);
console.log('[App] Loading dynamic panels:', dynamicPanels);
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
}
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);

View File

@@ -231,6 +231,73 @@ export class TauriAPI {
static async readFileAsBase64(path: string): Promise<string> {
return await invoke<string>('read_file_as_base64', { filePath: path });
}
/**
* 复制文件
* @param src 源文件路径
* @param dst 目标文件路径
*/
static async copyFile(src: string, dst: string): Promise<void> {
return await invoke<void>('copy_file', { src, dst });
}
/**
* 获取临时目录路径
* @returns 临时目录路径
*/
static async getTempDir(): Promise<string> {
return await invoke<string>('get_temp_dir');
}
/**
* 获取应用资源目录
* @returns 资源目录路径
*/
static async getAppResourceDir(): Promise<string> {
return await invoke<string>('get_app_resource_dir');
}
/**
* 获取当前工作目录
* @returns 当前工作目录路径
*/
static async getCurrentDir(): Promise<string> {
return await invoke<string>('get_current_dir');
}
/**
* 启动本地HTTP服务器
* @param rootPath 服务器根目录
* @param port 端口号
* @returns 服务器URL
*/
static async startLocalServer(rootPath: string, port: number): Promise<string> {
return await invoke<string>('start_local_server', { rootPath, port });
}
/**
* 停止本地HTTP服务器
*/
static async stopLocalServer(): Promise<void> {
return await invoke<void>('stop_local_server');
}
/**
* 获取本机局域网IP地址
* @returns 局域网IP地址
*/
static async getLocalIp(): Promise<string> {
return await invoke<string>('get_local_ip');
}
/**
* 生成二维码
* @param text 要编码的文本
* @returns base64编码的PNG图片
*/
static async generateQRCode(text: string): Promise<string> {
return await invoke<string>('generate_qrcode', { text });
}
}
export interface DirectoryEntry {

View File

@@ -1,4 +1,4 @@
import { Core } from '@esengine/ecs-framework';
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
import {
UIRegistry,
MessageHub,
@@ -17,6 +17,17 @@ import {
PropertyRendererRegistry,
FieldEditorRegistry
} from '@esengine/editor-core';
import {
TransformComponent,
SpriteComponent,
SpriteAnimatorComponent,
TextComponent,
CameraComponent,
RigidBodyComponent,
BoxColliderComponent,
CircleColliderComponent,
AudioSourceComponent
} from '@esengine/ecs-components';
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
import { DIContainer } from '../../core/di/DIContainer';
import { TypedEventBus } from '../../core/events/TypedEventBus';
@@ -43,7 +54,8 @@ import {
Vector2FieldEditor,
Vector3FieldEditor,
Vector4FieldEditor,
ColorFieldEditor
ColorFieldEditor,
AnimationClipsFieldEditor
} from '../../infrastructure/field-editors';
export interface EditorServices {
@@ -81,6 +93,34 @@ export class ServiceRegistry {
const serializerRegistry = new SerializerRegistry();
const entityStore = new EntityStoreService(messageHub);
const componentRegistry = new ComponentRegistry();
// 注册标准组件到编辑器和核心注册表
// Register to both editor registry (for UI) and core registry (for serialization)
const standardComponents = [
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description' },
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description' },
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description' },
{ name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description' },
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description' },
{ name: 'RigidBodyComponent', type: RigidBodyComponent, editorName: 'RigidBody', category: 'components.category.physics', description: 'components.rigidBody.description' },
{ name: 'BoxColliderComponent', type: BoxColliderComponent, editorName: 'BoxCollider', category: 'components.category.physics', description: 'components.boxCollider.description' },
{ name: 'CircleColliderComponent', type: CircleColliderComponent, editorName: 'CircleCollider', category: 'components.category.physics', description: 'components.circleCollider.description' },
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description' }
];
for (const comp of standardComponents) {
// Register to editor registry for UI
componentRegistry.register({
name: comp.editorName,
type: comp.type,
category: comp.category,
description: comp.description
});
// Register to core registry for serialization/deserialization
CoreComponentRegistry.register(comp.type as any);
}
const projectService = new ProjectService(messageHub, fileAPI);
const componentDiscovery = new ComponentDiscoveryService(messageHub);
const propertyMetadata = new PropertyMetadataService();
@@ -114,6 +154,8 @@ export class ServiceRegistry {
const fileSystem = new TauriFileSystemService();
const dialog = new TauriDialogService();
const notification = new NotificationService();
Core.services.registerInstance(NotificationService, notification);
const inspectorRegistry = new InspectorRegistry();
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
@@ -140,6 +182,7 @@ export class ServiceRegistry {
fieldEditorRegistry.register(new Vector3FieldEditor());
fieldEditorRegistry.register(new Vector4FieldEditor());
fieldEditorRegistry.register(new ColorFieldEditor());
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
return {
uiRegistry,

View File

@@ -23,7 +23,7 @@ export class UpdateComponentCommand extends BaseCommand {
execute(): void {
(this.component as any)[this.propertyName] = this.newValue;
this.messageHub.publish('component:updated', {
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName: this.propertyName,
@@ -34,7 +34,7 @@ export class UpdateComponentCommand extends BaseCommand {
undo(): void {
(this.component as any)[this.propertyName] = this.oldValue;
this.messageHub.publish('component:updated', {
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName: this.propertyName,

View File

@@ -0,0 +1,64 @@
import { Core, Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
import { BaseCommand } from '../BaseCommand';
/**
* 创建带动画组件的Sprite实体命令
*/
export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加Transform、Sprite和Animator组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new SpriteComponent());
this.entity.addComponent(new SpriteAnimatorComponent());
if (this.parentEntity) {
this.parentEntity.addChild(this.entity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建动画Sprite实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}

View File

@@ -0,0 +1,63 @@
import { Core, Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
import { BaseCommand } from '../BaseCommand';
/**
* 创建带Camera组件的实体命令
*/
export class CreateCameraEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加Transform和Camera组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new CameraComponent());
if (this.parentEntity) {
this.parentEntity.addChild(this.entity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建Camera实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}

View File

@@ -1,5 +1,6 @@
import { Core, Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/ecs-components';
import { BaseCommand } from '../BaseCommand';
/**
@@ -27,6 +28,9 @@ export class CreateEntityCommand extends BaseCommand {
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 自动添加Transform组件
this.entity.addComponent(new TransformComponent());
if (this.parentEntity) {
this.parentEntity.addChild(this.entity);
}

View File

@@ -0,0 +1,63 @@
import { Core, Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent, SpriteComponent } from '@esengine/ecs-components';
import { BaseCommand } from '../BaseCommand';
/**
* 创建带Sprite组件的实体命令
*/
export class CreateSpriteEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加Transform和Sprite组件
this.entity.addComponent(new TransformComponent());
this.entity.addComponent(new SpriteComponent());
if (this.parentEntity) {
this.parentEntity.addChild(this.entity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建Sprite实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}

View File

@@ -1,2 +1,6 @@
export { CreateEntityCommand } from './CreateEntityCommand';
export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand';
export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand';
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
export { DeleteEntityCommand } from './DeleteEntityCommand';

View File

@@ -29,7 +29,8 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const detailViewFileTreeRef = useRef<FileTreeHandle>(null);
const treeOnlyViewFileTreeRef = useRef<FileTreeHandle>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [lastSelectedPath, setLastSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
@@ -83,7 +84,7 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
} else {
setAssets([]);
setCurrentPath(null);
setSelectedPath(null);
setSelectedPaths(new Set());
}
}, [projectPath]);
@@ -92,21 +93,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const messageHub = Core.services.resolve(MessageHub);
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('asset:reveal', (data: any) => {
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
const filePath = data.path;
if (filePath) {
setSelectedPath(filePath);
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
if (dirPath) {
setCurrentPath(dirPath);
loadAssets(dirPath);
// Load assets first, then set selection after list is populated
await loadAssets(dirPath);
setSelectedPaths(new Set([filePath]));
// Expand tree to reveal the file
if (showDetailView) {
detailViewFileTreeRef.current?.revealPath(filePath);
} else {
treeOnlyViewFileTreeRef.current?.revealPath(filePath);
}
}
}
});
return () => unsubscribe();
}, []);
}, [showDetailView]);
const loadAssets = async (path: string) => {
setLoading(true);
@@ -211,8 +220,57 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
loadAssets(path);
};
const handleAssetClick = (asset: AssetItem) => {
setSelectedPath(asset.path);
const handleTreeMultiSelect = (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => {
if (paths.length === 0) return;
const path = paths[0];
if (!path) return;
if (modifiers.shiftKey && paths.length > 1) {
// Range select - paths already contains the range from FileTree
setSelectedPaths(new Set(paths));
} else if (modifiers.ctrlKey) {
const newSelected = new Set(selectedPaths);
if (newSelected.has(path)) {
newSelected.delete(path);
} else {
newSelected.add(path);
}
setSelectedPaths(newSelected);
setLastSelectedPath(path);
} else {
setSelectedPaths(new Set([path]));
setLastSelectedPath(path);
}
};
const handleAssetClick = (asset: AssetItem, e: React.MouseEvent) => {
const filteredAssets = searchQuery.trim() ? searchResults : assets;
if (e.shiftKey && lastSelectedPath) {
// Range select with Shift
const lastIndex = filteredAssets.findIndex((a) => a.path === lastSelectedPath);
const currentIndex = filteredAssets.findIndex((a) => a.path === asset.path);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const rangePaths = filteredAssets.slice(start, end + 1).map((a) => a.path);
setSelectedPaths(new Set(rangePaths));
}
} else if (e.ctrlKey || e.metaKey) {
// Multi-select with Ctrl/Cmd
const newSelected = new Set(selectedPaths);
if (newSelected.has(asset.path)) {
newSelected.delete(asset.path);
} else {
newSelected.add(asset.path);
}
setSelectedPaths(newSelected);
setLastSelectedPath(asset.path);
} else {
// Single select
setSelectedPaths(new Set([asset.path]));
setLastSelectedPath(asset.path);
}
messageHub?.publish('asset-file:selected', {
fileInfo: {
@@ -275,8 +333,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
}
// 更新选中路径
if (selectedPath === asset.path) {
setSelectedPath(newPath);
if (selectedPaths.has(asset.path)) {
const newSelected = new Set(selectedPaths);
newSelected.delete(asset.path);
newSelected.add(newPath);
setSelectedPaths(newSelected);
}
setRenameDialog(null);
@@ -300,8 +361,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
}
// 清除选中状态
if (selectedPath === asset.path) {
setSelectedPath(null);
if (selectedPaths.has(asset.path)) {
const newSelected = new Set(selectedPaths);
newSelected.delete(asset.path);
setSelectedPaths(newSelected);
}
setDeleteConfirmDialog(null);
@@ -637,100 +700,120 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
</div>
}
rightOrBottom={
<div className="asset-browser-list">
<div className="asset-browser-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => {
setCurrentPath(crumb.path);
loadAssets(crumb.path);
}}
>
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
{(loading || isSearching) ? (
<div className="asset-browser-loading">
<p>{isSearching ? '搜索中...' : t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => {
const relativePath = getRelativePath(asset.path);
const showPath = searchQuery.trim() && relativePath;
return (
<div
key={index}
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('asset-name', asset.name);
e.dataTransfer.setData('asset-extension', asset.extension || '');
e.dataTransfer.setData('text/plain', asset.path);
// 设置拖拽时的视觉效果
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.opacity = '0.8';
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 0, 0);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}}
style={{
cursor: asset.type === 'file' ? 'grab' : 'pointer'
<div className="asset-browser-list">
<div className="asset-browser-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => {
setCurrentPath(crumb.path);
loadAssets(crumb.path);
}}
>
{getFileIcon(asset)}
<div className="asset-info">
<div className="asset-name" title={asset.path}>
{asset.name}
</div>
{showPath && (
<div className="asset-path" style={{
fontSize: '11px',
color: '#666',
marginTop: '2px'
}}>
{relativePath}
</div>
)}
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
);
})}
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
)}
</div>
}
/>
{(loading || isSearching) ? (
<div className="asset-browser-loading">
<p>{isSearching ? '搜索中...' : t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => {
const relativePath = getRelativePath(asset.path);
const showPath = searchQuery.trim() && relativePath;
return (
<div
key={index}
className={`asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''}`}
onClick={(e) => handleAssetClick(asset, e)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.effectAllowed = 'copy';
// Get all selected file assets
const selectedFiles = selectedPaths.has(asset.path) && selectedPaths.size > 1
? Array.from(selectedPaths)
.filter((p) => {
const a = assets?.find((item) => item.path === p);
return a && a.type === 'file';
})
.map((p) => {
const a = assets?.find((item) => item.path === p);
return { type: 'file', path: p, name: a?.name, extension: a?.extension };
})
: [{ type: 'file', path: asset.path, name: asset.name, extension: asset.extension }];
// Set drag data as JSON array for multi-file support
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('asset-name', asset.name);
e.dataTransfer.setData('asset-extension', asset.extension || '');
e.dataTransfer.setData('text/plain', asset.path);
// 设置拖拽时的视觉效果
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.opacity = '0.8';
if (selectedFiles.length > 1) {
dragImage.textContent = `${selectedFiles.length} files`;
}
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 0, 0);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}}
style={{
cursor: asset.type === 'file' ? 'grab' : 'pointer'
}}
>
{getFileIcon(asset)}
<div className="asset-info">
<div className="asset-name" title={asset.path}>
{asset.name}
</div>
{showPath && (
<div className="asset-path" style={{
fontSize: '11px',
color: '#666',
marginTop: '2px'
}}>
{relativePath}
</div>
)}
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
);
})}
</div>
)}
</div>
}
/>
) : (
<div className="asset-browser-tree-only">
<FileTree
ref={treeOnlyViewFileTreeRef}
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
onSelectFiles={handleTreeMultiSelect}
selectedPath={Array.from(selectedPaths)[0] || currentPath}
selectedPaths={selectedPaths}
messageHub={messageHub}
searchQuery={searchQuery}
showFiles={true}

View File

@@ -40,7 +40,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
try {
const registry = Core.services.resolve(CompilerRegistry);
console.log('[CompilerConfigDialog] Registry resolved:', registry);
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map(c => c.id));
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map((c) => c.id));
const comp = registry.get(compilerId);
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
setCompiler(comp || null);
@@ -74,7 +74,7 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
},
listDirectory: async (path: string): Promise<FileEntry[]> => {
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
return entries.map(e => ({
return entries.map((e) => ({
name: e.name,
path: e.path,
isDirectory: e.is_dir
@@ -96,8 +96,8 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
const ext = pattern.replace(/\*/g, '');
return entries
.filter(e => !e.is_dir && e.name.endsWith(ext))
.map(e => e.name.replace(ext, ''));
.filter((e) => !e.is_dir && e.name.endsWith(ext))
.map((e) => e.name.replace(ext, ''));
}
});

View File

@@ -19,10 +19,20 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
useEffect(() => {
const handleSelection = (data: { entity: Entity | null }) => {
setSelectedEntity(data.entity);
setSelectedEntity((prev) => {
// Only reset version when selecting a different entity
// 只在选择不同实体时重置版本
if (prev?.id !== data.entity?.id) {
setComponentVersion(0);
} else {
// Same entity re-selected, trigger refresh
// 同一实体重新选择,触发刷新
setComponentVersion((v) => v + 1);
}
return data.entity;
});
setRemoteEntity(null);
setRemoteEntityDetails(null);
setComponentVersion(0);
};
const handleRemoteSelection = (data: { entity: any }) => {
@@ -45,6 +55,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
window.addEventListener('profiler:entity-details', handleEntityDetails);
@@ -53,6 +64,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
unsubRemoteSelect();
unsubComponentAdded();
unsubComponentRemoved();
unsubPropertyChanged();
window.removeEventListener('profiler:entity-details', handleEntityDetails);
};
}, [messageHub]);
@@ -80,6 +92,11 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
if (!selectedEntity) return;
// Actually update the component property
// 实际更新组件属性
component[propertyName] = value;
messageHub.publish('component:property:changed', {
entity: selectedEntity,
component,
@@ -500,6 +517,7 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
{isExpanded && (
<div className="component-properties animate-slideDown">
<PropertyInspector
key={`${index}-${componentVersion}`}
component={component}
onChange={(propertyName, value) => handlePropertyChange(component, propertyName, value)}
/>

View File

@@ -22,7 +22,9 @@ interface TreeNode {
interface FileTreeProps {
rootPath: string | null;
onSelectFile?: (path: string) => void;
onSelectFiles?: (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => void;
selectedPath?: string | null;
selectedPaths?: Set<string>;
messageHub?: MessageHub;
searchQuery?: string;
showFiles?: boolean;
@@ -31,12 +33,30 @@ interface FileTreeProps {
export interface FileTreeHandle {
collapseAll: () => void;
refresh: () => void;
revealPath: (targetPath: string) => Promise<void>;
}
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => {
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, onSelectFiles, selectedPath, selectedPaths, messageHub, searchQuery, showFiles = true }, ref) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
const [lastSelectedFilePath, setLastSelectedFilePath] = useState<string | null>(null);
// Flatten visible file nodes for range selection
const getVisibleFilePaths = (nodes: TreeNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: TreeNode[]) => {
for (const node of nodeList) {
if (node.type === 'file') {
paths.push(node.path);
} else if (node.type === 'folder' && node.expanded && node.children) {
traverse(node.children);
}
}
};
traverse(nodes);
return paths;
};
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
node: TreeNode | null;
@@ -49,7 +69,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
parentPath: string;
templateExtension?: string;
templateContent?: (fileName: string) => Promise<string>;
} | null>(null);
} | null>(null);
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
@@ -65,13 +85,84 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
return node;
};
const collapsedTree = tree.map(node => collapseNode(node));
const collapsedTree = tree.map((node) => collapseNode(node));
setTree(collapsedTree);
};
// Expand tree to reveal a specific file path
const revealPath = async (targetPath: string) => {
if (!rootPath || !targetPath.startsWith(rootPath)) return;
// Get path segments between root and target
const relativePath = targetPath.substring(rootPath.length).replace(/^[/\\]/, '');
const segments = relativePath.split(/[/\\]/);
// Build list of folder paths to expand
const pathsToExpand: string[] = [];
let currentPath = rootPath;
for (let i = 0; i < segments.length - 1; i++) {
currentPath = `${currentPath}/${segments[i]}`;
pathsToExpand.push(currentPath.replace(/\//g, '\\'));
}
// Recursively expand nodes and load children
const expandToPath = async (nodes: TreeNode[], pathSet: Set<string>): Promise<TreeNode[]> => {
const result: TreeNode[] = [];
for (const node of nodes) {
const normalizedPath = node.path.replace(/\//g, '\\');
if (node.type === 'folder' && pathSet.has(normalizedPath)) {
// Load children if not loaded
let children = node.children;
if (!node.loaded || !children) {
try {
const entries = await TauriAPI.listDirectory(node.path);
children = entries.map((entry: DirectoryEntry) => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
size: entry.size,
modified: entry.modified,
expanded: false,
loaded: false
})).sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
} catch (error) {
children = [];
}
}
// Recursively expand children
const expandedChildren = await expandToPath(children, pathSet);
result.push({
...node,
expanded: true,
loaded: true,
children: expandedChildren
});
} else if (node.type === 'folder' && node.children) {
// Keep existing state for non-target folders
result.push({
...node,
children: await expandToPath(node.children, pathSet)
});
} else {
result.push(node);
}
}
return result;
};
const pathSet = new Set(pathsToExpand);
const expandedTree = await expandToPath(tree, pathSet);
setTree(expandedTree);
setInternalSelectedPath(targetPath);
};
useImperativeHandle(ref, () => ({
collapseAll,
refresh: refreshTree
refresh: refreshTree,
revealPath
}));
useEffect(() => {
@@ -92,8 +183,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
const performSearch = async () => {
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.filter(node => showFiles || node.type === 'folder')
.map(node => ({
.filter((node) => showFiles || node.type === 'folder')
.map((node) => ({
...node,
children: node.children ? filterByFileType(node.children) : node.children
}));
@@ -280,7 +371,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
children = await loadChildren(node);
}
const restoredChildren = await Promise.all(
children.map(child => restoreExpandedState(child))
children.map((child) => restoreExpandedState(child))
);
return {
...node,
@@ -290,7 +381,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
};
} else if (node.type === 'folder' && node.children) {
const restoredChildren = await Promise.all(
node.children.map(child => restoreExpandedState(child))
node.children.map((child) => restoreExpandedState(child))
);
return {
...node,
@@ -325,7 +416,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
}
const expandedChildren = await Promise.all(
children.map(child => expandNode(child))
children.map((child) => expandNode(child))
);
return {
@@ -338,7 +429,7 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
return node;
};
const expandedTree = await Promise.all(tree.map(node => expandNode(node)));
const expandedTree = await Promise.all(tree.map((node) => expandNode(node)));
setTree(expandedTree);
};
@@ -574,13 +665,39 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
return items;
};
const handleNodeClick = (node: TreeNode) => {
const handleNodeClick = (node: TreeNode, e: React.MouseEvent) => {
if (node.type === 'folder') {
setInternalSelectedPath(node.path);
onSelectFile?.(node.path);
toggleNode(node.path);
} else {
setInternalSelectedPath(node.path);
// Support multi-select with Ctrl/Cmd or Shift
if (onSelectFiles) {
if (e.shiftKey && lastSelectedFilePath) {
// Range select with Shift
const treeToUse = searchQuery ? filteredTree : tree;
const visiblePaths = getVisibleFilePaths(treeToUse);
const lastIndex = visiblePaths.indexOf(lastSelectedFilePath);
const currentIndex = visiblePaths.indexOf(node.path);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const rangePaths = visiblePaths.slice(start, end + 1);
onSelectFiles(rangePaths, { ctrlKey: false, shiftKey: true });
} else {
onSelectFiles([node.path], { ctrlKey: false, shiftKey: false });
setLastSelectedFilePath(node.path);
}
} else {
onSelectFiles([node.path], { ctrlKey: e.ctrlKey || e.metaKey, shiftKey: false });
setLastSelectedFilePath(node.path);
}
} else {
setLastSelectedFilePath(node.path);
}
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
messageHub?.publish('asset-file:selected', {
fileInfo: {
@@ -622,7 +739,9 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
};
const renderNode = (node: TreeNode, level: number = 0) => {
const isSelected = (internalSelectedPath || selectedPath) === node.path;
const isSelected = selectedPaths
? selectedPaths.has(node.path)
: (internalSelectedPath || selectedPath) === node.path;
const isRenaming = renamingNode === node.path;
const indent = level * 16;
@@ -631,14 +750,30 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
onClick={() => !isRenaming && handleNodeClick(node)}
onClick={(e) => !isRenaming && handleNodeClick(node, e)}
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
draggable={node.type === 'file' && !isRenaming}
onDragStart={(e) => {
if (node.type === 'file' && !isRenaming) {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
// Get all selected files for multi-file drag
const selectedFiles = selectedPaths && selectedPaths.has(node.path) && selectedPaths.size > 1
? Array.from(selectedPaths).map((p) => {
const name = p.split(/[/\\]/).pop() || '';
const ext = name.includes('.') ? name.split('.').pop() : '';
return { type: 'file', path: p, name, extension: ext };
})
: [{
type: 'file',
path: node.path,
name: node.name,
extension: node.name.includes('.') ? node.name.split('.').pop() : ''
}];
// Set drag data as JSON array for multi-file support
e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles));
e.dataTransfer.setData('asset-path', node.path);
e.dataTransfer.setData('asset-name', node.name);
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
@@ -748,18 +883,18 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
<PromptDialog
title={
promptDialog.type === 'create-file' ? '新建文件' :
promptDialog.type === 'create-folder' ? '新建文件夹' :
'新建文件'
promptDialog.type === 'create-folder' ? '新建文件夹' :
'新建文件'
}
message={
promptDialog.type === 'create-file' ? '请输入文件名:' :
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
}
placeholder={
promptDialog.type === 'create-file' ? '例如: config.json' :
promptDialog.type === 'create-folder' ? '例如: assets' :
'例如: MyFile'
promptDialog.type === 'create-folder' ? '例如: assets' :
'例如: MyFile'
}
confirmText="创建"
cancelText="取消"

View File

@@ -33,11 +33,11 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
useEffect(() => {
try {
// 检查面板ID列表是否真的变化了而不只是标题等属性变化
const currentPanelIds = panels.map(p => p.id).sort().join(',');
const currentPanelIds = panels.map((p) => p.id).sort().join(',');
const previousIds = previousPanelIdsRef.current;
// 检查标题是否变化
const currentTitles = new Map(panels.map(p => [p.id, p.title]));
const currentTitles = new Map(panels.map((p) => [p.id, p.title]));
const titleChanges: Array<{ id: string; newTitle: string }> = [];
for (const panel of panels) {
@@ -66,17 +66,17 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
}
// 计算新增和移除的面板
const prevSet = new Set(previousIds.split(',').filter(id => id));
const currSet = new Set(currentPanelIds.split(',').filter(id => id));
const newPanelIds = Array.from(currSet).filter(id => !prevSet.has(id));
const removedPanelIds = Array.from(prevSet).filter(id => !currSet.has(id));
const prevSet = new Set(previousIds.split(',').filter((id) => id));
const currSet = new Set(currentPanelIds.split(',').filter((id) => id));
const newPanelIds = Array.from(currSet).filter((id) => !prevSet.has(id));
const removedPanelIds = Array.from(prevSet).filter((id) => !currSet.has(id));
previousPanelIdsRef.current = currentPanelIds;
// 如果已经有布局且只是添加新面板使用Action动态添加
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
// 找到要添加的面板
const newPanels = panels.filter(p => newPanelIds.includes(p.id));
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
// 找到中心区域的tabset ID
let centerTabsetId: string | null = null;
@@ -101,7 +101,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
if (centerTabsetId) {
// 动态添加tab到中心tabset
newPanels.forEach(panel => {
newPanels.forEach((panel) => {
model.doAction(Actions.addNode(
{
type: 'tab',

View File

@@ -868,7 +868,7 @@ function PropertyValueRenderer({ name, value, depth, decimalPlaces = 4 }: Proper
const keys = Object.keys(val);
if (keys.length === 0) return '{}';
if (keys.length <= 2) {
const preview = keys.map(k => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
const preview = keys.map((k) => `${k}: ${typeof val[k] === 'object' ? '...' : (typeof val[k] === 'number' ? formatNumber(val[k], decimalPlaces) : val[k])}`).join(', ');
return `{${preview}}`;
}
return `{${keys.slice(0, 2).join(', ')}...}`;
@@ -996,7 +996,7 @@ function ImagePreview({ src, alt }: ImagePreviewProps) {
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setScale(prev => Math.min(Math.max(prev * delta, 0.1), 10));
setScale((prev) => Math.min(Math.max(prev * delta, 0.1), 10));
};
const handleMouseDown = (e: React.MouseEvent) => {

View File

@@ -83,12 +83,10 @@ export function MenuBar({
});
setPluginMenuItems(filteredItems);
console.log('[MenuBar] Updated menu items:', filteredItems);
} else if (uiRegistry) {
// 如果没有 pluginManager显示所有菜单项
const items = uiRegistry.getChildMenus('window');
setPluginMenuItems(items);
console.log('[MenuBar] Updated menu items (no filter):', items);
}
};
@@ -99,17 +97,14 @@ export function MenuBar({
useEffect(() => {
if (messageHub) {
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
console.log('[MenuBar] Plugin installed, updating menu items');
updateMenuItems();
});
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
console.log('[MenuBar] Plugin enabled, updating menu items');
updateMenuItems();
});
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
console.log('[MenuBar] Plugin disabled, updating menu items');
updateMenuItems();
});

View File

@@ -555,7 +555,7 @@ export function PluginPublishWizard({ githubService, onClose, locale, inline = f
};
const wizardContent = (
<div className={inline ? "plugin-publish-wizard inline" : "plugin-publish-wizard"} onClick={(e) => inline ? undefined : e.stopPropagation()}>
<div className={inline ? 'plugin-publish-wizard inline' : 'plugin-publish-wizard'} onClick={(e) => inline ? undefined : e.stopPropagation()}>
<div className="plugin-publish-header">
<h2>{t('title')}</h2>
{!inline && (
@@ -566,67 +566,67 @@ export function PluginPublishWizard({ githubService, onClose, locale, inline = f
</div>
<div className="plugin-publish-content">
{step === 'auth' && (
<div className="publish-step">
<h3>{t('stepAuth')}</h3>
<GitHubAuth
githubService={githubService}
onSuccess={handleAuthSuccess}
locale={locale}
/>
{step === 'auth' && (
<div className="publish-step">
<h3>{t('stepAuth')}</h3>
<GitHubAuth
githubService={githubService}
onSuccess={handleAuthSuccess}
locale={locale}
/>
</div>
)}
{step === 'selectSource' && (
<div className="publish-step">
<h3>{t('stepSelectSource')}</h3>
<p>{t('selectSourceDesc')}</p>
<div className="source-type-selection">
<button
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
onClick={() => handleSelectSource('folder')}
>
<FolderOpen size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeFolder')}</strong>
<p>{t('selectFolderDesc')}</p>
</div>
</button>
<button
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
onClick={() => handleSelectSource('zip')}
>
<FileArchive size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeZip')}</strong>
<p>{t('selectZipDesc')}</p>
</div>
</button>
</div>
)}
{step === 'selectSource' && (
<div className="publish-step">
<h3>{t('stepSelectSource')}</h3>
<p>{t('selectSourceDesc')}</p>
{/* ZIP 文件要求说明 */}
<details className="zip-requirements-details">
<summary>
<AlertCircle size={16} />
{t('zipRequirements')}
</summary>
<div className="zip-requirements-content">
<div className="requirement-section">
<h4>{t('zipStructure')}</h4>
<p>{t('zipStructureDetails')}</p>
<ul>
<li><code>package.json</code> - {t('zipFile1')}</li>
<li><code>dist/</code> - {t('zipFile2')}</li>
</ul>
</div>
<div className="source-type-selection">
<button
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
onClick={() => handleSelectSource('folder')}
>
<FolderOpen size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeFolder')}</strong>
<p>{t('selectFolderDesc')}</p>
</div>
</button>
<button
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
onClick={() => handleSelectSource('zip')}
>
<FileArchive size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeZip')}</strong>
<p>{t('selectZipDesc')}</p>
</div>
</button>
</div>
{/* ZIP 文件要求说明 */}
<details className="zip-requirements-details">
<summary>
<AlertCircle size={16} />
{t('zipRequirements')}
</summary>
<div className="zip-requirements-content">
<div className="requirement-section">
<h4>{t('zipStructure')}</h4>
<p>{t('zipStructureDetails')}</p>
<ul>
<li><code>package.json</code> - {t('zipFile1')}</li>
<li><code>dist/</code> - {t('zipFile2')}</li>
</ul>
</div>
<div className="requirement-section">
<h4>{t('zipBuildScript')}</h4>
<p>{t('zipBuildScriptDesc')}</p>
<pre className="build-script-example">
{`npm install
<div className="requirement-section">
<h4>{t('zipBuildScript')}</h4>
<p>{t('zipBuildScriptDesc')}</p>
<pre className="build-script-example">
{`npm install
npm run build
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
# ZIP 结构:
@@ -634,309 +634,309 @@ npm run build
# ├── package.json
# └── dist/
# └── index.esm.js`}
</pre>
</div>
<div className="recommendation-notice">
{t('recommendFolder')}
</div>
</pre>
</div>
</details>
{parsedPluginInfo && (
<div className="selected-source">
{parsedPluginInfo.sourceType === 'folder' ? (
<FolderOpen size={20} />
) : (
<FileArchive size={20} />
)}
<div className="source-details">
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
</div>
<div className="recommendation-notice">
{t('recommendFolder')}
</div>
)}
</div>
</details>
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
{parsedPluginInfo && (
<div className="selected-source">
{parsedPluginInfo.sourceType === 'folder' ? (
<FolderOpen size={20} />
) : (
<FileArchive size={20} />
)}
<div className="source-details">
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
</div>
)}
</div>
)}
{parsedPluginInfo && (
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('auth')}>
{t('back')}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
{parsedPluginInfo && (
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('auth')}>
{t('back')}
</button>
</div>
)}
</div>
)}
{step === 'info' && (
<div className="publish-step">
<h3>{t('stepInfo')}</h3>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
)}
</div>
)}
</div>
)}
{step === 'info' && (
<div className="publish-step">
<h3>{t('stepInfo')}</h3>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<div className="form-group">
<label>{t('version')} *</label>
{isUpdate && (
<div className="version-info">
<div className="version-notice">
<CheckCircle size={16} />
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
</div>
{suggestedVersion && (
<button
className="btn-link"
onClick={() => open(existingPR.url)}
type="button"
className="btn-version-suggest"
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
{t('suggestedVersion')}: {suggestedVersion}
</button>
</div>
)}
</div>
)}
<div className="form-group">
<label>{t('version')} *</label>
{isUpdate && (
<div className="version-info">
<div className="version-notice">
<CheckCircle size={16} />
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
</div>
{suggestedVersion && (
<button
type="button"
className="btn-version-suggest"
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
>
{t('suggestedVersion')}: {suggestedVersion}
</button>
)}
</div>
)}
<input
type="text"
value={publishInfo.version || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
placeholder="1.0.0"
/>
{isUpdate && (
<details className="version-history">
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
<ul>
{existingVersions.map((v) => (
<li key={v}>v{v}</li>
))}
</ul>
</details>
)}
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={4}
value={publishInfo.releaseNotes || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
{!isUpdate && (
<>
<div className="form-group">
<label>{t('category')} *</label>
<select
value={publishInfo.category}
onChange={(e) =>
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
}
>
<option value="community">{t('community')}</option>
<option value="official">{t('official')}</option>
</select>
</div>
<div className="form-group">
<label>{t('repositoryUrl')} *</label>
<input
type="text"
value={publishInfo.repositoryUrl || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
placeholder={t('repositoryPlaceholder')}
/>
</div>
<div className="form-group">
<label>{t('tags')}</label>
<input
type="text"
value={publishInfo.tags?.join(', ') || ''}
onChange={(e) =>
setPublishInfo({
...publishInfo,
tags: e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
}
placeholder={t('tagsPlaceholder')}
/>
</div>
</>
<input
type="text"
value={publishInfo.version || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
placeholder="1.0.0"
/>
{isUpdate && (
<details className="version-history">
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
<ul>
{existingVersions.map((v) => (
<li key={v}>v{v}</li>
))}
</ul>
</details>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handleNext}>
{sourceType === 'zip' ? t('next') : t('build')}
</button>
</div>
</div>
)}
{step === 'building' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('building')}</h3>
<div className="build-log">
{buildLog.map((log, i) => (
<div key={i} className="log-line">
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
<span>{log}</span>
</div>
))}
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={4}
value={publishInfo.releaseNotes || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
)}
{step === 'confirm' && (
<div className="publish-step">
<h3>{t('stepConfirm')}</h3>
<p>{t('confirmMessage')}</p>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
{!isUpdate && (
<>
<div className="form-group">
<label>{t('category')} *</label>
<select
value={publishInfo.category}
onChange={(e) =>
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
}
>
<option value="community">{t('community')}</option>
<option value="official">{t('official')}</option>
</select>
</div>
)}
<div className="confirm-details">
<div className="form-group">
<label>{t('repositoryUrl')} *</label>
<input
type="text"
value={publishInfo.repositoryUrl || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
placeholder={t('repositoryPlaceholder')}
/>
</div>
<div className="form-group">
<label>{t('tags')}</label>
<input
type="text"
value={publishInfo.tags?.join(', ') || ''}
onChange={(e) =>
setPublishInfo({
...publishInfo,
tags: e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
}
placeholder={t('tagsPlaceholder')}
/>
</div>
</>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handleNext}>
{sourceType === 'zip' ? t('next') : t('build')}
</button>
</div>
</div>
)}
{step === 'building' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('building')}</h3>
<div className="build-log">
{buildLog.map((log, i) => (
<div key={i} className="log-line">
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
<span>{log}</span>
</div>
))}
</div>
</div>
)}
{step === 'confirm' && (
<div className="publish-step">
<h3>{t('stepConfirm')}</h3>
<p>{t('confirmMessage')}</p>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
</div>
)}
<div className="confirm-details">
<div className="detail-row">
<span className="detail-label">{t('selectSource')}:</span>
<span className="detail-value">
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('version')}:</span>
<span className="detail-value">{publishInfo.version}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('category')}:</span>
<span className="detail-value">{t(publishInfo.category!)}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('repositoryUrl')}:</span>
<span className="detail-value">{publishInfo.repositoryUrl}</span>
</div>
{builtZipPath && (
<div className="detail-row">
<span className="detail-label">{t('selectSource')}:</span>
<span className="detail-value">
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
<span className="detail-label">Package Path:</span>
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
{builtZipPath}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('version')}:</span>
<span className="detail-value">{publishInfo.version}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('category')}:</span>
<span className="detail-value">{t(publishInfo.category!)}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('repositoryUrl')}:</span>
<span className="detail-value">{publishInfo.repositoryUrl}</span>
</div>
{builtZipPath && (
<div className="detail-row">
<span className="detail-label">Package Path:</span>
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
{builtZipPath}
</span>
</div>
)}
</div>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handlePublish}>
{t('confirm')}
</button>
</div>
</div>
)}
{step === 'publishing' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="publish-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${publishProgress.progress}%` }}
/>
</div>
<p className="progress-message">{publishProgress.message}</p>
<p className="progress-percent">{publishProgress.progress}%</p>
</div>
)}
</div>
)}
{step === 'success' && (
<div className="publish-step success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h3>{t('publishSuccess')}</h3>
<p>{t('prCreated')}</p>
<p className="review-message">{t('reviewMessage')}</p>
<button className="btn-link" onClick={openPR}>
<ExternalLink size={14} />
{t('viewPR')}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handlePublish}>
{t('confirm')}
</button>
</div>
</div>
)}
{step === 'publishing' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="publish-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${publishProgress.progress}%` }}
/>
</div>
<p className="progress-message">{publishProgress.message}</p>
<p className="progress-percent">{publishProgress.progress}%</p>
</div>
)}
</div>
)}
{step === 'success' && (
<div className="publish-step success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h3>{t('publishSuccess')}</h3>
<p>{t('prCreated')}</p>
<p className="review-message">{t('reviewMessage')}</p>
<button className="btn-link" onClick={openPR}>
<ExternalLink size={14} />
{t('viewPR')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
)}
{step === 'error' && (
<div className="publish-step error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h3>{t('publishError')}</h3>
<p>{error}</p>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
)}
{step === 'error' && (
<div className="publish-step error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h3>{t('publishError')}</h3>
<p>{error}</p>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@@ -36,6 +36,8 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
const [isServerRunning, setIsServerRunning] = useState(false);
const [port, setPort] = useState('8080');
const animationRef = useRef<number>();
const frameTimesRef = useRef<number[]>([]);
const lastFpsRef = useRef<number>(0);
useEffect(() => {
const settings = SettingsService.getInstance();
@@ -298,7 +300,29 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
return result;
};
const fps = totalFrameTime > 0 ? Math.round(1000 / totalFrameTime) : 0;
// Calculate FPS using rolling average for stability
// 使用滑动平均计算 FPS 以保持稳定
const calculateFps = () => {
// Add any positive frame time
// 添加任何正数的帧时间
if (totalFrameTime > 0) {
frameTimesRef.current.push(totalFrameTime);
// Keep last 60 samples
if (frameTimesRef.current.length > 60) {
frameTimesRef.current.shift();
}
}
if (frameTimesRef.current.length > 0) {
const avgFrameTime = frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length;
// Cap FPS between 0-999, and ensure avgFrameTime is reasonable
if (avgFrameTime > 0.01) {
lastFpsRef.current = Math.min(999, Math.round(1000 / avgFrameTime));
}
}
return lastFpsRef.current;
};
const fps = calculateFps();
const targetFrameTime = 16.67;
const isOverBudget = totalFrameTime > targetFrameTime;

View File

@@ -1,17 +1,69 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata } from '@esengine/editor-core';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
interface PropertyInspectorProps {
component: Component;
entity?: any;
version?: number;
onChange?: (propertyName: string, value: any) => void;
onAction?: (actionId: string, propertyName: string, component: Component) => void;
}
export function PropertyInspector({ component, onChange }: PropertyInspectorProps) {
export function PropertyInspector({ component, entity, version, onChange, onAction }: PropertyInspectorProps) {
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
const [values, setValues] = useState<Record<string, any>>({});
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
// version is used implicitly - when it changes, React re-renders and getValue reads fresh values
void version;
// Scan entity for components that control this component's properties
useEffect(() => {
if (!entity) return;
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
if (!propertyMetadataService) return;
const componentName = component.constructor.name;
const controlled = new Map<string, string>();
// Check all components on this entity
for (const otherComponent of entity.components) {
if (otherComponent === component) continue;
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent);
const otherComponentName = otherComponent.constructor.name;
// Check if any property has controls declaration
for (const [, propMeta] of Object.entries(otherMetadata)) {
if (propMeta.controls) {
for (const control of propMeta.controls) {
if (control.component === componentName ||
control.component === componentName.replace('Component', '')) {
controlled.set(control.property, otherComponentName.replace('Component', ''));
}
}
}
}
}
setControlledFields(controlled);
}, [component, entity, version]);
const getControlledBy = (propertyName: string): string | undefined => {
return controlledFields.get(propertyName);
};
const handleAction = (actionId: string, propertyName: string) => {
if (onAction) {
onAction(actionId, propertyName, component);
}
};
useEffect(() => {
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
@@ -19,35 +71,29 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
const metadata = propertyMetadataService.getEditableProperties(component);
setProperties(metadata);
const componentAsAny = component as any;
const currentValues: Record<string, any> = {};
for (const key in metadata) {
currentValues[key] = componentAsAny[key];
}
setValues(currentValues);
}, [component]);
const handleChange = (propertyName: string, value: any) => {
const componentAsAny = component as any;
componentAsAny[propertyName] = value;
setValues((prev) => ({
...prev,
[propertyName]: value
}));
if (onChange) {
onChange(propertyName, value);
}
};
// Read value directly from component to avoid state sync issues
const getValue = (propertyName: string) => {
return (component as any)[propertyName];
};
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
const value = values[propertyName];
const value = getValue(propertyName);
const label = metadata.label || propertyName;
switch (metadata.type) {
case 'number':
case 'integer':
return (
<NumberField
key={propertyName}
@@ -55,9 +101,12 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
value={value ?? 0}
min={metadata.min}
max={metadata.max}
step={metadata.step}
step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)}
isInteger={metadata.type === 'integer'}
readOnly={metadata.readOnly}
actions={metadata.actions}
onChange={(newValue) => handleChange(propertyName, newValue)}
onAction={(actionId) => handleAction(actionId, propertyName)}
/>
);
@@ -128,6 +177,39 @@ export function PropertyInspector({ component, onChange }: PropertyInspectorProp
/>
);
case 'asset': {
const controlledBy = getControlledBy(propertyName);
return (
<AssetDropField
key={propertyName}
label={label}
value={value ?? ''}
fileExtension={metadata.fileExtension}
readOnly={metadata.readOnly || !!controlledBy}
controlledBy={controlledBy}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
}
case 'animationClips':
return (
<div key={propertyName}>
{animationClipsEditor.render({
label,
value: value ?? [],
onChange: (newValue) => handleChange(propertyName, newValue),
context: {
readonly: metadata.readOnly ?? false,
metadata: {
component,
onDefaultAnimationChange: (val: string) => handleChange('defaultAnimation', val)
}
}
})}
</div>
);
default:
return null;
}
@@ -148,16 +230,33 @@ interface NumberFieldProps {
min?: number;
max?: number;
step?: number;
isInteger?: boolean;
readOnly?: boolean;
actions?: PropertyAction[];
onChange: (value: number) => void;
onAction?: (actionId: string) => void;
}
function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }: NumberFieldProps) {
function NumberField({ label, value, min, max, step = 0.1, isInteger = false, readOnly, actions, onChange, onAction }: NumberFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const renderActionButton = (action: PropertyAction) => {
const IconComponent = action.icon ? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon] : null;
return (
<button
key={action.id}
className="property-action-btn"
title={action.tooltip || action.label}
onClick={() => onAction?.(action.id)}
>
{IconComponent ? <IconComponent size={12} /> : action.label}
</button>
);
};
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
setIsDragging(true);
@@ -177,7 +276,14 @@ function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }:
if (min !== undefined) newValue = Math.max(min, newValue);
if (max !== undefined) newValue = Math.min(max, newValue);
onChange(parseFloat(newValue.toFixed(3)));
// 整数类型取整
if (isInteger) {
newValue = Math.round(newValue);
} else {
newValue = parseFloat(newValue.toFixed(3));
}
onChange(newValue);
};
const handleMouseUp = () => {
@@ -211,9 +317,17 @@ function NumberField({ label, value, min, max, step = 0.1, readOnly, onChange }:
max={max}
step={step}
disabled={readOnly}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
onChange(isInteger ? Math.round(val) : val);
}}
onFocus={(e) => e.target.select()}
/>
{actions && actions.length > 0 && (
<div className="property-actions">
{actions.map(renderActionButton)}
</div>
)}
</div>
);
}
@@ -271,27 +385,277 @@ interface ColorFieldProps {
}
function ColorField({ label, value, readOnly, onChange }: ColorFieldProps) {
const [showPicker, setShowPicker] = useState(false);
const [tempColor, setTempColor] = useState(value);
const [pickerPos, setPickerPos] = useState({ top: 0, left: 0 });
const pickerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// 解析十六进制颜色为 HSV
const hexToHsv = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
const s = max === 0 ? 0 : d / max;
const v = max;
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s: s * 100, v: v * 100 };
};
// HSV 转十六进制
const hsvToHex = (h: number, s: number, v: number) => {
h = h / 360;
s = s / 100;
v = v / 100;
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
const toHex = (n: number) => Math.round(n * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const hsv = hexToHsv(tempColor);
// 点击外部关闭
useEffect(() => {
if (!showPicker) return;
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowPicker(false);
onChange(tempColor);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPicker, tempColor, onChange]);
useEffect(() => {
setTempColor(value);
}, [value]);
const handleSaturationValueChange = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const s = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const v = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
const newColor = hsvToHex(hsv.h, s, v);
setTempColor(newColor);
};
const handleHueChange = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / rect.width) * 360));
const newColor = hsvToHex(h, hsv.s, hsv.v);
setTempColor(newColor);
};
return (
<div className="property-field">
<div className="property-field" style={{ position: 'relative' }}>
<label className="property-label">{label}</label>
<div className="property-color-wrapper">
<div className="property-color-preview" style={{ backgroundColor: value }} />
<input
type="color"
className="property-input property-input-color"
value={value}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
<div
ref={previewRef}
className="property-color-preview"
style={{ backgroundColor: value }}
onClick={() => {
if (readOnly) return;
if (!showPicker && previewRef.current) {
const rect = previewRef.current.getBoundingClientRect();
const pickerWidth = 200;
const pickerHeight = 220;
let top = rect.bottom + 4;
let left = rect.right - pickerWidth;
// Ensure picker stays within viewport
if (left < 8) left = 8;
if (top + pickerHeight > window.innerHeight - 8) {
top = rect.top - pickerHeight - 4;
}
setPickerPos({ top, left });
}
setShowPicker(!showPicker);
}}
/>
<input
type="text"
className="property-input property-input-color-text"
value={value.toUpperCase()}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
const val = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
onChange(val);
}
}}
onFocus={(e) => e.target.select()}
/>
</div>
{showPicker && (
<div
ref={pickerRef}
className="color-picker-popup"
style={{
position: 'fixed',
top: pickerPos.top,
left: pickerPos.left,
right: 'auto'
}}
>
<div
className="color-picker-saturation"
style={{ backgroundColor: hsvToHex(hsv.h, 100, 100) }}
onMouseDown={(e) => {
handleSaturationValueChange(e);
const rect = e.currentTarget.getBoundingClientRect();
const onMove = (ev: MouseEvent) => {
const s = Math.max(0, Math.min(100, ((ev.clientX - rect.left) / rect.width) * 100));
const v = Math.max(0, Math.min(100, 100 - ((ev.clientY - rect.top) / rect.height) * 100));
setTempColor(hsvToHex(hsv.h, s, v));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="color-picker-saturation-white" />
<div className="color-picker-saturation-black" />
<div
className="color-picker-cursor"
style={{
left: `${hsv.s}%`,
top: `${100 - hsv.v}%`
}}
/>
</div>
<div
className="color-picker-hue"
onMouseDown={(e) => {
handleHueChange(e);
const rect = e.currentTarget.getBoundingClientRect();
const onMove = (ev: MouseEvent) => {
const h = Math.max(0, Math.min(360, ((ev.clientX - rect.left) / rect.width) * 360));
setTempColor(hsvToHex(h, hsv.s, hsv.v));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div
className="color-picker-hue-cursor"
style={{ left: `${(hsv.h / 360) * 100}%` }}
/>
</div>
<div className="color-picker-preview-row">
<div className="color-picker-preview-box" style={{ backgroundColor: tempColor }} />
<span className="color-picker-hex">{tempColor.toUpperCase()}</span>
</div>
</div>
)}
</div>
);
}
// Draggable axis input component
interface DraggableAxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
readOnly?: boolean;
compact?: boolean;
onChange: (value: number) => void;
}
function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: DraggableAxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef({ x: 0, value: 0 });
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartRef.current.x;
const sensitivity = e.shiftKey ? 0.01 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
onChange(Math.round(newValue * 1000) / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
const axisClass = `property-vector-axis-${axis}`;
const inputClass = compact ? 'property-input property-input-number-compact' : 'property-input property-input-number';
return (
<div className={compact ? 'property-vector-axis-compact' : 'property-vector-axis'}>
<span
className={`property-vector-axis-label ${axisClass}`}
onMouseDown={handleMouseDown}
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
>
{axis.toUpperCase()}
</span>
<input
type="number"
className={inputClass}
value={value ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onFocus={(e) => e.target.select()}
/>
</div>
);
}
@@ -319,57 +683,35 @@ function Vector2Field({ label, value, readOnly, onChange }: Vector2FieldProps) {
</div>
{isExpanded ? (
<div className="property-vector-expanded">
<div className="property-vector-axis">
<span className="property-vector-axis-label property-vector-axis-x">X</span>
<input
type="number"
className="property-input property-input-number"
value={value?.x ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="property-vector-axis">
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
<input
type="number"
className="property-input property-input-number"
value={value?.y ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
onChange={(y) => onChange({ ...value, y })}
/>
</div>
) : (
<div className="property-vector-compact">
<div className="property-vector-axis-compact">
<span className="property-vector-axis-label property-vector-axis-x">X</span>
<input
type="number"
className="property-input property-input-number-compact"
value={value?.x ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="property-vector-axis-compact">
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
<input
type="number"
className="property-input property-input-number-compact"
value={value?.y ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
compact
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
compact
onChange={(y) => onChange({ ...value, y })}
/>
</div>
)}
</div>
@@ -399,81 +741,48 @@ function Vector3Field({ label, value, readOnly, onChange }: Vector3FieldProps) {
</div>
{isExpanded ? (
<div className="property-vector-expanded">
<div className="property-vector-axis">
<span className="property-vector-axis-label property-vector-axis-x">X</span>
<input
type="number"
className="property-input property-input-number"
value={value?.x ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="property-vector-axis">
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
<input
type="number"
className="property-input property-input-number"
value={value?.y ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="property-vector-axis">
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
<input
type="number"
className="property-input property-input-number"
value={value?.z ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
onChange={(y) => onChange({ ...value, y })}
/>
<DraggableAxisInput
axis="z"
value={value?.z ?? 0}
readOnly={readOnly}
onChange={(z) => onChange({ ...value, z })}
/>
</div>
) : (
<div className="property-vector-compact">
<div className="property-vector-axis-compact">
<span className="property-vector-axis-label property-vector-axis-x">X</span>
<input
type="number"
className="property-input property-input-number-compact"
value={value?.x ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, x: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="property-vector-axis-compact">
<span className="property-vector-axis-label property-vector-axis-y">Y</span>
<input
type="number"
className="property-input property-input-number-compact"
value={value?.y ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, y: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="property-vector-axis-compact">
<span className="property-vector-axis-label property-vector-axis-z">Z</span>
<input
type="number"
className="property-input property-input-number-compact"
value={value?.z ?? 0}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange({ ...value, z: parseFloat(e.target.value) || 0 })}
onFocus={(e) => e.target.select()}
/>
</div>
<DraggableAxisInput
axis="x"
value={value?.x ?? 0}
readOnly={readOnly}
compact
onChange={(x) => onChange({ ...value, x })}
/>
<DraggableAxisInput
axis="y"
value={value?.y ?? 0}
readOnly={readOnly}
compact
onChange={(y) => onChange({ ...value, y })}
/>
<DraggableAxisInput
axis="z"
value={value?.z ?? 0}
readOnly={readOnly}
compact
onChange={(z) => onChange({ ...value, z })}
/>
</div>
)}
</div>
@@ -489,29 +798,165 @@ interface EnumFieldProps {
}
function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label || (options.length === 0 ? 'No options' : '');
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
return (
<div className="property-field">
<label className="property-label">{label}</label>
<select
className="property-input property-input-select"
value={value ?? ''}
disabled={readOnly}
onChange={(e) => {
const selectedOption = options.find((opt) => String(opt.value) === e.target.value);
if (selectedOption) {
onChange(selectedOption.value);
}
}}
>
{options.length === 0 && (
<option value="">No options</option>
<div className="property-dropdown" ref={dropdownRef}>
<button
className={`property-dropdown-trigger ${isOpen ? 'open' : ''}`}
onClick={() => !readOnly && setIsOpen(!isOpen)}
disabled={readOnly}
type="button"
>
<span className="property-dropdown-value">{displayLabel}</span>
<span className="property-dropdown-arrow"></span>
</button>
{isOpen && (
<div className="property-dropdown-menu">
{options.map((option, index) => (
<button
key={index}
className={`property-dropdown-item ${option.value === value ? 'selected' : ''}`}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
type="button"
>
{option.label}
</button>
))}
</div>
)}
{options.map((option, index) => (
<option key={index} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</div>
</div>
);
}
interface AssetDropFieldProps {
label: string;
value: string;
fileExtension?: string;
readOnly?: boolean;
controlledBy?: string;
onChange: (value: string) => void;
}
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, onChange }: AssetDropFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readOnly) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (readOnly) return;
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath) {
if (fileExtension) {
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
const fileExt = assetPath.toLowerCase().split('.').pop();
if (fileExt && extensions.some((ext) => ext === `.${fileExt}` || ext === fileExt)) {
onChange(assetPath);
}
} else {
onChange(assetPath);
}
}
};
const getFileName = (path: string) => {
if (!path) return '';
const parts = path.split(/[\\/]/);
return parts[parts.length - 1];
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
if (!readOnly) onChange('');
};
const handleNavigate = (e: React.MouseEvent) => {
e.stopPropagation();
if (value) {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path: value });
}
}
};
return (
<div className="property-field">
<label className="property-label">
{label}
{controlledBy && (
<span className="property-controlled-icon" title={`Controlled by ${controlledBy}`}>
<Lock size={10} />
</span>
)}
</label>
<div
className={`property-asset-drop ${isDragging ? 'dragging' : ''} ${value ? 'has-value' : ''} ${controlledBy ? 'controlled' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
title={controlledBy ? `Controlled by ${controlledBy}` : (value || 'Drop asset here')}
>
<span className="property-asset-text">
{value ? getFileName(value) : 'None'}
</span>
<div className="property-asset-actions">
{value && (
<button
className="property-asset-btn"
onClick={handleNavigate}
title="在资产浏览器中显示"
>
<ArrowRight size={12} />
</button>
)}
{value && !readOnly && (
<button className="property-asset-clear" onClick={handleClear}>×</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { X, Copy, Check } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
import '../styles/QRCodeDialog.css';
interface QRCodeDialogProps {
url: string;
isOpen: boolean;
onClose: () => void;
}
export const QRCodeDialog: React.FC<QRCodeDialogProps> = ({ url, isOpen, onClose }) => {
const [qrCodeData, setQrCodeData] = useState<string>('');
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && url) {
setLoading(true);
TauriAPI.generateQRCode(url)
.then((base64) => {
setQrCodeData(`data:image/png;base64,${base64}`);
})
.catch((error) => {
console.error('Failed to generate QR code:', error);
})
.finally(() => {
setLoading(false);
});
}
}, [isOpen, url]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy URL:', error);
}
};
if (!isOpen) return null;
return (
<div className="qrcode-dialog-overlay">
<div className="qrcode-dialog">
<div className="qrcode-dialog-header">
<h3></h3>
<button className="qrcode-dialog-close" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="qrcode-dialog-content">
{loading ? (
<div className="qrcode-loading">...</div>
) : qrCodeData ? (
<img src={qrCodeData} alt="QR Code" width={200} height={200} />
) : (
<div className="qrcode-error"></div>
)}
<div className="qrcode-url-container">
<input
type="text"
value={url}
readOnly
className="qrcode-url-input"
/>
<button
className="qrcode-copy-button"
onClick={handleCopy}
title={copied ? '已复制' : '复制链接'}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<p className="qrcode-hint">
</p>
</div>
</div>
</div>
);
};
export default QRCodeDialog;

View File

@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react';
import { Entity, Core } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe } from 'lucide-react';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import { confirm } from '@tauri-apps/plugin-dialog';
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import { CreateEntityCommand, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import '../styles/SceneHierarchy.css';
type ViewMode = 'local' | 'remote';
@@ -201,6 +201,43 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager }: Scen
commandManager.execute(command);
};
const handleCreateSpriteEntity = () => {
// Count only Sprite entities for naming
const spriteCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('Sprite ')).length;
const entityName = `Sprite ${spriteCount + 1}`;
const command = new CreateSpriteEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleCreateAnimatedSpriteEntity = () => {
const animCount = entityStore.getAllEntities().filter((e) => e.name.startsWith('AnimatedSprite ')).length;
const entityName = `AnimatedSprite ${animCount + 1}`;
const command = new CreateAnimatedSpriteEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleCreateCameraEntity = () => {
const entityCount = entityStore.getAllEntities().length;
const entityName = `Camera ${entityCount + 1}`;
const command = new CreateCameraEntityCommand(
entityStore,
messageHub,
entityName
);
commandManager.execute(command);
};
const handleDeleteEntity = async () => {
if (!selectedId) return;
@@ -431,7 +468,19 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager }: Scen
>
<button onClick={() => { handleCreateEntity(); closeContextMenu(); }}>
<Plus size={12} />
<span>{locale === 'zh' ? '创建实体' : 'Create Entity'}</span>
<span>{locale === 'zh' ? '创建实体' : 'Create Empty Entity'}</span>
</button>
<button onClick={() => { handleCreateSpriteEntity(); closeContextMenu(); }}>
<Image size={12} />
<span>{locale === 'zh' ? '创建 Sprite' : 'Create Sprite'}</span>
</button>
<button onClick={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}>
<Film size={12} />
<span>{locale === 'zh' ? '创建动画 Sprite' : 'Create Animated Sprite'}</span>
</button>
<button onClick={() => { handleCreateCameraEntity(); closeContextMenu(); }}>
<Camera size={12} />
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
</button>
{contextMenu.entityId && (
<>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import '../styles/StartupPage.css';
interface StartupPageProps {
@@ -12,6 +13,11 @@ interface StartupPageProps {
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, recentProjects = [], locale }: StartupPageProps) {
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
useEffect(() => {
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
}, []);
const translations = {
en: {
@@ -22,7 +28,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
profilerMode: 'Profiler Mode',
recentProjects: 'Recent Projects',
noRecentProjects: 'No recent projects',
version: 'Version 1.0.0',
comingSoon: 'Coming Soon'
},
zh: {
@@ -33,12 +38,12 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
profilerMode: '性能分析模式',
recentProjects: '最近的项目',
noRecentProjects: '没有最近的项目',
version: '版本 1.0.0',
comingSoon: '即将推出'
}
};
const t = translations[locale as keyof typeof translations] || translations.en;
const versionText = locale === 'zh' ? `版本 ${appVersion}` : `Version ${appVersion}`;
return (
<div className="startup-page">
@@ -101,7 +106,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</div>
<div className="startup-footer">
<span className="startup-version">{t.version}</span>
<span className="startup-version">{versionText}</span>
</div>
</div>
);

View File

@@ -293,7 +293,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
deleteReason
);
console.log(`[UserDashboard] Delete PR created:`, prUrl);
console.log('[UserDashboard] Delete PR created:', prUrl);
setConfirmDeletePlugin(null);
setDeleteReason('');
@@ -407,7 +407,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
const pluginName = removeMatch[0];
const plugin = publishedPlugins.find(p => p.name === pluginName);
const plugin = publishedPlugins.find((p) => p.name === pluginName);
if (!plugin) {
alert(t('recreatePRFailed') + ': Plugin not found in published list');
return;
@@ -443,7 +443,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
true
);
console.log(`[UserDashboard] Recreated delete PR:`, prUrl);
console.log('[UserDashboard] Recreated delete PR:', prUrl);
alert(t('recreatePRSuccess'));
await loadData();
@@ -482,12 +482,12 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
if (prFilter === 'all') {
return pendingReviews;
}
return pendingReviews.filter(review => review.status === prFilter);
return pendingReviews.filter((review) => review.status === prFilter);
};
const handleLinkClick = (href: string) => (e: React.MouseEvent) => {
e.preventDefault();
open(href).catch(err => {
open(href).catch((err) => {
console.error('[UserDashboard] Failed to open link:', err);
});
};
@@ -568,7 +568,7 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
{publishedPlugins.map((plugin) => {
const isExpanded = expandedVersions.has(plugin.id);
const hasMultipleVersions = plugin.versions.length > 1;
const pendingPR = pendingReviews.find(pr => pr.pluginName === plugin.name && pr.status === 'open');
const pendingPR = pendingReviews.find((pr) => pr.pluginName === plugin.name && pr.status === 'open');
return (
<div key={plugin.id} className="plugin-card">
@@ -633,55 +633,55 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
</div>
</div>
)}
<div className="plugin-actions">
{pendingPR && (
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
<AlertCircle size={14} />
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
</div>
)}
<button
className="btn-update"
onClick={() => handleUpdatePlugin(plugin)}
disabled={!!pendingPR}
title={pendingPR
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
: t('updatePlugin')
}
>
<Upload size={14} />
{t('updatePlugin')}
</button>
{plugin.repositoryUrl && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
<div className="plugin-actions">
{pendingPR && (
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
<AlertCircle size={14} />
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
</div>
)}
<button
className="btn-update"
onClick={() => handleUpdatePlugin(plugin)}
disabled={!!pendingPR}
title={pendingPR
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
: t('updatePlugin')
}
>
{t('viewRepo')} <ExternalLink size={14} />
</a>
)}
{plugin.versions[0]?.prUrl && (
<a
href={plugin.versions[0].prUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
<Upload size={14} />
{t('updatePlugin')}
</button>
{plugin.repositoryUrl && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewRepo')} <ExternalLink size={14} />
</a>
)}
{plugin.versions[0]?.prUrl && (
<a
href={plugin.versions[0].prUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewPR')} <ExternalLink size={14} />
</a>
)}
<button
className="btn-delete"
onClick={() => setConfirmDeletePlugin(plugin)}
title={t('deletePlugin')}
>
{t('viewPR')} <ExternalLink size={14} />
</a>
)}
<button
className="btn-delete"
onClick={() => setConfirmDeletePlugin(plugin)}
title={t('deletePlugin')}
>
<Trash2 size={14} />
{t('deletePlugin')}
</button>
<Trash2 size={14} />
{t('deletePlugin')}
</button>
</div>
</div>
</div>
);
})}
</div>
@@ -729,19 +729,19 @@ export function UserDashboard({ githubService, onClose, locale }: UserDashboardP
className={`filter-btn ${prFilter === 'open' ? 'active' : ''}`}
onClick={() => setPRFilter('open')}
>
{t('filterOpen')} ({pendingReviews.filter(r => r.status === 'open').length})
{t('filterOpen')} ({pendingReviews.filter((r) => r.status === 'open').length})
</button>
<button
className={`filter-btn ${prFilter === 'merged' ? 'active' : ''}`}
onClick={() => setPRFilter('merged')}
>
{t('filterMerged')} ({pendingReviews.filter(r => r.status === 'merged').length})
{t('filterMerged')} ({pendingReviews.filter((r) => r.status === 'merged').length})
</button>
<button
className={`filter-btn ${prFilter === 'closed' ? 'active' : ''}`}
onClick={() => setPRFilter('closed')}
>
{t('filterClosed')} ({pendingReviews.filter(r => r.status === 'closed').length})
{t('filterClosed')} ({pendingReviews.filter((r) => r.status === 'closed').length})
</button>
</div>
<div className="review-list">

View File

@@ -52,7 +52,6 @@ export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }:
useEffect(() => {
// 监听加载状态变化
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
console.log('[UserProfile] User load state changed:', isLoading);
setIsLoadingUser(isLoading);
});
@@ -65,10 +64,8 @@ export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }:
const currentUser = githubService.getUser();
setUser((prevUser) => {
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
console.log('[UserProfile] User state changed:', currentUser.login);
return currentUser;
} else if (!currentUser && prevUser) {
console.log('[UserProfile] User logged out');
return null;
}
return prevUser;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
.asset-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.asset-picker-dialog {
width: 480px;
max-width: 90vw;
max-height: 70vh;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.asset-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
}
.asset-picker-header h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
}
.asset-picker-close {
background: transparent;
border: none;
color: #888;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.asset-picker-close:hover {
background: #333;
color: #e0e0e0;
}
.asset-picker-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #252525;
border-bottom: 1px solid #333;
}
.asset-picker-search svg {
color: #666;
flex-shrink: 0;
}
.asset-picker-search input {
flex: 1;
background: transparent;
border: none;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-picker-search input::placeholder {
color: #666;
}
.asset-picker-content {
flex: 1;
overflow-y: auto;
min-height: 200px;
max-height: 400px;
}
.asset-picker-loading,
.asset-picker-empty {
padding: 32px;
text-align: center;
color: #666;
font-size: 12px;
}
.asset-picker-tree {
padding: 4px 0;
}
.asset-picker-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
}
.asset-picker-item:hover {
background: #2a2a2a;
}
.asset-picker-item.selected {
background: #0d47a1;
}
.asset-picker-item__icon {
display: flex;
align-items: center;
color: #888;
}
.asset-picker-item.selected .asset-picker-item__icon {
color: #90caf9;
}
.asset-picker-item__name {
font-size: 12px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-picker-selected {
flex: 1;
min-width: 0;
font-size: 11px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-picker-selected .placeholder {
color: #666;
font-style: italic;
}
.asset-picker-actions {
display: flex;
gap: 8px;
margin-left: 16px;
}
.asset-picker-actions button {
padding: 6px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
}
.asset-picker-actions .btn-cancel {
background: #333;
color: #e0e0e0;
}
.asset-picker-actions .btn-cancel:hover {
background: #444;
}
.asset-picker-actions .btn-confirm {
background: #1976d2;
color: white;
}
.asset-picker-actions .btn-confirm:hover {
background: #1565c0;
}
.asset-picker-actions .btn-confirm:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}

View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { ProjectService } from '@esengine/editor-core';
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
import './AssetPickerDialog.css';
interface AssetPickerDialogProps {
isOpen: boolean;
onClose: () => void;
onSelect: (path: string) => void;
title?: string;
fileExtensions?: string[]; // e.g., ['.png', '.jpg']
placeholder?: string;
}
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
export function AssetPickerDialog({
isOpen,
onClose,
onSelect,
title = 'Select Asset',
fileExtensions = [],
placeholder = 'Search assets...'
}: AssetPickerDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
// Load project assets
useEffect(() => {
if (!isOpen) return;
const loadAssets = async () => {
setLoading(true);
try {
const projectService = Core.services.tryResolve(ProjectService);
const fileSystem = new TauriFileSystemService();
const currentProject = projectService?.getCurrentProject();
if (projectService && currentProject) {
const projectPath = currentProject.path;
const assetsPath = `${projectPath}/assets`;
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
const entries = await fileSystem.listDirectory(dirPath);
const nodes: FileNode[] = [];
for (const entry of entries) {
const node: FileNode = {
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory
};
if (entry.isDirectory) {
try {
node.children = await buildTree(entry.path);
} catch {
node.children = [];
}
}
nodes.push(node);
}
// Sort: folders first, then files, alphabetically
return nodes.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
};
const tree = await buildTree(assetsPath);
setAssets(tree);
}
} catch (error) {
console.error('Failed to load assets:', error);
} finally {
setLoading(false);
}
};
loadAssets();
setSelectedPath(null);
setSearchTerm('');
}, [isOpen]);
// Filter assets based on search and file extensions
const filteredAssets = useMemo(() => {
const filterNode = (node: FileNode): FileNode | null => {
// Check file extension filter
if (!node.isDirectory && fileExtensions.length > 0) {
const hasValidExtension = fileExtensions.some((ext) =>
node.name.toLowerCase().endsWith(ext.toLowerCase())
);
if (!hasValidExtension) return null;
}
// Check search term
const matchesSearch = !searchTerm ||
node.name.toLowerCase().includes(searchTerm.toLowerCase());
if (node.isDirectory && node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileNode => n !== null);
if (filteredChildren.length > 0 || matchesSearch) {
return { ...node, children: filteredChildren };
}
return null;
}
return matchesSearch ? node : null;
};
return assets
.map(filterNode)
.filter((n): n is FileNode => n !== null);
}, [assets, searchTerm, fileExtensions]);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleSelect = useCallback((node: FileNode) => {
if (node.isDirectory) {
toggleFolder(node.path);
} else {
setSelectedPath(node.path);
}
}, [toggleFolder]);
const handleConfirm = useCallback(() => {
if (selectedPath) {
onSelect(selectedPath);
onClose();
}
}, [selectedPath, onSelect, onClose]);
const handleDoubleClick = useCallback((node: FileNode) => {
if (!node.isDirectory) {
onSelect(node.path);
onClose();
}
}, [onSelect, onClose]);
const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return <Image size={14} />;
case 'mp3':
case 'wav':
case 'ogg':
return <Music size={14} />;
case 'mp4':
case 'webm':
return <Video size={14} />;
case 'json':
case 'txt':
case 'md':
return <FileText size={14} />;
default:
return <File size={14} />;
}
};
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedPath === node.path;
return (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelect(node)}
onDoubleClick={() => handleDoubleClick(node)}
>
<span className="asset-picker-item__icon">
{node.isDirectory ? (
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
) : (
getFileIcon(node.name)
)}
</span>
<span className="asset-picker-item__name">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div className="asset-picker-children">
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!isOpen) return null;
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="asset-picker-search">
<Search size={14} />
<input
type="text"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">Loading assets...</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty">No assets found</div>
) : (
<div className="asset-picker-tree">
{filteredAssets.map((node) => renderNode(node))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="asset-picker-selected">
{selectedPath ? (
<span title={selectedPath}>
{selectedPath.split(/[\\/]/).pop()}
</span>
) : (
<span className="placeholder">No asset selected</span>
)}
</div>
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
Cancel
</button>
<button
className="btn-confirm"
onClick={handleConfirm}
disabled={!selectedPath}
>
Select
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -130,6 +130,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
window.addEventListener('profiler:entity-details', handleEntityDetails);
@@ -140,6 +141,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
unsubAssetFileSelect();
unsubComponentAdded();
unsubComponentRemoved();
unsubPropertyChanged();
window.removeEventListener('profiler:entity-details', handleEntityDetails);
};
}, [messageHub]);

View File

@@ -38,7 +38,7 @@ export function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemPro
}}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
<span
style={{
marginLeft: '6px',

View File

@@ -51,4 +51,4 @@ export function PropertyField({
</span>
</div>
);
}
}

View File

@@ -1,10 +1,10 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
import './AssetField.css';
interface AssetFieldProps {
label: string;
label?: string;
value: string | null;
onChange: (value: string | null) => void;
fileExtension?: string; // 例如: '.btree'
@@ -24,6 +24,7 @@ export function AssetField({
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const inputRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -54,7 +55,7 @@ export function AssetField({
// 处理从文件系统拖入的文件
const files = Array.from(e.dataTransfer.files);
const file = files.find(f =>
const file = files.find((f) =>
!fileExtension || f.name.endsWith(fileExtension)
);
@@ -78,25 +79,15 @@ export function AssetField({
}
}, [onChange, fileExtension, readonly]);
const handleBrowse = useCallback(async () => {
const handleBrowse = useCallback(() => {
if (readonly) return;
setShowPicker(true);
}, [readonly]);
try {
const selected = await open({
multiple: false,
filters: fileExtension ? [{
name: `${fileExtension} Files`,
extensions: [fileExtension.replace('.', '')]
}] : []
});
if (selected) {
onChange(selected as string);
}
} catch (error) {
console.error('Failed to open file dialog:', error);
}
}, [onChange, fileExtension, readonly]);
const handlePickerSelect = useCallback((path: string) => {
onChange(path);
setShowPicker(false);
}, [onChange]);
const handleClear = useCallback(() => {
if (!readonly) {
@@ -111,7 +102,7 @@ export function AssetField({
return (
<div className="asset-field">
<label className="asset-field__label">{label}</label>
{label && <label className="asset-field__label">{label}</label>}
<div
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
@@ -160,17 +151,21 @@ export function AssetField({
</button>
)}
{/* 导航按钮 */}
{value && onNavigate && (
{/* 导航/定位按钮 */}
{onNavigate && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
if (value) {
onNavigate(value);
} else {
handleBrowse();
}
}}
title="在资产浏览器中显示"
title={value ? '在资产浏览器中显示' : '选择资产'}
>
<ArrowRight size={12} />
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
</button>
)}
@@ -189,6 +184,14 @@ export function AssetField({
)}
</div>
</div>
<AssetPickerDialog
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onSelect={handlePickerSelect}
title="Select Asset"
fileExtensions={fileExtension ? [fileExtension] : []}
/>
</div>
);
}
}

View File

@@ -54,8 +54,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
{fileInfo.isDirectory
? '文件夹'
: fileInfo.extension
? `.${fileInfo.extension}`
: '文件'}
? `.${fileInfo.extension}`
: '文件'}
</span>
</div>
{fileInfo.size !== undefined && !fileInfo.isDirectory && (

View File

@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react';
import { Entity, Component, Core } from '@esengine/ecs-framework';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
import { NotificationService } from '../../../services/NotificationService';
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
import '../../../styles/EntityInspector.css';
import * as LucideIcons from 'lucide-react';
interface EntityInspectorProps {
entity: Entity;
@@ -16,6 +18,7 @@ interface EntityInspectorProps {
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
const [showComponentMenu, setShowComponentMenu] = useState(false);
const [localVersion, setLocalVersion] = useState(0);
const componentRegistry = Core.services.resolve(ComponentRegistry);
const availableComponents = componentRegistry?.getAllComponents() || [];
@@ -40,14 +43,42 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const handleRemoveComponent = (index: number) => {
const component = entity.components[index];
if (component) {
const command = new RemoveComponentCommand(
messageHub,
entity,
component
);
commandManager.execute(command);
if (!component) return;
const componentName = getComponentTypeName(component.constructor as any);
console.log('Removing component:', componentName);
// Check if any other component depends on this one
const dependentComponents: string[] = [];
for (const otherComponent of entity.components) {
if (otherComponent === component) continue;
const dependencies = getComponentDependencies(otherComponent.constructor as any);
const otherName = getComponentTypeName(otherComponent.constructor as any);
console.log('Checking', otherName, 'dependencies:', dependencies);
if (dependencies && dependencies.includes(componentName)) {
dependentComponents.push(otherName);
}
}
console.log('Dependent components:', dependentComponents);
if (dependentComponents.length > 0) {
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
if (notificationService) {
notificationService.warning(
'无法删除组件',
`${componentName} 被以下组件依赖: ${dependentComponents.join(', ')}。请先删除这些组件。`
);
}
return;
}
const command = new RemoveComponentCommand(
messageHub,
entity,
component
);
commandManager.execute(command);
};
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
@@ -61,6 +92,34 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
commandManager.execute(command);
};
const handlePropertyAction = async (actionId: string, _propertyName: string, component: Component) => {
if (actionId === 'nativeSize' && component.constructor.name === 'SpriteComponent') {
const sprite = component as unknown as { texture: string; width: number; height: number };
if (!sprite.texture) {
console.warn('No texture set for sprite');
return;
}
try {
const { convertFileSrc } = await import('@tauri-apps/api/core');
const assetUrl = convertFileSrc(sprite.texture);
const img = new Image();
img.onload = () => {
handlePropertyChange(component, 'width', img.naturalWidth);
handlePropertyChange(component, 'height', img.naturalHeight);
setLocalVersion((v) => v + 1);
};
img.onerror = () => {
console.error('Failed to load texture for native size:', sprite.texture);
};
img.src = assetUrl;
} catch (error) {
console.error('Error getting texture size:', error);
}
}
};
return (
<div className="entity-inspector">
<div className="inspector-header">
@@ -82,149 +141,123 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
</div>
<div className="inspector-section">
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="section-title section-title-with-action">
<span></span>
<div style={{ position: 'relative' }}>
<div className="component-menu-container">
<button
className="add-component-trigger"
onClick={() => setShowComponentMenu(!showComponentMenu)}
style={{
background: 'transparent',
border: '1px solid #4a4a4a',
borderRadius: '4px',
color: '#e0e0e0',
cursor: 'pointer',
padding: '2px 6px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px'
}}
>
<Plus size={12} />
</button>
{showComponentMenu && (
<div
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: '4px',
backgroundColor: '#2a2a2a',
border: '1px solid #4a4a4a',
borderRadius: '4px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000,
minWidth: '150px',
maxHeight: '200px',
overflowY: 'auto'
}}
>
{availableComponents.length === 0 ? (
<div style={{ padding: '8px 12px', color: '#888', fontSize: '11px' }}>
</div>
) : (
availableComponents.map((info) => (
<button
key={info.name}
onClick={() => info.type && handleAddComponent(info.type)}
style={{
display: 'block',
width: '100%',
padding: '6px 12px',
background: 'transparent',
border: 'none',
color: '#e0e0e0',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer'
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#3a3a3a')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
>
{info.name}
</button>
))
)}
</div>
<>
<div className="component-dropdown-overlay" onClick={() => setShowComponentMenu(false)} />
<div className="component-dropdown">
<div className="component-dropdown-header"></div>
{availableComponents.length === 0 ? (
<div className="component-dropdown-empty">
</div>
) : (
<div className="component-dropdown-list">
{/* 按分类分组显示 */}
{(() => {
const categories = new Map<string, typeof availableComponents>();
availableComponents.forEach((info) => {
const cat = info.category || 'components.category.other';
if (!categories.has(cat)) {
categories.set(cat, []);
}
categories.get(cat)!.push(info);
});
return Array.from(categories.entries()).map(([category, components]) => (
<div key={category} className="component-category-group">
<div className="component-category-label">{category}</div>
{components.map((info) => (
<button
key={info.name}
className="component-dropdown-item"
onClick={() => info.type && handleAddComponent(info.type)}
>
<span className="component-dropdown-item-name">{info.name}</span>
</button>
))}
</div>
));
})()}
</div>
)}
</div>
</>
)}
</div>
</div>
{entity.components.map((component: Component, index: number) => {
{entity.components.length === 0 ? (
<div className="empty-state-small"></div>
) : (
entity.components.map((component: Component, index: number) => {
const isExpanded = expandedComponents.has(index);
const componentName = component.constructor?.name || 'Component';
const componentInfo = componentRegistry?.getComponent(componentName);
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
return (
<div
key={`${componentName}-${index}-${componentVersion}`}
style={{
marginBottom: '2px',
backgroundColor: '#2a2a2a',
borderRadius: '4px',
overflow: 'hidden'
}}
key={`${componentName}-${index}`}
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
>
<div
className="component-item-header"
onClick={() => toggleComponentExpanded(index)}
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
backgroundColor: '#3a3a3a',
cursor: 'pointer',
userSelect: 'none',
borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none'
}}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span
style={{
marginLeft: '6px',
fontSize: '12px',
fontWeight: 500,
color: '#e0e0e0',
flex: 1
}}
>
<span className="component-expand-icon">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
{IconComponent ? (
<span className="component-icon">
<IconComponent size={14} />
</span>
) : (
<span className="component-icon">
<Box size={14} />
</span>
)}
<span className="component-item-name">
{componentName}
</span>
<button
className="component-remove-btn"
onClick={(e) => {
e.stopPropagation();
handleRemoveComponent(index);
}}
title="移除组件"
style={{
background: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
padding: '2px',
borderRadius: '3px',
display: 'flex',
alignItems: 'center'
}}
onMouseEnter={(e) => (e.currentTarget.style.color = '#dc2626')}
onMouseLeave={(e) => (e.currentTarget.style.color = '#888')}
>
<X size={12} />
</button>
</div>
{isExpanded && (
<div style={{ padding: '6px 8px' }}>
<div className="component-item-content">
<PropertyInspector
component={component}
entity={entity}
version={componentVersion + localVersion}
onChange={(propName: string, value: unknown) =>
handlePropertyChange(component, propName, value)
}
onAction={handlePropertyAction}
/>
</div>
)}
</div>
);
})}
})
)}
</div>
</div>
</div>

View File

@@ -242,33 +242,33 @@ export function RemoteEntityInspector({
details.components &&
Array.isArray(details.components) &&
details.components.length > 0 && (
<div className="inspector-section">
<div className="section-title"> ({details.components.length})</div>
{details.components.map((comp, index) => {
const registry = Core.services.resolve(PropertyRendererRegistry);
const context: PropertyContext = {
name: comp.typeName || `Component ${index}`,
decimalPlaces,
readonly: true,
expandByDefault: true,
depth: 0
};
const rendered = registry.render(comp, context);
return rendered ? <div key={index}>{rendered}</div> : null;
})}
</div>
)}
<div className="inspector-section">
<div className="section-title"> ({details.components.length})</div>
{details.components.map((comp, index) => {
const registry = Core.services.resolve(PropertyRendererRegistry);
const context: PropertyContext = {
name: comp.typeName || `Component ${index}`,
decimalPlaces,
readonly: true,
expandByDefault: true,
depth: 0
};
const rendered = registry.render(comp, context);
return rendered ? <div key={index}>{rendered}</div> : null;
})}
</div>
)}
{details &&
Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes')
.length > 0 && (
<div className="inspector-section">
<div className="section-title"></div>
{Object.entries(details)
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
.map(([key, value]) => renderRemoteProperty(key, value))}
</div>
)}
<div className="inspector-section">
<div className="section-title"></div>
{Object.entries(details)
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
.map(([key, value]) => renderRemoteProperty(key, value))}
</div>
)}
</div>
</div>
);

View File

@@ -28,7 +28,7 @@ export class PanelRegistry implements IPanelRegistry {
}
return allPanels
.filter(panel => panel.category === category)
.filter((panel) => panel.category === category)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
}

View File

@@ -40,9 +40,9 @@ export class TypedEventBus<TEvents = Record<string, unknown>> implements IEventB
}
dispose(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.subscriptions = [];
this.subjects.forEach(subject => subject.complete());
this.subjects.forEach((subject) => subject.complete());
this.subjects.clear();
}

View File

@@ -0,0 +1,207 @@
/**
* Asset system integration hook
* 资产系统集成Hook
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {
AssetManager,
AssetGUID,
IAssetLoadProgress,
AssetReference,
EngineIntegration
} from '@esengine/asset-system';
/**
* Asset system hook
* 资产系统Hook
*/
export function useAssetSystem() {
const [assetManager, setAssetManager] = useState<AssetManager | null>(null);
const [engineIntegration, setEngineIntegration] = useState<EngineIntegration | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [loadProgress, setLoadProgress] = useState<IAssetLoadProgress | null>(null);
const loadingCountRef = useRef(0);
/**
* Initialize asset system
* 初始化资产系统
*/
useEffect(() => {
// 创建资产管理器 / Create asset manager
const manager = new AssetManager();
setAssetManager(manager);
// 创建引擎集成 / Create engine integration
const integration = new EngineIntegration(manager);
setEngineIntegration(integration);
return () => {
if (assetManager) {
assetManager.dispose();
}
};
}, []);
/**
* Load asset by path
* 通过路径加载资产
*/
const loadAssetByPath = useCallback(async <T = unknown>(path: string): Promise<T | null> => {
if (!assetManager) return null;
try {
loadingCountRef.current++;
setIsLoading(true);
const result = await assetManager.loadAssetByPath<T>(path, {
onProgress: (progress) => {
setLoadProgress({
currentAsset: path,
loadedCount: Math.floor(progress * 100),
totalCount: 100,
loadedBytes: 0,
totalBytes: 0,
progress
});
}
});
return result.asset;
} catch (error) {
console.error(`Failed to load asset at ${path}:`, error);
return null;
} finally {
loadingCountRef.current--;
if (loadingCountRef.current === 0) {
setIsLoading(false);
setLoadProgress(null);
}
}
}, [assetManager]);
/**
* Load texture for sprite component
* 为精灵组件加载纹理
*/
const loadTextureForSprite = useCallback(async (path: string): Promise<number> => {
if (!engineIntegration) return 0;
try {
return await engineIntegration.loadTextureForComponent(path);
} catch (error) {
console.error(`Failed to load texture ${path}:`, error);
return 0;
}
}, [engineIntegration]);
/**
* Create asset reference
* 创建资产引用
*/
const createAssetReference = useCallback((guid: AssetGUID): AssetReference | null => {
if (!assetManager) return null;
return new AssetReference(guid, assetManager);
}, [assetManager]);
/**
* Unload unused assets
* 卸载未使用的资产
*/
const unloadUnusedAssets = useCallback(() => {
if (!assetManager) return;
assetManager.unloadUnusedAssets();
}, [assetManager]);
/**
* Get statistics
* 获取统计信息
*/
const getStatistics = useCallback(() => {
if (!assetManager) {
return { loadedCount: 0, loadQueue: 0, failedCount: 0 };
}
return assetManager.getStatistics();
}, [assetManager]);
return {
assetManager,
engineIntegration,
isLoading,
loadProgress,
loadAssetByPath,
loadTextureForSprite,
createAssetReference,
unloadUnusedAssets,
getStatistics
};
}
/**
* Asset reference hook
* 资产引用Hook
*/
export function useAssetReference<T = unknown>(
reference: AssetReference<T> | null,
autoLoad = false
) {
const [asset, setAsset] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// 自动加载 / Auto load
useEffect(() => {
if (autoLoad && reference) {
loadAsset();
}
}, [reference, autoLoad]);
/**
* Load asset
* 加载资产
*/
const loadAsset = useCallback(async () => {
if (!reference) return;
try {
setIsLoading(true);
setError(null);
const loadedAsset = await reference.loadAsync();
setAsset(loadedAsset);
} catch (err) {
setError(err as Error);
console.error('Failed to load asset reference:', err);
} finally {
setIsLoading(false);
}
}, [reference]);
/**
* Release asset
* 释放资产
*/
const release = useCallback(() => {
if (!reference) return;
reference.release();
setAsset(null);
}, [reference]);
// 清理 / Cleanup
useEffect(() => {
return () => {
if (reference && reference.isLoaded) {
reference.release();
}
};
}, [reference]);
return {
asset,
isLoading,
error,
load: loadAsset,
release
};
}

View File

@@ -3,8 +3,17 @@
* 使用Rust游戏引擎的React钩子。
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, EntityStoreService } from '@esengine/editor-core';
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
import { EngineService } from '../services/EngineService';
import { EditorEngineSync } from '../services/EditorEngineSync';
// Module-level initialization tracking (outside React lifecycle)
// 模块级别的初始化追踪在React生命周期外部
let engineInitialized = false;
let engineInitializing = false;
export interface EngineState {
initialized: boolean;
@@ -27,6 +36,66 @@ export interface UseEngineReturn {
height?: number;
}) => void;
loadTexture: (id: number, url: string) => void;
viewportId: string;
}
export interface UseEngineOptions {
viewportId: string;
canvasId: string;
showGrid?: boolean;
showGizmos?: boolean;
autoInit?: boolean;
}
/**
* Initialize engine once at module level
* 在模块级别初始化引擎一次
*/
async function initializeEngine(canvasId: string): Promise<void> {
if (engineInitialized || engineInitializing) {
return;
}
engineInitializing = true;
try {
const engine = EngineService.getInstance();
await engine.initialize(canvasId);
// Initialize sync service
try {
const messageHub = Core.services.resolve(MessageHub);
const entityStore = Core.services.resolve(EntityStoreService);
if (messageHub && entityStore) {
EditorEngineSync.getInstance().initialize(messageHub, entityStore);
// Create default camera if none exists
// 如果不存在相机则创建默认相机
const scene = Core.scene;
if (scene) {
const existingCameras = scene.entities.findEntitiesWithComponent(CameraComponent);
if (existingCameras.length === 0) {
const cameraEntity = scene.createEntity('Main Camera');
cameraEntity.addComponent(new TransformComponent());
const camera = new CameraComponent();
camera.orthographicSize = 1;
cameraEntity.addComponent(camera);
// Register with EntityStore so it appears in hierarchy
// 注册到 EntityStore 以便在层级视图中显示
entityStore.addEntity(cameraEntity);
messageHub.publish('entity:added', { entity: cameraEntity });
}
}
}
} catch (syncError) {
console.warn('Failed to initialize sync service | 同步服务初始化失败:', syncError);
}
engineInitialized = true;
} finally {
engineInitializing = false;
}
}
/**
@@ -36,12 +105,34 @@ export interface UseEngineReturn {
* @param canvasId - Canvas element ID | Canvas元素ID
* @param autoInit - Whether to auto-initialize | 是否自动初始化
*/
export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
export function useEngine(canvasId: string, autoInit?: boolean): UseEngineReturn;
export function useEngine(options: UseEngineOptions): UseEngineReturn;
export function useEngine(
canvasIdOrOptions: string | UseEngineOptions,
autoInit?: boolean
): UseEngineReturn {
// Parse options
const options: UseEngineOptions = typeof canvasIdOrOptions === 'string'
? {
viewportId: canvasIdOrOptions, // Use canvasId as viewportId for backward compatibility
canvasId: canvasIdOrOptions,
showGrid: true,
showGizmos: true,
autoInit
}
: {
showGrid: true,
showGizmos: true,
autoInit: true,
...canvasIdOrOptions
};
const engineRef = useRef<EngineService>(EngineService.getInstance());
const statsIntervalRef = useRef<number | null>(null);
const viewportRegisteredRef = useRef(false);
const [state, setState] = useState<EngineState>({
initialized: false,
initialized: engineInitialized,
running: false,
fps: 0,
drawCalls: 0,
@@ -49,28 +140,42 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
error: null
});
// Initialize engine | 初始化引擎
// Initialize engine and register viewport
useEffect(() => {
if (!autoInit) return;
if (!options.autoInit) return;
const init = async () => {
try {
await engineRef.current.initialize(canvasId);
setState(prev => ({ ...prev, initialized: true, error: null }));
// Initialize engine with primary canvas (first viewport)
await initializeEngine(options.canvasId);
setState((prev) => ({ ...prev, initialized: true, error: null }));
// Start stats update interval | 启动统计更新间隔
statsIntervalRef.current = window.setInterval(() => {
const stats = engineRef.current.getStats();
setState(prev => ({
...prev,
fps: stats.fps,
drawCalls: stats.drawCalls,
spriteCount: stats.spriteCount
}));
}, 100);
// Register this viewport
if (!viewportRegisteredRef.current) {
engineRef.current.registerViewport(options.viewportId, options.canvasId);
engineRef.current.setViewportConfig(
options.viewportId,
options.showGrid ?? true,
options.showGizmos ?? true
);
viewportRegisteredRef.current = true;
}
// Start stats update interval
if (!statsIntervalRef.current) {
statsIntervalRef.current = window.setInterval(() => {
const stats = engineRef.current.getStats();
setState((prev) => ({
...prev,
fps: stats.fps,
drawCalls: stats.drawCalls,
spriteCount: stats.spriteCount
}));
}, 100);
}
} catch (error) {
console.error('Failed to initialize engine | 引擎初始化失败:', error);
setState(prev => ({
setState((prev) => ({
...prev,
error: error instanceof Error ? error.message : String(error)
}));
@@ -82,24 +187,29 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
return () => {
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
statsIntervalRef.current = null;
}
// Unregister viewport on cleanup
if (viewportRegisteredRef.current) {
engineRef.current.unregisterViewport(options.viewportId);
viewportRegisteredRef.current = false;
}
engineRef.current.dispose();
};
}, [canvasId, autoInit]);
}, [options.canvasId, options.viewportId, options.autoInit, options.showGrid, options.showGizmos]);
// Start engine | 启动引擎
// Start engine
const start = useCallback(() => {
engineRef.current.start();
setState(prev => ({ ...prev, running: true }));
setState((prev) => ({ ...prev, running: true }));
}, []);
// Stop engine | 停止引擎
// Stop engine
const stop = useCallback(() => {
engineRef.current.stop();
setState(prev => ({ ...prev, running: false }));
setState((prev) => ({ ...prev, running: false }));
}, []);
// Create sprite entity | 创建精灵实体
// Create sprite entity
const createSprite = useCallback((name: string, options?: {
x?: number;
y?: number;
@@ -110,7 +220,7 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
engineRef.current.createSpriteEntity(name, options);
}, []);
// Load texture | 加载纹理
// Load texture
const loadTexture = useCallback((id: number, url: string) => {
engineRef.current.loadTexture(id, url);
}, []);
@@ -120,7 +230,8 @@ export function useEngine(canvasId: string, autoInit = true): UseEngineReturn {
start,
stop,
createSprite,
loadTexture
loadTexture,
viewportId: options.viewportId
};
}

View File

@@ -44,5 +44,37 @@
"executionResumed": "Execution resumed",
"resetToInitial": "Reset to initial state",
"currentValue": "Current Value"
},
"components": {
"category": {
"core": "Core",
"rendering": "Rendering",
"physics": "Physics",
"audio": "Audio"
},
"transform": {
"description": "Transform - Position, Rotation, Scale"
},
"sprite": {
"description": "Sprite - 2D Image Rendering"
},
"text": {
"description": "Text - Text Rendering"
},
"camera": {
"description": "Camera - View Control"
},
"rigidBody": {
"description": "RigidBody - Physics Simulation"
},
"boxCollider": {
"description": "Box Collider"
},
"circleCollider": {
"description": "Circle Collider"
},
"audioSource": {
"description": "Audio Source"
}
}
}

View File

@@ -44,5 +44,37 @@
"executionResumed": "执行已恢复",
"resetToInitial": "重置到初始状态",
"currentValue": "当前值"
},
"components": {
"category": {
"core": "基础",
"rendering": "渲染",
"physics": "物理",
"audio": "音频"
},
"transform": {
"description": "变换组件 - 位置、旋转、缩放"
},
"sprite": {
"description": "精灵组件 - 2D图像渲染"
},
"text": {
"description": "文本组件 - 文本渲染"
},
"camera": {
"description": "相机组件 - 视图控制"
},
"rigidBody": {
"description": "刚体组件 - 物理模拟"
},
"boxCollider": {
"description": "盒型碰撞器"
},
"circleCollider": {
"description": "圆形碰撞器"
},
"audioSource": {
"description": "音频源组件"
}
}
}

View File

@@ -0,0 +1,493 @@
import React, { useState, useCallback, useEffect } from 'react';
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/ecs-components';
import { AssetField } from '../../components/inspectors/fields/AssetField';
import { EngineService } from '../../services/EngineService';
interface DraggableNumberProps {
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
disabled?: boolean;
label: string;
}
function DraggableNumber({ value, min = 0, max = 10, step = 0.1, onChange, disabled, label }: DraggableNumberProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
if (disabled) return;
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartValue(value);
e.preventDefault();
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartX;
const sensitivity = e.shiftKey ? 0.1 : 1;
let newValue = dragStartValue + delta * step * sensitivity;
newValue = Math.max(min, Math.min(max, newValue));
newValue = parseFloat(newValue.toFixed(2));
onChange(newValue);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
return (
<label className="clip-draggable-number">
<span
className="clip-draggable-label"
onMouseDown={handleMouseDown}
style={{ cursor: disabled ? 'default' : 'ew-resize' }}
>
{label}
</span>
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(parseFloat(e.target.value) || 1)}
disabled={disabled}
/>
</label>
);
}
export class AnimationClipsFieldEditor implements IFieldEditor<AnimationClip[]> {
readonly type = 'animationClips';
readonly name = 'Animation Clips Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'animationClips';
}
render({ label, value, onChange, context }: FieldEditorProps<AnimationClip[]>): React.ReactElement {
return (
<AnimationClipsEditor
label={label}
clips={value || []}
onChange={onChange}
readonly={context.readonly}
component={context.metadata?.component as SpriteAnimatorComponent}
onDefaultAnimationChange={context.metadata?.onDefaultAnimationChange}
/>
);
}
}
interface AnimationClipsEditorProps {
label: string;
clips: AnimationClip[];
onChange: (clips: AnimationClip[]) => void;
readonly?: boolean;
component?: SpriteAnimatorComponent;
onDefaultAnimationChange?: (value: string) => void;
}
function AnimationClipsEditor({ label, clips, onChange, readonly, component, onDefaultAnimationChange }: AnimationClipsEditorProps) {
const [expandedClips, setExpandedClips] = useState<Set<number>>(new Set());
const [playingClip, setPlayingClip] = useState<string | null>(null);
const handleNavigate = useCallback((path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
}, []);
const toggleClip = (index: number) => {
const newExpanded = new Set(expandedClips);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedClips(newExpanded);
};
const addClip = () => {
const newName = `Animation ${clips.length + 1}`;
const newClip: AnimationClip = {
name: newName,
frames: [],
loop: true,
speed: 1
};
onChange([...clips, newClip]);
setExpandedClips(new Set([...expandedClips, clips.length]));
// Auto-set first clip as default animation
if (clips.length === 0 && component && !component.defaultAnimation) {
component.defaultAnimation = newName;
setDefaultAnimation(newName);
if (onDefaultAnimationChange) {
onDefaultAnimationChange(newName);
}
}
};
const removeClip = (index: number) => {
const newClips = clips.filter((_, i) => i !== index);
onChange(newClips);
};
const updateClip = (index: number, updates: Partial<AnimationClip>) => {
const newClips = [...clips];
const existingClip = newClips[index];
if (!existingClip) return;
newClips[index] = { ...existingClip, ...updates } as AnimationClip;
onChange(newClips);
};
const addFrame = (clipIndex: number) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = [...clip.frames, { texture: '', duration: 0.1 }];
onChange(newClips);
};
const removeFrame = (clipIndex: number, frameIndex: number) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = clip.frames.filter((_, i) => i !== frameIndex);
onChange(newClips);
};
const updateFrame = (clipIndex: number, frameIndex: number, updates: Partial<AnimationFrame>) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = [...clip.frames];
const existingFrame = clip.frames[frameIndex];
if (!existingFrame) return;
clip.frames[frameIndex] = { ...existingFrame, ...updates } as AnimationFrame;
onChange(newClips);
};
const addFramesBatch = (clipIndex: number, texturePaths: string[]) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
const newFrames = texturePaths.map((texture) => ({
texture,
duration: 0.1
}));
clip.frames = [...clip.frames, ...newFrames];
onChange(newClips);
};
const handleFramesDrop = useCallback((clipIndex: number, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const data = e.dataTransfer.getData('application/json');
if (data) {
try {
const items = JSON.parse(data);
if (Array.isArray(items)) {
const textures = items
.filter((item: { type: string; path: string }) =>
item.type === 'file' && /\.(png|jpg|jpeg|webp|gif)$/i.test(item.path))
.map((item: { path: string }) => item.path)
.sort();
if (textures.length > 0) {
addFramesBatch(clipIndex, textures);
}
}
} catch {
// Try text data for single file
const text = e.dataTransfer.getData('text/plain');
if (text && /\.(png|jpg|jpeg|webp|gif)$/i.test(text)) {
addFramesBatch(clipIndex, [text]);
}
}
}
}, [clips, onChange]);
const handleFramesDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const [defaultAnimation, setDefaultAnimation] = useState(component?.defaultAnimation || '');
// Sync with component changes
useEffect(() => {
if (component) {
setDefaultAnimation(component.defaultAnimation || '');
}
}, [component?.defaultAnimation]);
const setAsDefaultAnimationHandler = (clipName: string) => {
if (component) {
component.defaultAnimation = clipName;
setDefaultAnimation(clipName);
// Notify parent to update the defaultAnimation field
if (onDefaultAnimationChange) {
onDefaultAnimationChange(clipName);
}
}
};
const isDefaultAnimation = (clipName: string) => {
return defaultAnimation === clipName;
};
const handlePlayPreview = (clipName: string) => {
if (component) {
const engineService = EngineService.getInstance();
// Get the actual component from scene entity (not the one passed as prop)
const scene = engineService.getScene();
const entityId = component.entityId;
let actualComponent = component;
if (scene && entityId !== undefined && entityId !== null) {
const sceneEntity = scene.findEntityById(entityId);
if (sceneEntity) {
const sceneAnimator = sceneEntity.getComponent(component.constructor as any);
if (sceneAnimator) {
actualComponent = sceneAnimator as SpriteAnimatorComponent;
}
}
}
if (playingClip === clipName) {
// Stop playing
actualComponent.stop();
setPlayingClip(null);
engineService.disableAnimationPreview();
} else {
// Stop previous animation if any
actualComponent.stop();
// Sync clips data to component before playing
actualComponent.clips = clips;
// Enable animation preview if not already enabled
if (!engineService.isAnimationPreviewEnabled()) {
engineService.enableAnimationPreview();
}
// Play this clip
actualComponent.play(clipName);
setPlayingClip(clipName);
}
}
};
// Sync playingClip state with actual component state
useEffect(() => {
if (component && playingClip) {
// Check if component is still playing
if (!component.isPlaying()) {
setPlayingClip(null);
}
}
});
// Stop preview when component unmounts
useEffect(() => {
return () => {
if (component) {
component.stop();
const engineService = EngineService.getInstance();
engineService.disableAnimationPreview();
}
};
}, [component]);
return (
<div className="animation-clips-editor">
<div className="clips-header">
<span className="clips-label">{label}</span>
{!readonly && (
<button className="add-clip-btn" onClick={addClip} title="Add Animation Clip">
<Plus size={12} />
</button>
)}
</div>
{clips.length === 0 ? (
<div className="clips-empty">
<Film size={24} strokeWidth={1} />
<span>No animation clips</span>
</div>
) : (
<div className="clips-list">
{clips.map((clip, clipIndex) => (
<div key={clipIndex} className="clip-item">
<div className="clip-header" onClick={() => toggleClip(clipIndex)}>
{expandedClips.has(clipIndex) ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<Film size={14} />
<input
className="clip-name-input"
value={clip.name}
onChange={(e) => {
e.stopPropagation();
updateClip(clipIndex, { name: e.target.value });
}}
onClick={(e) => e.stopPropagation()}
disabled={readonly}
/>
<span className="frame-count">{clip.frames.length} frames</span>
{component && clip.frames.length > 0 && (
<button
className={`preview-clip-btn ${playingClip === clip.name ? 'is-playing' : ''}`}
onClick={(e) => {
e.stopPropagation();
handlePlayPreview(clip.name);
}}
title={playingClip === clip.name ? 'Stop Preview' : 'Preview Animation'}
>
{playingClip === clip.name ? <Square size={10} /> : <Play size={10} />}
</button>
)}
{component && !readonly && (
<button
className={`set-default-btn ${isDefaultAnimation(clip.name) ? 'is-default' : ''}`}
onClick={(e) => {
e.stopPropagation();
setAsDefaultAnimationHandler(clip.name);
}}
title={isDefaultAnimation(clip.name) ? 'Current Default Animation' : 'Set as Default Animation'}
>
<Star size={12} fill={isDefaultAnimation(clip.name) ? 'currentColor' : 'none'} />
</button>
)}
{!readonly && (
<button
className="remove-clip-btn"
onClick={(e) => {
e.stopPropagation();
removeClip(clipIndex);
}}
title="Remove Clip"
>
<Trash2 size={12} />
</button>
)}
</div>
{expandedClips.has(clipIndex) && (
<div className="clip-content">
<div className="clip-settings">
<label>
<input
type="checkbox"
checked={clip.loop}
onChange={(e) => updateClip(clipIndex, { loop: e.target.checked })}
disabled={readonly}
/>
Loop
</label>
<DraggableNumber
label="Speed:"
value={clip.speed}
min={0}
max={10}
step={0.1}
onChange={(val) => updateClip(clipIndex, { speed: val })}
disabled={readonly}
/>
</div>
<div
className="frames-section"
onDrop={(e) => handleFramesDrop(clipIndex, e)}
onDragOver={handleFramesDragOver}
>
<div className="frames-header">
<span>Frames</span>
{!readonly && (
<button onClick={() => addFrame(clipIndex)} title="Add Frame">
<Plus size={10} />
</button>
)}
</div>
{clip.frames.length === 0 ? (
<div className="frames-empty frames-drop-zone">
<Upload size={16} />
<span>Drop images here or click + to add</span>
</div>
) : (
<div className="frames-list">
{clip.frames.map((frame, frameIndex) => (
<div key={frameIndex} className="frame-item">
<span className="frame-index">{frameIndex + 1}</span>
<div className="frame-texture-field">
<AssetField
value={frame.texture}
onChange={(val) => updateFrame(clipIndex, frameIndex, { texture: val || '' })}
fileExtension=".png"
placeholder="Texture..."
readonly={readonly}
onNavigate={handleNavigate}
/>
</div>
<input
className="frame-duration"
type="number"
min={0.01}
step={0.01}
value={frame.duration}
onChange={(e) => updateFrame(clipIndex, frameIndex, { duration: parseFloat(e.target.value) || 0.1 })}
disabled={readonly}
title="Duration (seconds)"
/>
{!readonly && (
<button
onClick={() => removeFrame(clipIndex, frameIndex)}
title="Remove Frame"
>
<Trash2 size={10} />
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { AssetField } from '../../components/inspectors/fields/AssetField';
export class AssetFieldEditor implements IFieldEditor<string | null> {
@@ -15,6 +16,13 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
const fileExtension = context.metadata?.fileExtension || '';
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
const handleNavigate = (path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
};
return (
<AssetField
label={label}
@@ -23,7 +31,8 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
fileExtension={fileExtension}
placeholder={placeholder}
readonly={context.readonly}
onNavigate={handleNavigate}
/>
);
}
}
}

View File

@@ -196,4 +196,4 @@ export class ColorFieldEditor implements IFieldEditor<Color> {
</div>
);
}
}
}

View File

@@ -1,3 +1,4 @@
export * from './AssetFieldEditor';
export * from './VectorFieldEditors';
export * from './ColorFieldEditor';
export * from './ColorFieldEditor';
export * from './AnimationClipsFieldEditor';

View File

@@ -43,7 +43,7 @@ export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
}}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
<span
style={{
marginLeft: '6px',
@@ -86,4 +86,4 @@ export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
</div>
);
}
}
}

View File

@@ -80,7 +80,7 @@ export class ArrayRenderer implements IPropertyRenderer<any[]> {
);
}
const isStringArray = value.every(item => typeof item === 'string');
const isStringArray = value.every((item) => typeof item === 'string');
if (isStringArray && value.length <= 5) {
return (
<div className="property-field">
@@ -136,4 +136,4 @@ export class ArrayRenderer implements IPropertyRenderer<any[]> {
</div>
);
}
}
}

View File

@@ -91,4 +91,4 @@ export class NullRenderer implements IPropertyRenderer<null> {
</div>
);
}
}
}

View File

@@ -1,4 +1,4 @@
export * from './PrimitiveRenderers';
export * from './VectorRenderers';
export * from './ComponentRenderer';
export * from './FallbackRenderer';
export * from './FallbackRenderer';

View File

@@ -1,10 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { setGlobalLogLevel, LogLevel } from '@esengine/ecs-framework';
import App from './App';
import './styles/global.css';
import './styles/index.css';
import './i18n/config';
// Set log level to Warn in production to reduce console noise
setGlobalLogLevel(LogLevel.Warn);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -91,8 +91,6 @@ export class ProfilerPlugin implements IEditorPlugin {
// 将服务实例存储到全局,供组件访问
(window as any).__PROFILER_SERVICE__ = this.profilerService;
console.log('[ProfilerPlugin] Installed and ProfilerService started');
}
async uninstall(): Promise<void> {
@@ -103,12 +101,9 @@ export class ProfilerPlugin implements IEditorPlugin {
}
delete (window as any).__PROFILER_SERVICE__;
console.log('[ProfilerPlugin] Uninstalled and ProfilerService stopped');
}
async onEditorReady(): Promise<void> {
console.log('[ProfilerPlugin] Editor is ready');
}
registerMenuItems(): MenuItem[] {
@@ -119,12 +114,10 @@ export class ProfilerPlugin implements IEditorPlugin {
parentId: 'window',
order: 100,
onClick: () => {
console.log('[ProfilerPlugin] Menu item clicked!');
this.messageHub?.publish('ui:openWindow', { windowId: 'profiler' });
}
}
];
console.log('[ProfilerPlugin] Registering menu items:', items);
return items;
}

View File

@@ -63,7 +63,7 @@ export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
{onDeleteNode && (
<div
onClick={onDeleteNode}
style={{...menuItemStyle, color: '#f48771'}}
style={{ ...menuItemStyle, color: '#f48771' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>

View File

@@ -67,13 +67,13 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
}, [filteredTemplates, expandedCategories, searchTextLower]);
const flattenedTemplates = React.useMemo(() => {
return categoryGroups.flatMap(group =>
return categoryGroups.flatMap((group) =>
group.isExpanded ? group.templates : []
);
}, [categoryGroups]);
const toggleCategory = (category: string) => {
setExpandedCategories(prev => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
@@ -86,7 +86,7 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
useEffect(() => {
if (allTemplates.length > 0 && expandedCategories.size === 0) {
const categories = new Set(allTemplates.map(t => t.category || '未分类'));
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
setExpandedCategories(categories);
}
}, [allTemplates, expandedCategories.size]);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo } from 'lucide-react';
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo, Box } from 'lucide-react';
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
@@ -7,6 +7,7 @@ interface EditorToolbarProps {
executionMode: ExecutionMode;
canUndo: boolean;
canRedo: boolean;
showGizmos: boolean;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
@@ -16,12 +17,14 @@ interface EditorToolbarProps {
onRedo: () => void;
onResetView: () => void;
onClearCanvas: () => void;
onToggleGizmos: () => void;
}
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
executionMode,
canUndo,
canRedo,
showGizmos,
onPlay,
onPause,
onStop,
@@ -30,7 +33,8 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
onUndo,
onRedo,
onResetView,
onClearCanvas
onClearCanvas,
onToggleGizmos
}) => {
return (
<div style={{
@@ -200,6 +204,27 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
</button>
{/* Gizmo 开关按钮 */}
<button
onClick={onToggleGizmos}
style={{
padding: '8px 12px',
backgroundColor: showGizmos ? '#4a9eff' : '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: showGizmos ? '#fff' : '#cccccc',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
title="显示/隐藏选择边框 (Gizmos)"
>
<Box size={14} />
Gizmos
</button>
{/* 分隔符 */}
<div style={{
width: '1px',

View File

@@ -0,0 +1,331 @@
/**
* Editor-Engine Sync Service
* 编辑器-引擎同步服务
*
* Synchronizes editor entities to Rust engine for rendering.
* 将编辑器实体同步到Rust引擎进行渲染。
*/
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub, EntityStoreService } from '@esengine/editor-core';
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent } from '@esengine/ecs-components';
import { EngineService } from './EngineService';
export class EditorEngineSync {
private static instance: EditorEngineSync | null = null;
private engineService: EngineService;
private messageHub: MessageHub | null = null;
private entityStore: EntityStoreService | null = null;
// Track synced entities: editor entity id -> engine entity id
private syncedEntities: Map<number, Entity> = new Map();
// Subscription IDs
private subscriptions: Array<() => void> = [];
private initialized = false;
private constructor() {
this.engineService = EngineService.getInstance();
}
/**
* Get singleton instance.
* 获取单例实例。
*/
static getInstance(): EditorEngineSync {
if (!EditorEngineSync.instance) {
EditorEngineSync.instance = new EditorEngineSync();
}
return EditorEngineSync.instance;
}
/**
* Initialize sync service.
* 初始化同步服务。
*/
initialize(messageHub: MessageHub, entityStore: EntityStoreService): void {
if (this.initialized) {
return;
}
this.messageHub = messageHub;
this.entityStore = entityStore;
// Subscribe to entity events
this.subscribeToEvents();
// Sync existing entities
this.syncAllEntities();
this.initialized = true;
}
/**
* Subscribe to MessageHub events.
* 订阅MessageHub事件。
*/
private subscribeToEvents(): void {
if (!this.messageHub) return;
// Entity added
const unsubAdd = this.messageHub.subscribe('entity:added', (data: { entity: Entity }) => {
this.syncEntity(data.entity);
});
this.subscriptions.push(unsubAdd);
// Entity removed
const unsubRemove = this.messageHub.subscribe('entity:removed', (data: { entity: Entity }) => {
this.removeEntityFromEngine(data.entity);
});
this.subscriptions.push(unsubRemove);
// Component property changed - need to re-sync entity
const unsubComponent = this.messageHub.subscribe('component:property:changed', (data: { entity: Entity; component: Component; propertyName: string; value: any }) => {
this.updateEntityInEngine(data.entity, data.component, data.propertyName, data.value);
});
this.subscriptions.push(unsubComponent);
// Component added - sync entity if it has sprite
const unsubComponentAdded = this.messageHub.subscribe('component:added', (data: { entity: Entity; component: Component }) => {
this.syncEntity(data.entity);
});
this.subscriptions.push(unsubComponentAdded);
// Entities cleared
const unsubClear = this.messageHub.subscribe('entities:cleared', () => {
this.clearAllFromEngine();
});
this.subscriptions.push(unsubClear);
// Entity selected - update gizmo display
const unsubSelected = this.messageHub.subscribe('entity:selected', (data: { entity: Entity | null }) => {
this.updateSelectedEntity(data.entity);
});
this.subscriptions.push(unsubSelected);
}
/**
* Update selected entity for gizmo display.
* 更新选中的实体用于Gizmo显示。
*/
private updateSelectedEntity(entity: Entity | null): void {
if (entity) {
this.engineService.setSelectedEntityIds([entity.id]);
} else {
this.engineService.setSelectedEntityIds([]);
}
}
/**
* Sync all existing entities.
* 同步所有现有实体。
*/
private syncAllEntities(): void {
if (!this.entityStore) return;
const entities = this.entityStore.getAllEntities();
for (const entity of entities) {
this.syncEntity(entity);
}
}
/**
* Sync a single entity to engine.
* 将单个实体同步到引擎。
*
* Note: Texture loading is now handled automatically by EngineRenderSystem
* via Rust engine's path-based texture loading.
* 注意纹理加载现在由EngineRenderSystem通过Rust引擎的路径加载自动处理。
*/
private syncEntity(entity: Entity): void {
// Check if entity has sprite component
const spriteComponent = entity.getComponent(SpriteComponent);
if (!spriteComponent) {
return;
}
// Preload animator textures and set first frame
// 预加载动画纹理并设置第一帧
const animator = entity.getComponent(SpriteAnimatorComponent);
if (animator && animator.clips) {
const bridge = this.engineService.getBridge();
if (bridge) {
for (const clip of animator.clips) {
for (const frame of clip.frames) {
if (frame.texture) {
// Trigger texture loading
bridge.getOrLoadTextureByPath(frame.texture);
}
}
}
// Set sprite texture to first frame (static preview in editor)
// 设置精灵纹理为第一帧(编辑器中的静态预览)
if (animator.clips && animator.clips.length > 0) {
const firstClip = animator.clips[0];
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
const firstFrame = firstClip.frames[0];
if (firstFrame && firstFrame.texture && spriteComponent) {
spriteComponent.texture = firstFrame.texture;
}
}
}
}
}
// Track synced entity
this.syncedEntities.set(entity.id, entity);
}
/**
* Remove entity from tracking.
* 从跟踪中移除实体。
*/
private removeEntityFromEngine(entity: Entity): void {
if (!entity) {
return;
}
// Just remove from tracking, entity destruction is handled by the command
this.syncedEntities.delete(entity.id);
}
/**
* Update entity in engine when component changes.
* 当组件变化时更新引擎中的实体。
*/
private updateEntityInEngine(entity: Entity, component: Component, propertyName: string, value: any): void {
const engineEntity = this.syncedEntities.get(entity.id);
if (!engineEntity) {
// Entity not synced yet, try to sync it
this.syncEntity(entity);
return;
}
// Update based on component type
if (component instanceof TransformComponent) {
this.updateTransform(engineEntity, component);
} else if (component instanceof SpriteComponent) {
this.updateSprite(engineEntity, component, propertyName, value);
} else if (component instanceof SpriteAnimatorComponent) {
this.updateAnimator(engineEntity, component, propertyName);
}
}
/**
* Update animator - preload textures and set initial frame.
* 更新动画器 - 预加载纹理并设置初始帧。
*/
private updateAnimator(entity: Entity, animator: SpriteAnimatorComponent, propertyName: string): void {
// In editor mode, only preload textures and show first frame (no animation playback)
// 编辑模式下只预加载纹理并显示第一帧(不播放动画)
const bridge = this.engineService.getBridge();
const sprite = entity.getComponent(SpriteComponent);
if (bridge && animator.clips) {
// Preload all frame textures
for (const clip of animator.clips) {
for (const frame of clip.frames) {
if (frame.texture) {
bridge.getOrLoadTextureByPath(frame.texture);
}
}
}
// Set sprite texture to first frame if available (static preview in editor)
// 设置精灵纹理为第一帧(编辑器中的静态预览)
if (sprite && animator.clips && animator.clips.length > 0) {
const firstClip = animator.clips[0];
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
const firstFrame = firstClip.frames[0];
if (firstFrame && firstFrame.texture) {
sprite.texture = firstFrame.texture;
}
}
}
}
}
/**
* Update transform in engine entity.
* 更新引擎实体的变换。
*/
private updateTransform(engineEntity: Entity, transform: TransformComponent): void {
// Get engine transform component (same type as editor)
const engineTransform = engineEntity.getComponent(TransformComponent);
if (engineTransform) {
engineTransform.position = {
x: transform.position?.x ?? 0,
y: transform.position?.y ?? 0,
z: transform.position?.z ?? 0
};
engineTransform.rotation = {
x: transform.rotation?.x ?? 0,
y: transform.rotation?.y ?? 0,
z: transform.rotation?.z ?? 0
};
engineTransform.scale = {
x: transform.scale?.x ?? 1,
y: transform.scale?.y ?? 1,
z: transform.scale?.z ?? 1
};
}
}
/**
* Update sprite in engine entity.
* 更新引擎实体的精灵。
*
* Note: Texture loading is now handled automatically by EngineRenderSystem.
* 注意纹理加载现在由EngineRenderSystem自动处理。
*/
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
// No manual texture loading needed - EngineRenderSystem handles it
// 不需要手动加载纹理 - EngineRenderSystem会处理
}
/**
* Clear all synced entities from tracking.
* 清除所有已同步实体的跟踪。
*/
private clearAllFromEngine(): void {
// Just clear tracking, entity destruction is handled elsewhere
this.syncedEntities.clear();
}
/**
* Check if initialized.
* 检查是否已初始化。
*/
isInitialized(): boolean {
return this.initialized;
}
/**
* Get synced entity count.
* 获取已同步实体数量。
*/
getSyncedCount(): number {
return this.syncedEntities.size;
}
/**
* Dispose sync service.
* 释放同步服务。
*/
dispose(): void {
// Unsubscribe from all events
for (const unsub of this.subscriptions) {
unsub();
}
this.subscriptions = [];
// Clear synced entities
this.syncedEntities.clear();
this.initialized = false;
}
}
export default EditorEngineSync;

View File

@@ -3,20 +3,14 @@
* 管理Rust引擎生命周期的服务。
*/
import { EngineBridge, SpriteComponent, EngineRenderSystem, ITransformComponent } from '@esengine/ecs-engine-bindgen';
import { Core, Scene, Entity, Component, ECSComponent } from '@esengine/ecs-framework';
import { EngineBridge, EngineRenderSystem, CameraConfig } from '@esengine/ecs-engine-bindgen';
import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { TransformComponent, SpriteComponent, SpriteAnimatorSystem, SpriteAnimatorComponent } from '@esengine/ecs-components';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import * as esEngine from '@esengine/engine';
/**
* Transform component for editor entities.
* 编辑器实体的变换组件。
*/
@ECSComponent('Transform')
export class TransformComponent extends Component implements ITransformComponent {
position = { x: 0, y: 0 };
rotation = 0;
scale = { x: 1, y: 1 };
}
import { AssetManager, EngineIntegration, AssetPathResolver, AssetPlatform } from '@esengine/asset-system';
import { convertFileSrc } from '@tauri-apps/api/core';
import { IdGenerator } from '../utils/idGenerator';
/**
* Engine service singleton for editor integration.
@@ -28,10 +22,17 @@ export class EngineService {
private bridge: EngineBridge | null = null;
private scene: Scene | null = null;
private renderSystem: EngineRenderSystem | null = null;
private animatorSystem: SpriteAnimatorSystem | null = null;
private initialized = false;
private running = false;
private animationFrameId: number | null = null;
private lastTime = 0;
private sceneSnapshot: string | null = null;
private assetManager: AssetManager | null = null;
private engineIntegration: EngineIntegration | null = null;
private assetPathResolver: AssetPathResolver | null = null;
private assetSystemInitialized = false;
private initializationError: Error | null = null;
private constructor() {}
@@ -64,29 +65,119 @@ export class EngineService {
// Initialize WASM with pre-imported module | 使用预导入模块初始化WASM
await this.bridge.initializeWithModule(esEngine);
// Set path resolver for Tauri asset URLs | 设置Tauri资产URL的路径解析器
this.bridge.setPathResolver((path: string) => {
// If already a URL, return as-is
if (path.startsWith('http://') ||
path.startsWith('https://') ||
path.startsWith('data:') ||
path.startsWith('asset://')) {
return path;
}
// Convert file path to Tauri asset URL
return convertFileSrc(path);
});
// Initialize Core if not already | 初始化Core如果尚未初始化
if (!Core.scene) {
Core.create({ debug: false });
}
// Create ECS scene and set it via Core | 通过Core创建并设置ECS场景
this.scene = new Scene({ name: 'EditorScene' });
// Use existing Core scene or create new one | 使用现有Core场景或创建新的
if (Core.scene) {
this.scene = Core.scene as Scene;
} else {
this.scene = new Scene({ name: 'EditorScene' });
Core.setScene(this.scene);
}
// Add render system | 添加渲染系统
// Add sprite animator system (disabled by default in editor mode)
// 添加精灵动画系统(编辑器模式下默认禁用)
this.animatorSystem = new SpriteAnimatorSystem();
this.animatorSystem.enabled = false;
this.scene!.addSystem(this.animatorSystem);
// Add render system to the scene | 将渲染系统添加到场景
this.renderSystem = new EngineRenderSystem(this.bridge, TransformComponent);
this.scene.addSystem(this.renderSystem);
this.scene!.addSystem(this.renderSystem);
// Set scene via Core | 通过Core设置场景
Core.setScene(this.scene);
// Initialize asset system | 初始化资产系统
await this.initializeAssetSystem();
// Start the default world to enable system updates
// 启动默认world以启用系统更新
const defaultWorld = Core.worldManager.getWorld('__default__');
if (defaultWorld && !defaultWorld.isActive) {
defaultWorld.start();
}
this.initialized = true;
console.log('EngineService initialized | 引擎服务初始化完成');
// Sync viewport size immediately after initialization
// 初始化后立即同步视口尺寸
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
if (canvas && canvas.parentElement) {
// Get container size in CSS pixels
// 获取容器尺寸CSS像素
const rect = canvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Canvas internal size uses DPR for sharpness
// Canvas内部尺寸使用DPR以保持清晰
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
// Camera uses actual canvas pixels for correct rendering
// 相机使用实际canvas像素以保证正确渲染
this.bridge.resize(canvas.width, canvas.height);
}
// Auto-start render loop for editor preview | 自动启动渲染循环用于编辑器预览
this.startRenderLoop();
} catch (error) {
console.error('Failed to initialize engine | 引擎初始化失败:', error);
throw error;
}
}
/**
* Start render loop (editor preview mode).
* 启动渲染循环(编辑器预览模式)。
*/
private startRenderLoop(): void {
if (this.animationFrameId !== null) {
return;
}
this.lastTime = performance.now();
this.renderLoop();
}
private frameCount = 0;
/**
* Render loop for editor preview (always runs).
* 编辑器预览的渲染循环(始终运行)。
*/
private renderLoop = (): void => {
const currentTime = performance.now();
const deltaTime = (currentTime - this.lastTime) / 1000;
this.lastTime = currentTime;
this.frameCount++;
// Update via Core (handles deltaTime internally) | 通过Core更新
Core.update(deltaTime);
// Note: Rendering is handled by EngineRenderSystem.process()
// Texture loading is handled automatically via Rust engine's path-based loading
// 注意:渲染由 EngineRenderSystem.process() 处理
// 纹理加载由Rust引擎的路径加载自动处理
this.animationFrameId = requestAnimationFrame(this.renderLoop);
};
/**
* Check if engine is initialized.
* 检查引擎是否已初始化。
@@ -114,19 +205,82 @@ export class EngineService {
this.running = true;
this.lastTime = performance.now();
// Enable animator system and start auto-play animations
// 启用动画系统并启动自动播放的动画
if (this.animatorSystem) {
this.animatorSystem.enabled = true;
}
this.startAutoPlayAnimations();
this.gameLoop();
}
/**
* Start all auto-play animations.
* 启动所有自动播放的动画。
*/
private startAutoPlayAnimations(): void {
if (!this.scene) return;
const entities = this.scene.entities.findEntitiesWithComponent(SpriteAnimatorComponent);
for (const entity of entities) {
const animator = entity.getComponent(SpriteAnimatorComponent);
if (animator && animator.autoPlay && animator.defaultAnimation) {
animator.play();
}
}
}
/**
* Stop all animations and reset to first frame.
* 停止所有动画并重置到第一帧。
*/
private stopAllAnimations(): void {
if (!this.scene) return;
const entities = this.scene.entities.findEntitiesWithComponent(SpriteAnimatorComponent);
for (const entity of entities) {
const animator = entity.getComponent(SpriteAnimatorComponent);
if (animator) {
animator.stop();
// Reset sprite texture to first frame
// 重置精灵纹理到第一帧
const sprite = entity.getComponent(SpriteComponent);
if (sprite && animator.clips && animator.clips.length > 0) {
const firstClip = animator.clips[0];
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
const firstFrame = firstClip.frames[0];
if (firstFrame && firstFrame.texture) {
sprite.texture = firstFrame.texture;
}
}
}
}
}
}
/**
* Stop the game loop.
* 停止游戏循环。
*/
stop(): void {
this.running = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
// Disable animator system and stop all animations
// 禁用动画系统并停止所有动画
if (this.animatorSystem) {
this.animatorSystem.enabled = false;
}
this.stopAllAnimations();
// Note: Don't cancel animationFrameId here, as renderLoop should keep running
// for editor preview. The renderLoop will continue but gameLoop will stop
// because this.running is false.
// 注意:这里不要取消 animationFrameId因为 renderLoop 应该继续运行
// 用于编辑器预览。renderLoop 会继续运行,但 gameLoop 会停止
// 因为 this.running 是 false。
}
/**
@@ -185,6 +339,53 @@ export class EngineService {
return entity;
}
/**
* Initialize asset system
* 初始化资产系统
*/
private async initializeAssetSystem(): Promise<void> {
try {
// 创建资产管理器 / Create asset manager
this.assetManager = new AssetManager();
// 创建路径解析器 / Create path resolver
this.assetPathResolver = new AssetPathResolver({
platform: AssetPlatform.Editor,
pathTransformer: (path: string) => {
// 编辑器平台使用Tauri的convertFileSrc
// Use Tauri's convertFileSrc for editor platform
if (!path.startsWith('http://') && !path.startsWith('https://') && !path.startsWith('data:')) {
return convertFileSrc(path);
}
return path;
}
});
// 创建引擎集成 / Create engine integration
if (this.bridge) {
this.engineIntegration = new EngineIntegration(this.assetManager, this.bridge);
}
this.assetSystemInitialized = true;
this.initializationError = null;
} catch (error) {
this.assetSystemInitialized = false;
this.initializationError = error instanceof Error ? error : new Error(String(error));
console.error('Failed to initialize asset system:', error);
// Notify user of failure
const messageHub = Core.services.tryResolve<MessageHub>(MessageHub);
if (messageHub) {
messageHub.publish('notification:error', {
title: 'Asset System Error',
message: 'Failed to initialize asset system. Some features may not work properly.'
});
}
throw this.initializationError;
}
}
/**
* Load texture.
* 加载纹理。
@@ -195,6 +396,70 @@ export class EngineService {
}
}
/**
* Load texture through asset system
* 通过资产系统加载纹理
*/
async loadTextureAsset(path: string): Promise<number> {
// Check if asset system is properly initialized
if (!this.assetSystemInitialized || this.initializationError) {
console.warn('Asset system not initialized, using fallback texture loading');
const textureId = IdGenerator.nextId('texture-fallback');
this.loadTexture(textureId, path);
return textureId;
}
if (!this.engineIntegration) {
// 回退到直接加载 / Fallback to direct loading
const textureId = IdGenerator.nextId('texture');
this.loadTexture(textureId, path);
return textureId;
}
try {
return await this.engineIntegration.loadTextureForComponent(path);
} catch (error) {
console.error('Failed to load texture asset:', error);
// Return a valid fallback ID instead of 0
const fallbackId = IdGenerator.nextId('texture-fallback');
// Notify about texture loading failure
const messageHub = Core.services.tryResolve<MessageHub>(MessageHub);
if (messageHub) {
messageHub.publish('notification:warning', {
title: 'Texture Loading Failed',
message: `Could not load texture: ${path}`
});
}
return fallbackId;
}
}
/**
* Get asset manager
* 获取资产管理器
*/
getAssetManager(): AssetManager | null {
return this.assetManager;
}
/**
* Get engine integration
* 获取引擎集成
*/
getEngineIntegration(): EngineIntegration | null {
return this.engineIntegration;
}
/**
* Get asset path resolver
* 获取资产路径解析器
*/
getAssetPathResolver(): AssetPathResolver | null {
return this.assetPathResolver;
}
/**
* Get engine statistics.
* 获取引擎统计信息。
@@ -220,6 +485,45 @@ export class EngineService {
return this.scene;
}
/**
* Enable animation preview in editor mode.
* 在编辑器模式下启用动画预览。
*/
enableAnimationPreview(): void {
if (this.animatorSystem && !this.running) {
// Clear entity cache to force re-query when enabled
// 清除实体缓存以便启用时强制重新查询
this.animatorSystem.clearEntityCache();
this.animatorSystem.enabled = true;
}
}
/**
* Disable animation preview in editor mode.
* 在编辑器模式下禁用动画预览。
*/
disableAnimationPreview(): void {
if (this.animatorSystem && !this.running) {
this.animatorSystem.enabled = false;
}
}
/**
* Check if animation preview is enabled.
* 检查动画预览是否启用。
*/
isAnimationPreviewEnabled(): boolean {
return this.animatorSystem?.enabled ?? false;
}
/**
* Get the engine bridge.
* 获取引擎桥接。
*/
getBridge(): EngineBridge | null {
return this.bridge;
}
/**
* Resize the engine viewport.
* 调整引擎视口大小。
@@ -230,6 +534,278 @@ export class EngineService {
}
}
/**
* Set camera position, zoom, and rotation.
* 设置相机位置、缩放和旋转。
*/
setCamera(config: CameraConfig): void {
if (this.bridge) {
this.bridge.setCamera(config);
}
}
/**
* Get camera state.
* 获取相机状态。
*/
getCamera(): CameraConfig {
if (this.bridge) {
return this.bridge.getCamera();
}
return { x: 0, y: 0, zoom: 1, rotation: 0 };
}
/**
* Set grid visibility.
* 设置网格可见性。
*/
setShowGrid(show: boolean): void {
if (this.bridge) {
this.bridge.setShowGrid(show);
}
}
/**
* Set clear color (background color).
* 设置清除颜色(背景颜色)。
*/
setClearColor(r: number, g: number, b: number, a: number = 1.0): void {
if (this.bridge) {
this.bridge.setClearColor(r, g, b, a);
}
}
/**
* Set gizmo visibility.
* 设置Gizmo可见性。
*/
setShowGizmos(show: boolean): void {
if (this.renderSystem) {
this.renderSystem.setShowGizmos(show);
}
}
/**
* Get gizmo visibility.
* 获取Gizmo可见性。
*/
getShowGizmos(): boolean {
return this.renderSystem?.getShowGizmos() ?? true;
}
// ===== Scene Snapshot API =====
// ===== 场景快照 API =====
/**
* Save a snapshot of the current scene state.
* 保存当前场景状态的快照。
*/
saveSceneSnapshot(): boolean {
if (!this.scene) {
console.warn('Cannot save snapshot: no scene available');
return false;
}
try {
// Use SceneSerializer from core library
this.sceneSnapshot = SceneSerializer.serialize(this.scene, {
format: 'json',
pretty: false,
includeMetadata: false
}) as string;
return true;
} catch (error) {
console.error('Failed to save scene snapshot:', error);
return false;
}
}
/**
* Restore scene state from saved snapshot.
* 从保存的快照恢复场景状态。
*/
restoreSceneSnapshot(): boolean {
if (!this.scene || !this.sceneSnapshot) {
console.warn('Cannot restore snapshot: no scene or snapshot available');
return false;
}
try {
// Use SceneSerializer from core library
SceneSerializer.deserialize(this.scene, this.sceneSnapshot, {
strategy: 'replace',
preserveIds: true
});
// Sync EntityStore with restored scene entities
const entityStore = Core.services.tryResolve(EntityStoreService);
const messageHub = Core.services.tryResolve(MessageHub);
if (entityStore && messageHub) {
// Remember selected entity ID before clearing
const selectedEntity = entityStore.getSelectedEntity();
const selectedId = selectedEntity?.id;
// Clear old entities from store
entityStore.clear();
// Add restored entities to store
for (const entity of this.scene.entities.buffer) {
entityStore.addEntity(entity);
}
// Re-select the same entity (now with new reference)
if (selectedId !== undefined) {
const newEntity = entityStore.getEntity(selectedId);
if (newEntity) {
entityStore.selectEntity(newEntity);
}
}
// Notify UI to refresh
messageHub.publish('scene:restored', {});
}
this.sceneSnapshot = null;
return true;
} catch (error) {
console.error('Failed to restore scene snapshot:', error);
return false;
}
}
/**
* Check if a snapshot exists.
* 检查是否存在快照。
*/
hasSnapshot(): boolean {
return this.sceneSnapshot !== null;
}
/**
* Set selected entity IDs for gizmo display.
* 设置选中的实体ID用于Gizmo显示。
*/
setSelectedEntityIds(ids: number[]): void {
if (this.renderSystem) {
this.renderSystem.setSelectedEntityIds(ids);
}
}
/**
* Set transform tool mode.
* 设置变换工具模式。
*/
setTransformMode(mode: 'select' | 'move' | 'rotate' | 'scale'): void {
if (this.renderSystem) {
this.renderSystem.setTransformMode(mode);
}
}
/**
* Get transform tool mode.
* 获取变换工具模式。
*/
getTransformMode(): 'select' | 'move' | 'rotate' | 'scale' {
return this.renderSystem?.getTransformMode() ?? 'select';
}
// ===== Multi-viewport API =====
// ===== 多视口 API =====
/**
* Register a new viewport.
* 注册新视口。
*/
registerViewport(id: string, canvasId: string): void {
if (this.bridge) {
this.bridge.registerViewport(id, canvasId);
}
}
/**
* Unregister a viewport.
* 注销视口。
*/
unregisterViewport(id: string): void {
if (this.bridge) {
this.bridge.unregisterViewport(id);
}
}
/**
* Set the active viewport.
* 设置活动视口。
*/
setActiveViewport(id: string): boolean {
if (this.bridge) {
return this.bridge.setActiveViewport(id);
}
return false;
}
/**
* Set camera for a specific viewport.
* 为特定视口设置相机。
*/
setViewportCamera(viewportId: string, config: CameraConfig): void {
if (this.bridge) {
this.bridge.setViewportCamera(viewportId, config);
}
}
/**
* Get camera for a specific viewport.
* 获取特定视口的相机。
*/
getViewportCamera(viewportId: string): CameraConfig | null {
if (this.bridge) {
return this.bridge.getViewportCamera(viewportId);
}
return null;
}
/**
* Set viewport configuration.
* 设置视口配置。
*/
setViewportConfig(viewportId: string, showGrid: boolean, showGizmos: boolean): void {
if (this.bridge) {
this.bridge.setViewportConfig(viewportId, showGrid, showGizmos);
}
}
/**
* Resize a specific viewport.
* 调整特定视口大小。
*/
resizeViewport(viewportId: string, width: number, height: number): void {
if (this.bridge) {
this.bridge.resizeViewport(viewportId, width, height);
}
}
/**
* Render to a specific viewport.
* 渲染到特定视口。
*/
renderToViewport(viewportId: string): void {
if (this.bridge) {
this.bridge.renderToViewport(viewportId);
}
}
/**
* Get all registered viewport IDs.
* 获取所有已注册的视口ID。
*/
getViewportIds(): string[] {
if (this.bridge) {
return this.bridge.getViewportIds();
}
return [];
}
/**
* Dispose engine resources.
* 释放引擎资源。
@@ -237,6 +813,19 @@ export class EngineService {
dispose(): void {
this.stop();
// Stop render loop | 停止渲染循环
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Dispose asset system | 释放资产系统
if (this.assetManager) {
this.assetManager.dispose();
this.assetManager = null;
}
this.engineIntegration = null;
// Scene doesn't have a destroy method, just clear reference
// 场景没有destroy方法只需清除引用
this.scene = null;

View File

@@ -667,7 +667,7 @@ export class GitHubService {
}
// 转换为最终结果,并对版本排序
const plugins: PublishedPlugin[] = Array.from(pluginVersionsMap.values()).map(plugin => {
const plugins: PublishedPlugin[] = Array.from(pluginVersionsMap.values()).map((plugin) => {
// 按版本号降序排序(最新版本在前)
const sortedVersions = plugin.versions.sort((a, b) => {
const parseVersion = (v: string) => {
@@ -714,9 +714,9 @@ export class GitHubService {
detailsUrl: run.html_url,
output: run.output
? {
title: run.output.title || '',
summary: run.output.summary || ''
}
title: run.output.title || '',
summary: run.output.summary || ''
}
: undefined
}));
} catch (error) {
@@ -782,8 +782,8 @@ export class GitHubService {
const files = await this.request<any[]>(`GET /repos/${owner}/${repo}/pulls/${prNumber}/files`);
const conflictFiles = files
.filter(file => file.status === 'modified' || file.status === 'added' || file.status === 'deleted')
.map(file => file.filename);
.filter((file) => file.status === 'modified' || file.status === 'added' || file.status === 'deleted')
.map((file) => file.filename);
return {
hasConflicts: true,
@@ -932,7 +932,7 @@ export class GitHubService {
this.scheduleRetryLoadUser();
}
});
}
}
} catch (error) {
console.error('[GitHubService] Failed to load token:', error);
this.notifyUserLoadStateChange(false);

View File

@@ -22,6 +22,22 @@ export class NotificationService implements INotification {
}
}
warning(title: string, message: string, duration: number = 5000): void {
this.show(`${title}: ${message}`, 'warning', duration);
}
error(title: string, message: string, duration: number = 5000): void {
this.show(`${title}: ${message}`, 'error', duration);
}
info(title: string, message: string, duration: number = 3000): void {
this.show(`${title}: ${message}`, 'info', duration);
}
success(title: string, message: string, duration: number = 3000): void {
this.show(`${title}: ${message}`, 'success', duration);
}
hide(id: string): void {
if (this.hideCallback) {
this.hideCallback(id);
@@ -29,4 +45,9 @@ export class NotificationService implements INotification {
console.warn('[NotificationService] hideCallback not set');
}
}
dispose(): void {
this.showCallback = undefined;
this.hideCallback = undefined;
}
}

View File

@@ -155,7 +155,7 @@ export class PluginMarketService {
try {
// 获取指定版本信息
const versionInfo = plugin.versions.find(v => v.version === targetVersion);
const versionInfo = plugin.versions.find((v) => v.version === targetVersion);
if (!versionInfo) {
throw new Error(`Version ${targetVersion} not found for plugin ${plugin.name}`);
}
@@ -279,7 +279,7 @@ export class PluginMarketService {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const plugins: InstalledPluginInfo[] = JSON.parse(stored);
this.installedPlugins = new Map(plugins.map(p => [p.id, p]));
this.installedPlugins = new Map(plugins.map((p) => [p.id, p]));
}
} catch (error) {
console.error('[PluginMarketService] Failed to load installed plugins:', error);

View File

@@ -221,7 +221,7 @@ export class PluginPublishService {
manifest: Record<string, unknown>,
publishInfo: PluginPublishInfo
): Promise<void> {
this.notifyProgress('uploading-files', `Checking for existing manifest...`, 65);
this.notifyProgress('uploading-files', 'Checking for existing manifest...', 65);
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
const existingManifest = await this.getExistingManifest(pluginId, publishInfo.category);
@@ -229,7 +229,7 @@ export class PluginPublishService {
let finalManifest = manifest;
if (existingManifest) {
this.notifyProgress('uploading-files', `Merging with existing manifest...`, 68);
this.notifyProgress('uploading-files', 'Merging with existing manifest...', 68);
finalManifest = this.mergeManifestVersions(existingManifest, manifest, publishInfo.version);
}

View File

@@ -86,7 +86,7 @@ export class PluginSourceParser {
const packageJson = JSON.parse(packageJsonContent) as PluginPackageJson;
// 验证 ZIP 中必须包含 dist 目录
const distFiles = Object.keys(zip.files).filter(f => f.startsWith('dist/'));
const distFiles = Object.keys(zip.files).filter((f) => f.startsWith('dist/'));
if (distFiles.length === 0) {
throw new Error('dist/ directory not found in ZIP file. Please ensure the plugin is properly built.');
}

View File

@@ -0,0 +1,219 @@
/**
* Runtime Module Resolver
* 运行时模块解析器
*
* Resolves runtime module paths based on environment and configuration
* 根据环境和配置解析运行时模块路径
*/
import { TauriAPI } from '../api/tauri';
// Sanitize path by removing path traversal sequences and normalizing
const sanitizePath = (path: string): string => {
// Split by path separators, filter out '..' and empty segments, rejoin
const segments = path.split(/[/\\]/).filter((segment) =>
segment !== '..' && segment !== '.' && segment !== ''
);
return segments.join('/');
};
// Check if we're in development mode
const isDevelopment = (): boolean => {
try {
// Vite environment variable
return (import.meta as any).env?.DEV === true;
} catch {
return false;
}
};
export interface RuntimeModule {
type: 'javascript' | 'wasm' | 'binary';
files: string[];
sourcePath: string;
}
export interface RuntimeConfig {
runtime: {
version: string;
modules: Record<string, any>;
};
}
export class RuntimeResolver {
private static instance: RuntimeResolver;
private config: RuntimeConfig | null = null;
private baseDir: string = '';
private constructor() {}
static getInstance(): RuntimeResolver {
if (!RuntimeResolver.instance) {
RuntimeResolver.instance = new RuntimeResolver();
}
return RuntimeResolver.instance;
}
/**
* Initialize the runtime resolver
* 初始化运行时解析器
*/
async initialize(): Promise<void> {
// Load runtime configuration
const response = await fetch('/runtime.config.json');
this.config = await response.json();
// Determine base directory based on environment
if (isDevelopment()) {
// In development, use the project root
// We need to go up from src-tauri to get the actual project root
const currentDir = await TauriAPI.getCurrentDir();
// currentDir might be src-tauri, so we need to find the actual workspace root
this.baseDir = await this.findWorkspaceRoot(currentDir);
} else {
// In production, use the resource directory
this.baseDir = await TauriAPI.getAppResourceDir();
}
}
/**
* Find workspace root by looking for package.json or specific markers
* 通过查找 package.json 或特定标记来找到工作区根目录
*/
private async findWorkspaceRoot(startPath: string): Promise<string> {
let currentPath = startPath;
// Try to find the workspace root by looking for key files
// We'll check up to 3 levels up from current directory
for (let i = 0; i < 3; i++) {
// Check if we're in src-tauri
if (currentPath.endsWith('src-tauri')) {
// Go up two levels to get to workspace root
const parts = currentPath.split(/[/\\]/);
parts.pop(); // Remove src-tauri
parts.pop(); // Remove editor-app
parts.pop(); // Remove packages
return parts.join('\\');
}
// Check for workspace markers
const workspaceMarkers = [
`${currentPath}\\pnpm-workspace.yaml`,
`${currentPath}\\packages\\editor-app`,
`${currentPath}\\packages\\platform-web`
];
for (const marker of workspaceMarkers) {
if (await TauriAPI.pathExists(marker)) {
return currentPath;
}
}
// Go up one level
const parts = currentPath.split(/[/\\]/);
parts.pop();
currentPath = parts.join('\\');
}
// Fallback to current directory
return startPath;
}
/**
* Get runtime module files
* 获取运行时模块文件
*/
async getModuleFiles(moduleName: string): Promise<RuntimeModule> {
if (!this.config) {
await this.initialize();
}
const moduleConfig = this.config!.runtime.modules[moduleName];
if (!moduleConfig) {
throw new Error(`Runtime module ${moduleName} not found in configuration`);
}
const isDev = isDevelopment();
const files: string[] = [];
let sourcePath: string;
if (isDev) {
// Development mode - use relative paths from workspace root
const devPath = moduleConfig.development.path;
sourcePath = `${this.baseDir}\\packages\\${sanitizePath(devPath)}`;
if (moduleConfig.main) {
files.push(`${sourcePath}\\${moduleConfig.main}`);
}
if (moduleConfig.files) {
for (const file of moduleConfig.files) {
files.push(`${sourcePath}\\${file}`);
}
}
} else {
// Production mode - files are bundled with the app
sourcePath = this.baseDir;
if (moduleConfig.main) {
files.push(`${sourcePath}\\${moduleConfig.main}`);
}
if (moduleConfig.files) {
for (const file of moduleConfig.files) {
files.push(`${sourcePath}\\${file}`);
}
}
}
return {
type: moduleConfig.type,
files,
sourcePath
};
}
/**
* Prepare runtime files for browser preview
* 为浏览器预览准备运行时文件
*/
async prepareRuntimeFiles(targetDir: string): Promise<void> {
// Ensure target directory exists
const dirExists = await TauriAPI.pathExists(targetDir);
if (!dirExists) {
await TauriAPI.createDirectory(targetDir);
}
// Copy platform-web runtime
const platformWeb = await this.getModuleFiles('platform-web');
for (const srcFile of platformWeb.files) {
const filename = srcFile.split(/[/\\]/).pop() || '';
const dstFile = `${targetDir}\\${filename}`;
if (await TauriAPI.pathExists(srcFile)) {
await TauriAPI.copyFile(srcFile, dstFile);
} else {
throw new Error(`Runtime file not found: ${srcFile}`);
}
}
// Copy engine WASM files
const engine = await this.getModuleFiles('engine');
for (const srcFile of engine.files) {
const filename = srcFile.split(/[/\\]/).pop() || '';
const dstFile = `${targetDir}\\${filename}`;
if (await TauriAPI.pathExists(srcFile)) {
await TauriAPI.copyFile(srcFile, dstFile);
} else {
throw new Error(`Engine file not found: ${srcFile}`);
}
}
}
/**
* Get workspace root directory
* 获取工作区根目录
*/
getBaseDir(): string {
return this.baseDir;
}
}

View File

@@ -30,11 +30,11 @@ export class TauriFileSystemService implements IFileSystem {
}
async listDirectory(path: string): Promise<FileEntry[]> {
const entries = await invoke<Array<{ name: string; isDir: boolean }>>('list_directory', { path });
return entries.map(entry => ({
const entries = await invoke<Array<{ name: string; path: string; is_dir: boolean }>>('list_directory', { path });
return entries.map((entry) => ({
name: entry.name,
isDirectory: entry.isDir,
path: `${path}/${entry.name}`
isDirectory: entry.is_dir,
path: entry.path
}));
}

View File

@@ -5,10 +5,10 @@ import { hasChildren, isTabNode, isTabsetNode } from './FlexLayoutTypes';
export class LayoutMerger {
static merge(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel {
const currentPanelIds = new Set(currentPanels.map(p => p.id));
const currentPanelIds = new Set(currentPanels.map((p) => p.id));
const savedPanelIds = this.collectPanelIds(savedLayout);
const newPanelIds = Array.from(currentPanelIds).filter(id => !savedPanelIds.has(id));
const removedPanelIds = Array.from(savedPanelIds).filter(id => !currentPanelIds.has(id));
const newPanelIds = Array.from(currentPanelIds).filter((id) => !savedPanelIds.has(id));
const removedPanelIds = Array.from(savedPanelIds).filter((id) => !currentPanelIds.has(id));
const mergedLayout = JSON.parse(JSON.stringify(savedLayout));

View File

@@ -34,30 +34,28 @@
.inspector-content {
flex: 1;
overflow: auto;
overflow-y: scroll;
overflow-x: hidden;
padding: var(--spacing-md);
min-height: 0;
}
.inspector-content::-webkit-scrollbar {
width: 14px;
height: 14px;
width: 8px;
height: 8px;
}
.inspector-content::-webkit-scrollbar-track {
background: transparent;
background: var(--color-bg-base);
}
.inspector-content::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.4);
border-radius: 8px;
border: 3px solid transparent;
background-clip: padding-box;
border-radius: 4px;
}
.inspector-content::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
background-clip: padding-box;
}
.inspector-content::-webkit-scrollbar-corner {
@@ -216,6 +214,8 @@
}
.component-icon {
display: flex;
align-items: center;
color: var(--color-text-secondary);
flex-shrink: 0;
transition: color var(--transition-fast);
@@ -465,3 +465,249 @@
transform: rotate(360deg);
}
}
/* 添加组件菜单样式 */
.section-title-with-action {
display: flex;
justify-content: space-between;
align-items: center;
}
.component-menu-container {
position: relative;
}
.add-component-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--color-primary);
border: none;
border-radius: 4px;
color: var(--color-text-inverse);
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: all 0.15s ease;
}
.add-component-trigger:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.add-component-trigger:active {
transform: translateY(0);
}
.component-dropdown-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.component-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 220px;
max-height: 320px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-strong);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
animation: dropdownSlide 0.15s ease;
}
@keyframes dropdownSlide {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.component-dropdown-header {
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border-default);
background: var(--color-bg-base);
}
.component-dropdown-empty {
padding: 16px 12px;
text-align: center;
color: var(--color-text-tertiary);
font-size: 12px;
}
.component-dropdown-list {
max-height: 260px;
overflow-y: auto;
padding: 4px 0;
}
.component-dropdown-list::-webkit-scrollbar {
width: 6px;
}
.component-dropdown-list::-webkit-scrollbar-track {
background: transparent;
}
.component-dropdown-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.component-dropdown-list::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.component-category-group {
margin-bottom: 4px;
}
.component-category-group:last-child {
margin-bottom: 0;
}
.component-category-label {
padding: 6px 12px 4px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.component-dropdown-item {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--color-text-primary);
cursor: pointer;
text-align: left;
transition: all 0.1s ease;
}
.component-dropdown-item:hover {
background: var(--color-bg-hover);
}
.component-dropdown-item:active {
background: var(--color-primary);
}
.component-dropdown-item-name {
font-size: 12px;
font-weight: 500;
}
.component-dropdown-item-desc {
font-size: 10px;
color: var(--color-text-tertiary);
margin-top: 2px;
}
/* 组件列表项样式 */
.component-item-card {
margin-bottom: 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.component-item-card:hover {
border-color: var(--color-border-strong);
}
.component-item-card.expanded {
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
}
.component-item-header {
display: flex;
align-items: center;
padding: 8px 10px;
background: var(--color-bg-base);
cursor: pointer;
user-select: none;
transition: background 0.1s ease;
}
.component-item-header:hover {
background: var(--color-bg-hover);
}
.component-expand-icon {
display: flex;
align-items: center;
color: var(--color-text-tertiary);
transition: color 0.1s ease;
}
.component-item-card:hover .component-expand-icon,
.component-item-card.expanded .component-expand-icon {
color: var(--color-primary);
}
.component-item-name {
flex: 1;
margin-left: 6px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-primary);
}
.component-item-card .component-remove-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0;
transition: all 0.1s ease;
}
.component-item-header:hover .component-remove-btn {
opacity: 1;
}
.component-item-card .component-remove-btn:hover {
background: var(--color-error);
color: var(--color-text-inverse);
}
.component-item-content {
padding: 8px 10px;
border-top: 1px solid var(--color-border-default);
background: var(--color-bg-base);
overflow: hidden;
min-width: 0;
}

View File

@@ -0,0 +1,234 @@
.game-view {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.game-view-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-default);
flex-shrink: 0;
gap: 8px;
z-index: 10;
}
.game-view-toolbar-left {
display: flex;
align-items: center;
gap: 4px;
}
.game-view-toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.game-view-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 32px;
height: 32px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0 6px;
}
.game-view-btn:hover:not(:disabled) {
background: var(--color-bg-hover);
color: var(--color-text-primary);
border-color: var(--color-border-hover);
}
.game-view-btn.active {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.game-view-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.game-view-btn:active:not(:disabled) {
transform: scale(0.95);
}
.game-view-divider {
width: 1px;
height: 24px;
background: var(--color-border-default);
margin: 0 4px;
}
.game-view-dropdown {
position: relative;
}
.game-view-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 160px;
padding: 4px;
animation: dropdownFadeIn 0.15s ease-out;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.game-view-dropdown-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: var(--radius-xs);
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.game-view-dropdown-menu button:hover {
background: var(--color-bg-hover);
}
.game-view-canvas {
flex: 1;
width: 100%;
height: 100%;
display: block;
background: #000;
user-select: none;
}
.game-view-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
pointer-events: none;
}
.game-view-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--color-text-secondary);
}
.game-view-overlay-content svg {
opacity: 0.5;
}
.game-view-overlay-content span {
font-size: 14px;
}
.game-view-stats {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 4px;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-family: var(--font-family-mono);
font-size: 11px;
pointer-events: none;
z-index: 5;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.game-view-stat {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.game-view-stat-label {
color: var(--color-text-secondary);
font-weight: 500;
}
.game-view-stat-value {
color: var(--color-primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.game-view:fullscreen {
background: #000;
}
.game-view:fullscreen .game-view-toolbar {
display: none;
}
.game-view:fullscreen .game-view-overlay {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.game-view-btn {
transition: none;
}
.game-view-dropdown-menu {
animation: none;
}
}

View File

@@ -115,6 +115,7 @@
border-bottom: 1px solid var(--color-border-default);
flex-shrink: 0;
gap: 12px;
min-height: 48px;
}
.profiler-toolbar-left {
@@ -122,13 +123,15 @@
align-items: center;
gap: 12px;
flex: 1;
flex-wrap: wrap;
min-width: 0;
overflow-x: auto;
}
.profiler-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.profiler-mode-switch {
@@ -138,9 +141,10 @@
background: var(--color-bg-inset);
padding: 2px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.mode-btn {
.profiler-window .profiler-mode-switch .mode-btn {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -153,14 +157,19 @@
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
width: auto;
min-width: auto;
height: auto;
justify-content: flex-start;
}
.mode-btn:hover {
.profiler-window .profiler-mode-switch .mode-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.mode-btn.active {
.profiler-window .profiler-mode-switch .mode-btn.active {
background: var(--color-primary);
color: white;
}
@@ -257,7 +266,10 @@
.profiler-stats-summary {
display: flex;
align-items: center;
gap: 20px;
gap: 16px;
flex-shrink: 0;
padding-left: 12px;
border-left: 1px solid var(--color-border-default);
}
.summary-item {
@@ -266,6 +278,7 @@
gap: 6px;
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
}
.summary-item svg {

View File

@@ -16,6 +16,7 @@
border-bottom: 1px solid var(--color-border-subtle);
gap: 8px;
transition: background-color var(--transition-fast);
overflow: hidden;
}
.property-field:hover {
@@ -37,6 +38,21 @@
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 4px;
}
.property-controlled-icon {
color: var(--color-text-muted);
opacity: 0.6;
display: flex;
align-items: center;
}
.property-asset-drop.controlled {
opacity: 0.6;
cursor: not-allowed;
}
.property-label-draggable {
@@ -98,39 +114,98 @@
text-align: right;
}
.property-input-select {
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23a0a0a0' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
padding-right: 24px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
/* Custom Dropdown */
.property-dropdown {
flex: 1;
position: relative;
min-width: 0;
}
.property-input-select:hover {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
}
.property-input-select:focus {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23007acc' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
}
.property-input-select option {
background-color: var(--color-bg-elevated);
.property-dropdown-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-bg-inset);
border: 1px solid var(--color-border-default);
border-radius: 3px;
padding: 3px 6px;
color: var(--color-text-primary);
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-family-mono);
cursor: pointer;
transition: all var(--transition-fast);
}
.property-input-number::-webkit-inner-spin-button {
opacity: 0;
width: 0;
.property-dropdown-trigger:hover {
border-color: var(--color-border-hover);
background: var(--color-bg-base);
}
.property-input-number:hover::-webkit-inner-spin-button {
opacity: 0.5;
width: auto;
.property-dropdown-trigger.open {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.property-dropdown-trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.property-dropdown-value {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.property-dropdown-arrow {
margin-left: 4px;
font-size: 10px;
color: var(--color-text-tertiary);
}
.property-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
}
.property-dropdown-item {
width: 100%;
display: block;
padding: 6px 8px;
background: none;
border: none;
color: var(--color-text-primary);
font-size: 11px;
text-align: left;
cursor: pointer;
}
.property-dropdown-item:hover {
background: var(--color-bg-hover);
}
.property-dropdown-item.selected {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.property-input-number::-webkit-inner-spin-button,
.property-input-number::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.property-field-boolean {
@@ -240,6 +315,208 @@
text-align: center;
}
/* Custom Color Picker */
.color-picker-popup {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
width: 200px;
padding: 8px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
.color-picker-saturation {
position: relative;
width: 100%;
height: 120px;
border-radius: 4px;
cursor: crosshair;
overflow: hidden;
}
.color-picker-saturation-white {
position: absolute;
inset: 0;
background: linear-gradient(to right, #fff, transparent);
}
.color-picker-saturation-black {
position: absolute;
inset: 0;
background: linear-gradient(to top, #000, transparent);
}
.color-picker-cursor {
position: absolute;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(0, 0, 0, 0.3);
transform: translate(-50%, -50%);
pointer-events: none;
}
.color-picker-hue {
position: relative;
width: 100%;
height: 12px;
margin-top: 8px;
border-radius: 6px;
background: linear-gradient(to right,
#ff0000 0%,
#ffff00 17%,
#00ff00 33%,
#00ffff 50%,
#0000ff 67%,
#ff00ff 83%,
#ff0000 100%
);
cursor: pointer;
}
.color-picker-hue-cursor {
position: absolute;
top: 50%;
width: 6px;
height: 14px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 2px;
transform: translate(-50%, -50%);
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.color-picker-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border-subtle);
}
.color-picker-preview-box {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid var(--color-border-default);
flex-shrink: 0;
}
.color-picker-hex {
font-family: var(--font-family-mono);
font-size: 11px;
color: var(--color-text-secondary);
letter-spacing: 0.05em;
}
.property-asset-drop {
flex: 1;
display: flex;
align-items: center;
height: 22px;
padding: 0 8px;
background: var(--color-bg-input);
border: 1px solid var(--color-border-default);
border-radius: 3px;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 0;
}
.property-asset-drop:hover {
border-color: var(--color-border-hover);
}
.property-asset-drop.dragging {
border-color: var(--color-accent);
background: rgba(99, 102, 241, 0.1);
}
.property-asset-text {
flex: 1;
font-size: 11px;
color: var(--color-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
}
.property-asset-drop.has-value .property-asset-text {
color: var(--color-text-primary);
font-style: normal;
}
.property-asset-clear {
width: 16px;
height: 16px;
margin-left: 4px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
border-radius: 2px;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.property-asset-drop:hover .property-asset-clear {
opacity: 1;
}
.property-asset-clear:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary);
}
.property-asset-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: 4px;
}
.property-asset-btn {
width: 16px;
height: 16px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
border-radius: 2px;
opacity: 0;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.property-asset-drop:hover .property-asset-btn {
opacity: 1;
}
.property-asset-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--color-accent);
}
.property-label-row {
flex: 0 0 40%;
display: flex;
@@ -285,9 +562,8 @@
}
.property-input-number-compact {
flex: 1;
min-width: 24px;
max-width: 40px;
flex: 1 1 0;
min-width: 0;
text-align: right;
padding: 1px 4px;
font-size: 11px;
@@ -301,12 +577,20 @@
flex-direction: column;
gap: 2px;
padding: 4px 0;
min-width: 0;
overflow: hidden;
}
.property-vector-axis {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.property-vector-axis .property-input {
flex: 1;
min-width: 0;
}
.property-vector-axis-label {
@@ -393,3 +677,416 @@ input[type="number"].property-input::-webkit-inner-spin-button {
transition: none;
}
}
/* Property Action Buttons */
.property-actions {
display: flex;
gap: 4px;
margin-left: 4px;
flex-shrink: 0;
}
.property-action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 3px 6px;
background: var(--color-bg-base);
border: 1px solid var(--color-border-default);
border-radius: 3px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 10px;
transition: all var(--transition-fast);
}
.property-action-btn:hover {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-inverse);
}
.property-action-btn:active {
transform: scale(0.95);
}
/* Animation Clips Editor */
.animation-clips-editor {
padding: 8px;
background: var(--color-bg-elevated);
border-radius: 4px;
}
.clips-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.clips-label {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
}
.add-clip-btn {
padding: 4px;
background: var(--color-bg-default);
border: 1px solid var(--color-border-default);
border-radius: 3px;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
}
.add-clip-btn:hover {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-inverse);
}
.clips-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
color: var(--color-text-muted);
font-size: 11px;
}
.clips-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.clip-item {
background: var(--color-bg-default);
border: 1px solid var(--color-border-subtle);
border-radius: 4px;
overflow: hidden;
}
.clip-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
cursor: pointer;
font-size: 11px;
color: var(--color-text-primary);
min-width: 0;
}
.clip-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.clip-header > svg {
flex-shrink: 0;
}
.clip-name-input {
flex: 1;
min-width: 40px;
background: transparent;
border: none;
color: var(--color-text-primary);
font-size: 11px;
padding: 2px 4px;
border-radius: 2px;
}
.clip-name-input:focus {
outline: none;
background: var(--color-bg-elevated);
}
.frame-count {
flex-shrink: 0;
font-size: 10px;
color: var(--color-text-muted);
white-space: nowrap;
}
.preview-clip-btn {
flex-shrink: 0;
padding: 2px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
border-radius: 2px;
transition: all 0.2s ease;
}
.preview-clip-btn:hover {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.preview-clip-btn.is-playing {
color: #22c55e;
background: rgba(34, 197, 94, 0.15);
}
.preview-clip-btn.is-playing:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.set-default-btn {
flex-shrink: 0;
padding: 2px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
border-radius: 2px;
transition: all 0.2s ease;
}
.set-default-btn:hover {
background: rgba(250, 204, 21, 0.2);
color: #facc15;
}
.set-default-btn.is-default {
color: #facc15;
background: rgba(250, 204, 21, 0.15);
}
.set-default-btn.is-default:hover {
background: rgba(250, 204, 21, 0.25);
}
.clip-draggable-number {
display: flex;
align-items: center;
gap: 4px;
}
.clip-draggable-label {
color: var(--color-text-secondary);
user-select: none;
}
.clip-draggable-number input {
width: 50px;
padding: 2px 4px;
background: var(--color-bg-input);
border: 1px solid var(--color-border-default);
border-radius: 3px;
color: var(--color-text-primary);
font-size: 11px;
-moz-appearance: textfield;
appearance: textfield;
}
.clip-draggable-number input::-webkit-outer-spin-button,
.clip-draggable-number input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.clip-draggable-number input:focus {
outline: none;
border-color: var(--color-primary);
}
.remove-clip-btn {
flex-shrink: 0;
padding: 2px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
border-radius: 2px;
}
.remove-clip-btn:hover {
background: var(--color-danger);
color: var(--color-text-inverse);
}
.clip-content {
padding: 8px;
border-top: 1px solid var(--color-border-subtle);
background: rgba(0, 0, 0, 0.1);
}
.clip-settings {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 10px;
color: var(--color-text-secondary);
}
.clip-settings label {
display: flex;
align-items: center;
gap: 4px;
}
.clip-settings input[type="checkbox"] {
width: 12px;
height: 12px;
}
.clip-settings input[type="number"] {
width: 50px;
padding: 2px 4px;
background: var(--color-bg-default);
border: 1px solid var(--color-border-default);
border-radius: 2px;
color: var(--color-text-primary);
font-size: 10px;
}
.frames-section {
margin-top: 8px;
}
.frames-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 10px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.frames-header button {
padding: 2px 4px;
background: var(--color-bg-default);
border: 1px solid var(--color-border-default);
border-radius: 2px;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
}
.frames-header button:hover {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-inverse);
}
.frames-empty {
font-size: 10px;
color: var(--color-text-muted);
padding: 8px;
text-align: center;
}
.frames-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
border: 1px dashed var(--color-border);
border-radius: 4px;
cursor: pointer;
}
.frames-drop-zone:hover {
border-color: var(--color-accent);
background: rgba(59, 130, 246, 0.05);
}
.frames-section {
transition: background 0.2s;
}
.frames-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.frame-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: var(--color-bg-default);
border-radius: 2px;
font-size: 10px;
}
.frame-index {
width: 16px;
color: var(--color-text-muted);
text-align: center;
}
.frame-texture-field {
flex: 1;
min-width: 0;
}
.frame-texture-field .asset-field {
margin-bottom: 0;
}
.frame-texture-field .asset-field__container {
height: 22px;
}
.frame-texture-field .asset-field__icon {
width: 22px;
height: 20px;
}
.frame-texture-field .asset-field__input {
height: 20px;
padding: 0 4px;
}
.frame-texture-field .asset-field__value {
font-size: 10px;
}
.frame-texture-field .asset-field__button {
width: 18px;
height: 18px;
}
.frame-duration {
width: 45px;
padding: 2px 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: 2px;
color: var(--color-text-primary);
font-size: 10px;
text-align: right;
}
.frame-item button {
padding: 2px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
border-radius: 2px;
}
.frame-item button:hover {
background: var(--color-danger);
color: var(--color-text-inverse);
}

View File

@@ -0,0 +1,125 @@
.qrcode-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.qrcode-dialog {
background: var(--panel-background, #2d2d2d);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
width: 320px;
overflow: hidden;
}
.qrcode-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color, #404040);
}
.qrcode-dialog-header h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--text-color, #e0e0e0);
}
.qrcode-dialog-close {
background: transparent;
border: none;
color: var(--text-secondary, #808080);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.qrcode-dialog-close:hover {
background: var(--hover-background, #3d3d3d);
color: var(--text-color, #e0e0e0);
}
.qrcode-dialog-content {
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.qrcode-dialog-content canvas,
.qrcode-dialog-content img {
background: white;
border-radius: 4px;
padding: 8px;
}
.qrcode-loading,
.qrcode-error {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--input-background, #1e1e1e);
border-radius: 4px;
color: var(--text-secondary, #808080);
font-size: 12px;
}
.qrcode-url-container {
display: flex;
width: 100%;
gap: 8px;
}
.qrcode-url-input {
flex: 1;
padding: 8px 12px;
background: var(--input-background, #1e1e1e);
border: 1px solid var(--border-color, #404040);
border-radius: 4px;
color: var(--text-color, #e0e0e0);
font-size: 12px;
font-family: monospace;
}
.qrcode-url-input:focus {
outline: none;
border-color: var(--accent-color, #0078d4);
}
.qrcode-copy-button {
padding: 8px;
background: var(--button-background, #3d3d3d);
border: 1px solid var(--border-color, #404040);
border-radius: 4px;
color: var(--text-color, #e0e0e0);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode-copy-button:hover {
background: var(--button-hover-background, #4d4d4d);
}
.qrcode-hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary, #808080);
text-align: center;
}

View File

@@ -63,6 +63,60 @@
transform: scale(0.95);
}
.viewport-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.viewport-btn:disabled:hover {
background: transparent;
color: var(--color-text-secondary);
border-color: transparent;
}
/* Dropdown */
.viewport-dropdown {
position: relative;
}
.viewport-dropdown .viewport-btn {
width: auto;
padding: 0 8px;
gap: 4px;
}
.viewport-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 160px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
overflow: hidden;
}
.viewport-dropdown-menu button {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: none;
border: none;
color: var(--color-text-primary);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.viewport-dropdown-menu button:hover {
background: var(--color-bg-hover);
}
.viewport-divider {
width: 1px;
height: 24px;

View File

@@ -47,28 +47,62 @@ select {
color: var(--color-text-inverse);
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
::-webkit-scrollbar-thumb:active {
background: rgba(255, 255, 255, 0.3);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* 滚动条悬停时显示更宽 */
*:hover::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
.scrollable {
overflow: auto;
}
.scrollable::-webkit-scrollbar {
width: 14px;
height: 14px;
}
.scrollable::-webkit-scrollbar-track {
background: transparent;
width: 10px;
height: 10px;
}
.scrollable::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.4);
border-radius: 8px;
border: 3px solid transparent;
background: rgba(255, 255, 255, 0.12);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.scrollable::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
background: rgba(255, 255, 255, 0.2);
background-clip: padding-box;
}

View File

@@ -0,0 +1,96 @@
/**
* ID Generator Utility
* 提供安全可靠的ID生成机制
*/
/**
* Generates unique sequential IDs for textures and other resources
* 为纹理和其他资源生成唯一的顺序ID
*/
export class IdGenerator {
private static counters = new Map<string, number>();
private static usedIds = new Map<string, Set<number>>();
/**
* Generate next sequential ID for a given namespace
* 为给定的命名空间生成下一个顺序ID
*/
static nextId(namespace: string): number {
const current = this.counters.get(namespace) || 1000;
const next = current + 1;
this.counters.set(namespace, next);
// Track used IDs
if (!this.usedIds.has(namespace)) {
this.usedIds.set(namespace, new Set());
}
this.usedIds.get(namespace)!.add(next);
return next;
}
/**
* Generate UUID v4
* 生成 UUID v4
*/
static uuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Check if ID is already used
* 检查ID是否已被使用
*/
static isUsed(namespace: string, id: number): boolean {
return this.usedIds.get(namespace)?.has(id) || false;
}
/**
* Reserve a specific ID
* 保留特定的ID
*/
static reserve(namespace: string, id: number): void {
if (!this.usedIds.has(namespace)) {
this.usedIds.set(namespace, new Set());
}
this.usedIds.get(namespace)!.add(id);
// Update counter if needed
const current = this.counters.get(namespace) || 1000;
if (id >= current) {
this.counters.set(namespace, id);
}
}
/**
* Release an ID for reuse
* 释放ID以供重用
*/
static release(namespace: string, id: number): void {
this.usedIds.get(namespace)?.delete(id);
}
/**
* Reset a namespace
* 重置命名空间
*/
static reset(namespace: string): void {
this.counters.delete(namespace);
this.usedIds.delete(namespace);
}
/**
* Get statistics for a namespace
* 获取命名空间的统计信息
*/
static getStats(namespace: string): { nextId: number; usedCount: number } {
return {
nextId: (this.counters.get(namespace) || 1000) + 1,
usedCount: this.usedIds.get(namespace)?.size || 0
};
}
}