Feature/physics and tilemap enhancement (#247)

* feat(behavior-tree,tilemap): 修复编辑器连线缩放问题并增强插件系统

* feat(node-editor,blueprint): 新增通用节点编辑器和蓝图可视化脚本系统

* feat(editor,tilemap): 优化编辑器UI样式和Tilemap编辑器功能

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误
This commit is contained in:
YHH
2025-11-29 23:00:48 +08:00
committed by GitHub
parent f03b73b58e
commit 359886c72f
198 changed files with 33879 additions and 13121 deletions

View File

@@ -75,6 +75,8 @@ jobs:
cd ../behavior-tree && pnpm run build
cd ../tilemap && pnpm run build
cd ../physics-rapier2d && pnpm run build
cd ../node-editor && pnpm run build
cd ../blueprint && pnpm run build
- name: Build ecs-engine-bindgen
run: |

View File

@@ -11,6 +11,10 @@ on:
- core
- behavior-tree
- editor-core
- node-editor
- blueprint
- tilemap
- physics-rapier2d
version_type:
description: '版本更新类型'
required: true
@@ -57,11 +61,17 @@ jobs:
run: pnpm install
- name: Build core package (if needed)
if: ${{ github.event.inputs.package == 'behavior-tree' || github.event.inputs.package == 'editor-core' }}
if: ${{ github.event.inputs.package != 'core' && github.event.inputs.package != 'node-editor' }}
run: |
cd packages/core
pnpm run build
- name: Build node-editor package (if needed for blueprint)
if: ${{ github.event.inputs.package == 'blueprint' }}
run: |
cd packages/node-editor
pnpm run build
# - name: Run tests
# run: |
# cd packages/${{ github.event.inputs.package }}

View File

@@ -135,7 +135,19 @@ export class AssetManager implements IAssetManager {
}
// 创建加载器 / Create loader
const loader = this._loaderFactory.createLoader(metadata.type);
let loader = this._loaderFactory.createLoader(metadata.type);
// 如果没有找到 loader 且类型是 Custom尝试重新解析类型
// If no loader found and type is Custom, try to re-resolve the type
if (!loader && metadata.type === AssetType.Custom) {
const newType = this.resolveAssetType(metadata.path);
if (newType !== AssetType.Custom) {
// 更新 metadata 类型 / Update metadata type
this._database.updateAsset(guid, { type: newType });
loader = this._loaderFactory.createLoader(newType);
}
}
if (!loader) {
throw AssetLoadError.unsupportedType(guid, metadata.type);
}
@@ -238,17 +250,7 @@ export class AssetManager implements IAssetManager {
let metadata = this._database.getMetadataByPath(path);
if (!metadata) {
// 动态创建元数据 / Create metadata dynamically
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
let assetType = AssetType.Custom;
// 根据文件扩展名确定资产类型 / Determine asset type by file extension
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
assetType = AssetType.Texture;
} else if (['.json'].includes(fileExt)) {
assetType = AssetType.Json;
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
assetType = AssetType.Text;
}
const assetType = this.resolveAssetType(path);
// 生成唯一GUID / Generate unique GUID
const dynamicGuid = `dynamic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
@@ -271,15 +273,59 @@ export class AssetManager implements IAssetManager {
this._database.addAsset(metadata);
this._pathToGuid.set(path, metadata.guid);
} else {
// 如果之前缓存的类型是 Custom检查是否现在有注册的 loader 可以处理
// If previously cached as Custom, check if a registered loader can now handle it
if (metadata.type === AssetType.Custom) {
const newType = this.resolveAssetType(path);
if (newType !== AssetType.Custom) {
metadata.type = newType;
}
}
this._pathToGuid.set(path, metadata.guid);
}
return this.loadAsset<T>(metadata.guid, options);
}
// 同样检查已缓存的资产,如果类型是 Custom 但现在有 loader 可以处理
// Also check cached assets, if type is Custom but now a loader can handle it
const entry = this._assets.get(guid);
if (entry && entry.metadata.type === AssetType.Custom) {
const newType = this.resolveAssetType(path);
if (newType !== AssetType.Custom) {
entry.metadata.type = newType;
}
}
return this.loadAsset<T>(guid, options);
}
/**
* Resolve asset type from path
* 从路径解析资产类型
*/
private resolveAssetType(path: string): AssetType {
// 首先尝试从已注册的加载器获取资产类型 / First try to get asset type from registered loaders
const loaderType = (this._loaderFactory as AssetLoaderFactory).getAssetTypeByPath(path);
if (loaderType !== null) {
return loaderType;
}
// 如果没有找到匹配的加载器,使用默认的扩展名映射 / Fallback to default extension mapping
const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase();
// 默认支持的基础类型 / Default supported basic types
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) {
return AssetType.Texture;
} else if (['.json'].includes(fileExt)) {
return AssetType.Json;
} else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) {
return AssetType.Text;
}
return AssetType.Custom;
}
/**
* Load multiple assets
* 批量加载资产

View File

@@ -73,6 +73,18 @@ export interface IAssetLoaderFactory {
* 检查类型是否有加载器
*/
hasLoader(type: AssetType): boolean;
/**
* Get asset type by file extension
* 根据文件扩展名获取资产类型
*/
getAssetTypeByExtension(extension: string): AssetType | null;
/**
* Get asset type by file path
* 根据文件路径获取资产类型
*/
getAssetTypeByPath(path: string): AssetType | null;
}
/**

View File

@@ -72,6 +72,57 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
return this._loaders.has(type);
}
/**
* Get asset type by file extension
* 根据文件扩展名获取资产类型
*
* @param extension - File extension including dot (e.g., '.btree', '.png')
* @returns Asset type if a loader supports this extension, null otherwise
*/
getAssetTypeByExtension(extension: string): AssetType | null {
const ext = extension.toLowerCase();
for (const [type, loader] of this._loaders) {
if (loader.supportedExtensions.some(e => e.toLowerCase() === ext)) {
return type;
}
}
return null;
}
/**
* Get asset type by file path
* 根据文件路径获取资产类型
*
* Checks for compound extensions (like .tilemap.json) first, then simple extensions
*
* @param path - File path
* @returns Asset type if a loader supports this file, null otherwise
*/
getAssetTypeByPath(path: string): AssetType | null {
const lowerPath = path.toLowerCase();
// First check compound extensions (e.g., .tilemap.json)
for (const [type, loader] of this._loaders) {
for (const ext of loader.supportedExtensions) {
if (ext.includes('.') && ext.split('.').length > 2) {
// This is a compound extension like .tilemap.json
if (lowerPath.endsWith(ext.toLowerCase())) {
return type;
}
}
}
}
// Then check simple extensions
const lastDot = path.lastIndexOf('.');
if (lastDot !== -1) {
const ext = path.substring(lastDot).toLowerCase();
return this.getAssetTypeByExtension(ext);
}
return null;
}
/**
* Get all registered loaders
* 获取所有注册的加载器

View File

@@ -33,45 +33,50 @@ export enum AssetState {
}
/**
* Asset types supported by the system
* 系统支持的资产类型
* Asset type - string based for extensibility
* 资产类型 - 使用字符串以支持插件扩展
*
* Plugins can define their own asset types by using custom strings.
* Built-in types are provided as constants below.
* 插件可以通过使用自定义字符串定义自己的资产类型。
* 内置类型作为常量提供如下。
*/
export enum AssetType {
export type AssetType = string;
/**
* Built-in asset types provided by asset-system
* asset-system 提供的内置资产类型
*/
export const AssetType = {
/** 纹理 */
Texture = 'texture',
Texture: 'texture',
/** 网格 */
Mesh = 'mesh',
Mesh: 'mesh',
/** 材质 */
Material = 'material',
Material: 'material',
/** 着色器 */
Shader = 'shader',
Shader: 'shader',
/** 音频 */
Audio = 'audio',
Audio: 'audio',
/** 字体 */
Font = 'font',
Font: 'font',
/** 预制体 */
Prefab = 'prefab',
Prefab: 'prefab',
/** 场景 */
Scene = 'scene',
Scene: 'scene',
/** 脚本 */
Script = 'script',
Script: 'script',
/** 动画片段 */
AnimationClip = 'animation',
/** 行为树 */
BehaviorTree = 'behaviortree',
/** 瓦片地图 */
Tilemap = 'tilemap',
/** 瓦片集 */
Tileset = 'tileset',
AnimationClip: 'animation',
/** JSON数据 */
Json = 'json',
Json: 'json',
/** 文本 */
Text = 'text',
Text: 'text',
/** 二进制 */
Binary = 'binary',
Binary: 'binary',
/** 自定义 */
Custom = 'custom'
}
Custom: 'custom'
} as const;
/**
* Platform variants for assets

View File

@@ -3,6 +3,7 @@
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",

View File

@@ -45,6 +45,7 @@
"peerDependencies": {
"@esengine/ecs-framework": ">=2.0.0",
"@esengine/ecs-components": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
@@ -54,6 +55,9 @@
"@esengine/ecs-components": {
"optional": true
},
"@esengine/asset-system": {
"optional": true
},
"@esengine/editor-runtime": {
"optional": true
},

View File

@@ -6,17 +6,22 @@
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components';
import type { AssetManager } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
import { BehaviorTreeExecutionSystem } from './execution/BehaviorTreeExecutionSystem';
import { BehaviorTreeAssetManager } from './execution/BehaviorTreeAssetManager';
import { GlobalBlackboardService } from './Services/GlobalBlackboardService';
import { BehaviorTreeLoader } from './loaders/BehaviorTreeLoader';
import { BehaviorTreeAssetType } from './index';
/**
* Behavior Tree Runtime Module
* 行为树运行时模块
*/
export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader {
private _loaderRegistered = false;
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
@@ -31,8 +36,28 @@ export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader {
}
createSystems(scene: IScene, context: SystemContext): void {
// 注册行为树加载器到 AssetManager
// Register behavior tree loader to AssetManager
const assetManager = context.assetManager as AssetManager | undefined;
console.log('[BehaviorTreeRuntimeModule] createSystems called, assetManager:', assetManager ? 'exists' : 'null');
if (!this._loaderRegistered && assetManager) {
assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader());
this._loaderRegistered = true;
console.log('[BehaviorTreeRuntimeModule] Registered BehaviorTreeLoader for type:', BehaviorTreeAssetType);
}
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
// 设置 AssetManager 引用
// Set AssetManager reference
if (assetManager) {
behaviorTreeSystem.setAssetManager(assetManager);
console.log('[BehaviorTreeRuntimeModule] Set assetManager on behaviorTreeSystem');
} else {
console.warn('[BehaviorTreeRuntimeModule] assetManager is null, cannot set on behaviorTreeSystem');
}
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}

View File

@@ -15,6 +15,17 @@ interface EditorNode {
children?: string[];
}
/**
* 编辑器连接数据接口
*/
interface EditorConnection {
from: string;
to: string;
connectionType: 'node' | 'property';
fromProperty?: string;
toProperty?: string;
}
/**
* 编辑器行为树数据接口
*/
@@ -27,6 +38,7 @@ interface EditorBehaviorTreeData {
modifiedAt?: string;
};
nodes: EditorNode[];
connections?: EditorConnection[];
blackboard?: Record<string, any>;
}
@@ -57,10 +69,18 @@ export class EditorToBehaviorTreeDataConverter {
throw new Error('Behavior tree must have a root node');
}
// 转换所有节点
// 构建属性绑定映射nodeId -> { propertyName -> blackboardKey }
const propertyBindingsMap = this.buildPropertyBindingsMap(editorData);
// 转换所有节点(过滤掉不可执行的节点,如黑板变量节点)
const nodesMap = new Map<string, BehaviorNodeData>();
for (const editorNode of editorData.nodes) {
const behaviorNodeData = this.convertNode(editorNode);
// 跳过黑板变量节点,它们只用于编辑器的可视化绑定
if (this.isNonExecutableNode(editorNode)) {
continue;
}
const propertyBindings = propertyBindingsMap.get(editorNode.id);
const behaviorNodeData = this.convertNode(editorNode, propertyBindings);
nodesMap.set(behaviorNodeData.id, behaviorNodeData);
}
@@ -79,19 +99,81 @@ export class EditorToBehaviorTreeDataConverter {
}
/**
* 转换单个节点
* 从连接数据构建属性绑定映射
* 处理 connectionType === 'property' 的连接,将黑板变量节点连接到目标节点的属性
*/
private static convertNode(editorNode: EditorNode): BehaviorNodeData {
private static buildPropertyBindingsMap(
editorData: EditorBehaviorTreeData
): Map<string, Record<string, string>> {
const bindingsMap = new Map<string, Record<string, string>>();
if (!editorData.connections) {
return bindingsMap;
}
// 构建节点 ID 到变量名的映射(用于黑板变量节点)
const nodeToVariableMap = new Map<string, string>();
for (const node of editorData.nodes) {
if (node.data['nodeType'] === 'blackboard-variable' && node.data['variableName']) {
nodeToVariableMap.set(node.id, node.data['variableName']);
}
}
// 处理属性连接
for (const conn of editorData.connections) {
if (conn.connectionType === 'property' && conn.toProperty) {
const variableName = nodeToVariableMap.get(conn.from);
if (variableName) {
// 获取或创建目标节点的绑定记录
let bindings = bindingsMap.get(conn.to);
if (!bindings) {
bindings = {};
bindingsMap.set(conn.to, bindings);
}
// 将属性绑定到黑板变量
bindings[conn.toProperty] = variableName;
}
}
}
return bindingsMap;
}
/**
* 转换单个节点
* @param editorNode 编辑器节点数据
* @param propertyBindings 从连接中提取的属性绑定(可选)
*/
private static convertNode(
editorNode: EditorNode,
propertyBindings?: Record<string, string>
): BehaviorNodeData {
const nodeType = this.mapNodeType(editorNode.template.type);
const config = this.extractConfig(editorNode.data);
const bindings = this.extractBindings(editorNode.data);
// 从节点数据中提取绑定
const dataBindings = this.extractBindings(editorNode.data);
// 合并连接绑定和数据绑定(连接绑定优先)
const bindings = { ...dataBindings, ...propertyBindings };
const abortType = this.extractAbortType(editorNode.data);
// 获取 implementationType优先从 template.className其次从 data 中的类型字段
let implementationType: string | undefined = editorNode.template.className;
if (!implementationType) {
// 尝试从 data 中提取类型
implementationType = this.extractImplementationType(editorNode.data, nodeType);
}
if (!implementationType) {
console.warn(`[EditorToBehaviorTreeDataConverter] Node ${editorNode.id} has no implementationType, using fallback`);
// 根据节点类型使用默认实现
implementationType = this.getDefaultImplementationType(nodeType);
}
return {
id: editorNode.id,
name: editorNode.template.displayName || editorNode.template.className,
name: editorNode.template.displayName || editorNode.template.className || implementationType,
nodeType,
implementationType: editorNode.template.className,
implementationType,
children: editorNode.children || [],
config,
...(Object.keys(bindings).length > 0 && { bindings }),
@@ -99,6 +181,64 @@ export class EditorToBehaviorTreeDataConverter {
};
}
/**
* 检查是否为不可执行的节点(如黑板变量节点)
* 这些节点只在编辑器中使用,不参与运行时执行
*/
private static isNonExecutableNode(editorNode: EditorNode): boolean {
const nodeType = editorNode.data['nodeType'];
// 黑板变量节点不需要执行,只用于可视化绑定
return nodeType === 'blackboard-variable';
}
/**
* 从节点数据中提取实现类型
*
* 优先级:
* 1. template.className标准方式
* 2. data 中的类型字段compositeType, actionType 等)
* 3. 特殊节点类型的默认值(如 Root
*/
private static extractImplementationType(data: Record<string, any>, nodeType: NodeType): string | undefined {
// 节点类型到数据字段的映射
const typeFieldMap: Record<NodeType, string> = {
[NodeType.Composite]: 'compositeType',
[NodeType.Decorator]: 'decoratorType',
[NodeType.Action]: 'actionType',
[NodeType.Condition]: 'conditionType',
[NodeType.Root]: '', // Root 没有对应的数据字段
};
const field = typeFieldMap[nodeType];
if (field && data[field]) {
return data[field];
}
// Root 节点的特殊处理
if (nodeType === NodeType.Root) {
return 'Root';
}
return undefined;
}
/**
* 获取节点类型的默认实现
* 当无法确定具体实现类型时使用
*/
private static getDefaultImplementationType(nodeType: NodeType): string {
// 节点类型到默认实现的映射
const defaultImplementations: Record<NodeType, string> = {
[NodeType.Root]: 'Root',
[NodeType.Composite]: 'Sequence',
[NodeType.Decorator]: 'Inverter',
[NodeType.Action]: 'Wait',
[NodeType.Condition]: 'AlwaysTrue',
};
return defaultImplementations[nodeType] || 'Unknown';
}
/**
* 映射节点类型
*/

View File

@@ -1,22 +1,9 @@
/**
* Behavior Tree Unified Plugin
* 行为树统一插件
* Behavior Tree Plugin Descriptor
* 行为树插件描述符
*/
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
import type {
IPluginLoader,
IRuntimeModuleLoader,
PluginDescriptor,
SystemContext
} from '@esengine/editor-runtime';
// Runtime imports
import { BehaviorTreeRuntimeComponent } from '../execution/BehaviorTreeRuntimeComponent';
import { BehaviorTreeExecutionSystem } from '../execution/BehaviorTreeExecutionSystem';
import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager';
import { GlobalBlackboardService } from '../Services/GlobalBlackboardService';
import type { PluginDescriptor } from '@esengine/editor-runtime';
/**
* 插件描述符
@@ -49,50 +36,3 @@ export const descriptor: PluginDescriptor = {
],
icon: 'GitBranch'
};
/**
* Behavior Tree Runtime Module
* 行为树运行时模块
*/
export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader {
registerComponents(registry: typeof ComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
registerServices(services: ServiceContainer): void {
if (!services.isRegistered(GlobalBlackboardService)) {
services.registerSingleton(GlobalBlackboardService);
}
if (!services.isRegistered(BehaviorTreeAssetManager)) {
services.registerSingleton(BehaviorTreeAssetManager);
}
}
createSystems(scene: IScene, context: SystemContext): void {
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
// 编辑器模式下默认禁用
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}
scene.addSystem(behaviorTreeSystem);
// 保存引用
context.behaviorTreeSystem = behaviorTreeSystem;
}
}
/**
* Behavior Tree Plugin Loader
* 行为树插件加载器
*
* 注意editorModule 在 ./index.ts 中通过 createBehaviorTreePlugin() 设置
*/
export const BehaviorTreePlugin: IPluginLoader = {
descriptor,
runtimeModule: new BehaviorTreeRuntimeModule(),
// editorModule 将在 index.ts 中设置
};
export default BehaviorTreePlugin;

View File

@@ -462,8 +462,13 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
handleNodeMouseUp();
};
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds);
// 使用 useCallback 包装 getPortPosition确保在 canvasScale/canvasOffset 变化时更新
// Use useCallback to wrap getPortPosition to ensure updates when canvasScale/canvasOffset changes
const getPortPosition = useCallback(
(nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds),
[canvasOffset, canvasScale, nodes, draggingNodeId, dragDelta, selectedNodeIds]
);
stopExecutionRef.current = handleStop;

View File

@@ -11,6 +11,9 @@ interface ConnectionLayerProps {
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
/** 用于强制刷新连线(当 canvasScale 等变化时) */
/** Used to force refresh connections (when canvasScale etc. changes) */
refreshKey?: number;
}
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
connections,

View File

@@ -1,4 +1,4 @@
import { React, useMemo } from '@esengine/editor-runtime';
import { React } from '@esengine/editor-runtime';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';
@@ -20,45 +20,40 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
}) => {
const { connection, isSelected } = connectionData;
const pathData = useMemo(() => {
let fromPos, toPos;
// 直接计算路径数据,不使用 useMemo
// getPortPosition 使用节点数据直接计算,不依赖缩放状态
let fromPos, toPos;
if (connection.connectionType === 'property') {
// 属性连接:使用 fromProperty 和 toProperty
fromPos = getPortPosition(connection.from, connection.fromProperty);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
// 节点连接:使用输出和输入端口
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (connection.connectionType === 'property') {
fromPos = getPortPosition(connection.from, connection.fromProperty);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (!fromPos || !toPos) {
return null;
}
if (!fromPos || !toPos) {
return null;
}
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
let pathD: string;
let pathD: string;
if (connection.connectionType === 'property') {
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
if (connection.connectionType === 'property') {
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return {
path: pathD,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2
};
}, [connection, fromNode, toNode, getPortPosition]);
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const isPropertyConnection = connection.connectionType === 'property';
@@ -69,11 +64,6 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
const gradientId = `gradient-${connection.from}-${connection.to}`;
if (!pathData) {
return null;
}
const pathD = pathData.path;
const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/);
const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0;
const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0;
@@ -106,14 +96,14 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
</defs>
<path
d={pathData.path}
d={pathD}
fill="none"
stroke="transparent"
strokeWidth={24}
/>
<path
d={pathData.path}
d={pathD}
fill="none"
stroke={glowColor}
strokeWidth={strokeWidth + 2}
@@ -122,7 +112,7 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
/>
<path
d={pathData.path}
d={pathD}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
@@ -141,15 +131,15 @@ const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
{isSelected && (
<>
<circle
cx={pathData.midX}
cy={pathData.midY}
cx={midX}
cy={midY}
r="8"
fill={strokeColor}
opacity="0.3"
/>
<circle
cx={pathData.midX}
cy={pathData.midY}
cx={midX}
cy={midY}
r="5"
fill={strokeColor}
stroke="rgba(0, 0, 0, 0.5)"

View File

@@ -19,6 +19,11 @@ import {
IInspectorRegistry,
MessageHub,
IMessageHub,
FileActionRegistry,
IDialogService,
IFileSystemService,
type IDialog,
type IFileSystem,
createLogger,
PluginAPI,
} from '@esengine/editor-runtime';
@@ -36,14 +41,14 @@ import { useBehaviorTreeDataStore } from './stores';
import { createRootNode } from './domain/constants/RootNode';
import { PluginContext } from './PluginContext';
// Import runtime module and descriptor
import { BehaviorTreeRuntimeModule, descriptor } from './BehaviorTreePlugin';
// Import descriptor from local file, runtime module from main module
import { descriptor } from './BehaviorTreePlugin';
import { BehaviorTreeRuntimeModule } from '../BehaviorTreeRuntimeModule';
// 导入编辑器 CSS 样式
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM
// Import editor CSS styles (automatically handled and injected by vite)
import './styles/BehaviorTreeNode.css';
import './styles/Toast.css';
import './components/panels/BehaviorTreeEditorPanel.css';
import './components/panels/BehaviorTreePropertiesPanel.css';
const logger = createLogger('BehaviorTreeEditorModule');
@@ -53,6 +58,7 @@ const logger = createLogger('BehaviorTreeEditorModule');
*/
export class BehaviorTreeEditorModule implements IEditorModuleLoader {
private services?: ServiceContainer;
private unsubscribers: Array<() => void> = [];
async install(services: ServiceContainer): Promise<void> {
this.services = services;
@@ -69,10 +75,102 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
// 注册节点检视器
this.registerInspectorProviders(services);
// 注册资产创建消息映射
this.registerAssetCreationMappings(services);
// 订阅创建资产消息
this.subscribeToMessages(services);
logger.info('BehaviorTree editor module installed');
}
private registerAssetCreationMappings(services: ServiceContainer): void {
try {
const fileActionRegistry = services.resolve(FileActionRegistry);
if (fileActionRegistry) {
fileActionRegistry.registerAssetCreationMapping({
extension: '.btree',
createMessage: 'behavior-tree:create-asset'
});
}
} catch (error) {
logger.warn('FileActionRegistry not available:', error);
}
}
private subscribeToMessages(services: ServiceContainer): void {
try {
const messageHub = services.resolve<MessageHub>(IMessageHub);
if (messageHub) {
const unsubscribe = messageHub.subscribe('behavior-tree:create-asset', async (payload: {
entityId?: string;
onChange?: (value: string | null) => void;
}) => {
await this.handleCreateBehaviorTreeAsset(services, payload);
});
this.unsubscribers.push(unsubscribe);
}
} catch (error) {
logger.warn('MessageHub not available:', error);
}
}
private async handleCreateBehaviorTreeAsset(
services: ServiceContainer,
payload: { entityId?: string; onChange?: (value: string | null) => void }
): Promise<void> {
try {
const dialog = services.resolve<IDialog>(IDialogService);
const fileSystem = services.resolve<IFileSystem>(IFileSystemService);
const messageHub = services.resolve<MessageHub>(IMessageHub);
if (!dialog || !fileSystem) {
logger.error('Dialog or FileSystem service not available');
return;
}
const filePath = await dialog.saveDialog({
title: 'Create Behavior Tree Asset',
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
defaultPath: 'new-behavior-tree.btree'
});
if (!filePath) {
return;
}
// 获取默认行为树内容
const templates = this.getFileCreationTemplates();
const btreeTemplate = templates.find(t => t.extension === 'btree');
const content = btreeTemplate
? await btreeTemplate.getContent(filePath.split(/[\\/]/).pop() || 'new-behavior-tree.btree')
: '{}';
await fileSystem.writeFile(filePath, content);
if (payload.onChange) {
payload.onChange(filePath);
}
// 打开行为树编辑器
if (messageHub) {
messageHub.publish('dynamic-panel:open', {
panelId: 'behavior-tree-editor',
title: `Behavior Tree - ${filePath.split(/[\\/]/).pop()}`
});
}
logger.info('Created behavior tree asset:', filePath);
} catch (error) {
logger.error('Failed to create behavior tree asset:', error);
}
}
async uninstall(): Promise<void> {
// 清理订阅
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
if (this.services) {
this.services.unregister(FileSystemService);
this.services.unregister(BehaviorTreeService);
@@ -155,7 +253,7 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
label: 'Behavior Tree',
extension: 'btree',
icon: 'GitBranch',
create: async (filePath: string) => {
getContent: (fileName: string) => {
const rootNode = createRootNode();
const rootNodeData = {
id: rootNode.id,
@@ -170,16 +268,13 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
};
const emptyTree = {
name: filePath.replace(/.*[/\\]/, '').replace('.btree', ''),
name: fileName.replace('.btree', ''),
nodes: [rootNodeData],
connections: [],
variables: {}
};
const content = JSON.stringify(emptyTree, null, 2);
// Write using Tauri FS API
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
await writeTextFile(filePath, content);
return JSON.stringify(emptyTree, null, 2);
}
}];
}

View File

@@ -3,9 +3,14 @@ import { Node as BehaviorTreeNode } from '../domain/models/Node';
const logger = createLogger('portUtils');
// 端口偏移常量(与 CSS 保持一致)
const NODE_PORT_OFFSET = 8; // top: -8px / bottom: -8px
/**
* 获取端口在画布世界坐标系中的位置
* 直接从 DOM 元素获取实际渲染位置,避免硬编码和手动计算
*
* 由于 SVG 和节点都在同一个 transform 容器内,直接使用节点的世界坐标计算。
* 这种方式不受缩放影响,因为不依赖 getBoundingClientRect。
*/
export function getPortPosition(
canvasRef: RefObject<HTMLDivElement>,
@@ -20,41 +25,85 @@ export function getPortPosition(
selectedNodeIds?: string[]
): { x: number; y: number } | null {
const canvas = canvasRef.current;
if (!canvas) return null;
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
if (!node) return null;
// 构造端口选择器
let portSelector: string;
if (propertyName) {
// 属性端口:使用 data-property 属性定位
portSelector = `[data-node-id="${nodeId}"][data-property="${propertyName}"]`;
} else {
// 节点端口:使用 data-port-type 属性定位
const portTypeAttr = portType === 'input' ? 'node-input' : 'node-output';
portSelector = `[data-node-id="${nodeId}"][data-port-type="${portTypeAttr}"]`;
}
const portElement = canvas.querySelector(portSelector) as HTMLElement;
if (!portElement) {
logger.warn(`Port not found: ${portSelector}`);
if (!canvas) {
return null;
}
// 获取端口和画布的屏幕矩形
const portRect = portElement.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
if (!node) {
return null;
}
// 计算端口中心相对于画布的屏幕坐标
const screenX = portRect.left + portRect.width / 2 - canvasRect.left;
const screenY = portRect.top + portRect.height / 2 - canvasRect.top;
// 获取节点 DOM 元素来获取尺寸
const nodeElement = canvas.querySelector(`[data-node-id="${nodeId}"].bt-node`) as HTMLElement;
if (!nodeElement) {
return null;
}
// 转换为世界坐标
// 屏幕坐标到世界坐标的转换world = (screen - offset) / scale
const worldX = (screenX - canvasOffset.x) / canvasScale;
const worldY = (screenY - canvasOffset.y) / canvasScale;
// 使用 offsetWidth/offsetHeight 获取未缩放的原始尺寸
const nodeWidth = nodeElement.offsetWidth;
const nodeHeight = nodeElement.offsetHeight;
return { x: worldX, y: worldY };
// 节点世界坐标(考虑拖拽偏移)
let nodeX = node.position.x;
let nodeY = node.position.y;
if (draggingNodeId && dragDelta) {
const isBeingDragged = draggingNodeId === nodeId ||
(selectedNodeIds && selectedNodeIds.includes(nodeId) && selectedNodeIds.includes(draggingNodeId));
if (isBeingDragged) {
nodeX += dragDelta.dx;
nodeY += dragDelta.dy;
}
}
// 节点使用 transform: translate(-50%, -50%) 居中,所以 (nodeX, nodeY) 是视觉中心
if (propertyName) {
// 属性端口:需要找到端口在节点内的相对位置
const portElement = nodeElement.querySelector(`[data-property="${propertyName}"]`) as HTMLElement;
if (!portElement) {
return null;
}
// 使用 offsetLeft/offsetTop 获取相对于 offsetParent 的位置
// 需要累加到节点元素
let offsetX = 0;
let offsetY = 0;
let el: HTMLElement | null = portElement;
while (el && el !== nodeElement) {
offsetX += el.offsetLeft;
offsetY += el.offsetTop;
el = el.offsetParent as HTMLElement | null;
}
// 端口中心相对于节点左上角的偏移
const portCenterX = offsetX + portElement.offsetWidth / 2;
const portCenterY = offsetY + portElement.offsetHeight / 2;
// 节点左上角世界坐标
const nodeLeft = nodeX - nodeWidth / 2;
const nodeTop = nodeY - nodeHeight / 2;
return {
x: nodeLeft + portCenterX,
y: nodeTop + portCenterY
};
} else {
// 节点端口(输入/输出)
if (portType === 'input') {
// 输入端口在顶部中央
return {
x: nodeX,
y: nodeY - nodeHeight / 2 - NODE_PORT_OFFSET
};
} else {
// 输出端口在底部中央
return {
x: nodeX,
y: nodeY + nodeHeight / 2 + NODE_PORT_OFFSET
};
}
}
}

View File

@@ -1,4 +1,5 @@
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem } from '@esengine/ecs-framework';
import type { AssetManager } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor';
@@ -14,10 +15,13 @@ import './Executors';
*/
@ECSSystem('BehaviorTreeExecution')
export class BehaviorTreeExecutionSystem extends EntitySystem {
private assetManager: BehaviorTreeAssetManager | null = null;
private btAssetManager: BehaviorTreeAssetManager | null = null;
private executorRegistry: NodeExecutorRegistry;
private coreInstance: typeof Core | null = null;
/** 引用 asset-system 的 AssetManager由 BehaviorTreeRuntimeModule 设置) */
private _assetManager: AssetManager | null = null;
constructor(coreInstance?: typeof Core) {
super(Matcher.empty().all(BehaviorTreeRuntimeComponent));
this.coreInstance = coreInstance || null;
@@ -25,12 +29,102 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
this.registerBuiltInExecutors();
}
private getAssetManager(): BehaviorTreeAssetManager {
if (!this.assetManager) {
const core = this.coreInstance || Core;
this.assetManager = core.services.resolve(BehaviorTreeAssetManager);
/**
* 设置 AssetManager 引用
* Set AssetManager reference
*/
setAssetManager(assetManager: AssetManager | null): void {
this._assetManager = assetManager;
}
/**
* 启动所有 autoStart 的行为树(用于预览模式)
* Start all autoStart behavior trees (for preview mode)
*
* 由于编辑器模式下系统默认禁用,实体添加时 onAdded 不会处理自动启动。
* 预览开始时需要手动调用此方法来启动所有需要自动启动的行为树。
*/
startAllAutoStartTrees(): void {
if (!this.scene) {
this.logger.warn('Scene not available, cannot start auto-start trees');
return;
}
return this.assetManager;
const entities = this.scene.entities.findEntitiesWithComponent(BehaviorTreeRuntimeComponent);
for (const entity of entities) {
const runtime = entity.getComponent(BehaviorTreeRuntimeComponent);
if (runtime && runtime.autoStart && runtime.treeAssetId && !runtime.isRunning) {
this.ensureAssetLoaded(runtime.treeAssetId).then(() => {
if (runtime && runtime.autoStart && !runtime.isRunning) {
runtime.start();
this.logger.debug(`Auto-started behavior tree for entity: ${entity.name}`);
}
}).catch(e => {
this.logger.error(`Failed to load behavior tree for entity ${entity.name}:`, e);
});
}
}
}
/**
* 当实体添加到系统时,处理自动启动
* Handle auto-start when entity is added to system
*/
protected override onAdded(entity: Entity): void {
// 只有在系统启用时才自动启动
// Only auto-start when system is enabled
if (!this.enabled) return;
const runtime = entity.getComponent(BehaviorTreeRuntimeComponent);
if (runtime && runtime.autoStart && runtime.treeAssetId && !runtime.isRunning) {
// 先尝试加载资产(如果是文件路径)
this.ensureAssetLoaded(runtime.treeAssetId).then(() => {
// 检查实体是否仍然有效
if (runtime && runtime.autoStart && !runtime.isRunning) {
runtime.start();
this.logger.debug(`Auto-started behavior tree for entity: ${entity.name}`);
}
}).catch(e => {
this.logger.error(`Failed to load behavior tree for entity ${entity.name}:`, e);
});
}
}
/**
* 确保行为树资产已加载
* Ensure behavior tree asset is loaded
*/
private async ensureAssetLoaded(assetIdOrPath: string): Promise<void> {
const btAssetManager = this.getBTAssetManager();
// 如果资产已存在,直接返回
if (btAssetManager.hasAsset(assetIdOrPath)) {
return;
}
// 使用 AssetManager 加载(必须通过 setAssetManager 设置)
// Use AssetManager (must be set via setAssetManager)
if (!this._assetManager) {
this.logger.warn(`AssetManager not set, cannot load: ${assetIdOrPath}`);
return;
}
try {
const result = await this._assetManager.loadAssetByPath(assetIdOrPath);
if (result && result.asset) {
this.logger.debug(`Behavior tree loaded via AssetManager: ${assetIdOrPath}`);
}
} catch (e) {
this.logger.warn(`Failed to load via AssetManager: ${assetIdOrPath}`, e);
}
}
private getBTAssetManager(): BehaviorTreeAssetManager {
if (!this.btAssetManager) {
const core = this.coreInstance || Core;
this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager);
}
return this.btAssetManager;
}
/**
@@ -64,7 +158,7 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
continue;
}
const treeData = this.getAssetManager().getAsset(runtime.treeAssetId);
const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId);
if (!treeData) {
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
continue;
@@ -76,6 +170,12 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
runtime.needsReset = false;
}
// 初始化黑板变量(如果行为树定义了默认值)
// Initialize blackboard variables from tree definition
if (treeData.blackboardVariables && treeData.blackboardVariables.size > 0) {
runtime.initializeBlackboard(treeData.blackboardVariables);
}
this.executeTree(entity, runtime, treeData);
}
}

View File

@@ -0,0 +1,37 @@
import { TaskStatus, NodeType } from '../../Types/TaskStatus';
import { INodeExecutor, NodeExecutionContext } from '../NodeExecutor';
import { NodeExecutorMetadata } from '../NodeMetadata';
/**
* 根节点执行器
*
* 行为树的入口节点,执行其唯一的子节点
*/
@NodeExecutorMetadata({
implementationType: 'Root',
nodeType: NodeType.Root,
displayName: '根节点',
description: '行为树的入口节点',
category: 'Root',
childrenConstraints: {
min: 1,
max: 1
}
})
export class RootExecutor implements INodeExecutor {
execute(context: NodeExecutionContext): TaskStatus {
const { nodeData } = context;
// 根节点必须有且仅有一个子节点
if (!nodeData.children || nodeData.children.length === 0) {
return TaskStatus.Failure;
}
const childId = nodeData.children[0]!;
return context.executeChild(childId);
}
reset(_context: NodeExecutionContext): void {
// 根节点没有需要重置的状态
}
}

View File

@@ -1,3 +1,4 @@
export { RootExecutor } from './RootExecutor';
export { SequenceExecutor } from './SequenceExecutor';
export { SelectorExecutor } from './SelectorExecutor';
export { ParallelExecutor } from './ParallelExecutor';

View File

@@ -7,6 +7,10 @@
* @packageDocumentation
*/
// Asset type constant for behavior tree
// 行为树资产类型常量
export const BehaviorTreeAssetType = 'behaviortree' as const;
// Types
export * from './Types/TaskStatus';

View File

@@ -0,0 +1,110 @@
/**
* Behavior Tree Asset Loader
* 行为树资产加载器
*
* 实现 IAssetLoader 接口,用于通过 AssetManager 加载行为树文件
*/
import type {
IAssetLoader,
IAssetMetadata,
IAssetLoadOptions,
IAssetLoadResult
} from '@esengine/asset-system';
import { Core } from '@esengine/ecs-framework';
import { BehaviorTreeData } from '../execution/BehaviorTreeData';
import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager';
import { EditorToBehaviorTreeDataConverter } from '../Serialization/EditorToBehaviorTreeDataConverter';
import { BehaviorTreeAssetType } from '../index';
/**
* 行为树资产接口
*/
export interface IBehaviorTreeAsset {
/** 行为树数据 */
data: BehaviorTreeData;
/** 文件路径 */
path: string;
}
/**
* 行为树加载器
* Behavior tree loader implementing IAssetLoader interface
*/
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
readonly supportedType = BehaviorTreeAssetType;
readonly supportedExtensions = ['.btree'];
/**
* 加载行为树资产
* Load behavior tree asset
*/
async load(
path: string,
metadata: IAssetMetadata,
_options?: IAssetLoadOptions
): Promise<IAssetLoadResult<IBehaviorTreeAsset>> {
// 获取文件系统服务
const IFileSystemServiceKey = Symbol.for('IFileSystemService');
const fileSystem = Core.services.tryResolve(IFileSystemServiceKey) as IFileSystem | null;
if (!fileSystem) {
throw new Error('FileSystem service not available');
}
// 读取文件内容
const content = await fileSystem.readFile(path);
// 转换为运行时数据
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content);
// 使用文件路径作为 ID
treeData.id = path;
// 注册到 BehaviorTreeAssetManager保持兼容性
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager) {
btAssetManager.loadAsset(treeData);
}
const asset: IBehaviorTreeAsset = {
data: treeData,
path
};
return {
asset,
handle: 0, // 由 AssetManager 分配
metadata,
loadTime: 0
};
}
/**
* 检查是否可以加载
* Check if can load this asset
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
return path.endsWith('.btree');
}
/**
* 释放资产
* Dispose asset
*/
dispose(asset: IBehaviorTreeAsset): void {
// 从 BehaviorTreeAssetManager 卸载
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager && asset.data) {
btAssetManager.unloadAsset(asset.data.id);
}
}
}
/**
* 文件系统接口(简化版,仅用于类型)
*/
interface IFileSystem {
readFile(path: string): Promise<string>;
exists(path: string): Promise<boolean>;
}

View File

@@ -3,51 +3,65 @@ import { resolve } from 'path';
import dts from 'vite-plugin-dts';
import react from '@vitejs/plugin-react';
// 自定义插件:将 CSS 内联到 JS 中
function inlineCSS(): any {
/**
* 自定义插件:将 CSS 转换为自执行的样式注入代码
* Custom plugin: Convert CSS to self-executing style injection code
*
* 当用户写 `import './styles.css'` 时,这个插件会:
* 1. 在构建时将 CSS 内容转换为 JS 代码
* 2. JS 代码在模块导入时自动执行,将样式注入到 DOM
* 3. 使用唯一 ID 防止重复注入
*/
function escapeUnsafeChars(str: string): string {
const charMap: Record<string, string> = {
'<': '\\u003C',
'>': '\\u003E',
'/': '\\u002F',
'\\': '\\\\',
'\u2028': '\\u2028',
'\u2029': '\\u2029'
};
return str.replace(/[<>\\/\u2028\u2029]/g, (x) => charMap[x] || x);
}
function injectCSSPlugin(): unknown {
const cssIdMap = new Map<string, string>();
let cssCounter = 0;
return {
name: 'inline-css',
name: 'inject-css-plugin',
enforce: 'post' as const,
// 在生成 bundle 时注入 CSS
generateBundle(_options: any, bundle: any) {
generateBundle(_options: unknown, bundle: Record<string, { type?: string; source?: string; code?: string }>) {
const bundleKeys = Object.keys(bundle);
// 找到 CSS 文件
const cssFile = bundleKeys.find(key => key.endsWith('.css'));
if (!cssFile || !bundle[cssFile]) {
return;
// 找到所有 CSS 文件
const cssFiles = bundleKeys.filter(key => key.endsWith('.css'));
for (const cssFile of cssFiles) {
const cssChunk = bundle[cssFile];
if (!cssChunk || !cssChunk.source) continue;
const cssContent = cssChunk.source;
const styleId = `esengine-behavior-tree-style-${cssCounter++}`;
cssIdMap.set(cssFile, styleId);
// 生成样式注入代码
const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${escapeUnsafeChars(JSON.stringify(cssContent))};document.head.appendChild(s);}}})();`;
// 注入到 editor/index.js 或共享 chunk
for (const jsKey of bundleKeys) {
if (!jsKey.endsWith('.js')) continue;
const jsChunk = bundle[jsKey];
if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue;
if (jsKey === 'editor/index.js' || jsKey.match(/^index-[^/]+\.js$/)) {
jsChunk.code = injectCode + '\n' + jsChunk.code;
}
}
// 删除独立的 CSS 文件
delete bundle[cssFile];
}
const cssContent = bundle[cssFile].source;
if (!cssContent) return;
// 找到包含编辑器代码的主要 JS 文件
// 优先查找 editor/index.js然后是带 hash 的 index-*.js
const mainJsFile = bundleKeys.find(key =>
(key === 'editor/index.js' || key.includes('index-')) &&
key.endsWith('.js') &&
bundle[key].type === 'chunk' &&
bundle[key].code
);
if (mainJsFile && bundle[mainJsFile]) {
const injectCode = `
(function() {
if (typeof document !== 'undefined') {
var style = document.createElement('style');
style.id = 'esengine-behavior-tree-styles';
if (!document.getElementById(style.id)) {
style.textContent = ${JSON.stringify(cssContent)};
document.head.appendChild(style);
}
}
})();
`;
bundle[mainJsFile].code = injectCode + bundle[mainJsFile].code;
}
// 删除独立的 CSS 文件(已内联)
delete bundle[cssFile];
}
};
}
@@ -60,7 +74,7 @@ export default defineConfig({
outDir: 'dist',
rollupTypes: false
}),
inlineCSS()
injectCSSPlugin()
],
esbuild: {
jsx: 'automatic',

View File

@@ -0,0 +1,85 @@
{
"name": "@esengine/blueprint",
"version": "1.0.0",
"description": "Visual scripting system for ECS Framework",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./editor": {
"types": "./dist/editor/index.d.ts",
"import": "./dist/editor/index.js"
},
"./plugin.json": "./plugin.json"
},
"files": [
"dist",
"plugin.json"
],
"scripts": {
"clean": "rimraf dist tsconfig.tsbuildinfo",
"build": "vite build",
"build:watch": "vite build --watch",
"type-check": "tsc --noEmit"
},
"keywords": [
"ecs",
"blueprint",
"visual-scripting",
"game-engine",
"node-editor"
],
"author": "yhh",
"license": "MIT",
"peerDependencies": {
"@esengine/ecs-framework": ">=2.0.0",
"@esengine/editor-runtime": "workspace:*",
"@esengine/node-editor": "workspace:*",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
"zustand": "^4.5.2"
},
"peerDependenciesMeta": {
"@esengine/editor-runtime": {
"optional": true
},
"@esengine/node-editor": {
"optional": true
},
"lucide-react": {
"optional": true
},
"react": {
"optional": true
},
"zustand": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^20.19.17",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.7.0",
"rimraf": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^6.0.7",
"vite-plugin-dts": "^3.7.0"
},
"dependencies": {
"tslib": "^2.8.1"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/esengine/ecs-framework.git",
"directory": "packages/blueprint"
}
}

View File

@@ -0,0 +1,30 @@
{
"id": "@esengine/blueprint",
"name": "Blueprint System",
"version": "1.0.0",
"description": "Visual scripting system for creating game logic without code",
"category": "scripting",
"loadingPhase": "default",
"enabledByDefault": true,
"canContainContent": true,
"isEnginePlugin": false,
"modules": [
{
"name": "BlueprintRuntime",
"type": "runtime",
"entry": "./src/runtime.ts"
},
{
"name": "BlueprintEditor",
"type": "editor",
"entry": "./src/editor/index.ts"
}
],
"dependencies": [
{
"id": "@esengine/core",
"version": ">=1.0.0"
}
],
"icon": "Workflow"
}

View File

@@ -0,0 +1,180 @@
/**
* Blueprint Editor Plugin - Integrates blueprint editor with the editor
* 蓝图编辑器插件 - 将蓝图编辑器与编辑器集成
*/
import {
type ServiceContainer,
type IPluginLoader,
type IEditorModuleLoader,
type PluginDescriptor,
type PanelDescriptor,
type MenuItemDescriptor,
type FileActionHandler,
type FileCreationTemplate,
PanelPosition,
FileSystem,
createLogger,
MessageHub,
IMessageHub
} from '@esengine/editor-runtime';
import { BlueprintEditorPanel } from './components/BlueprintEditorPanel';
import { useBlueprintEditorStore } from './stores/blueprintEditorStore';
import { createEmptyBlueprint, validateBlueprintAsset } from '../types/blueprint';
const logger = createLogger('BlueprintEditorModule');
/**
* Blueprint 编辑器模块
* Blueprint editor module
*/
class BlueprintEditorModule implements IEditorModuleLoader {
private services?: ServiceContainer;
async install(services: ServiceContainer): Promise<void> {
this.services = services;
logger.info('Blueprint editor module installed');
}
async uninstall(): Promise<void> {
logger.info('Blueprint editor module uninstalled');
}
getPanels(): PanelDescriptor[] {
return [
{
id: 'panel-blueprint-editor',
title: 'Blueprint Editor',
position: PanelPosition.Center,
defaultSize: 800,
resizable: true,
closable: true,
icon: 'Workflow',
order: 20,
isDynamic: true,
component: BlueprintEditorPanel
}
];
}
getMenuItems(): MenuItemDescriptor[] {
return [
{
id: 'blueprint-new',
label: 'New Blueprint',
parentId: 'file',
shortcut: 'Ctrl+Shift+B',
execute: () => {
useBlueprintEditorStore.getState().createNewBlueprint('New Blueprint');
}
},
{
id: 'view-blueprint-editor',
label: 'Blueprint Editor',
parentId: 'view',
shortcut: 'Ctrl+B'
}
];
}
getFileActionHandlers(): FileActionHandler[] {
const services = this.services;
return [
{
extensions: ['bp'],
onDoubleClick: async (filePath: string) => {
try {
// 使用 FileSystem API 读取文件
const content = await FileSystem.readTextFile(filePath);
const data = JSON.parse(content);
if (validateBlueprintAsset(data)) {
useBlueprintEditorStore.getState().loadBlueprint(data, filePath);
logger.info('Loaded blueprint:', filePath);
// 打开蓝图编辑器面板
if (services) {
const messageHub = services.resolve<MessageHub>(IMessageHub);
if (messageHub) {
const fileName = filePath.split(/[\\/]/).pop() || 'Blueprint';
messageHub.publish('dynamic-panel:open', {
panelId: 'panel-blueprint-editor',
title: `Blueprint - ${fileName}`
});
}
}
} else {
logger.error('Invalid blueprint file:', filePath);
}
} catch (error) {
logger.error('Failed to load blueprint:', error);
}
}
}
];
}
getFileCreationTemplates(): FileCreationTemplate[] {
return [
{
id: 'create-blueprint',
label: 'Blueprint',
extension: 'bp',
icon: 'Workflow',
category: 'scripting',
getContent: (fileName: string) => {
const name = fileName.replace('.bp', '');
const blueprint = createEmptyBlueprint(name);
return JSON.stringify(blueprint, null, 2);
}
}
];
}
async onEditorReady(): Promise<void> {
logger.info('Editor ready');
}
async onProjectOpen(_projectPath: string): Promise<void> {
logger.info('Project opened');
}
async onProjectClose(): Promise<void> {
useBlueprintEditorStore.getState().createNewBlueprint('New Blueprint');
logger.info('Project closed');
}
}
/**
* Plugin descriptor
* 插件描述符
*/
const descriptor: PluginDescriptor = {
id: '@esengine/blueprint',
name: 'Blueprint Visual Scripting',
version: '1.0.0',
description: 'Visual scripting system for creating game logic without code',
category: 'scripting',
icon: 'Workflow',
enabledByDefault: true,
canContainContent: true,
isEnginePlugin: true,
isCore: false,
modules: [
{
name: 'BlueprintEditor',
type: 'editor',
loadingPhase: 'default',
panels: ['panel-blueprint-editor']
}
]
};
/**
* Blueprint Plugin Export
* 蓝图插件导出
*/
export const BlueprintPlugin: IPluginLoader = {
descriptor,
editorModule: new BlueprintEditorModule()
};

View File

@@ -0,0 +1,383 @@
/**
* Blueprint Canvas - Main canvas for editing blueprints using NodeEditor
* 蓝图画布 - 使用 NodeEditor 编辑蓝图的主画布
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
NodeEditor,
Graph,
GraphNode,
Position,
Connection,
NodeContextMenu,
ConfirmDialog,
type NodeTemplate,
type NodeCategory,
type PinCategory
} from '@esengine/node-editor';
import { useBlueprintEditorStore } from '../stores/blueprintEditorStore';
import { NodeRegistry } from '../../runtime/NodeRegistry';
import type { BlueprintNode, BlueprintConnection, BlueprintNodeTemplate } from '../../types/nodes';
import type { BlueprintPinDefinition } from '../../types/pins';
interface ContextMenuState {
isOpen: boolean;
screenPosition: { x: number; y: number };
canvasPosition: Position;
}
interface DeleteDialogState {
isOpen: boolean;
nodeId: string;
nodeTitle: string;
}
/**
* Map blueprint pin type to node-editor PinCategory
*/
function mapPinCategory(type: string): PinCategory {
switch (type) {
case 'exec':
return 'exec';
case 'boolean':
case 'bool':
return 'bool';
case 'integer':
case 'int':
return 'int';
case 'float':
case 'number':
return 'float';
case 'string':
return 'string';
case 'vector2':
return 'vector2';
case 'vector3':
return 'vector3';
case 'vector4':
return 'vector4';
case 'color':
return 'color';
case 'object':
case 'reference':
return 'object';
case 'array':
return 'array';
case 'struct':
return 'struct';
case 'enum':
return 'enum';
default:
return 'any';
}
}
/**
* Map blueprint category to node-editor NodeCategory
*/
function mapNodeCategory(category?: string): NodeCategory {
switch (category) {
case 'event':
return 'event';
case 'function':
return 'function';
case 'pure':
return 'pure';
case 'flow':
return 'flow';
case 'variable':
return 'variable';
case 'literal':
return 'literal';
case 'comment':
return 'comment';
default:
return 'function';
}
}
/**
* Convert blueprint node template to node-editor template
*/
function convertNodeTemplate(bpTemplate: BlueprintNodeTemplate): NodeTemplate {
return {
id: bpTemplate.type,
title: bpTemplate.title,
category: mapNodeCategory(bpTemplate.category),
icon: bpTemplate.icon,
inputPins: bpTemplate.inputs.map((p: BlueprintPinDefinition) => ({
name: p.name,
displayName: p.displayName || p.name,
category: mapPinCategory(p.type),
defaultValue: p.defaultValue
})),
outputPins: bpTemplate.outputs.map((p: BlueprintPinDefinition) => ({
name: p.name,
displayName: p.displayName || p.name,
category: mapPinCategory(p.type)
}))
};
}
/**
* Convert blueprint node to graph node
*/
function convertToGraphNode(node: BlueprintNode): GraphNode | null {
const bpTemplate = NodeRegistry.instance.getTemplate(node.type);
if (!bpTemplate) return null;
const template = convertNodeTemplate(bpTemplate);
return new GraphNode(
node.id,
template,
new Position(node.position.x, node.position.y),
node.data
);
}
/**
* Convert blueprint connection to graph connection
*/
function convertToGraphConnection(
conn: BlueprintConnection,
nodes: BlueprintNode[],
graphNodes: GraphNode[]
): Connection | null {
const fromNode = nodes.find(n => n.id === conn.fromNodeId);
const toNode = nodes.find(n => n.id === conn.toNodeId);
if (!fromNode || !toNode) return null;
const fromTemplate = NodeRegistry.instance.getTemplate(fromNode.type);
if (!fromTemplate) return null;
const fromPin = fromTemplate.outputs.find(p => p.name === conn.fromPin);
if (!fromPin) return null;
// Find graph nodes to get the actual pin IDs
const fromGraphNode = graphNodes.find(n => n.id === conn.fromNodeId);
const toGraphNode = graphNodes.find(n => n.id === conn.toNodeId);
if (!fromGraphNode || !toGraphNode) return null;
// Find pins by name
const fromGraphPin = fromGraphNode.outputPins.find(p => p.name === conn.fromPin);
const toGraphPin = toGraphNode.inputPins.find(p => p.name === conn.toPin);
if (!fromGraphPin || !toGraphPin) return null;
return new Connection(
conn.id,
conn.fromNodeId,
fromGraphPin.id,
conn.toNodeId,
toGraphPin.id,
mapPinCategory(fromPin.type)
);
}
/**
* Blueprint Canvas Component using NodeEditor
*/
export const BlueprintCanvas: React.FC = () => {
const {
blueprint,
selectedNodeIds,
selectNodes,
updateNodePosition,
addNode,
addConnection,
removeNode,
removeConnection
} = useBlueprintEditorStore();
const [selectedConnections, setSelectedConnections] = useState<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
screenPosition: { x: 0, y: 0 },
canvasPosition: new Position(0, 0)
});
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState>({
isOpen: false,
nodeId: '',
nodeTitle: ''
});
// Convert blueprint to Graph
const graph = useMemo(() => {
if (!blueprint) return Graph.empty('blueprint', 'Blueprint');
const graphNodes: GraphNode[] = [];
for (const node of blueprint.nodes) {
const graphNode = convertToGraphNode(node);
if (graphNode) {
graphNodes.push(graphNode);
}
}
const graphConnections: Connection[] = [];
for (const conn of blueprint.connections) {
const graphConn = convertToGraphConnection(conn, blueprint.nodes, graphNodes);
if (graphConn) {
graphConnections.push(graphConn);
}
}
return new Graph('blueprint', blueprint.metadata.name, graphNodes, graphConnections);
}, [blueprint]);
// Handle graph changes
const handleGraphChange = useCallback((newGraph: Graph) => {
if (!blueprint) return;
// Update node positions
for (const graphNode of newGraph.nodes) {
const oldNode = blueprint.nodes.find(n => n.id === graphNode.id);
if (oldNode) {
if (oldNode.position.x !== graphNode.position.x || oldNode.position.y !== graphNode.position.y) {
updateNodePosition(graphNode.id, graphNode.position.x, graphNode.position.y);
}
}
}
// Handle new connections
for (const graphConn of newGraph.connections) {
const exists = blueprint.connections.some(c => c.id === graphConn.id);
if (!exists) {
// Extract pin names from graph connection
const fromNode = newGraph.getNode(graphConn.fromNodeId);
const toNode = newGraph.getNode(graphConn.toNodeId);
if (fromNode && toNode) {
const fromPin = fromNode.outputPins.find(p => p.id === graphConn.fromPinId);
const toPin = toNode.inputPins.find(p => p.id === graphConn.toPinId);
if (fromPin && toPin) {
addConnection({
id: graphConn.id,
fromNodeId: graphConn.fromNodeId,
fromPin: fromPin.name,
toNodeId: graphConn.toNodeId,
toPin: toPin.name
});
}
}
}
}
// Handle removed connections
for (const oldConn of blueprint.connections) {
const exists = newGraph.connections.some(c => c.id === oldConn.id);
if (!exists) {
removeConnection(oldConn.id);
}
}
}, [blueprint, updateNodePosition, addConnection, removeConnection]);
// Handle selection changes
const handleSelectionChange = useCallback((nodeIds: Set<string>, connectionIds: Set<string>) => {
selectNodes(Array.from(nodeIds));
setSelectedConnections(connectionIds);
}, [selectNodes]);
// Handle canvas context menu - open node selection menu
const handleCanvasContextMenu = useCallback((position: Position, e: React.MouseEvent) => {
setContextMenu({
isOpen: true,
screenPosition: { x: e.clientX, y: e.clientY },
canvasPosition: position
});
}, []);
// Handle template selection from context menu
const handleSelectTemplate = useCallback((template: NodeTemplate, position: Position) => {
addNode({
id: '',
type: template.id,
position: { x: position.x, y: position.y },
data: {}
});
}, [addNode]);
// Close context menu
const handleCloseContextMenu = useCallback(() => {
setContextMenu(prev => ({ ...prev, isOpen: false }));
}, []);
// Handle node context menu
const handleNodeContextMenu = useCallback((node: GraphNode, e: React.MouseEvent) => {
e.preventDefault();
setDeleteDialog({
isOpen: true,
nodeId: node.id,
nodeTitle: node.title
});
}, []);
// Handle delete confirmation
const handleConfirmDelete = useCallback(() => {
if (deleteDialog.nodeId) {
removeNode(deleteDialog.nodeId);
}
setDeleteDialog({ isOpen: false, nodeId: '', nodeTitle: '' });
}, [deleteDialog.nodeId, removeNode]);
// Handle delete cancel
const handleCancelDelete = useCallback(() => {
setDeleteDialog({ isOpen: false, nodeId: '', nodeTitle: '' });
}, []);
// Get available templates
const templates = useMemo(() => {
const allTemplates = NodeRegistry.instance.getAllTemplates();
return allTemplates.map(t => convertNodeTemplate(t));
}, []);
if (!blueprint) {
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1a1a2e',
color: '#666'
}}>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: '16px', marginBottom: '8px' }}>No blueprint loaded</p>
<p style={{ fontSize: '12px', opacity: 0.7 }}>Create a new blueprint or open an existing one</p>
</div>
</div>
);
}
return (
<>
<NodeEditor
graph={graph}
templates={templates}
selectedNodeIds={new Set(selectedNodeIds)}
selectedConnectionIds={selectedConnections}
onGraphChange={handleGraphChange}
onSelectionChange={handleSelectionChange}
onCanvasContextMenu={handleCanvasContextMenu}
onNodeContextMenu={handleNodeContextMenu}
/>
<NodeContextMenu
isOpen={contextMenu.isOpen}
position={contextMenu.screenPosition}
canvasPosition={contextMenu.canvasPosition}
templates={templates}
onSelectTemplate={handleSelectTemplate}
onClose={handleCloseContextMenu}
/>
<ConfirmDialog
isOpen={deleteDialog.isOpen}
title="Delete Node"
message={`Are you sure you want to delete "${deleteDialog.nodeTitle}"?`}
confirmText="Delete"
cancelText="Cancel"
type="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
);
};

View File

@@ -0,0 +1,48 @@
/**
* Blueprint Editor Panel - Main panel for blueprint editing
* 蓝图编辑器面板 - 蓝图编辑的主面板
*/
import React, { useEffect } from 'react';
import { BlueprintCanvas } from './BlueprintCanvas';
import { useBlueprintEditorStore } from '../stores/blueprintEditorStore';
// Import nodes to register them
// 导入节点以注册它们
import '../../nodes';
/**
* Panel container styles
* 面板容器样式
*/
const panelStyles: React.CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1a1a2e',
color: '#fff',
overflow: 'hidden'
};
/**
* Blueprint Editor Panel Component
* 蓝图编辑器面板组件
*/
export const BlueprintEditorPanel: React.FC = () => {
const { blueprint, createNewBlueprint } = useBlueprintEditorStore();
// Create a default blueprint if none exists
// 如果不存在则创建默认蓝图
useEffect(() => {
if (!blueprint) {
createNewBlueprint('New Blueprint');
}
}, [blueprint, createNewBlueprint]);
return (
<div style={panelStyles}>
<BlueprintCanvas />
</div>
);
};

View File

@@ -0,0 +1,7 @@
/**
* Blueprint Editor Components
* 蓝图编辑器组件
*/
export * from './BlueprintCanvas';
export * from './BlueprintEditorPanel';

View File

@@ -0,0 +1,8 @@
/**
* Blueprint Editor Module
* 蓝图编辑器模块
*/
export * from './components';
export * from './stores';
export * from './BlueprintPlugin';

View File

@@ -0,0 +1,256 @@
/**
* Blueprint Editor Store - State management for blueprint editor
* 蓝图编辑器状态管理
*/
import { create } from 'zustand';
import { BlueprintAsset, createEmptyBlueprint } from '../../types/blueprint';
import { BlueprintNode, BlueprintConnection } from '../../types/nodes';
/**
* Blueprint editor state interface
* 蓝图编辑器状态接口
*/
interface BlueprintEditorState {
/** Current blueprint being edited (当前编辑的蓝图) */
blueprint: BlueprintAsset | null;
/** Selected node IDs (选中的节点ID) */
selectedNodeIds: string[];
/** Currently dragging node (当前拖拽的节点) */
draggingNodeId: string | null;
/** Canvas pan offset (画布平移偏移) */
panOffset: { x: number; y: number };
/** Canvas zoom level (画布缩放级别) */
zoom: number;
/** Whether the blueprint has unsaved changes (是否有未保存的更改) */
isDirty: boolean;
/** Current file path if saved (当前文件路径) */
filePath: string | null;
// Actions (操作)
/** Create new blueprint (创建新蓝图) */
createNewBlueprint: (name: string) => void;
/** Load blueprint from asset (从资产加载蓝图) */
loadBlueprint: (asset: BlueprintAsset, filePath?: string) => void;
/** Add a node (添加节点) */
addNode: (node: BlueprintNode) => void;
/** Remove a node (移除节点) */
removeNode: (nodeId: string) => void;
/** Update node position (更新节点位置) */
updateNodePosition: (nodeId: string, x: number, y: number) => void;
/** Update node data (更新节点数据) */
updateNodeData: (nodeId: string, data: Record<string, unknown>) => void;
/** Add connection (添加连接) */
addConnection: (connection: BlueprintConnection) => void;
/** Remove connection (移除连接) */
removeConnection: (connectionId: string) => void;
/** Select nodes (选择节点) */
selectNodes: (nodeIds: string[]) => void;
/** Clear selection (清除选择) */
clearSelection: () => void;
/** Set pan offset (设置平移偏移) */
setPanOffset: (x: number, y: number) => void;
/** Set zoom level (设置缩放级别) */
setZoom: (zoom: number) => void;
/** Mark as dirty (标记为已修改) */
markDirty: () => void;
/** Mark as clean (标记为未修改) */
markClean: () => void;
}
/**
* Generate unique ID for nodes and connections
* 为节点和连接生成唯一ID
*/
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Blueprint editor store
* 蓝图编辑器状态存储
*/
export const useBlueprintEditorStore = create<BlueprintEditorState>((set, get) => ({
blueprint: null,
selectedNodeIds: [],
draggingNodeId: null,
panOffset: { x: 0, y: 0 },
zoom: 1,
isDirty: false,
filePath: null,
createNewBlueprint: (name: string) => {
const blueprint = createEmptyBlueprint(name);
set({
blueprint,
selectedNodeIds: [],
panOffset: { x: 0, y: 0 },
zoom: 1,
isDirty: false,
filePath: null
});
},
loadBlueprint: (asset: BlueprintAsset, filePath?: string) => {
set({
blueprint: asset,
selectedNodeIds: [],
panOffset: { x: 0, y: 0 },
zoom: 1,
isDirty: false,
filePath: filePath ?? null
});
},
addNode: (node: BlueprintNode) => {
const { blueprint } = get();
if (!blueprint) return;
const newNode = { ...node, id: node.id || generateId() };
set({
blueprint: {
...blueprint,
nodes: [...blueprint.nodes, newNode],
metadata: { ...blueprint.metadata, modifiedAt: Date.now() }
},
isDirty: true
});
},
removeNode: (nodeId: string) => {
const { blueprint } = get();
if (!blueprint) return;
set({
blueprint: {
...blueprint,
nodes: blueprint.nodes.filter(n => n.id !== nodeId),
connections: blueprint.connections.filter(
c => c.fromNodeId !== nodeId && c.toNodeId !== nodeId
),
metadata: { ...blueprint.metadata, modifiedAt: Date.now() }
},
selectedNodeIds: get().selectedNodeIds.filter(id => id !== nodeId),
isDirty: true
});
},
updateNodePosition: (nodeId: string, x: number, y: number) => {
const { blueprint } = get();
if (!blueprint) return;
set({
blueprint: {
...blueprint,
nodes: blueprint.nodes.map(n =>
n.id === nodeId ? { ...n, position: { x, y } } : n
),
metadata: { ...blueprint.metadata, modifiedAt: Date.now() }
},
isDirty: true
});
},
updateNodeData: (nodeId: string, data: Record<string, unknown>) => {
const { blueprint } = get();
if (!blueprint) return;
set({
blueprint: {
...blueprint,
nodes: blueprint.nodes.map(n =>
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n
),
metadata: { ...blueprint.metadata, modifiedAt: Date.now() }
},
isDirty: true
});
},
addConnection: (connection: BlueprintConnection) => {
const { blueprint } = get();
if (!blueprint) return;
const newConnection = { ...connection, id: connection.id || generateId() };
// Check for existing connection to the same input pin
// 检查是否已存在到同一输入引脚的连接
const existingIndex = blueprint.connections.findIndex(
c => c.toNodeId === connection.toNodeId && c.toPin === connection.toPin
);
const newConnections = [...blueprint.connections];
if (existingIndex >= 0) {
// Replace existing connection (替换现有连接)
newConnections[existingIndex] = newConnection;
} else {
newConnections.push(newConnection);
}
set({
blueprint: {
...blueprint,
connections: newConnections,
metadata: { ...blueprint.metadata, modifiedAt: Date.now() }
},
isDirty: true
});
},
removeConnection: (connectionId: string) => {
const { blueprint } = get();
if (!blueprint) return;
set({
blueprint: {
...blueprint,
connections: blueprint.connections.filter(c => c.id !== connectionId),
metadata: { ...blueprint.metadata, modifiedAt: Date.now() }
},
isDirty: true
});
},
selectNodes: (nodeIds: string[]) => {
set({ selectedNodeIds: nodeIds });
},
clearSelection: () => {
set({ selectedNodeIds: [] });
},
setPanOffset: (x: number, y: number) => {
set({ panOffset: { x, y } });
},
setZoom: (zoom: number) => {
set({ zoom: Math.max(0.1, Math.min(2, zoom)) });
},
markDirty: () => {
set({ isDirty: true });
},
markClean: () => {
set({ isDirty: false });
}
}));

View File

@@ -0,0 +1,6 @@
/**
* Blueprint Editor Stores
* 蓝图编辑器状态存储
*/
export * from './blueprintEditorStore';

View File

@@ -0,0 +1,31 @@
/**
* @esengine/blueprint - Visual scripting system for ECS Framework
* 蓝图可视化脚本系统
*/
// Types
export * from './types';
// Runtime
export * from './runtime';
// Nodes (import to register)
import './nodes';
// Re-export commonly used items
export { NodeRegistry, RegisterNode } from './runtime/NodeRegistry';
export { BlueprintVM } from './runtime/BlueprintVM';
export {
createBlueprintComponentData,
initializeBlueprintVM,
startBlueprint,
stopBlueprint,
tickBlueprint,
cleanupBlueprint
} from './runtime/BlueprintComponent';
export {
createBlueprintSystem,
triggerBlueprintEvent,
triggerCustomBlueprintEvent
} from './runtime/BlueprintSystem';
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';

View File

@@ -0,0 +1,91 @@
/**
* Print Node - Outputs a message for debugging
* 打印节点 - 输出调试消息
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* Print node template
* Print 节点模板
*/
export const PrintTemplate: BlueprintNodeTemplate = {
type: 'Print',
title: 'Print String',
category: 'debug',
color: '#785EF0',
description: 'Prints a message to the console for debugging (打印消息到控制台用于调试)',
keywords: ['log', 'debug', 'console', 'output', 'print'],
inputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'message',
type: 'string',
displayName: 'Message',
defaultValue: 'Hello Blueprint!'
},
{
name: 'printToScreen',
type: 'bool',
displayName: 'Print to Screen',
defaultValue: true
},
{
name: 'duration',
type: 'float',
displayName: 'Duration',
defaultValue: 2.0
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
}
]
};
/**
* Print node executor
* Print 节点执行器
*/
@RegisterNode(PrintTemplate)
export class PrintExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const message = context.evaluateInput(node.id, 'message', 'Hello Blueprint!');
const printToScreen = context.evaluateInput(node.id, 'printToScreen', true);
const duration = context.evaluateInput(node.id, 'duration', 2.0);
// Console output
// 控制台输出
console.log(`[Blueprint] ${message}`);
// Screen output via event (handled by runtime)
// 通过事件输出到屏幕(由运行时处理)
if (printToScreen) {
const event = new CustomEvent('blueprint:print', {
detail: {
message: String(message),
duration: Number(duration),
entityId: context.entity.id,
entityName: context.entity.name
}
});
if (typeof window !== 'undefined') {
window.dispatchEvent(event);
}
}
return {
nextExec: 'exec'
};
}
}

View File

@@ -0,0 +1,6 @@
/**
* Debug Nodes - Tools for debugging blueprints
* 调试节点 - 蓝图调试工具
*/
export * from './Print';

View File

@@ -0,0 +1,44 @@
/**
* Event Begin Play Node - Triggered when the blueprint starts
* 开始播放事件节点 - 蓝图启动时触发
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* EventBeginPlay node template
* EventBeginPlay 节点模板
*/
export const EventBeginPlayTemplate: BlueprintNodeTemplate = {
type: 'EventBeginPlay',
title: 'Event Begin Play',
category: 'event',
color: '#CC0000',
description: 'Triggered once when the blueprint starts executing (蓝图开始执行时触发一次)',
keywords: ['start', 'begin', 'init', 'event'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
}
]
};
/**
* EventBeginPlay node executor
* EventBeginPlay 节点执行器
*/
@RegisterNode(EventBeginPlayTemplate)
export class EventBeginPlayExecutor implements INodeExecutor {
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
// Event nodes just trigger execution flow
// 事件节点只触发执行流
return {
nextExec: 'exec'
};
}
}

View File

@@ -0,0 +1,42 @@
/**
* Event End Play Node - Triggered when the blueprint stops
* 结束播放事件节点 - 蓝图停止时触发
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* EventEndPlay node template
* EventEndPlay 节点模板
*/
export const EventEndPlayTemplate: BlueprintNodeTemplate = {
type: 'EventEndPlay',
title: 'Event End Play',
category: 'event',
color: '#CC0000',
description: 'Triggered once when the blueprint stops executing (蓝图停止执行时触发一次)',
keywords: ['stop', 'end', 'destroy', 'event'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
}
]
};
/**
* EventEndPlay node executor
* EventEndPlay 节点执行器
*/
@RegisterNode(EventEndPlayTemplate)
export class EventEndPlayExecutor implements INodeExecutor {
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult {
return {
nextExec: 'exec'
};
}
}

View File

@@ -0,0 +1,50 @@
/**
* Event Tick Node - Triggered every frame
* 每帧事件节点 - 每帧触发
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* EventTick node template
* EventTick 节点模板
*/
export const EventTickTemplate: BlueprintNodeTemplate = {
type: 'EventTick',
title: 'Event Tick',
category: 'event',
color: '#CC0000',
description: 'Triggered every frame during execution (执行期间每帧触发)',
keywords: ['update', 'frame', 'tick', 'event'],
inputs: [],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'deltaTime',
type: 'float',
displayName: 'Delta Seconds'
}
]
};
/**
* EventTick node executor
* EventTick 节点执行器
*/
@RegisterNode(EventTickTemplate)
export class EventTickExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return {
nextExec: 'exec',
outputs: {
deltaTime: context.deltaTime
}
};
}
}

View File

@@ -0,0 +1,8 @@
/**
* Event Nodes - Entry points for blueprint execution
* 事件节点 - 蓝图执行的入口点
*/
export * from './EventBeginPlay';
export * from './EventTick';
export * from './EventEndPlay';

View File

@@ -0,0 +1,11 @@
/**
* Blueprint Nodes - All node definitions and executors
* 蓝图节点 - 所有节点定义和执行器
*/
// Import all nodes to trigger registration
// 导入所有节点以触发注册
export * from './events';
export * from './debug';
export * from './time';
export * from './math';

View File

@@ -0,0 +1,122 @@
/**
* Math Operation Nodes - Basic arithmetic operations
* 数学运算节点 - 基础算术运算
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// Add Node (加法节点)
export const AddTemplate: BlueprintNodeTemplate = {
type: 'Add',
title: 'Add',
category: 'math',
color: '#4CAF50',
description: 'Adds two numbers together (将两个数字相加)',
keywords: ['add', 'plus', 'sum', '+', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(AddTemplate)
export class AddExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a + b } };
}
}
// Subtract Node (减法节点)
export const SubtractTemplate: BlueprintNodeTemplate = {
type: 'Subtract',
title: 'Subtract',
category: 'math',
color: '#4CAF50',
description: 'Subtracts B from A (从 A 减去 B)',
keywords: ['subtract', 'minus', '-', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(SubtractTemplate)
export class SubtractExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a - b } };
}
}
// Multiply Node (乘法节点)
export const MultiplyTemplate: BlueprintNodeTemplate = {
type: 'Multiply',
title: 'Multiply',
category: 'math',
color: '#4CAF50',
description: 'Multiplies two numbers (将两个数字相乘)',
keywords: ['multiply', 'times', '*', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MultiplyTemplate)
export class MultiplyExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
return { outputs: { result: a * b } };
}
}
// Divide Node (除法节点)
export const DivideTemplate: BlueprintNodeTemplate = {
type: 'Divide',
title: 'Divide',
category: 'math',
color: '#4CAF50',
description: 'Divides A by B (A 除以 B)',
keywords: ['divide', '/', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(DivideTemplate)
export class DivideExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
// Prevent division by zero (防止除零)
if (b === 0) {
return { outputs: { result: 0 } };
}
return { outputs: { result: a / b } };
}
}

View File

@@ -0,0 +1,6 @@
/**
* Math Nodes - Mathematical operation nodes
* 数学节点 - 数学运算节点
*/
export * from './MathOperations';

View File

@@ -0,0 +1,57 @@
/**
* Delay Node - Pauses execution for a specified duration
* 延迟节点 - 暂停执行指定的时长
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* Delay node template
* Delay 节点模板
*/
export const DelayTemplate: BlueprintNodeTemplate = {
type: 'Delay',
title: 'Delay',
category: 'flow',
color: '#FFFFFF',
description: 'Pauses execution for a specified number of seconds (暂停执行指定的秒数)',
keywords: ['wait', 'delay', 'pause', 'sleep', 'timer'],
inputs: [
{
name: 'exec',
type: 'exec',
displayName: ''
},
{
name: 'duration',
type: 'float',
displayName: 'Duration',
defaultValue: 1.0
}
],
outputs: [
{
name: 'exec',
type: 'exec',
displayName: 'Completed'
}
]
};
/**
* Delay node executor
* Delay 节点执行器
*/
@RegisterNode(DelayTemplate)
export class DelayExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const duration = context.evaluateInput(node.id, 'duration', 1.0) as number;
return {
nextExec: 'exec',
delay: duration
};
}
}

View File

@@ -0,0 +1,45 @@
/**
* Get Delta Time Node - Returns the time since last frame
* 获取增量时间节点 - 返回上一帧以来的时间
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* GetDeltaTime node template
* GetDeltaTime 节点模板
*/
export const GetDeltaTimeTemplate: BlueprintNodeTemplate = {
type: 'GetDeltaTime',
title: 'Get Delta Time',
category: 'time',
color: '#4FC3F7',
description: 'Returns the time elapsed since the last frame in seconds (返回上一帧以来经过的时间,单位秒)',
keywords: ['delta', 'time', 'frame', 'dt'],
isPure: true,
inputs: [],
outputs: [
{
name: 'deltaTime',
type: 'float',
displayName: 'Delta Seconds'
}
]
};
/**
* GetDeltaTime node executor
* GetDeltaTime 节点执行器
*/
@RegisterNode(GetDeltaTimeTemplate)
export class GetDeltaTimeExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return {
outputs: {
deltaTime: context.deltaTime
}
};
}
}

View File

@@ -0,0 +1,45 @@
/**
* Get Time Node - Returns the total time since blueprint started
* 获取时间节点 - 返回蓝图启动以来的总时间
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
/**
* GetTime node template
* GetTime 节点模板
*/
export const GetTimeTemplate: BlueprintNodeTemplate = {
type: 'GetTime',
title: 'Get Game Time',
category: 'time',
color: '#4FC3F7',
description: 'Returns the total time since the blueprint started in seconds (返回蓝图启动以来的总时间,单位秒)',
keywords: ['time', 'total', 'elapsed', 'game'],
isPure: true,
inputs: [],
outputs: [
{
name: 'time',
type: 'float',
displayName: 'Seconds'
}
]
};
/**
* GetTime node executor
* GetTime 节点执行器
*/
@RegisterNode(GetTimeTemplate)
export class GetTimeExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return {
outputs: {
time: context.time
}
};
}
}

View File

@@ -0,0 +1,8 @@
/**
* Time Nodes - Time-related utility nodes
* 时间节点 - 时间相关的工具节点
*/
export * from './GetDeltaTime';
export * from './GetTime';
export * from './Delay';

View File

@@ -0,0 +1,116 @@
/**
* Blueprint Component - Attaches a blueprint to an entity
* 蓝图组件 - 将蓝图附加到实体
*/
import { BlueprintAsset } from '../types/blueprint';
import { BlueprintVM } from './BlueprintVM';
import { IEntity, IScene } from './ExecutionContext';
/**
* Component interface for ECS integration
* 用于 ECS 集成的组件接口
*/
export interface IBlueprintComponent {
/** Entity ID this component belongs to (此组件所属的实体ID) */
entityId: number | null;
/** Blueprint asset reference (蓝图资产引用) */
blueprintAsset: BlueprintAsset | null;
/** Blueprint asset path for serialization (用于序列化的蓝图资产路径) */
blueprintPath: string;
/** Auto-start execution when entity is created (实体创建时自动开始执行) */
autoStart: boolean;
/** Enable debug mode for VM (启用 VM 调试模式) */
debug: boolean;
/** Runtime VM instance (运行时 VM 实例) */
vm: BlueprintVM | null;
/** Whether the blueprint has started (蓝图是否已启动) */
isStarted: boolean;
}
/**
* Creates a blueprint component data object
* 创建蓝图组件数据对象
*/
export function createBlueprintComponentData(): IBlueprintComponent {
return {
entityId: null,
blueprintAsset: null,
blueprintPath: '',
autoStart: true,
debug: false,
vm: null,
isStarted: false
};
}
/**
* Initialize the VM for a blueprint component
* 为蓝图组件初始化 VM
*/
export function initializeBlueprintVM(
component: IBlueprintComponent,
entity: IEntity,
scene: IScene
): void {
if (!component.blueprintAsset) {
return;
}
// Create VM instance
// 创建 VM 实例
component.vm = new BlueprintVM(component.blueprintAsset, entity, scene);
component.vm.debug = component.debug;
}
/**
* Start blueprint execution
* 开始蓝图执行
*/
export function startBlueprint(component: IBlueprintComponent): void {
if (component.vm && !component.isStarted) {
component.vm.start();
component.isStarted = true;
}
}
/**
* Stop blueprint execution
* 停止蓝图执行
*/
export function stopBlueprint(component: IBlueprintComponent): void {
if (component.vm && component.isStarted) {
component.vm.stop();
component.isStarted = false;
}
}
/**
* Update blueprint execution
* 更新蓝图执行
*/
export function tickBlueprint(component: IBlueprintComponent, deltaTime: number): void {
if (component.vm && component.isStarted) {
component.vm.tick(deltaTime);
}
}
/**
* Clean up blueprint resources
* 清理蓝图资源
*/
export function cleanupBlueprint(component: IBlueprintComponent): void {
if (component.vm) {
if (component.isStarted) {
component.vm.stop();
}
component.vm = null;
component.isStarted = false;
}
}

View File

@@ -0,0 +1,121 @@
/**
* Blueprint Execution System - Manages blueprint lifecycle and execution
* 蓝图执行系统 - 管理蓝图生命周期和执行
*/
import {
IBlueprintComponent,
initializeBlueprintVM,
startBlueprint,
tickBlueprint,
cleanupBlueprint
} from './BlueprintComponent';
import { IEntity, IScene } from './ExecutionContext';
/**
* Blueprint system interface for engine integration
* 用于引擎集成的蓝图系统接口
*/
export interface IBlueprintSystem {
/** Process entities with blueprint components (处理带有蓝图组件的实体) */
process(entities: IBlueprintEntity[], deltaTime: number): void;
/** Called when entity is added to system (实体添加到系统时调用) */
onEntityAdded(entity: IBlueprintEntity): void;
/** Called when entity is removed from system (实体从系统移除时调用) */
onEntityRemoved(entity: IBlueprintEntity): void;
}
/**
* Entity with blueprint component
* 带有蓝图组件的实体
*/
export interface IBlueprintEntity extends IEntity {
/** Blueprint component data (蓝图组件数据) */
blueprintComponent: IBlueprintComponent;
}
/**
* Creates a blueprint execution system
* 创建蓝图执行系统
*/
export function createBlueprintSystem(scene: IScene): IBlueprintSystem {
return {
process(entities: IBlueprintEntity[], deltaTime: number): void {
for (const entity of entities) {
const component = entity.blueprintComponent;
// Skip if no blueprint asset loaded
// 如果没有加载蓝图资产则跳过
if (!component.blueprintAsset) {
continue;
}
// Initialize VM if needed
// 如果需要则初始化 VM
if (!component.vm) {
initializeBlueprintVM(component, entity, scene);
}
// Auto-start if enabled
// 如果启用则自动启动
if (component.autoStart && !component.isStarted) {
startBlueprint(component);
}
// Tick the blueprint
// 更新蓝图
tickBlueprint(component, deltaTime);
}
},
onEntityAdded(entity: IBlueprintEntity): void {
const component = entity.blueprintComponent;
if (component.blueprintAsset) {
initializeBlueprintVM(component, entity, scene);
if (component.autoStart) {
startBlueprint(component);
}
}
},
onEntityRemoved(entity: IBlueprintEntity): void {
cleanupBlueprint(entity.blueprintComponent);
}
};
}
/**
* Utility to manually trigger blueprint events
* 手动触发蓝图事件的工具
*/
export function triggerBlueprintEvent(
entity: IBlueprintEntity,
eventType: string,
data?: Record<string, unknown>
): void {
const vm = entity.blueprintComponent.vm;
if (vm && entity.blueprintComponent.isStarted) {
vm.triggerEvent(eventType, data);
}
}
/**
* Utility to trigger custom events by name
* 按名称触发自定义事件的工具
*/
export function triggerCustomBlueprintEvent(
entity: IBlueprintEntity,
eventName: string,
data?: Record<string, unknown>
): void {
const vm = entity.blueprintComponent.vm;
if (vm && entity.blueprintComponent.isStarted) {
vm.triggerCustomEvent(eventName, data);
}
}

View File

@@ -0,0 +1,335 @@
/**
* Blueprint Virtual Machine - Executes blueprint graphs
* 蓝图虚拟机 - 执行蓝图图
*/
import { BlueprintNode } from '../types/nodes';
import { BlueprintAsset } from '../types/blueprint';
import { ExecutionContext, ExecutionResult, IEntity, IScene } from './ExecutionContext';
import { NodeRegistry } from './NodeRegistry';
/**
* Pending execution frame (for delayed/async execution)
* 待处理的执行帧(用于延迟/异步执行)
*/
interface PendingExecution {
nodeId: string;
execPin: string;
resumeTime: number;
}
/**
* Event trigger types
* 事件触发类型
*/
export type EventType =
| 'BeginPlay'
| 'Tick'
| 'EndPlay'
| 'Collision'
| 'TriggerEnter'
| 'TriggerExit'
| 'Custom';
/**
* Blueprint Virtual Machine
* 蓝图虚拟机
*/
export class BlueprintVM {
/** Execution context (执行上下文) */
private _context: ExecutionContext;
/** Pending executions (delayed nodes) (待处理的执行) */
private _pendingExecutions: PendingExecution[] = [];
/** Event node cache by type (按类型缓存的事件节点) */
private _eventNodes: Map<string, BlueprintNode[]> = new Map();
/** Whether the VM is running (VM 是否运行中) */
private _isRunning: boolean = false;
/** Current execution time (当前执行时间) */
private _currentTime: number = 0;
/** Maximum execution steps per frame (每帧最大执行步骤) */
private _maxStepsPerFrame: number = 1000;
/** Debug mode (调试模式) */
debug: boolean = false;
constructor(blueprint: BlueprintAsset, entity: IEntity, scene: IScene) {
this._context = new ExecutionContext(blueprint, entity, scene);
this._cacheEventNodes();
}
get context(): ExecutionContext {
return this._context;
}
get isRunning(): boolean {
return this._isRunning;
}
/**
* Cache event nodes by type for quick lookup
* 按类型缓存事件节点以便快速查找
*/
private _cacheEventNodes(): void {
for (const node of this._context.blueprint.nodes) {
// Event nodes start with "Event"
// 事件节点以 "Event" 开头
if (node.type.startsWith('Event')) {
const eventType = node.type;
if (!this._eventNodes.has(eventType)) {
this._eventNodes.set(eventType, []);
}
this._eventNodes.get(eventType)!.push(node);
}
}
}
/**
* Start the VM
* 启动 VM
*/
start(): void {
this._isRunning = true;
this._currentTime = 0;
// Trigger BeginPlay event
// 触发 BeginPlay 事件
this.triggerEvent('EventBeginPlay');
}
/**
* Stop the VM
* 停止 VM
*/
stop(): void {
// Trigger EndPlay event
// 触发 EndPlay 事件
this.triggerEvent('EventEndPlay');
this._isRunning = false;
this._pendingExecutions = [];
}
/**
* Pause the VM
* 暂停 VM
*/
pause(): void {
this._isRunning = false;
}
/**
* Resume the VM
* 恢复 VM
*/
resume(): void {
this._isRunning = true;
}
/**
* Update the VM (called every frame)
* 更新 VM每帧调用
*/
tick(deltaTime: number): void {
if (!this._isRunning) return;
this._currentTime += deltaTime;
this._context.deltaTime = deltaTime;
this._context.time = this._currentTime;
// Process pending delayed executions
// 处理待处理的延迟执行
this._processPendingExecutions();
// Trigger Tick event
// 触发 Tick 事件
this.triggerEvent('EventTick');
}
/**
* Trigger an event by type
* 按类型触发事件
*/
triggerEvent(eventType: string, data?: Record<string, unknown>): void {
const eventNodes = this._eventNodes.get(eventType);
if (!eventNodes) return;
for (const node of eventNodes) {
this._executeFromNode(node, 'exec', data);
}
}
/**
* Trigger a custom event by name
* 按名称触发自定义事件
*/
triggerCustomEvent(eventName: string, data?: Record<string, unknown>): void {
const eventNodes = this._eventNodes.get('EventCustom');
if (!eventNodes) return;
for (const node of eventNodes) {
if (node.data.eventName === eventName) {
this._executeFromNode(node, 'exec', data);
}
}
}
/**
* Execute from a starting node
* 从起始节点执行
*/
private _executeFromNode(
startNode: BlueprintNode,
startPin: string,
eventData?: Record<string, unknown>
): void {
// Clear output cache for new execution
// 为新执行清除输出缓存
this._context.clearOutputCache();
// Set event data as node outputs
// 设置事件数据为节点输出
if (eventData) {
this._context.setOutputs(startNode.id, eventData);
}
// Follow execution chain
// 跟随执行链
let currentNodeId: string | null = startNode.id;
let currentPin: string = startPin;
let steps = 0;
while (currentNodeId && steps < this._maxStepsPerFrame) {
steps++;
// Get connected nodes from current exec pin
// 从当前执行引脚获取连接的节点
const connections = this._context.getConnectionsFromPin(currentNodeId, currentPin);
if (connections.length === 0) {
// No more connections, end execution
// 没有更多连接,结束执行
break;
}
// Execute connected node
// 执行连接的节点
const nextConn = connections[0];
const result = this._executeNode(nextConn.toNodeId);
if (result.error) {
console.error(`Blueprint error in node ${nextConn.toNodeId}: ${result.error}`);
break;
}
if (result.delay && result.delay > 0) {
// Schedule delayed execution
// 安排延迟执行
this._pendingExecutions.push({
nodeId: nextConn.toNodeId,
execPin: result.nextExec ?? 'exec',
resumeTime: this._currentTime + result.delay
});
break;
}
if (result.yield) {
// Yield execution until next frame
// 暂停执行直到下一帧
break;
}
if (result.nextExec === null) {
// Explicitly stop execution
// 显式停止执行
break;
}
// Continue to next node
// 继续到下一个节点
currentNodeId = nextConn.toNodeId;
currentPin = result.nextExec ?? 'exec';
}
if (steps >= this._maxStepsPerFrame) {
console.warn('Blueprint execution exceeded maximum steps, possible infinite loop');
}
}
/**
* Execute a single node
* 执行单个节点
*/
private _executeNode(nodeId: string): ExecutionResult {
const node = this._context.getNode(nodeId);
if (!node) {
return { error: `Node not found: ${nodeId}` };
}
const executor = NodeRegistry.instance.getExecutor(node.type);
if (!executor) {
return { error: `No executor for node type: ${node.type}` };
}
try {
if (this.debug) {
console.log(`[Blueprint] Executing: ${node.type} (${nodeId})`);
}
const result = executor.execute(node, this._context);
// Cache outputs
// 缓存输出
if (result.outputs) {
this._context.setOutputs(nodeId, result.outputs);
}
return result;
} catch (error) {
return { error: `Execution error: ${error}` };
}
}
/**
* Process pending delayed executions
* 处理待处理的延迟执行
*/
private _processPendingExecutions(): void {
const stillPending: PendingExecution[] = [];
for (const pending of this._pendingExecutions) {
if (this._currentTime >= pending.resumeTime) {
// Resume execution
// 恢复执行
const node = this._context.getNode(pending.nodeId);
if (node) {
this._executeFromNode(node, pending.execPin);
}
} else {
stillPending.push(pending);
}
}
this._pendingExecutions = stillPending;
}
/**
* Get instance variables for serialization
* 获取实例变量用于序列化
*/
getInstanceVariables(): Map<string, unknown> {
return this._context.getInstanceVariables();
}
/**
* Set instance variables from serialization
* 从序列化设置实例变量
*/
setInstanceVariables(variables: Map<string, unknown>): void {
this._context.setInstanceVariables(variables);
}
}

View File

@@ -0,0 +1,294 @@
/**
* Execution Context - Runtime context for blueprint execution
* 执行上下文 - 蓝图执行的运行时上下文
*/
import { BlueprintNode, BlueprintConnection } from '../types/nodes';
import { BlueprintAsset } from '../types/blueprint';
/**
* Result of node execution
* 节点执行的结果
*/
export interface ExecutionResult {
/**
* Next exec pin to follow (null to stop, undefined to continue default)
* 下一个要执行的引脚null 停止undefined 继续默认)
*/
nextExec?: string | null;
/**
* Output values by pin name
* 按引脚名称的输出值
*/
outputs?: Record<string, unknown>;
/**
* Whether to yield execution (for async operations)
* 是否暂停执行(用于异步操作)
*/
yield?: boolean;
/**
* Delay before continuing (in seconds)
* 继续前的延迟(秒)
*/
delay?: number;
/**
* Error message if execution failed
* 执行失败时的错误消息
*/
error?: string;
}
/**
* Entity interface (minimal for decoupling)
* 实体接口(最小化以解耦)
*/
export interface IEntity {
id: number;
name: string;
active: boolean;
getComponent<T>(type: new (...args: unknown[]) => T): T | null;
addComponent<T>(component: T): T;
removeComponent<T>(type: new (...args: unknown[]) => T): void;
hasComponent<T>(type: new (...args: unknown[]) => T): boolean;
}
/**
* Scene interface (minimal for decoupling)
* 场景接口(最小化以解耦)
*/
export interface IScene {
createEntity(name?: string): IEntity;
destroyEntity(entity: IEntity): void;
findEntityByName(name: string): IEntity | null;
findEntitiesByTag(tag: number): IEntity[];
}
/**
* Execution context provides access to runtime services
* 执行上下文提供对运行时服务的访问
*/
export class ExecutionContext {
/** Current blueprint asset (当前蓝图资产) */
readonly blueprint: BlueprintAsset;
/** Owner entity (所有者实体) */
readonly entity: IEntity;
/** Current scene (当前场景) */
readonly scene: IScene;
/** Frame delta time (帧增量时间) */
deltaTime: number = 0;
/** Total time since start (开始以来的总时间) */
time: number = 0;
/** Instance variables (实例变量) */
private _instanceVariables: Map<string, unknown> = new Map();
/** Local variables (per-execution) (局部变量,每次执行) */
private _localVariables: Map<string, unknown> = new Map();
/** Global variables (shared) (全局变量,共享) */
private static _globalVariables: Map<string, unknown> = new Map();
/** Node output cache for current execution (当前执行的节点输出缓存) */
private _outputCache: Map<string, Record<string, unknown>> = new Map();
/** Connection lookup by target (按目标的连接查找) */
private _connectionsByTarget: Map<string, BlueprintConnection[]> = new Map();
/** Connection lookup by source (按源的连接查找) */
private _connectionsBySource: Map<string, BlueprintConnection[]> = new Map();
constructor(blueprint: BlueprintAsset, entity: IEntity, scene: IScene) {
this.blueprint = blueprint;
this.entity = entity;
this.scene = scene;
// Initialize instance variables with defaults
// 使用默认值初始化实例变量
for (const variable of blueprint.variables) {
if (variable.scope === 'instance') {
this._instanceVariables.set(variable.name, variable.defaultValue);
}
}
// Build connection lookup maps
// 构建连接查找映射
this._buildConnectionMaps();
}
private _buildConnectionMaps(): void {
for (const conn of this.blueprint.connections) {
// By target
const targetKey = `${conn.toNodeId}.${conn.toPin}`;
if (!this._connectionsByTarget.has(targetKey)) {
this._connectionsByTarget.set(targetKey, []);
}
this._connectionsByTarget.get(targetKey)!.push(conn);
// By source
const sourceKey = `${conn.fromNodeId}.${conn.fromPin}`;
if (!this._connectionsBySource.has(sourceKey)) {
this._connectionsBySource.set(sourceKey, []);
}
this._connectionsBySource.get(sourceKey)!.push(conn);
}
}
/**
* Get a node by ID
* 通过ID获取节点
*/
getNode(nodeId: string): BlueprintNode | undefined {
return this.blueprint.nodes.find(n => n.id === nodeId);
}
/**
* Get connections to a target pin
* 获取到目标引脚的连接
*/
getConnectionsToPin(nodeId: string, pinName: string): BlueprintConnection[] {
return this._connectionsByTarget.get(`${nodeId}.${pinName}`) ?? [];
}
/**
* Get connections from a source pin
* 获取从源引脚的连接
*/
getConnectionsFromPin(nodeId: string, pinName: string): BlueprintConnection[] {
return this._connectionsBySource.get(`${nodeId}.${pinName}`) ?? [];
}
/**
* Evaluate an input pin value (follows connections or uses default)
* 计算输入引脚值(跟随连接或使用默认值)
*/
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown {
const connections = this.getConnectionsToPin(nodeId, pinName);
if (connections.length === 0) {
// Use default from node data or provided default
// 使用节点数据的默认值或提供的默认值
const node = this.getNode(nodeId);
return node?.data[pinName] ?? defaultValue;
}
// Get value from connected output
// 从连接的输出获取值
const conn = connections[0];
const cachedOutputs = this._outputCache.get(conn.fromNodeId);
if (cachedOutputs && conn.fromPin in cachedOutputs) {
return cachedOutputs[conn.fromPin];
}
// Need to execute the source node first (lazy evaluation)
// 需要先执行源节点(延迟求值)
return defaultValue;
}
/**
* Set output values for a node (cached for current execution)
* 设置节点的输出值(为当前执行缓存)
*/
setOutputs(nodeId: string, outputs: Record<string, unknown>): void {
this._outputCache.set(nodeId, outputs);
}
/**
* Get cached outputs for a node
* 获取节点的缓存输出
*/
getOutputs(nodeId: string): Record<string, unknown> | undefined {
return this._outputCache.get(nodeId);
}
/**
* Clear output cache (call at start of new execution)
* 清除输出缓存(在新执行开始时调用)
*/
clearOutputCache(): void {
this._outputCache.clear();
this._localVariables.clear();
}
/**
* Get a variable value
* 获取变量值
*/
getVariable(name: string): unknown {
// Check local first, then instance, then global
// 先检查局部,然后实例,然后全局
if (this._localVariables.has(name)) {
return this._localVariables.get(name);
}
if (this._instanceVariables.has(name)) {
return this._instanceVariables.get(name);
}
if (ExecutionContext._globalVariables.has(name)) {
return ExecutionContext._globalVariables.get(name);
}
// Return default from variable definition
// 返回变量定义的默认值
const varDef = this.blueprint.variables.find(v => v.name === name);
return varDef?.defaultValue;
}
/**
* Set a variable value
* 设置变量值
*/
setVariable(name: string, value: unknown): void {
const varDef = this.blueprint.variables.find(v => v.name === name);
if (!varDef) {
// Treat unknown variables as local
// 将未知变量视为局部变量
this._localVariables.set(name, value);
return;
}
switch (varDef.scope) {
case 'local':
this._localVariables.set(name, value);
break;
case 'instance':
this._instanceVariables.set(name, value);
break;
case 'global':
ExecutionContext._globalVariables.set(name, value);
break;
}
}
/**
* Get all instance variables (for serialization)
* 获取所有实例变量(用于序列化)
*/
getInstanceVariables(): Map<string, unknown> {
return new Map(this._instanceVariables);
}
/**
* Set instance variables (for deserialization)
* 设置实例变量(用于反序列化)
*/
setInstanceVariables(variables: Map<string, unknown>): void {
this._instanceVariables = new Map(variables);
}
/**
* Clear global variables (for scene reset)
* 清除全局变量(用于场景重置)
*/
static clearGlobalVariables(): void {
ExecutionContext._globalVariables.clear();
}
}

View File

@@ -0,0 +1,151 @@
/**
* Node Registry - Manages node templates and executors
* 节点注册表 - 管理节点模板和执行器
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../types/nodes';
import { ExecutionContext, ExecutionResult } from './ExecutionContext';
/**
* Node executor interface - implements the logic for a node type
* 节点执行器接口 - 实现节点类型的逻辑
*/
export interface INodeExecutor {
/**
* Execute the node
* 执行节点
*
* @param node - Node instance (节点实例)
* @param context - Execution context (执行上下文)
* @returns Execution result (执行结果)
*/
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult;
}
/**
* Node definition combines template with executor
* 节点定义组合模板和执行器
*/
export interface NodeDefinition {
template: BlueprintNodeTemplate;
executor: INodeExecutor;
}
/**
* Node Registry - singleton that holds all registered node types
* 节点注册表 - 持有所有注册节点类型的单例
*/
export class NodeRegistry {
private static _instance: NodeRegistry;
private _nodes: Map<string, NodeDefinition> = new Map();
private constructor() {}
static get instance(): NodeRegistry {
if (!NodeRegistry._instance) {
NodeRegistry._instance = new NodeRegistry();
}
return NodeRegistry._instance;
}
/**
* Register a node type
* 注册节点类型
*/
register(template: BlueprintNodeTemplate, executor: INodeExecutor): void {
if (this._nodes.has(template.type)) {
console.warn(`Node type "${template.type}" is already registered, overwriting`);
}
this._nodes.set(template.type, { template, executor });
}
/**
* Get a node definition by type
* 通过类型获取节点定义
*/
get(type: string): NodeDefinition | undefined {
return this._nodes.get(type);
}
/**
* Get node template by type
* 通过类型获取节点模板
*/
getTemplate(type: string): BlueprintNodeTemplate | undefined {
return this._nodes.get(type)?.template;
}
/**
* Get node executor by type
* 通过类型获取节点执行器
*/
getExecutor(type: string): INodeExecutor | undefined {
return this._nodes.get(type)?.executor;
}
/**
* Check if a node type is registered
* 检查节点类型是否已注册
*/
has(type: string): boolean {
return this._nodes.has(type);
}
/**
* Get all registered templates
* 获取所有注册的模板
*/
getAllTemplates(): BlueprintNodeTemplate[] {
return Array.from(this._nodes.values()).map(d => d.template);
}
/**
* Get templates by category
* 按类别获取模板
*/
getTemplatesByCategory(category: string): BlueprintNodeTemplate[] {
return this.getAllTemplates().filter(t => t.category === category);
}
/**
* Search templates by keyword
* 按关键词搜索模板
*/
searchTemplates(keyword: string): BlueprintNodeTemplate[] {
const lower = keyword.toLowerCase();
return this.getAllTemplates().filter(t =>
t.title.toLowerCase().includes(lower) ||
t.type.toLowerCase().includes(lower) ||
t.keywords?.some(k => k.toLowerCase().includes(lower)) ||
t.description?.toLowerCase().includes(lower)
);
}
/**
* Clear all registrations (for testing)
* 清除所有注册(用于测试)
*/
clear(): void {
this._nodes.clear();
}
}
/**
* Decorator for registering node executors
* 用于注册节点执行器的装饰器
*
* @example
* ```typescript
* @RegisterNode(EventTickTemplate)
* class EventTickExecutor implements INodeExecutor {
* execute(node, context) { ... }
* }
* ```
*/
export function RegisterNode(template: BlueprintNodeTemplate) {
return function<T extends new () => INodeExecutor>(constructor: T) {
const executor = new constructor();
NodeRegistry.instance.register(template, executor);
return constructor;
};
}

View File

@@ -0,0 +1,10 @@
/**
* Blueprint Runtime - Execution engine for blueprints
* 蓝图运行时 - 蓝图执行引擎
*/
export * from './ExecutionContext';
export * from './NodeRegistry';
export * from './BlueprintVM';
export * from './BlueprintComponent';
export * from './BlueprintSystem';

View File

@@ -0,0 +1,125 @@
/**
* Blueprint Asset Types
* 蓝图资产类型
*/
import { BlueprintNode, BlueprintConnection } from './nodes';
/**
* Variable scope determines lifetime and accessibility
* 变量作用域决定生命周期和可访问性
*/
export type VariableScope =
| 'local' // Per-execution (每次执行)
| 'instance' // Per-entity (每个实体)
| 'global'; // Shared across all (全局共享)
/**
* Blueprint variable definition
* 蓝图变量定义
*/
export interface BlueprintVariable {
/** Variable name (变量名) */
name: string;
/** Variable type (变量类型) */
type: string;
/** Default value (默认值) */
defaultValue: unknown;
/** Variable scope (变量作用域) */
scope: VariableScope;
/** Category for organization (分类) */
category?: string;
/** Description tooltip (描述提示) */
tooltip?: string;
}
/**
* Blueprint asset metadata
* 蓝图资产元数据
*/
export interface BlueprintMetadata {
/** Blueprint name (蓝图名称) */
name: string;
/** Description (描述) */
description?: string;
/** Category for organization (分类) */
category?: string;
/** Author (作者) */
author?: string;
/** Creation timestamp (创建时间戳) */
createdAt?: number;
/** Last modified timestamp (最后修改时间戳) */
modifiedAt?: number;
}
/**
* Blueprint asset format - saved to .bp files
* 蓝图资产格式 - 保存为 .bp 文件
*/
export interface BlueprintAsset {
/** Format version (格式版本) */
version: number;
/** Asset type identifier (资产类型标识符) */
type: 'blueprint';
/** Metadata (元数据) */
metadata: BlueprintMetadata;
/** Variable definitions (变量定义) */
variables: BlueprintVariable[];
/** Node instances (节点实例) */
nodes: BlueprintNode[];
/** Connections between nodes (节点之间的连接) */
connections: BlueprintConnection[];
}
/**
* Creates an empty blueprint asset
* 创建空的蓝图资产
*/
export function createEmptyBlueprint(name: string): BlueprintAsset {
return {
version: 1,
type: 'blueprint',
metadata: {
name,
createdAt: Date.now(),
modifiedAt: Date.now()
},
variables: [],
nodes: [],
connections: []
};
}
/**
* Validates a blueprint asset structure
* 验证蓝图资产结构
*/
export function validateBlueprintAsset(asset: unknown): asset is BlueprintAsset {
if (!asset || typeof asset !== 'object') return false;
const bp = asset as BlueprintAsset;
return (
typeof bp.version === 'number' &&
bp.type === 'blueprint' &&
typeof bp.metadata === 'object' &&
Array.isArray(bp.variables) &&
Array.isArray(bp.nodes) &&
Array.isArray(bp.connections)
);
}

View File

@@ -0,0 +1,3 @@
export * from './pins';
export * from './nodes';
export * from './blueprint';

View File

@@ -0,0 +1,138 @@
/**
* Blueprint Node Types
* 蓝图节点类型
*/
import { BlueprintPinDefinition } from './pins';
/**
* Node category for visual styling and organization
* 节点类别,用于视觉样式和组织
*/
export type BlueprintNodeCategory =
| 'event' // Event nodes - red (事件节点 - 红色)
| 'flow' // Flow control - gray (流程控制 - 灰色)
| 'entity' // Entity operations - blue (实体操作 - 蓝色)
| 'component' // Component access - cyan (组件访问 - 青色)
| 'math' // Math operations - green (数学运算 - 绿色)
| 'logic' // Logic operations - red (逻辑运算 - 红色)
| 'variable' // Variable access - purple (变量访问 - 紫色)
| 'input' // Input handling - orange (输入处理 - 橙色)
| 'physics' // Physics - yellow (物理 - 黄色)
| 'audio' // Audio - pink (音频 - 粉色)
| 'time' // Time utilities - cyan (时间工具 - 青色)
| 'debug' // Debug utilities - gray (调试工具 - 灰色)
| 'custom'; // Custom nodes (自定义节点)
/**
* Node template definition - describes a type of node
* 节点模板定义 - 描述一种节点类型
*/
export interface BlueprintNodeTemplate {
/** Unique type identifier (唯一类型标识符) */
type: string;
/** Display title (显示标题) */
title: string;
/** Node category (节点类别) */
category: BlueprintNodeCategory;
/** Optional subtitle (可选副标题) */
subtitle?: string;
/** Icon name (图标名称) */
icon?: string;
/** Description for documentation (文档描述) */
description?: string;
/** Search keywords (搜索关键词) */
keywords?: string[];
/** Menu path for node palette (节点面板的菜单路径) */
menuPath?: string[];
/** Input pin definitions (输入引脚定义) */
inputs: BlueprintPinDefinition[];
/** Output pin definitions (输出引脚定义) */
outputs: BlueprintPinDefinition[];
/** Whether this node is pure (no exec pins) (是否是纯节点,无执行引脚) */
isPure?: boolean;
/** Whether this node can be collapsed (是否可折叠) */
collapsible?: boolean;
/** Custom header color override (自定义头部颜色) */
headerColor?: string;
/** Node color for visual distinction (节点颜色用于视觉区分) */
color?: string;
}
/**
* Node instance in a blueprint graph
* 蓝图图中的节点实例
*/
export interface BlueprintNode {
/** Unique instance ID (唯一实例ID) */
id: string;
/** Template type reference (模板类型引用) */
type: string;
/** Position in graph (图中位置) */
position: { x: number; y: number };
/** Custom data for this instance (此实例的自定义数据) */
data: Record<string, unknown>;
/** Comment/note for this node (此节点的注释) */
comment?: string;
}
/**
* Connection between two pins
* 两个引脚之间的连接
*/
export interface BlueprintConnection {
/** Unique connection ID (唯一连接ID) */
id: string;
/** Source node ID (源节点ID) */
fromNodeId: string;
/** Source pin name (源引脚名称) */
fromPin: string;
/** Target node ID (目标节点ID) */
toNodeId: string;
/** Target pin name (目标引脚名称) */
toPin: string;
}
/**
* Gets the header color for a node category
* 获取节点类别的头部颜色
*/
export function getNodeCategoryColor(category: BlueprintNodeCategory): string {
const colors: Record<BlueprintNodeCategory, string> = {
event: '#8b1e1e',
flow: '#4a4a4a',
entity: '#1e5a8b',
component: '#1e8b8b',
math: '#1e8b5a',
logic: '#8b1e5a',
variable: '#5a1e8b',
input: '#8b5a1e',
physics: '#8b8b1e',
audio: '#8b1e6b',
time: '#1e6b8b',
debug: '#5a5a5a',
custom: '#4a4a4a'
};
return colors[category] ?? colors.custom;
}

View File

@@ -0,0 +1,135 @@
/**
* Blueprint Pin Types
* 蓝图引脚类型
*/
/**
* Pin data type for blueprint nodes
* 蓝图节点的引脚数据类型
*/
export type BlueprintPinType =
| 'exec' // Execution flow (执行流)
| 'bool' // Boolean (布尔)
| 'int' // Integer (整数)
| 'float' // Float (浮点数)
| 'string' // String (字符串)
| 'vector2' // 2D Vector (二维向量)
| 'vector3' // 3D Vector (三维向量)
| 'color' // RGBA Color (颜色)
| 'entity' // Entity reference (实体引用)
| 'component' // Component reference (组件引用)
| 'object' // Generic object (通用对象)
| 'array' // Array (数组)
| 'any'; // Wildcard (通配符)
/**
* Pin direction
* 引脚方向
*/
export type BlueprintPinDirection = 'input' | 'output';
/**
* Pin definition for node templates
* 节点模板的引脚定义
*
* Note: direction is determined by whether the pin is in inputs[] or outputs[] array
* 注意:方向由引脚在 inputs[] 还是 outputs[] 数组中决定
*/
export interface BlueprintPinDefinition {
/** Unique name within node (节点内唯一名称) */
name: string;
/** Pin data type (引脚数据类型) */
type: BlueprintPinType;
/** Display name shown in the editor (编辑器中显示的名称) */
displayName?: string;
/** Default value when not connected (未连接时的默认值) */
defaultValue?: unknown;
/** Allow multiple connections (允许多个连接) */
allowMultiple?: boolean;
/** Array element type if type is 'array' (数组元素类型) */
arrayType?: BlueprintPinType;
/** Whether this pin is optional (是否可选) */
optional?: boolean;
/** Tooltip description (提示描述) */
tooltip?: string;
}
/**
* Runtime pin with direction - used when processing pins
* 带方向的运行时引脚 - 处理引脚时使用
*/
export interface BlueprintRuntimePin extends BlueprintPinDefinition {
/** Pin direction (引脚方向) */
direction: BlueprintPinDirection;
}
/**
* Pin instance in a node
* 节点中的引脚实例
*/
export interface BlueprintPin {
id: string;
nodeId: string;
definition: BlueprintPinDefinition;
value?: unknown;
}
/**
* Gets the color for a pin type
* 获取引脚类型的颜色
*/
export function getPinTypeColor(type: BlueprintPinType): string {
const colors: Record<BlueprintPinType, string> = {
exec: '#ffffff',
bool: '#cc0000',
int: '#00d4aa',
float: '#88cc00',
string: '#ff88cc',
vector2: '#d4aa00',
vector3: '#ffcc00',
color: '#ff8844',
entity: '#0088ff',
component: '#44aaff',
object: '#4444aa',
array: '#8844ff',
any: '#888888'
};
return colors[type] ?? colors.any;
}
/**
* Checks if two pin types are compatible for connection
* 检查两个引脚类型是否兼容连接
*/
export function arePinTypesCompatible(from: BlueprintPinType, to: BlueprintPinType): boolean {
// Same type always compatible
// 相同类型始终兼容
if (from === to) return true;
// Any type is compatible with everything
// any 类型与所有类型兼容
if (from === 'any' || to === 'any') return true;
// Exec can only connect to exec
// exec 只能连接 exec
if (from === 'exec' || to === 'exec') return false;
// Numeric coercion
// 数值类型转换
const numericTypes: BlueprintPinType[] = ['int', 'float'];
if (numericTypes.includes(from) && numericTypes.includes(to)) return true;
// Vector coercion
// 向量类型转换
const vectorTypes: BlueprintPinType[] = ['vector2', 'vector3', 'color'];
if (vectorTypes.includes(from) && vectorTypes.includes(to)) return true;
return false;
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,106 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
import react from '@vitejs/plugin-react';
/**
* 自定义插件:将 CSS 转换为自执行的样式注入代码
* Custom plugin: Convert CSS to self-executing style injection code
*/
function escapeUnsafeChars(str: string): string {
const charMap: Record<string, string> = {
'<': '\\u003C',
'>': '\\u003E',
'/': '\\u002F',
'\\': '\\\\',
'\u2028': '\\u2028',
'\u2029': '\\u2029'
};
return str.replace(/[<>\\/\u2028\u2029]/g, (x) => charMap[x] || x);
}
function injectCSSPlugin(): unknown {
const cssIdMap = new Map<string, string>();
let cssCounter = 0;
return {
name: 'inject-css-plugin',
enforce: 'post' as const,
generateBundle(_options: unknown, bundle: Record<string, { type?: string; source?: string; code?: string }>) {
const bundleKeys = Object.keys(bundle);
// 找到所有 CSS 文件
const cssFiles = bundleKeys.filter(key => key.endsWith('.css'));
for (const cssFile of cssFiles) {
const cssChunk = bundle[cssFile];
if (!cssChunk || !cssChunk.source) continue;
const cssContent = cssChunk.source;
const styleId = `esengine-blueprint-style-${cssCounter++}`;
cssIdMap.set(cssFile, styleId);
// 生成样式注入代码
const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${escapeUnsafeChars(JSON.stringify(cssContent))};document.head.appendChild(s);}}})();`;
// 注入到 editor/index.js 或共享 chunk
for (const jsKey of bundleKeys) {
if (!jsKey.endsWith('.js')) continue;
const jsChunk = bundle[jsKey];
if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue;
if (jsKey === 'editor/index.js' || jsKey.match(/^index-[^/]+\.js$/)) {
jsChunk.code = injectCode + '\n' + jsChunk.code;
}
}
// 删除独立的 CSS 文件
delete bundle[cssFile];
}
}
};
}
export default defineConfig({
plugins: [
react(),
dts({
include: ['src'],
outDir: 'dist',
rollupTypes: false
}),
injectCSSPlugin()
],
esbuild: {
jsx: 'automatic',
},
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
'editor/index': resolve(__dirname, 'src/editor/index.ts')
},
formats: ['es'],
fileName: (_format, entryName) => `${entryName}.js`
},
rollupOptions: {
external: [
'@esengine/ecs-framework',
'@esengine/editor-runtime',
'react',
'react/jsx-runtime',
'lucide-react',
'zustand',
/^@esengine\//,
/^@tauri-apps\//
],
output: {
exports: 'named',
preserveModules: false
}
},
target: 'es2020',
minify: false,
sourcemap: true
}
});

View File

@@ -60,6 +60,12 @@ export interface IRuntimeModuleLoader {
registerComponents(registry: typeof ComponentRegistryType): void;
registerServices?(services: ServiceContainer): void;
createSystems?(scene: IScene, context: SystemContext): void;
/**
* 所有系统创建完成后调用
* 用于处理跨插件的系统依赖关系
* Called after all systems are created, used for cross-plugin system dependencies
*/
onSystemsCreated?(scene: IScene, context: SystemContext): void;
onInitialize?(): Promise<void>;
onDestroy?(): void;
}

View File

@@ -1,6 +1,6 @@
import 'reflect-metadata';
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips';
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips' | 'collisionLayer' | 'collisionMask';
/**
* 资源类型
@@ -132,6 +132,22 @@ interface AnimationClipsPropertyOptions extends PropertyOptionsBase {
type: 'animationClips';
}
/**
* 碰撞层属性选项
* Collision layer property options
*/
interface CollisionLayerPropertyOptions extends PropertyOptionsBase {
type: 'collisionLayer';
}
/**
* 碰撞掩码属性选项
* Collision mask property options
*/
interface CollisionMaskPropertyOptions extends PropertyOptionsBase {
type: 'collisionMask';
}
/**
* 属性选项联合类型
* Property options union type
@@ -144,7 +160,9 @@ export type PropertyOptions =
| VectorPropertyOptions
| EnumPropertyOptions
| AssetPropertyOptions
| AnimationClipsPropertyOptions;
| AnimationClipsPropertyOptions
| CollisionLayerPropertyOptions
| CollisionMaskPropertyOptions;
// 使用 Symbol.for 创建全局 Symbol确保跨包共享元数据
// Use Symbol.for to create a global Symbol to ensure metadata sharing across packages

View File

@@ -520,6 +520,58 @@ export class EngineBridge implements IEngineBridge {
this.getEngine().addGizmoRect(x, y, width, height, rotation, originX, originY, r, g, b, a, showHandles);
}
/**
* Add a circle outline gizmo (native rendering).
* 添加圆形边框Gizmo原生渲染
*/
addGizmoCircle(
x: number,
y: number,
radius: number,
r: number,
g: number,
b: number,
a: number
): void {
if (!this.initialized) return;
this.getEngine().addGizmoCircle(x, y, radius, r, g, b, a);
}
/**
* Add a line gizmo (native rendering).
* 添加线条Gizmo原生渲染
*/
addGizmoLine(
points: number[],
r: number,
g: number,
b: number,
a: number,
closed: boolean
): void {
if (!this.initialized) return;
this.getEngine().addGizmoLine(new Float32Array(points), r, g, b, a, closed);
}
/**
* Add a capsule outline gizmo (native rendering).
* 添加胶囊边框Gizmo原生渲染
*/
addGizmoCapsule(
x: number,
y: number,
radius: number,
halfHeight: number,
rotation: number,
r: number,
g: number,
b: number,
a: number
): void {
if (!this.initialized) return;
this.getEngine().addGizmoCapsule(x, y, radius, halfHeight, rotation, r, g, b, a);
}
/**
* Set transform tool mode.
* 设置变换工具模式。

View File

@@ -68,7 +68,7 @@ interface GizmoColorInternal {
* @internal
*/
interface GizmoRenderDataInternal {
type: 'rect' | 'circle' | 'line' | 'grid';
type: 'rect' | 'circle' | 'line' | 'grid' | 'capsule';
color: GizmoColorInternal;
// Rect specific
x?: number;
@@ -87,6 +87,8 @@ interface GizmoRenderDataInternal {
// Grid specific
cols?: number;
rows?: number;
// Capsule specific
halfHeight?: number;
}
/**
@@ -568,8 +570,6 @@ export class EngineRenderSystem extends EntitySystem {
break;
case 'grid':
// Render grid as multiple line segments
// 将网格渲染为多条线段
if (data.x !== undefined && data.y !== undefined &&
data.width !== undefined && data.height !== undefined &&
data.cols !== undefined && data.rows !== undefined) {
@@ -578,18 +578,32 @@ export class EngineRenderSystem extends EntitySystem {
break;
case 'line':
// Lines are rendered as connected rect segments (thin)
// 线条渲染为连接的细矩形段
if (data.points && data.points.length >= 2) {
this.renderLineGizmo(data.points, data.closed ?? false, r, g, b, a);
const flatPoints: number[] = [];
for (const p of data.points) {
flatPoints.push(p.x, p.y);
}
this.bridge.addGizmoLine(flatPoints, r, g, b, a, data.closed ?? false);
}
break;
case 'circle':
// Circle rendered as polygon approximation
// 圆形渲染为多边形近似
if (data.x !== undefined && data.y !== undefined && data.radius !== undefined) {
this.renderCircleGizmo(data.x, data.y, data.radius, r, g, b, a);
this.bridge.addGizmoCircle(data.x, data.y, data.radius, r, g, b, a);
}
break;
case 'capsule':
if (data.x !== undefined && data.y !== undefined &&
data.radius !== undefined && data.halfHeight !== undefined) {
this.bridge.addGizmoCapsule(
data.x,
data.y,
data.radius,
data.halfHeight,
data.rotation ?? 0,
r, g, b, a
);
}
break;
}
@@ -1041,9 +1055,9 @@ export class EngineRenderSystem extends EntitySystem {
* 将十六进制颜色字符串转换为打包的RGBA。
*/
private hexToPackedColor(hex: string, alpha: number): number {
// Parse hex color like "#ffffff" or "#fff"
let r = 255, g = 255, b = 255;
if (hex.startsWith('#')) {
if (typeof hex === 'string' && hex.startsWith('#')) {
const hexValue = hex.slice(1);
if (hexValue.length === 3) {
r = parseInt(hexValue[0] + hexValue[0], 16);
@@ -1055,6 +1069,7 @@ export class EngineRenderSystem extends EntitySystem {
b = parseInt(hexValue.slice(4, 6), 16);
}
}
const a = Math.round(alpha * 255);
// Pack as 0xAABBGGRR for WebGL
return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);

View File

@@ -69,6 +69,11 @@ export class GameEngine {
* 设置网格可见性。
*/
setShowGrid(show: boolean): void;
/**
* Add a line gizmo.
* 添加线条Gizmo。
*/
addGizmoLine(points: Float32Array, r: number, g: number, b: number, a: number, closed: boolean): void;
/**
* Add a rectangle gizmo outline.
* 添加矩形Gizmo边框。
@@ -111,11 +116,21 @@ export class GameEngine {
* 设置辅助工具可见性。
*/
setShowGizmos(show: boolean): void;
/**
* Add a circle gizmo outline.
* 添加圆形Gizmo边框。
*/
addGizmoCircle(x: number, y: number, radius: number, r: number, g: number, b: number, a: number): void;
/**
* Get all registered viewport IDs.
* 获取所有已注册的视口ID。
*/
getViewportIds(): string[];
/**
* Add a capsule gizmo outline.
* 添加胶囊Gizmo边框。
*/
addGizmoCapsule(x: number, y: number, radius: number, half_height: number, rotation: number, r: number, g: number, b: number, a: number): void;
/**
* Register a new viewport.
* 注册新视口。
@@ -252,6 +267,9 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly __wbg_gameengine_free: (a: number, b: number) => void;
readonly gameengine_addGizmoCapsule: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => void;
readonly gameengine_addGizmoCircle: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
readonly gameengine_addGizmoLine: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void;
readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];

View File

@@ -17,6 +17,7 @@
"dependencies": {
"@esengine/asset-system": "workspace:*",
"@esengine/behavior-tree": "workspace:*",
"@esengine/blueprint": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/ecs-components": "workspace:*",
"@esengine/physics-rapier2d": "workspace:*",

View File

@@ -58,7 +58,7 @@
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"decorations": false,
"transparent": false,
"center": true,
"skipTaskbar": false,
@@ -84,6 +84,11 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"shell:default",
"dialog:default",
"updater:default",

View File

@@ -44,8 +44,10 @@ import { ErrorDialog } from './components/ErrorDialog';
import { ConfirmDialog } from './components/ConfirmDialog';
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
import { ToastProvider, useToast } from './components/Toast';
import { MenuBar } from './components/MenuBar';
import { TitleBar } from './components/TitleBar';
import { MainToolbar } from './components/MainToolbar';
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
import { StatusBar } from './components/StatusBar';
import { TauriAPI } from './api/tauri';
import { SettingsService } from './services/SettingsService';
import { PluginLoader } from './services/PluginLoader';
@@ -55,7 +57,7 @@ import { checkForUpdatesOnStartup } from './utils/updater';
import { useLocale } from './hooks/useLocale';
import { en, zh } from './locales';
import type { Locale } from '@esengine/editor-core';
import { Loader2, Globe, ChevronDown } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import './styles/App.css';
const coreInstance = Core.create({ debug: true });
@@ -129,8 +131,6 @@ function App() {
compilerId: string;
currentFileName?: string;
}>({ isOpen: false, compilerId: '' });
const [showLocaleMemu, setShowLocaleMenu] = useState(false);
const localeMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 禁用默认右键菜单
@@ -145,17 +145,6 @@ function App() {
};
}, []);
// 语言菜单点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (localeMenuRef.current && !localeMenuRef.current.contains(e.target as Node)) {
setShowLocaleMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 快捷键监听
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
@@ -727,12 +716,6 @@ function App() {
title: locale === 'zh' ? '检视器' : 'Inspector',
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false
},
{
id: 'console',
title: locale === 'zh' ? '控制台' : 'Console',
content: <ConsolePanel logService={logService} />,
closable: false
}
];
} else {
@@ -754,18 +737,6 @@ function App() {
title: locale === 'zh' ? '检视器' : 'Inspector',
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false
},
{
id: 'assets',
title: locale === 'zh' ? '资产' : 'Assets',
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} />,
closable: false
},
{
id: 'console',
title: locale === 'zh' ? '控制台' : 'Console',
content: <ConsolePanel logService={logService} />,
closable: false
}
];
}
@@ -899,12 +870,8 @@ function App() {
<div className="editor-container">
{!isEditorFullscreen && (
<>
<div className="editor-titlebar" data-tauri-drag-region>
<span className="titlebar-project-name">{projectName}</span>
<span className="titlebar-app-name">ESEngine Editor</span>
</div>
<div className={`editor-header ${isRemoteConnected ? 'remote-connected' : ''}`}>
<MenuBar
<TitleBar
projectName={projectName}
locale={locale}
uiRegistry={uiRegistry || undefined}
messageHub={messageHub || undefined}
@@ -928,42 +895,13 @@ function App() {
onCreatePlugin={handleCreatePlugin}
onReloadPlugins={handleReloadPlugins}
/>
<div className="header-right">
<div className="locale-dropdown" ref={localeMenuRef}>
<button
className="toolbar-btn locale-btn"
onClick={() => setShowLocaleMenu(!showLocaleMemu)}
>
<Globe size={14} />
<span className="locale-label">{locale === 'en' ? 'EN' : '中'}</span>
<ChevronDown size={10} />
</button>
{showLocaleMemu && (
<div className="locale-menu">
<button
className={`locale-menu-item ${locale === 'en' ? 'active' : ''}`}
onClick={() => {
handleLocaleChange('en');
setShowLocaleMenu(false);
}}
>
English
</button>
<button
className={`locale-menu-item ${locale === 'zh' ? 'active' : ''}`}
onClick={() => {
handleLocaleChange('zh');
setShowLocaleMenu(false);
}}
>
</button>
</div>
)}
</div>
<span className="status">{status}</span>
</div>
</div>
<MainToolbar
locale={locale}
messageHub={messageHub || undefined}
commandManager={commandManager}
onSaveScene={handleSaveScene}
onOpenScene={handleOpenScene}
/>
</>
)}
@@ -986,6 +924,7 @@ function App() {
<FlexLayoutDockContainer
panels={panels}
activePanelId={activePanelId}
messageHub={messageHub}
onPanelClose={(panelId) => {
logger.info('Panel closed:', panelId);
setActiveDynamicPanels((prev) => prev.filter((id) => id !== panelId));
@@ -993,11 +932,15 @@ function App() {
/>
</div>
<div className="editor-footer">
<span>{t('footer.plugins')}: {pluginManager?.getAllPlugins().length ?? 0}</span>
<span>{t('footer.entities')}: {entityStore?.getAllEntities().length ?? 0}</span>
<span>{t('footer.core')}: {t('footer.active')}</span>
</div>
<StatusBar
pluginCount={pluginManager?.getAllPlugins().length ?? 0}
entityCount={entityStore?.getAllEntities().length ?? 0}
messageHub={messageHub}
logService={logService}
locale={locale}
projectPath={currentProjectPath}
onOpenScene={handleOpenSceneByPath}
/>
{showProfiler && (

View File

@@ -18,6 +18,7 @@ import { TilemapPlugin } from '@esengine/tilemap';
import { UIPlugin } from '@esengine/ui';
import { BehaviorTreePlugin } from '@esengine/behavior-tree';
import { Physics2DPlugin } from '@esengine/physics-rapier2d';
import { BlueprintPlugin } from '@esengine/blueprint/editor';
export class PluginInstaller {
/**
@@ -52,6 +53,7 @@ export class PluginInstaller {
{ name: 'UIPlugin', plugin: UIPlugin },
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
];
for (const { name, plugin } of modulePlugins) {

View File

@@ -66,6 +66,7 @@ import {
ColorFieldEditor,
AnimationClipsFieldEditor
} from '../../infrastructure/field-editors';
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
export interface EditorServices {
uiRegistry: UIRegistry;
@@ -200,6 +201,10 @@ export class ServiceRegistry {
fieldEditorRegistry.register(new ColorFieldEditor());
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
// 注册组件检查器
// Register component inspectors
componentInspectorRegistry.register(new TransformComponentInspector());
// 注册默认场景模板 - 创建默认相机
// Register default scene template - creates default camera
this.registerDefaultSceneTemplate();

View File

@@ -1,968 +1,22 @@
import { useState, useEffect, useRef } from 'react';
import * as LucideIcons from 'lucide-react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw, Plus } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { FileTree, FileTreeHandle } from './FileTree';
import { ResizablePanel } from './ResizablePanel';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import '../styles/AssetBrowser.css';
/**
* 根据图标名称获取 Lucide 图标组件
* Asset Browser - 资产浏览器
* 包装 ContentBrowser 组件,保持向后兼容
*/
function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode {
if (!iconName) return <Plus size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComponent = icons[iconName];
if (IconComponent) {
return <IconComponent size={size} />;
}
return <Plus size={size} />;
}
interface AssetItem {
name: string;
path: string;
type: 'file' | 'folder';
extension?: string;
size?: number;
modified?: number;
}
import { ContentBrowser } from './ContentBrowser';
interface AssetBrowserProps {
projectPath: string | null;
locale: string;
onOpenScene?: (scenePath: string) => void;
projectPath: string | null;
locale: string;
onOpenScene?: (scenePath: string) => void;
}
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
const messageHub = Core.services.resolve(MessageHub);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
const detailViewFileTreeRef = useRef<FileTreeHandle>(null);
const treeOnlyViewFileTreeRef = useRef<FileTreeHandle>(null);
const [currentPath, setCurrentPath] = 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[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [loading, setLoading] = useState(false);
const [showDetailView, setShowDetailView] = useState(() => {
const saved = localStorage.getItem('asset-browser-detail-view');
return saved !== null ? saved === 'true' : false;
});
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
asset: AssetItem;
} | null>(null);
const [renameDialog, setRenameDialog] = useState<{
asset: AssetItem;
newName: string;
} | null>(null);
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<AssetItem | null>(null);
const translations = {
en: {
title: 'Content Browser',
noProject: 'No project loaded',
loading: 'Loading...',
empty: 'No assets found',
search: 'Search...',
name: 'Name',
type: 'Type',
file: 'File',
folder: 'Folder'
},
zh: {
title: '内容浏览器',
noProject: '没有加载项目',
loading: '加载中...',
empty: '没有找到资产',
search: '搜索...',
name: '名称',
type: '类型',
file: '文件',
folder: '文件夹'
}
};
const t = translations[locale as keyof typeof translations] || translations.en;
useEffect(() => {
if (projectPath) {
setCurrentPath(projectPath);
loadAssets(projectPath);
} else {
setAssets([]);
setCurrentPath(null);
setSelectedPaths(new Set());
}
}, [projectPath]);
// Listen for asset reveal requests
useEffect(() => {
const messageHub = Core.services.resolve(MessageHub);
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
const filePath = data.path;
if (!filePath || !projectPath) return;
// Convert relative path to absolute path if needed
let absoluteFilePath = filePath;
if (!filePath.includes(':') && !filePath.startsWith('/')) {
absoluteFilePath = `${projectPath}/${filePath}`.replace(/\\/g, '/');
}
const lastSlashIndex = Math.max(absoluteFilePath.lastIndexOf('/'), absoluteFilePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? absoluteFilePath.substring(0, lastSlashIndex) : null;
if (dirPath) {
try {
const dirExists = await TauriAPI.pathExists(dirPath);
if (!dirExists) return;
setCurrentPath(dirPath);
await loadAssets(dirPath);
setSelectedPaths(new Set([absoluteFilePath]));
// Expand tree to reveal the file
if (showDetailView) {
detailViewFileTreeRef.current?.revealPath(absoluteFilePath);
} else {
treeOnlyViewFileTreeRef.current?.revealPath(absoluteFilePath);
}
} catch (error) {
console.error(`[AssetBrowser] Failed to reveal asset: ${absoluteFilePath}`, error);
}
}
});
return () => unsubscribe();
}, [showDetailView, projectPath]);
const loadAssets = async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
return {
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
extension,
size: entry.size,
modified: entry.modified
};
});
setAssets(assetItems.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
}));
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
};
const searchProjectRecursively = async (rootPath: string, query: string): Promise<AssetItem[]> => {
const results: AssetItem[] = [];
const lowerQuery = query.toLowerCase();
const searchDirectory = async (dirPath: string) => {
try {
const entries = await TauriAPI.listDirectory(dirPath);
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (entry.name.toLowerCase().includes(lowerQuery)) {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
results.push({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
extension,
size: entry.size,
modified: entry.modified
});
}
if (entry.is_dir) {
await searchDirectory(entry.path);
}
}
} catch (error) {
console.error(`Failed to search directory ${dirPath}:`, error);
}
};
await searchDirectory(rootPath);
return results.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
};
useEffect(() => {
const performSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
setIsSearching(false);
return;
}
if (!projectPath) return;
setIsSearching(true);
try {
const results = await searchProjectRecursively(projectPath, searchQuery);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
const timeoutId = setTimeout(performSearch, 300);
return () => clearTimeout(timeoutId);
}, [searchQuery, projectPath]);
const handleFolderSelect = (path: string) => {
setCurrentPath(path);
loadAssets(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: {
name: asset.name,
path: asset.path,
extension: asset.extension,
size: asset.size,
modified: asset.modified,
isDirectory: asset.type === 'folder'
}
});
};
const handleAssetDoubleClick = async (asset: AssetItem) => {
if (asset.type === 'folder') {
setCurrentPath(asset.path);
loadAssets(asset.path);
} else if (asset.type === 'file') {
const ext = asset.extension?.toLowerCase();
if (ext === 'ecs' && onOpenScene) {
onOpenScene(asset.path);
return;
}
if (fileActionRegistry) {
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
if (handled) {
return;
}
}
try {
await TauriAPI.openFileWithSystemApp(asset.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
};
const handleRename = async (asset: AssetItem, newName: string) => {
if (!newName.trim() || newName === asset.name) {
setRenameDialog(null);
return;
}
try {
const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\'));
const parentPath = asset.path.substring(0, lastSlash);
const newPath = `${parentPath}/${newName}`;
await TauriAPI.renameFileOrFolder(asset.path, newPath);
// 刷新当前目录
if (currentPath) {
await loadAssets(currentPath);
}
// 更新选中路径
if (selectedPaths.has(asset.path)) {
const newSelected = new Set(selectedPaths);
newSelected.delete(asset.path);
newSelected.add(newPath);
setSelectedPaths(newSelected);
}
setRenameDialog(null);
} catch (error) {
console.error('Failed to rename:', error);
alert(`重命名失败: ${error}`);
}
};
const handleDelete = async (asset: AssetItem) => {
try {
if (asset.type === 'folder') {
await TauriAPI.deleteFolder(asset.path);
} else {
await TauriAPI.deleteFile(asset.path);
}
// 刷新当前目录
if (currentPath) {
await loadAssets(currentPath);
}
// 清除选中状态
if (selectedPaths.has(asset.path)) {
const newSelected = new Set(selectedPaths);
newSelected.delete(asset.path);
setSelectedPaths(newSelected);
}
setDeleteConfirmDialog(null);
} catch (error) {
console.error('Failed to delete:', error);
alert(`删除失败: ${error}`);
}
};
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
e.preventDefault();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
asset
});
};
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
const items: ContextMenuItem[] = [];
if (asset.type === 'file') {
items.push({
label: locale === 'zh' ? '打开' : 'Open',
icon: <File size={16} />,
onClick: () => handleAssetDoubleClick(asset)
});
if (fileActionRegistry) {
const handlers = fileActionRegistry.getHandlersForFile(asset.path);
for (const handler of handlers) {
if (handler.getContextMenuItems) {
const parentPath = asset.path.substring(0, asset.path.lastIndexOf('/'));
const pluginItems = handler.getContextMenuItems(asset.path, parentPath);
for (const pluginItem of pluginItems) {
items.push({
label: pluginItem.label,
icon: pluginItem.icon,
onClick: () => pluginItem.onClick(asset.path, parentPath),
disabled: pluginItem.disabled,
separator: pluginItem.separator
});
}
}
}
}
items.push({ label: '', separator: true, onClick: () => {} });
}
if (asset.type === 'folder' && fileActionRegistry) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
items.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
items.push({
label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`,
icon: getIconComponent(template.icon, 16),
onClick: async () => {
const fileName = `new_${template.id}.${template.extension}`;
const filePath = `${asset.path}/${fileName}`;
await template.create(filePath);
if (currentPath) {
await loadAssets(currentPath);
}
}
});
}
}
}
// 在文件管理器中显示
items.push({
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
icon: <FolderOpen size={16} />,
onClick: async () => {
try {
await TauriAPI.showInFolder(asset.path);
} catch (error) {
console.error('Failed to show in folder:', error);
}
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 复制路径
items.push({
label: locale === 'zh' ? '复制路径' : 'Copy Path',
icon: <Copy size={16} />,
onClick: () => {
navigator.clipboard.writeText(asset.path);
}
});
items.push({ label: '', separator: true, onClick: () => {} });
// 重命名
items.push({
label: locale === 'zh' ? '重命名' : 'Rename',
icon: <Edit3 size={16} />,
onClick: () => {
setRenameDialog({
asset,
newName: asset.name
});
setContextMenu(null);
},
disabled: false
});
// 删除
items.push({
label: locale === 'zh' ? '删除' : 'Delete',
icon: <Trash2 size={16} />,
onClick: () => {
setDeleteConfirmDialog(asset);
setContextMenu(null);
},
disabled: false
});
return items;
};
const getBreadcrumbs = () => {
if (!currentPath || !projectPath) return [];
const relative = currentPath.replace(projectPath, '');
const parts = relative.split(/[/\\]/).filter((p) => p);
const crumbs = [{ name: 'Content', path: projectPath }];
let accPath = projectPath;
for (const part of parts) {
accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`;
crumbs.push({ name: part, path: accPath });
}
return crumbs;
};
const filteredAssets = searchQuery.trim() ? searchResults : assets;
const getRelativePath = (fullPath: string): string => {
if (!projectPath) return fullPath;
const relativePath = fullPath.replace(projectPath, '').replace(/^[/\\]/, '');
const parts = relativePath.split(/[/\\]/);
return parts.slice(0, -1).join('/');
};
const getFileIcon = (asset: AssetItem) => {
if (asset.type === 'folder') {
// 检查是否为框架专用文件夹
const folderName = asset.name.toLowerCase();
if (folderName === 'plugins' || folderName === '.ecs') {
return <Folder className="asset-icon system-folder" style={{ color: '#42a5f5' }} size={20} />;
}
return <Folder className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
}
const ext = asset.extension?.toLowerCase();
switch (ext) {
case 'ecs':
return <File className="asset-icon" style={{ color: '#66bb6a' }} size={20} />;
case 'btree':
return <FileText className="asset-icon" style={{ color: '#ab47bc' }} size={20} />;
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return <FileCode className="asset-icon" style={{ color: '#42a5f5' }} size={20} />;
case 'json':
return <FileJson className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
return <FileImage className="asset-icon" style={{ color: '#ec407a' }} size={20} />;
default:
return <File className="asset-icon" size={20} />;
}
};
if (!projectPath) {
return (
<div className="asset-browser">
<div className="asset-browser-header">
<h3>{t.title}</h3>
</div>
<div className="asset-browser-empty">
<p>{t.noProject}</p>
</div>
</div>
);
}
const breadcrumbs = getBreadcrumbs();
return (
<div className="asset-browser">
<div className="asset-browser-content">
<div style={{
padding: '8px',
borderBottom: '1px solid #3e3e3e',
display: 'flex',
gap: '8px',
background: '#252526',
alignItems: 'center'
}}>
<div className="view-mode-buttons">
<button
className={`view-mode-btn ${showDetailView ? 'active' : ''}`}
onClick={() => {
setShowDetailView(true);
localStorage.setItem('asset-browser-detail-view', 'true');
}}
title="显示详细视图(树形图 + 资产列表)"
>
<LayoutGrid size={14} />
<span className="view-mode-text"></span>
</button>
<button
className={`view-mode-btn ${!showDetailView ? 'active' : ''}`}
onClick={() => {
setShowDetailView(false);
localStorage.setItem('asset-browser-detail-view', 'false');
}}
title="仅显示树形图(查看完整路径)"
>
<List size={14} />
<span className="view-mode-text"></span>
</button>
</div>
<button
onClick={() => {
if (showDetailView) {
detailViewFileTreeRef.current?.collapseAll();
} else {
treeOnlyViewFileTreeRef.current?.collapseAll();
}
}}
style={{
padding: '6px 8px',
background: 'transparent',
border: '1px solid #3e3e3e',
color: '#cccccc',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '3px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="收起所有文件夹"
>
<ChevronsUp size={14} />
</button>
<button
onClick={() => {
if (currentPath) {
loadAssets(currentPath);
}
if (showDetailView) {
detailViewFileTreeRef.current?.refresh();
} else {
treeOnlyViewFileTreeRef.current?.refresh();
}
}}
style={{
padding: '6px 8px',
background: 'transparent',
border: '1px solid #3e3e3e',
color: '#cccccc',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '3px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="刷新资产列表"
>
<RefreshCw size={14} />
</button>
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
flex: 1,
padding: '6px 10px',
background: '#3c3c3c',
border: '1px solid #3e3e3e',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
outline: 'none'
}}
/>
</div>
{showDetailView ? (
<ResizablePanel
direction="horizontal"
defaultSize={200}
minSize={150}
maxSize={400}
leftOrTop={
<div className="asset-browser-tree">
<FileTree
ref={detailViewFileTreeRef}
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
messageHub={messageHub}
searchQuery={searchQuery}
showFiles={false}
onOpenScene={onOpenScene}
/>
</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 ${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}
onSelectFiles={handleTreeMultiSelect}
selectedPath={Array.from(selectedPaths)[0] || currentPath}
selectedPaths={selectedPaths}
messageHub={messageHub}
searchQuery={searchQuery}
showFiles={true}
onOpenScene={onOpenScene}
/>
</div>
)}
</div>
{contextMenu && (
<ContextMenu
items={getContextMenuItems(contextMenu.asset)}
position={contextMenu.position}
onClose={() => setContextMenu(null)}
/>
)}
{/* 重命名对话框 */}
{renameDialog && (
<div className="dialog-overlay" onClick={() => setRenameDialog(null)}>
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3>{locale === 'zh' ? '重命名' : 'Rename'}</h3>
</div>
<div className="dialog-body">
<input
type="text"
value={renameDialog.newName}
onChange={(e) => setRenameDialog({ ...renameDialog, newName: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(renameDialog.asset, renameDialog.newName);
} else if (e.key === 'Escape') {
setRenameDialog(null);
}
}}
autoFocus
style={{
width: '100%',
padding: '8px',
backgroundColor: '#2d2d2d',
border: '1px solid #3e3e3e',
borderRadius: '4px',
color: '#cccccc',
fontSize: '13px'
}}
/>
</div>
<div className="dialog-footer">
<button
onClick={() => setRenameDialog(null)}
style={{
padding: '6px 16px',
backgroundColor: '#3e3e3e',
border: 'none',
borderRadius: '4px',
color: '#cccccc',
cursor: 'pointer',
marginRight: '8px'
}}
>
{locale === 'zh' ? '取消' : 'Cancel'}
</button>
<button
onClick={() => handleRename(renameDialog.asset, renameDialog.newName)}
style={{
padding: '6px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#ffffff',
cursor: 'pointer'
}}
>
{locale === 'zh' ? '确定' : 'OK'}
</button>
</div>
</div>
</div>
)}
{/* 删除确认对话框 */}
{deleteConfirmDialog && (
<div className="dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3>{locale === 'zh' ? '确认删除' : 'Confirm Delete'}</h3>
</div>
<div className="dialog-body">
<p style={{ margin: 0, color: '#cccccc' }}>
{locale === 'zh'
? `确定要删除 "${deleteConfirmDialog.name}" 吗?此操作不可撤销。`
: `Are you sure you want to delete "${deleteConfirmDialog.name}"? This action cannot be undone.`}
</p>
</div>
<div className="dialog-footer">
<button
onClick={() => setDeleteConfirmDialog(null)}
style={{
padding: '6px 16px',
backgroundColor: '#3e3e3e',
border: 'none',
borderRadius: '4px',
color: '#cccccc',
cursor: 'pointer',
marginRight: '8px'
}}
>
{locale === 'zh' ? '取消' : 'Cancel'}
</button>
<button
onClick={() => handleDelete(deleteConfirmDialog)}
style={{
padding: '6px 16px',
backgroundColor: '#c53030',
border: 'none',
borderRadius: '4px',
color: '#ffffff',
cursor: 'pointer'
}}
>
{locale === 'zh' ? '删除' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
<ContentBrowser
projectPath={projectPath}
locale={locale}
onOpenScene={onOpenScene}
/>
);
}

View File

@@ -0,0 +1,957 @@
/**
* Content Browser - 内容浏览器
* 用于浏览和管理项目资产
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Plus,
Download,
Save,
ChevronRight,
ChevronDown,
Search,
SlidersHorizontal,
LayoutGrid,
List,
FolderClosed,
FolderOpen,
Folder,
File,
FileCode,
FileJson,
FileImage,
FileText,
Copy,
Trash2,
Edit3,
ExternalLink,
PanelRightClose
} from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import { PromptDialog } from './PromptDialog';
import '../styles/ContentBrowser.css';
interface AssetItem {
name: string;
path: string;
type: 'file' | 'folder';
extension?: string;
size?: number;
modified?: number;
}
interface FolderNode {
name: string;
path: string;
children: FolderNode[];
isExpanded: boolean;
}
interface ContentBrowserProps {
projectPath: string | null;
locale?: string;
onOpenScene?: (scenePath: string) => void;
isDrawer?: boolean;
onDockInLayout?: () => void;
revealPath?: string | null;
}
// 获取资产类型显示名称
function getAssetTypeName(asset: AssetItem): string {
if (asset.type === 'folder') return 'Folder';
// Check for compound extensions first
const name = asset.name.toLowerCase();
if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap';
if (name.endsWith('.tileset.json') || name.endsWith('.tileset')) return 'Tileset';
const ext = asset.extension?.toLowerCase();
switch (ext) {
case 'ecs': return 'Scene';
case 'btree': return 'Behavior Tree';
case 'png':
case 'jpg':
case 'jpeg':
case 'webp': return 'Texture';
case 'ts':
case 'tsx': return 'TypeScript';
case 'js':
case 'jsx': return 'JavaScript';
case 'json': return 'JSON';
case 'prefab': return 'Prefab';
case 'mat': return 'Material';
case 'anim': return 'Animation';
default: return ext?.toUpperCase() || 'File';
}
}
export function ContentBrowser({
projectPath,
locale = 'en',
onOpenScene,
isDrawer = false,
onDockInLayout,
revealPath
}: ContentBrowserProps) {
const messageHub = Core.services.resolve(MessageHub);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
// State
const [currentPath, setCurrentPath] = 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 [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
// Folder tree state
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Sections collapse state
const [favoritesExpanded, setFavoritesExpanded] = useState(true);
const [collectionsExpanded, setCollectionsExpanded] = useState(true);
// Favorites (stored paths)
const [favorites] = useState<string[]>([]);
// Dialog states
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
asset: AssetItem | null;
isBackground?: boolean;
} | null>(null);
const [renameDialog, setRenameDialog] = useState<{
asset: AssetItem;
newName: string;
} | null>(null);
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<AssetItem | null>(null);
const [createFileDialog, setCreateFileDialog] = useState<{
parentPath: string;
template: FileCreationTemplate;
} | null>(null);
const t = {
en: {
favorites: 'Favorites',
collections: 'Collections',
add: 'Add',
import: 'Import',
saveAll: 'Save All',
search: 'Search',
items: 'items',
dockInLayout: 'Dock in Layout',
noProject: 'No project loaded',
empty: 'This folder is empty',
newFolder: 'New Folder'
},
zh: {
favorites: '收藏夹',
collections: '收藏集',
add: '添加',
import: '导入',
saveAll: '全部保存',
search: '搜索',
items: '项',
dockInLayout: '停靠到布局',
noProject: '未加载项目',
empty: '文件夹为空',
newFolder: '新建文件夹'
}
}[locale] || {
favorites: 'Favorites',
collections: 'Collections',
add: 'Add',
import: 'Import',
saveAll: 'Save All',
search: 'Search',
items: 'items',
dockInLayout: 'Dock in Layout',
noProject: 'No project loaded',
empty: 'This folder is empty',
newFolder: 'New Folder'
};
// Build folder tree - use ref to avoid dependency cycle
const expandedFoldersRef = useRef(expandedFolders);
expandedFoldersRef.current = expandedFolders;
const buildFolderTree = useCallback(async (rootPath: string): Promise<FolderNode> => {
const currentExpanded = expandedFoldersRef.current;
const buildNode = async (path: string, name: string): Promise<FolderNode> => {
const node: FolderNode = {
name,
path,
children: [],
isExpanded: currentExpanded.has(path)
};
try {
const entries = await TauriAPI.listDirectory(path);
const folders = entries
.filter((e: DirectoryEntry) => e.is_dir && !e.name.startsWith('.'))
.sort((a: DirectoryEntry, b: DirectoryEntry) => a.name.localeCompare(b.name));
for (const folder of folders) {
if (currentExpanded.has(path)) {
node.children.push(await buildNode(folder.path, folder.name));
} else {
node.children.push({
name: folder.name,
path: folder.path,
children: [],
isExpanded: false
});
}
}
} catch (error) {
console.error('Failed to build folder tree:', error);
}
return node;
};
return buildNode(rootPath, 'All');
}, []);
// Load assets
const loadAssets = useCallback(async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
extension: entry.is_dir ? undefined : entry.name.split('.').pop(),
size: entry.size,
modified: entry.modified
}));
setAssets(assetItems.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
}));
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
}, []);
// Initialize on mount
useEffect(() => {
if (projectPath) {
setCurrentPath(projectPath);
setExpandedFolders(new Set([projectPath]));
loadAssets(projectPath);
buildFolderTree(projectPath).then(setFolderTree);
}
// Only run on mount, not on every projectPath change
}, []);
// Handle projectPath change after initial mount
const prevProjectPath = useRef(projectPath);
useEffect(() => {
if (projectPath && projectPath !== prevProjectPath.current) {
prevProjectPath.current = projectPath;
setCurrentPath(projectPath);
setExpandedFolders(new Set([projectPath]));
loadAssets(projectPath);
buildFolderTree(projectPath).then(setFolderTree);
}
}, [projectPath, loadAssets, buildFolderTree]);
// Rebuild tree when expanded folders change
const expandedFoldersVersion = useRef(0);
useEffect(() => {
// Skip first render (handled by initialization)
if (expandedFoldersVersion.current === 0) {
expandedFoldersVersion.current = 1;
return;
}
if (projectPath) {
buildFolderTree(projectPath).then(setFolderTree);
}
}, [expandedFolders, projectPath, buildFolderTree]);
// Handle reveal path - navigate to folder and select file
const prevRevealPath = useRef<string | null>(null);
useEffect(() => {
if (revealPath && revealPath !== prevRevealPath.current && projectPath) {
prevRevealPath.current = revealPath;
// Remove timestamp query if present
const cleanPath = revealPath.split('?')[0] || revealPath;
// Get full path
const fullPath = cleanPath.startsWith('/') || cleanPath.includes(':')
? cleanPath
: `${projectPath}/${cleanPath}`;
// Get parent directory
const pathParts = fullPath.replace(/\\/g, '/').split('/');
pathParts.pop(); // Remove filename
const parentDir = pathParts.join('/');
// Expand all parent folders
const foldersToExpand = new Set<string>();
let currentFolder = parentDir;
while (currentFolder && currentFolder.length >= (projectPath?.length || 0)) {
foldersToExpand.add(currentFolder);
const parts = currentFolder.split('/');
parts.pop();
currentFolder = parts.join('/');
}
// Update expanded folders and navigate
setExpandedFolders((prev) => {
const next = new Set(prev);
foldersToExpand.forEach((f) => next.add(f));
return next;
});
// Navigate to parent folder and select the file
setCurrentPath(parentDir);
loadAssets(parentDir).then(() => {
// Select the file after assets are loaded
setSelectedPaths(new Set([fullPath]));
setLastSelectedPath(fullPath);
});
}
}, [revealPath, projectPath, loadAssets]);
// Handle folder selection in tree
const handleFolderSelect = useCallback((path: string) => {
setCurrentPath(path);
loadAssets(path);
}, [loadAssets]);
// Toggle folder expansion
const toggleFolderExpand = useCallback((path: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
// Handle asset click
const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => {
if (e.shiftKey && lastSelectedPath) {
const lastIndex = assets.findIndex(a => a.path === lastSelectedPath);
const currentIndex = assets.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 = assets.slice(start, end + 1).map(a => a.path);
setSelectedPaths(new Set(rangePaths));
}
} else if (e.ctrlKey || e.metaKey) {
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 {
setSelectedPaths(new Set([asset.path]));
setLastSelectedPath(asset.path);
}
messageHub?.publish('asset-file:selected', {
fileInfo: {
name: asset.name,
path: asset.path,
extension: asset.extension,
isDirectory: asset.type === 'folder'
}
});
}, [assets, lastSelectedPath, selectedPaths, messageHub]);
// Handle asset double click
const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => {
if (asset.type === 'folder') {
setCurrentPath(asset.path);
loadAssets(asset.path);
setExpandedFolders(prev => new Set([...prev, asset.path]));
} else {
const ext = asset.extension?.toLowerCase();
if (ext === 'ecs' && onOpenScene) {
onOpenScene(asset.path);
return;
}
if (fileActionRegistry) {
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
if (handled) return;
}
try {
await TauriAPI.openFileWithSystemApp(asset.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
}, [loadAssets, onOpenScene, fileActionRegistry]);
// Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => {
e.preventDefault();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
asset: asset || null,
isBackground: !asset
});
}, []);
// Handle rename
const handleRename = useCallback(async (asset: AssetItem, newName: string) => {
if (!newName.trim() || newName === asset.name) {
setRenameDialog(null);
return;
}
try {
const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\'));
const parentPath = asset.path.substring(0, lastSlash);
const newPath = `${parentPath}/${newName}`;
await TauriAPI.renameFileOrFolder(asset.path, newPath);
if (currentPath) {
await loadAssets(currentPath);
}
setRenameDialog(null);
} catch (error) {
console.error('Failed to rename:', error);
}
}, [currentPath, loadAssets]);
// Handle delete
const handleDelete = useCallback(async (asset: AssetItem) => {
try {
if (asset.type === 'folder') {
await TauriAPI.deleteFolder(asset.path);
} else {
await TauriAPI.deleteFile(asset.path);
}
if (currentPath) {
await loadAssets(currentPath);
}
setDeleteConfirmDialog(null);
} catch (error) {
console.error('Failed to delete:', error);
}
}, [currentPath, loadAssets]);
// Get breadcrumbs
const getBreadcrumbs = useCallback(() => {
if (!currentPath || !projectPath) return [];
const relative = currentPath.replace(projectPath, '');
const parts = relative.split(/[/\\]/).filter(p => p);
const crumbs = [{ name: 'All', path: projectPath }];
crumbs.push({ name: 'Content', path: projectPath });
let accPath = projectPath;
for (const part of parts) {
accPath = `${accPath}/${part}`;
crumbs.push({ name: part, path: accPath });
}
return crumbs;
}, [currentPath, projectPath]);
// Get file icon
const getFileIcon = useCallback((asset: AssetItem, size: number = 48) => {
if (asset.type === 'folder') {
return <Folder size={size} className="asset-thumbnail-icon folder" />;
}
const ext = asset.extension?.toLowerCase();
switch (ext) {
case 'ecs':
return <File size={size} className="asset-thumbnail-icon scene" />;
case 'btree':
return <FileText size={size} className="asset-thumbnail-icon btree" />;
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return <FileCode size={size} className="asset-thumbnail-icon code" />;
case 'json':
return <FileJson size={size} className="asset-thumbnail-icon json" />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return <FileImage size={size} className="asset-thumbnail-icon image" />;
default:
return <File size={size} className="asset-thumbnail-icon" />;
}
}, []);
// Get context menu items
const getContextMenuItems = useCallback((asset: AssetItem | null): ContextMenuItem[] => {
const items: ContextMenuItem[] = [];
if (!asset) {
// Background context menu
items.push({
label: t.newFolder,
icon: <FolderClosed size={16} />,
onClick: async () => {
if (!currentPath) return;
const folderName = `New Folder`;
const folderPath = `${currentPath}/${folderName}`;
try {
await TauriAPI.createDirectory(folderPath);
await loadAssets(currentPath);
} catch (error) {
console.error('Failed to create folder:', error);
}
}
});
if (fileActionRegistry) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
items.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
items.push({
label: `New ${template.label}`,
onClick: () => {
setContextMenu(null);
if (currentPath) {
setCreateFileDialog({
parentPath: currentPath,
template
});
}
}
});
}
}
}
return items;
}
// Asset context menu
if (asset.type === 'file') {
items.push({
label: locale === 'zh' ? '打开' : 'Open',
icon: <File size={16} />,
onClick: () => handleAssetDoubleClick(asset)
});
items.push({ label: '', separator: true, onClick: () => {} });
}
items.push({
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
icon: <ExternalLink size={16} />,
onClick: async () => {
try {
await TauriAPI.showInFolder(asset.path);
} catch (error) {
console.error('Failed to show in folder:', error);
}
}
});
items.push({
label: locale === 'zh' ? '复制路径' : 'Copy Path',
icon: <Copy size={16} />,
onClick: () => navigator.clipboard.writeText(asset.path)
});
items.push({ label: '', separator: true, onClick: () => {} });
items.push({
label: locale === 'zh' ? '重命名' : 'Rename',
icon: <Edit3 size={16} />,
onClick: () => {
setRenameDialog({ asset, newName: asset.name });
setContextMenu(null);
}
});
items.push({
label: locale === 'zh' ? '删除' : 'Delete',
icon: <Trash2 size={16} />,
onClick: () => {
setDeleteConfirmDialog(asset);
setContextMenu(null);
}
});
return items;
}, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]);
// Render folder tree node
const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => {
const isSelected = currentPath === node.path;
const isExpanded = expandedFolders.has(node.path);
const hasChildren = node.children.length > 0;
return (
<div key={node.path}>
<div
className={`folder-tree-item ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleFolderSelect(node.path)}
>
<span
className="folder-tree-expand"
onClick={(e) => toggleFolderExpand(node.path, e)}
>
{hasChildren ? (
isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
) : (
<span style={{ width: 14 }} />
)}
</span>
<span className="folder-tree-icon">
{isExpanded ? <FolderOpen size={14} /> : <FolderClosed size={14} />}
</span>
<span className="folder-tree-name">{node.name}</span>
</div>
{isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))}
</div>
);
}, [currentPath, expandedFolders, handleFolderSelect, toggleFolderExpand]);
// Filter assets by search
const filteredAssets = searchQuery.trim()
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
: assets;
const breadcrumbs = getBreadcrumbs();
if (!projectPath) {
return (
<div className="content-browser">
<div className="content-browser-empty">
<p>{t.noProject}</p>
</div>
</div>
);
}
return (
<div className={`content-browser ${isDrawer ? 'is-drawer' : ''}`}>
{/* Left Panel - Folder Tree */}
<div className="content-browser-left">
{/* Favorites Section */}
<div className="cb-section">
<div
className="cb-section-header"
onClick={() => setFavoritesExpanded(!favoritesExpanded)}
>
{favoritesExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>{t.favorites}</span>
<button className="cb-section-btn" onClick={(e) => e.stopPropagation()}>
<Search size={12} />
</button>
</div>
{favoritesExpanded && (
<div className="cb-section-content">
{favorites.length === 0 ? (
<div className="cb-section-empty">
{/* Empty favorites */}
</div>
) : (
favorites.map(fav => (
<div key={fav} className="folder-tree-item">
<FolderClosed size={14} />
<span>{fav.split('/').pop()}</span>
</div>
))
)}
</div>
)}
</div>
{/* Folder Tree */}
<div className="cb-folder-tree">
{folderTree && renderFolderNode(folderTree)}
</div>
{/* Collections Section */}
<div className="cb-section">
<div
className="cb-section-header"
onClick={() => setCollectionsExpanded(!collectionsExpanded)}
>
{collectionsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>{t.collections}</span>
<div className="cb-section-actions">
<button className="cb-section-btn" onClick={(e) => e.stopPropagation()}>
<Plus size={12} />
</button>
<button className="cb-section-btn" onClick={(e) => e.stopPropagation()}>
<Search size={12} />
</button>
</div>
</div>
{collectionsExpanded && (
<div className="cb-section-content">
{/* Collections list */}
</div>
)}
</div>
</div>
{/* Right Panel - Content Area */}
<div className="content-browser-right">
{/* Top Toolbar */}
<div className="cb-toolbar">
<div className="cb-toolbar-left">
<button className="cb-toolbar-btn primary">
<Plus size={14} />
<span>{t.add}</span>
</button>
<button className="cb-toolbar-btn">
<Download size={14} />
<span>{t.import}</span>
</button>
<button className="cb-toolbar-btn">
<Save size={14} />
<span>{t.saveAll}</span>
</button>
</div>
{/* Breadcrumb Navigation */}
<div className="cb-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path} className="cb-breadcrumb-item">
{index > 0 && <ChevronRight size={12} className="cb-breadcrumb-sep" />}
<span
className="cb-breadcrumb-link"
onClick={() => handleFolderSelect(crumb.path)}
>
{crumb.name}
</span>
</span>
))}
</div>
<div className="cb-toolbar-right">
{isDrawer && onDockInLayout && (
<button
className="cb-toolbar-btn dock-btn"
onClick={onDockInLayout}
title={t.dockInLayout}
>
<PanelRightClose size={14} />
<span>{t.dockInLayout}</span>
</button>
)}
</div>
</div>
{/* Search Bar */}
<div className="cb-search-bar">
<button className="cb-filter-btn">
<SlidersHorizontal size={14} />
<ChevronDown size={10} />
</button>
<div className="cb-search-input-wrapper">
<Search size={14} className="cb-search-icon" />
<input
type="text"
className="cb-search-input"
placeholder={`${t.search} ${breadcrumbs[breadcrumbs.length - 1]?.name || ''}`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="cb-view-options">
<button
className={`cb-view-btn ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={14} />
</button>
<button
className={`cb-view-btn ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
>
<List size={14} />
</button>
</div>
</div>
{/* Asset Grid */}
<div
className={`cb-asset-grid ${viewMode}`}
onContextMenu={(e) => handleContextMenu(e)}
>
{loading ? (
<div className="cb-loading">Loading...</div>
) : filteredAssets.length === 0 ? (
<div className="cb-empty">{t.empty}</div>
) : (
filteredAssets.map(asset => (
<div
key={asset.path}
className={`cb-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.setData('asset-path', asset.path);
e.dataTransfer.setData('text/plain', asset.path);
}
}}
>
<div className="cb-asset-thumbnail">
{getFileIcon(asset)}
</div>
<div className="cb-asset-info">
<div className="cb-asset-name" title={asset.name}>
{asset.name}
</div>
<div className="cb-asset-type">
{getAssetTypeName(asset)}
</div>
</div>
</div>
))
)}
</div>
{/* Status Bar */}
<div className="cb-status-bar">
<span>{filteredAssets.length} {t.items}</span>
</div>
</div>
{/* Context Menu */}
{contextMenu && (
<ContextMenu
items={getContextMenuItems(contextMenu.asset)}
position={contextMenu.position}
onClose={() => setContextMenu(null)}
/>
)}
{/* Rename Dialog */}
{renameDialog && (
<div className="cb-dialog-overlay" onClick={() => setRenameDialog(null)}>
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
<div className="cb-dialog-header">
<h3>{locale === 'zh' ? '重命名' : 'Rename'}</h3>
</div>
<div className="cb-dialog-body">
<input
type="text"
value={renameDialog.newName}
onChange={(e) => setRenameDialog({ ...renameDialog, newName: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(renameDialog.asset, renameDialog.newName);
if (e.key === 'Escape') setRenameDialog(null);
}}
autoFocus
/>
</div>
<div className="cb-dialog-footer">
<button className="cb-btn" onClick={() => setRenameDialog(null)}>
{locale === 'zh' ? '取消' : 'Cancel'}
</button>
<button
className="cb-btn primary"
onClick={() => handleRename(renameDialog.asset, renameDialog.newName)}
>
{locale === 'zh' ? '确定' : 'OK'}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirm Dialog */}
{deleteConfirmDialog && (
<div className="cb-dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
<div className="cb-dialog-header">
<h3>{locale === 'zh' ? '确认删除' : 'Confirm Delete'}</h3>
</div>
<div className="cb-dialog-body">
<p>
{locale === 'zh'
? `确定要删除 "${deleteConfirmDialog.name}" 吗?`
: `Delete "${deleteConfirmDialog.name}"?`}
</p>
</div>
<div className="cb-dialog-footer">
<button className="cb-btn" onClick={() => setDeleteConfirmDialog(null)}>
{locale === 'zh' ? '取消' : 'Cancel'}
</button>
<button
className="cb-btn danger"
onClick={() => handleDelete(deleteConfirmDialog)}
>
{locale === 'zh' ? '删除' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{/* Create File Dialog */}
{createFileDialog && (
<PromptDialog
title={`New ${createFileDialog.template.label}`}
message={`Enter file name (.${createFileDialog.template.extension} will be added):`}
placeholder="filename"
confirmText={locale === 'zh' ? '创建' : 'Create'}
cancelText={locale === 'zh' ? '取消' : 'Cancel'}
onConfirm={async (value) => {
const { parentPath, template } = createFileDialog;
setCreateFileDialog(null);
let fileName = value;
if (!fileName.endsWith(`.${template.extension}`)) {
fileName = `${fileName}.${template.extension}`;
}
const filePath = `${parentPath}/${fileName}`;
try {
const content = await template.getContent(fileName);
await TauriAPI.writeFileContent(filePath, content);
if (currentPath) {
await loadAssets(currentPath);
}
} catch (error) {
console.error('Failed to create file:', error);
}
}}
onCancel={() => setCreateFileDialog(null)}
/>
)}
</div>
);
}

View File

@@ -83,8 +83,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
type: 'create-file' | 'create-folder' | 'create-template';
parentPath: string;
templateExtension?: string;
templateContent?: (fileName: string) => Promise<string>;
} | null>(null);
templateGetContent?: (fileName: string) => string | Promise<string>;
} | null>(null);
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
@@ -515,14 +515,14 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
type: 'create-template',
parentPath,
templateExtension: template.extension,
templateContent: template.createContent
templateGetContent: template.getContent
});
};
const handlePromptConfirm = async (value: string) => {
if (!promptDialog) return;
const { type, parentPath, templateExtension, templateContent } = promptDialog;
const { type, parentPath, templateExtension, templateGetContent } = promptDialog;
setPromptDialog(null);
let fileName = value;
@@ -533,13 +533,13 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
await TauriAPI.createFile(targetPath);
} else if (type === 'create-folder') {
await TauriAPI.createDirectory(targetPath);
} else if (type === 'create-template' && templateExtension && templateContent) {
} else if (type === 'create-template' && templateExtension && templateGetContent) {
if (!fileName.endsWith(`.${templateExtension}`)) {
fileName = `${fileName}.${templateExtension}`;
targetPath = `${parentPath}/${fileName}`;
}
const content = await templateContent(fileName);
// 获取内容并通过后端 API 写入文件
const content = await templateGetContent(fileName);
await TauriAPI.writeFileContent(targetPath, content);
}
await refreshTree();

View File

@@ -4,7 +4,7 @@
*/
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
@@ -91,9 +91,10 @@ interface FlexLayoutDockContainerProps {
panels: FlexDockPanel[];
onPanelClose?: (panelId: string) => void;
activePanelId?: string;
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
}
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: FlexLayoutDockContainerProps) {
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }: FlexLayoutDockContainerProps) {
const layoutRef = useRef<Layout>(null);
const previousLayoutJsonRef = useRef<string | null>(null);
const previousPanelIdsRef = useRef<string>('');
@@ -104,6 +105,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
const [visiblePersistentPanels, setVisiblePersistentPanels] = useState<Set<string>>(
() => new Set(PERSISTENT_PANEL_IDS)
);
const [isAnyTabsetMaximized, setIsAnyTabsetMaximized] = useState(false);
const persistentPanels = useMemo(
() => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)),
@@ -337,8 +339,34 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
// 保存布局状态以便在panels变化时恢复
const layoutJson = newModel.toJson();
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
// Check if any tabset is maximized
let hasMaximized = false;
newModel.visitNodes((node) => {
if (node.getType() === 'tabset') {
const tabset = node as TabSetNode;
if (tabset.isMaximized()) {
hasMaximized = true;
}
}
});
setIsAnyTabsetMaximized(hasMaximized);
}, []);
useEffect(() => {
if (!messageHub || !model) return;
const unsubscribe = messageHub.subscribe('panel:select', (data: { panelId: string }) => {
const { panelId } = data;
const node = model.getNodeById(panelId);
if (node && node.getType() === 'tab') {
model.doAction(Actions.selectTab(panelId));
}
});
return () => unsubscribe?.();
}, [messageHub, model]);
return (
<div className="flexlayout-dock-container">
<Layout
@@ -357,6 +385,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
panel={panel}
rect={persistentPanelRects.get(panel.id)}
isVisible={visiblePersistentPanels.has(panel.id)}
isMaximized={isAnyTabsetMaximized}
/>
))}
</div>
@@ -370,14 +399,20 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
function PersistentPanelContainer({
panel,
rect,
isVisible
isVisible,
isMaximized
}: {
panel: FlexDockPanel;
rect?: DOMRect;
isVisible: boolean;
isMaximized: boolean;
}) {
const hasValidRect = rect && rect.width > 0 && rect.height > 0;
// Hide persistent panel completely when another tabset is maximized
// (unless this panel itself is in the maximized tabset)
const shouldHide = isMaximized && !isVisible;
return (
<div
className="persistent-panel-container"
@@ -387,9 +422,10 @@ function PersistentPanelContainer({
top: hasValidRect ? rect.y : 0,
width: hasValidRect ? rect.width : '100%',
height: hasValidRect ? rect.height : '100%',
visibility: isVisible ? 'visible' : 'hidden',
pointerEvents: isVisible ? 'auto' : 'none',
zIndex: isVisible ? 1 : -1,
visibility: (isVisible && !shouldHide) ? 'visible' : 'hidden',
pointerEvents: (isVisible && !shouldHide) ? 'auto' : 'none',
// 使用较低的 z-index确保不会遮挡 FlexLayout 的 tab bar
zIndex: 0,
overflow: 'hidden'
}}
>

View File

@@ -0,0 +1,323 @@
import { useState, useEffect, useRef } from 'react';
import {
Play,
Pause,
Square,
SkipForward,
Save,
FolderOpen,
Undo2,
Redo2,
Eye,
Globe,
QrCode,
ChevronDown
} from 'lucide-react';
import type { MessageHub, CommandManager } from '@esengine/editor-core';
import '../styles/MainToolbar.css';
export type PlayState = 'stopped' | 'playing' | 'paused';
interface MainToolbarProps {
locale?: string;
messageHub?: MessageHub;
commandManager?: CommandManager;
onSaveScene?: () => void;
onOpenScene?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onPlay?: () => void;
onPause?: () => void;
onStop?: () => void;
onStep?: () => void;
onRunInBrowser?: () => void;
onRunOnDevice?: () => void;
}
interface ToolButtonProps {
icon: React.ReactNode;
label: string;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
}
function ToolButton({ icon, label, active, disabled, onClick }: ToolButtonProps) {
return (
<button
className={`toolbar-button ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={onClick}
disabled={disabled}
title={label}
type="button"
>
{icon}
</button>
);
}
function ToolSeparator() {
return <div className="toolbar-separator" />;
}
export function MainToolbar({
locale = 'en',
messageHub,
commandManager,
onSaveScene,
onOpenScene,
onUndo,
onRedo,
onPlay,
onPause,
onStop,
onStep,
onRunInBrowser,
onRunOnDevice
}: MainToolbarProps) {
const [playState, setPlayState] = useState<PlayState>('stopped');
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [showRunMenu, setShowRunMenu] = useState(false);
const runMenuRef = useRef<HTMLDivElement>(null);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
en: {
play: 'Play',
pause: 'Pause',
stop: 'Stop',
step: 'Step Forward',
save: 'Save Scene (Ctrl+S)',
open: 'Open Scene',
undo: 'Undo (Ctrl+Z)',
redo: 'Redo (Ctrl+Y)',
preview: 'Preview Mode',
runOptions: 'Run Options',
runInBrowser: 'Run in Browser',
runOnDevice: 'Run on Device'
},
zh: {
play: '播放',
pause: '暂停',
stop: '停止',
step: '单步执行',
save: '保存场景 (Ctrl+S)',
open: '打开场景',
undo: '撤销 (Ctrl+Z)',
redo: '重做 (Ctrl+Y)',
preview: '预览模式',
runOptions: '运行选项',
runInBrowser: '浏览器运行',
runOnDevice: '真机运行'
}
};
return translations[locale]?.[key] || key;
};
// Close run menu when clicking outside
useEffect(() => {
if (!showRunMenu) return;
const handleClickOutside = (e: MouseEvent) => {
if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) {
setShowRunMenu(false);
}
};
const timer = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 10);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
}, [showRunMenu]);
useEffect(() => {
if (commandManager) {
const updateUndoRedo = () => {
setCanUndo(commandManager.canUndo());
setCanRedo(commandManager.canRedo());
};
updateUndoRedo();
if (messageHub) {
const unsubscribe = messageHub.subscribe('command:executed', updateUndoRedo);
return () => unsubscribe();
}
}
}, [commandManager, messageHub]);
useEffect(() => {
if (messageHub) {
const unsubscribePlay = messageHub.subscribe('preview:started', () => {
setPlayState('playing');
});
const unsubscribePause = messageHub.subscribe('preview:paused', () => {
setPlayState('paused');
});
const unsubscribeStop = messageHub.subscribe('preview:stopped', () => {
setPlayState('stopped');
});
return () => {
unsubscribePlay();
unsubscribePause();
unsubscribeStop();
};
}
}, [messageHub]);
const handlePlay = () => {
if (playState === 'stopped' || playState === 'paused') {
onPlay?.();
messageHub?.publish('preview:start', {});
}
};
const handlePause = () => {
if (playState === 'playing') {
onPause?.();
messageHub?.publish('preview:pause', {});
}
};
const handleStop = () => {
if (playState !== 'stopped') {
onStop?.();
messageHub?.publish('preview:stop', {});
}
};
const handleStep = () => {
onStep?.();
messageHub?.publish('preview:step', {});
};
const handleUndo = () => {
if (commandManager?.canUndo()) {
commandManager.undo();
onUndo?.();
}
};
const handleRedo = () => {
if (commandManager?.canRedo()) {
commandManager.redo();
onRedo?.();
}
};
const handleRunInBrowser = () => {
setShowRunMenu(false);
onRunInBrowser?.();
messageHub?.publish('viewport:run-in-browser', {});
};
const handleRunOnDevice = () => {
setShowRunMenu(false);
onRunOnDevice?.();
messageHub?.publish('viewport:run-on-device', {});
};
return (
<div className="main-toolbar">
{/* File Operations */}
<div className="toolbar-group">
<ToolButton
icon={<Save size={16} />}
label={t('save')}
onClick={onSaveScene}
/>
<ToolButton
icon={<FolderOpen size={16} />}
label={t('open')}
onClick={onOpenScene}
/>
</div>
<ToolSeparator />
{/* Undo/Redo */}
<div className="toolbar-group">
<ToolButton
icon={<Undo2 size={16} />}
label={t('undo')}
disabled={!canUndo}
onClick={handleUndo}
/>
<ToolButton
icon={<Redo2 size={16} />}
label={t('redo')}
disabled={!canRedo}
onClick={handleRedo}
/>
</div>
{/* Play Controls - Absolutely Centered */}
<div className="toolbar-center-wrapper">
<div className="toolbar-group toolbar-center">
<ToolButton
icon={playState === 'playing' ? <Pause size={18} /> : <Play size={18} />}
label={playState === 'playing' ? t('pause') : t('play')}
onClick={playState === 'playing' ? handlePause : handlePlay}
/>
<ToolButton
icon={<Square size={16} />}
label={t('stop')}
disabled={playState === 'stopped'}
onClick={handleStop}
/>
<ToolButton
icon={<SkipForward size={16} />}
label={t('step')}
disabled={playState === 'playing'}
onClick={handleStep}
/>
<ToolSeparator />
{/* Run Options Dropdown */}
<div className="toolbar-dropdown" ref={runMenuRef}>
<button
className="toolbar-button toolbar-dropdown-trigger"
onClick={(e) => {
e.stopPropagation();
setShowRunMenu(prev => !prev);
}}
title={t('runOptions')}
type="button"
>
<Globe size={16} />
<ChevronDown size={12} />
</button>
{showRunMenu && (
<div className="toolbar-dropdown-menu">
<button type="button" onClick={handleRunInBrowser}>
<Globe size={14} />
<span>{t('runInBrowser')}</span>
</button>
<button type="button" onClick={handleRunOnDevice}>
<QrCode size={14} />
<span>{t('runOnDevice')}</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Preview Mode Indicator - Right aligned */}
<div className="toolbar-right">
{playState !== 'stopped' && (
<div className="preview-indicator">
<Eye size={14} />
<span>{t('preview')}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,474 @@
import { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import {
Search, Filter, Settings, X, Trash2, ChevronDown,
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play
} from 'lucide-react';
import { JsonViewer } from './JsonViewer';
import '../styles/OutputLogPanel.css';
interface OutputLogPanelProps {
logService: LogService;
locale?: string;
onClose?: () => void;
}
const MAX_LOGS = 1000;
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
try {
const parsed: unknown = JSON.parse(message);
return { isJSON: true, parsed };
} catch {
return { isJSON: false };
}
}
function formatTime(date: Date): string {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
function getLevelIcon(level: LogLevel) {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
}
function getLevelClass(level: LogLevel): string {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
}
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
log: LogEntry;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onOpenJsonViewer: (data: any) => void;
}) => {
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
const shouldTruncate = log.message.length > 200;
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={`output-log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
<div className="output-log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="output-log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="output-log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
</div>
)}
<div className="output-log-entry-message">
<div className="output-log-message-container">
<div className="output-log-message-text">
{shouldTruncate && !isExpanded ? (
<>
<span className="output-log-message-preview">
{log.message.substring(0, 200)}...
</span>
<button
className="output-log-expand-btn"
onClick={() => setIsExpanded(true)}
>
Show more
</button>
</>
) : (
<>
<span>{log.message}</span>
{shouldTruncate && (
<button
className="output-log-expand-btn"
onClick={() => setIsExpanded(false)}
>
Show less
</button>
)}
</>
)}
</div>
{isJSON && parsed !== undefined && (
<button
className="output-log-json-btn"
onClick={() => onOpenJsonViewer(parsed)}
title="Open in JSON Viewer"
>
JSON
</button>
)}
</div>
</div>
</div>
);
});
LogEntryItem.displayName = 'LogEntryItem';
export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLogPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
const [searchQuery, setSearchQuery] = useState('');
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
LogLevel.Debug,
LogLevel.Info,
LogLevel.Warn,
LogLevel.Error,
LogLevel.Fatal
]));
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
const [showTimestamp, setShowTimestamp] = useState(true);
const [showSource, setShowSource] = useState(true);
const logContainerRef = useRef<HTMLDivElement>(null);
const filterMenuRef = useRef<HTMLDivElement>(null);
const settingsMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logService.subscribe((entry) => {
setLogs((prev) => {
const newLogs = [...prev, entry];
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
});
});
return unsubscribe;
}, [logService]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
// Close menus on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) {
setShowFilterMenu(false);
}
if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) {
setShowSettingsMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleScroll = useCallback(() => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
}
}, []);
const handleClear = useCallback(() => {
logService.clear();
setLogs([]);
}, [logService]);
const toggleLevelFilter = useCallback((level: LogLevel) => {
setLevelFilter((prev) => {
const newFilter = new Set(prev);
if (newFilter.has(level)) {
newFilter.delete(level);
} else {
newFilter.add(level);
}
return newFilter;
});
}, []);
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (!levelFilter.has(log.level)) return false;
if (showRemoteOnly && log.source !== 'remote') return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
if (!log.message.toLowerCase().includes(query) &&
!log.source.toLowerCase().includes(query)) {
return false;
}
}
return true;
});
}, [logs, levelFilter, showRemoteOnly, searchQuery]);
const levelCounts = useMemo(() => ({
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
[LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length,
[LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length
}), [logs]);
const remoteLogCount = useMemo(() =>
logs.filter((l) => l.source === 'remote').length
, [logs]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (!levelFilter.has(LogLevel.Debug)) count++;
if (!levelFilter.has(LogLevel.Info)) count++;
if (!levelFilter.has(LogLevel.Warn)) count++;
if (!levelFilter.has(LogLevel.Error)) count++;
if (showRemoteOnly) count++;
return count;
}, [levelFilter, showRemoteOnly]);
return (
<div className="output-log-panel">
{/* Toolbar */}
<div className="output-log-toolbar">
<div className="output-log-toolbar-left">
<div className="output-log-search">
<Search size={14} />
<input
type="text"
placeholder={locale === 'zh' ? '搜索日志...' : 'Search logs...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
className="output-log-search-clear"
onClick={() => setSearchQuery('')}
>
<X size={12} />
</button>
)}
</div>
</div>
<div className="output-log-toolbar-right">
{/* Filter Dropdown */}
<div className="output-log-dropdown" ref={filterMenuRef}>
<button
className={`output-log-btn ${showFilterMenu ? 'active' : ''} ${activeFilterCount > 0 ? 'has-filter' : ''}`}
onClick={() => {
setShowFilterMenu(!showFilterMenu);
setShowSettingsMenu(false);
}}
>
<Filter size={14} />
<span>{locale === 'zh' ? '过滤器' : 'Filters'}</span>
{activeFilterCount > 0 && (
<span className="filter-badge">{activeFilterCount}</span>
)}
<ChevronDown size={12} />
</button>
{showFilterMenu && (
<div className="output-log-menu">
<div className="output-log-menu-header">
{locale === 'zh' ? '日志级别' : 'Log Levels'}
</div>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Debug)}
onChange={() => toggleLevelFilter(LogLevel.Debug)}
/>
<Bug size={14} className="level-icon debug" />
<span>Debug</span>
<span className="level-count">{levelCounts[LogLevel.Debug]}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Info)}
onChange={() => toggleLevelFilter(LogLevel.Info)}
/>
<Info size={14} className="level-icon info" />
<span>Info</span>
<span className="level-count">{levelCounts[LogLevel.Info]}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Warn)}
onChange={() => toggleLevelFilter(LogLevel.Warn)}
/>
<AlertTriangle size={14} className="level-icon warn" />
<span>Warning</span>
<span className="level-count">{levelCounts[LogLevel.Warn]}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={levelFilter.has(LogLevel.Error)}
onChange={() => toggleLevelFilter(LogLevel.Error)}
/>
<XCircle size={14} className="level-icon error" />
<span>Error</span>
<span className="level-count">{levelCounts[LogLevel.Error]}</span>
</label>
<div className="output-log-menu-divider" />
<label className="output-log-menu-item">
<input
type="checkbox"
checked={showRemoteOnly}
onChange={() => setShowRemoteOnly(!showRemoteOnly)}
/>
<Wifi size={14} className="level-icon remote" />
<span>{locale === 'zh' ? '仅远程日志' : 'Remote Only'}</span>
<span className="level-count">{remoteLogCount}</span>
</label>
</div>
)}
</div>
{/* Auto Scroll Toggle */}
<button
className={`output-log-icon-btn ${autoScroll ? 'active' : ''}`}
onClick={() => setAutoScroll(!autoScroll)}
title={autoScroll
? (locale === 'zh' ? '暂停自动滚动' : 'Pause auto-scroll')
: (locale === 'zh' ? '恢复自动滚动' : 'Resume auto-scroll')
}
>
{autoScroll ? <Pause size={14} /> : <Play size={14} />}
</button>
{/* Settings Dropdown */}
<div className="output-log-dropdown" ref={settingsMenuRef}>
<button
className={`output-log-icon-btn ${showSettingsMenu ? 'active' : ''}`}
onClick={() => {
setShowSettingsMenu(!showSettingsMenu);
setShowFilterMenu(false);
}}
title={locale === 'zh' ? '设置' : 'Settings'}
>
<Settings size={14} />
</button>
{showSettingsMenu && (
<div className="output-log-menu settings-menu">
<div className="output-log-menu-header">
{locale === 'zh' ? '显示选项' : 'Display Options'}
</div>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={showTimestamp}
onChange={() => setShowTimestamp(!showTimestamp)}
/>
<span>{locale === 'zh' ? '显示时间戳' : 'Show Timestamp'}</span>
</label>
<label className="output-log-menu-item">
<input
type="checkbox"
checked={showSource}
onChange={() => setShowSource(!showSource)}
/>
<span>{locale === 'zh' ? '显示来源' : 'Show Source'}</span>
</label>
<div className="output-log-menu-divider" />
<button
className="output-log-menu-action"
onClick={handleClear}
>
<Trash2 size={14} />
<span>{locale === 'zh' ? '清空日志' : 'Clear Logs'}</span>
</button>
</div>
)}
</div>
{/* Close Button */}
{onClose && (
<button
className="output-log-close-btn"
onClick={onClose}
>
<X size={14} />
</button>
)}
</div>
</div>
{/* Log Content */}
<div
className={`output-log-content ${!showTimestamp ? 'hide-timestamp' : ''} ${!showSource ? 'hide-source' : ''}`}
ref={logContainerRef}
onScroll={handleScroll}
>
{filteredLogs.length === 0 ? (
<div className="output-log-empty">
<AlertCircle size={32} />
<p>{searchQuery
? (locale === 'zh' ? '没有匹配的日志' : 'No matching logs')
: (locale === 'zh' ? '暂无日志' : 'No logs to display')
}</p>
</div>
) : (
filteredLogs.map((log, index) => (
<LogEntryItem
key={`${log.id}-${index}`}
log={log}
onOpenJsonViewer={setJsonViewerData}
/>
))
)}
</div>
{/* Status Bar */}
<div className="output-log-status">
<span>{filteredLogs.length} / {logs.length} {locale === 'zh' ? '条日志' : 'logs'}</span>
{!autoScroll && (
<button
className="output-log-scroll-btn"
onClick={() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}}
>
{locale === 'zh' ? '滚动到底部' : 'Scroll to bottom'}
</button>
)}
</div>
{/* JSON Viewer Modal */}
{jsonViewerData && (
<JsonViewer
data={jsonViewerData}
onClose={() => setJsonViewerData(null)}
/>
)}
</div>
);
}

View File

@@ -29,10 +29,11 @@ const categoryLabels: Record<PluginCategory, { zh: string; en: string }> = {
audio: { zh: '音频', en: 'Audio' },
networking: { zh: '网络', en: 'Networking' },
tools: { zh: '工具', en: 'Tools' },
scripting: { zh: '脚本', en: 'Scripting' },
content: { zh: '内容', en: 'Content' }
};
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'physics', 'audio', 'networking', 'tools'];
const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tools', 'content'];
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetField } from './inspectors/fields/AssetField';
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
@@ -140,9 +141,9 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
);
case 'color': {
// Convert numeric color (0xRRGGBB) to hex string (#RRGGBB)
let colorValue = value ?? '#ffffff';
if (typeof colorValue === 'number') {
const wasNumber = typeof colorValue === 'number';
if (wasNumber) {
colorValue = '#' + colorValue.toString(16).padStart(6, '0');
}
return (
@@ -152,9 +153,12 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
value={colorValue}
readOnly={metadata.readOnly}
onChange={(newValue) => {
// Convert hex string back to number for storage
const numericValue = parseInt(newValue.slice(1), 16);
handleChange(propertyName, numericValue);
if (wasNumber) {
const numericValue = parseInt(newValue.slice(1), 16);
handleChange(propertyName, numericValue);
} else {
handleChange(propertyName, newValue);
}
}}
/>
);
@@ -206,25 +210,30 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
}
};
// 从 FileActionRegistry 获取资产创建消息映射
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
const getCreationMapping = () => {
if (!fileActionRegistry || !assetMeta.extensions) return null;
for (const ext of assetMeta.extensions) {
const mapping = fileActionRegistry.getAssetCreationMapping(ext);
if (mapping) return mapping;
}
return null;
};
const creationMapping = getCreationMapping();
const handleCreate = () => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
if (fileExtension === '.tilemap.json') {
messageHub.publish('tilemap:create-asset', {
entityId: entity?.id,
onChange: (newValue: string) => handleChange(propertyName, newValue)
});
} else if (fileExtension === '.btree') {
messageHub.publish('behavior-tree:create-asset', {
entityId: entity?.id,
onChange: (newValue: string) => handleChange(propertyName, newValue)
});
}
if (messageHub && creationMapping) {
messageHub.publish(creationMapping.createMessage, {
entityId: entity?.id,
onChange: (newValue: string) => handleChange(propertyName, newValue)
});
}
};
const creatableExtensions = ['.tilemap.json', '.btree'];
const canCreate = assetMeta.extensions?.some(ext => creatableExtensions.includes(ext));
const canCreate = creationMapping !== null;
return (
<div key={propertyName} className="property-field">
@@ -267,6 +276,30 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
</div>
);
case 'collisionLayer':
return (
<CollisionLayerField
key={propertyName}
label={label}
value={value ?? 1}
multiple={false}
readOnly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
case 'collisionMask':
return (
<CollisionLayerField
key={propertyName}
label={label}
value={value ?? 0xFFFF}
multiple={true}
readOnly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
default:
return null;
}

View File

@@ -3,32 +3,28 @@ import { Entity, Core } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import * as LucideIcons from 'lucide-react';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight } from 'lucide-react';
import {
Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown,
Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag,
SquareStack
} from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import { confirm } from '@tauri-apps/plugin-dialog';
import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
import '../styles/SceneHierarchy.css';
/**
* 根据图标名称获取 Lucide 图标组件
*/
function getIconComponent(iconName: string | undefined, size: number = 12): React.ReactNode {
if (!iconName) return <Plus size={size} />;
function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode {
if (!iconName) return <Box size={size} />;
// 获取图标组件
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComponent = icons[iconName];
if (IconComponent) {
return <IconComponent size={size} />;
}
// 回退到 Plus 图标
return <Plus size={size} />;
return <Box size={size} />;
}
/**
* 类别图标映射
*/
const categoryIconMap: Record<string, string> = {
'rendering': 'Image',
'ui': 'LayoutGrid',
@@ -38,13 +34,35 @@ const categoryIconMap: Record<string, string> = {
'other': 'MoreHorizontal',
};
// 实体类型到图标的映射
const entityTypeIcons: Record<string, React.ReactNode> = {
'World': <Mountain size={14} className="entity-type-icon world" />,
'Folder': <Folder size={14} className="entity-type-icon folder" />,
'DirectionalLight': <Sun size={14} className="entity-type-icon light" />,
'SkyLight': <Sun size={14} className="entity-type-icon light" />,
'SkyAtmosphere': <Cloud size={14} className="entity-type-icon atmosphere" />,
'VolumetricCloud': <Cloud size={14} className="entity-type-icon cloud" />,
'StaticMeshActor': <SquareStack size={14} className="entity-type-icon mesh" />,
'PlayerStart': <Flag size={14} className="entity-type-icon player" />,
'ExponentialHeightFog': <Cloud size={14} className="entity-type-icon fog" />,
};
type ViewMode = 'local' | 'remote';
type SortColumn = 'name' | 'type';
type SortDirection = 'asc' | 'desc';
interface SceneHierarchyProps {
entityStore: EntityStoreService;
messageHub: MessageHub;
commandManager: CommandManager;
isProfilerMode?: boolean;
entityStore: EntityStoreService;
messageHub: MessageHub;
commandManager: CommandManager;
isProfilerMode?: boolean;
}
interface EntityNode {
entity: Entity;
children: EntityNode[];
isExpanded: boolean;
depth: number;
}
export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) {
@@ -52,7 +70,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const [remoteEntities, setRemoteEntities] = useState<RemoteEntity[]>([]);
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>(isProfilerMode ? 'remote' : 'local');
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [sceneName, setSceneName] = useState<string>('Untitled');
const [remoteSceneName, setRemoteSceneName] = useState<string | null>(null);
@@ -62,9 +80,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<number>>(new Set());
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [showFilterMenu, setShowFilterMenu] = useState(false);
const { t, locale } = useLocale();
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null;
// Get entity creation templates from plugins
useEffect(() => {
@@ -77,7 +100,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
updateTemplates();
// Update when plugins are installed
const unsubInstalled = messageHub.subscribe('plugin:installed', updateTemplates);
const unsubUninstalled = messageHub.subscribe('plugin:uninstalled', updateTemplates);
@@ -134,7 +156,11 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
};
const handleSelection = (data: { entity: Entity | null }) => {
setSelectedId(data.entity?.id ?? null);
if (data.entity) {
setSelectedIds(new Set([data.entity.id]));
} else {
setSelectedIds(new Set());
}
};
updateEntities();
@@ -174,25 +200,22 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
setIsRemoteConnected(connected);
if (connected && data.entities && data.entities.length > 0) {
// 只在实体列表发生实质性变化时才更新
setRemoteEntities((prev) => {
if (prev.length !== data.entities!.length) {
return data.entities!;
}
// 检查实体ID和名称是否变化
const hasChanged = data.entities!.some((entity, index) => {
const prevEntity = prev[index];
return !prevEntity ||
prevEntity.id !== entity.id ||
prevEntity.name !== entity.name ||
prevEntity.componentCount !== entity.componentCount;
prevEntity.id !== entity.id ||
prevEntity.name !== entity.name ||
prevEntity.componentCount !== entity.componentCount;
});
return hasChanged ? data.entities! : prev;
});
// 请求第一个实体的详情以获取场景名称
if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) {
profilerService.requestEntityDetails(data.entities[0].id);
}
@@ -218,8 +241,21 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
return () => window.removeEventListener('profiler:entity-details', handleEntityDetails);
}, []);
const handleEntityClick = (entity: Entity) => {
entityStore.selectEntity(entity);
const handleEntityClick = (entity: Entity, e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(entity.id)) {
next.delete(entity.id);
} else {
next.add(entity.id);
}
return next;
});
} else {
setSelectedIds(new Set([entity.id]));
entityStore.selectEntity(entity);
}
};
const handleDragStart = (e: React.DragEvent, entityId: number) => {
@@ -253,15 +289,13 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
};
const handleRemoteEntityClick = (entity: RemoteEntity) => {
setSelectedId(entity.id);
setSelectedIds(new Set([entity.id]));
// 请求完整的实体详情(包含组件属性)
const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined;
if (profilerService) {
profilerService.requestEntityDetails(entity.id);
}
// 先发布基本信息,详细信息稍后通过 ProfilerService 异步返回
messageHub.publish('remote-entity:selected', {
entity: {
id: entity.id,
@@ -273,12 +307,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
});
};
const handleSceneNameClick = () => {
if (sceneFilePath) {
messageHub.publish('asset:reveal', { path: sceneFilePath });
}
};
const handleCreateEntity = () => {
const entityCount = entityStore.getAllEntities().length;
const entityName = `Entity ${entityCount + 1}`;
@@ -326,7 +354,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
setContextMenu(null);
};
// Close context menu on click outside
useEffect(() => {
const handleClick = () => closeContextMenu();
if (contextMenu) {
@@ -335,7 +362,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
}, [contextMenu]);
// Listen for Delete key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && selectedId && !isShowingRemote) {
@@ -347,6 +373,42 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, isShowingRemote]);
const toggleFolderExpand = (entityId: number) => {
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(entityId)) {
next.delete(entityId);
} else {
next.add(entityId);
}
return next;
});
};
const handleSortClick = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
// Get entity type for display
const getEntityType = (entity: Entity): string => {
const components = entity.components || [];
if (components.length > 0) {
const firstComponent = components[0];
return firstComponent?.constructor?.name || 'Entity';
}
return 'Entity';
};
// Get icon for entity type
const getEntityIcon = (entityType: string): React.ReactNode => {
return entityTypeIcons[entityType] || <Box size={14} className="entity-type-icon default" />;
};
// Filter entities based on search query
const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => {
if (!searchQuery.trim()) return entityList;
@@ -356,12 +418,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const name = entity.name;
const id = entity.id.toString();
// Search by name or ID
if (name.toLowerCase().includes(query) || id.includes(query)) {
return true;
}
// Search by component types
if (Array.isArray(entity.componentTypes)) {
return entity.componentTypes.some((type) =>
type.toLowerCase().includes(query)
@@ -377,41 +437,64 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const query = searchQuery.toLowerCase();
return entityList.filter((entity) => {
const name = entity.name || '';
const id = entity.id.toString();
return id.includes(query);
return name.toLowerCase().includes(query) || id.includes(query);
});
};
// Determine which entities to display
const displayEntities = isShowingRemote
? filterRemoteEntities(remoteEntities)
: filterLocalEntities(entities);
const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0;
const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName;
const totalCount = displayEntities.length;
const selectedCount = selectedIds.size;
return (
<div className="scene-hierarchy">
<div className="hierarchy-header">
<Layers size={16} className="hierarchy-header-icon" />
<h3>{t('hierarchy.title')}</h3>
<div
className={[
'scene-name-container',
!isRemoteConnected && sceneFilePath && 'clickable',
isSceneModified && 'modified'
].filter(Boolean).join(' ')}
onClick={!isRemoteConnected ? handleSceneNameClick : undefined}
title={!isRemoteConnected && sceneFilePath
? `${displaySceneName}${isSceneModified ? (locale === 'zh' ? ' (未保存 - Ctrl+S 保存)' : ' (Unsaved - Ctrl+S to save)') : ''} - ${locale === 'zh' ? '点击跳转到文件' : 'Click to reveal file'}`
: displaySceneName}
>
<span className="scene-name">
{displaySceneName}
</span>
{!isRemoteConnected && isSceneModified && (
<span className="modified-indicator"></span>
)}
<div className="scene-hierarchy outliner">
{/* Toolbar */}
<div className="outliner-toolbar">
<div className="outliner-toolbar-left">
<button
className="outliner-filter-btn"
onClick={() => setShowFilterMenu(!showFilterMenu)}
>
<Filter size={14} />
<ChevronDown size={10} />
</button>
</div>
<div className="outliner-search">
<Search size={14} />
<input
type="text"
placeholder={locale === 'zh' ? '搜索...' : 'Search...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<ChevronDown size={12} className="search-dropdown" />
</div>
<div className="outliner-toolbar-right">
{!isShowingRemote && (
<button
className="outliner-action-btn"
onClick={handleCreateEntity}
title={locale === 'zh' ? '添加' : 'Add'}
>
<Plus size={14} />
</button>
)}
<button
className="outliner-action-btn"
title={locale === 'zh' ? '设置' : 'Settings'}
>
<Settings size={14} />
</button>
</div>
{isRemoteConnected && !isProfilerMode && (
<div className="view-mode-toggle">
<button
@@ -430,97 +513,139 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
</button>
</div>
)}
{showRemoteIndicator && (
<div className="remote-indicator" title="Showing remote entities">
<Wifi size={12} />
</div>
)}
</div>
<div className="hierarchy-search">
<Search size={14} />
<input
type="text"
placeholder={t('hierarchy.search') || 'Search entities...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{!isShowingRemote && (
<div className="hierarchy-toolbar">
<button
className="toolbar-btn icon-only"
onClick={handleCreateEntity}
title={locale === 'zh' ? '创建实体' : 'Create Entity'}
>
<Plus size={14} />
</button>
<button
className="toolbar-btn icon-only"
onClick={handleDeleteEntity}
disabled={!selectedId}
title={locale === 'zh' ? '删除实体' : 'Delete Entity'}
>
<Trash2 size={14} />
</button>
{/* Column Headers */}
<div className="outliner-header">
<div className="outliner-header-icons">
<span title={locale === 'zh' ? '可见性' : 'Visibility'}><Eye size={12} className="header-icon" /></span>
<span title={locale === 'zh' ? '收藏' : 'Favorite'}><Star size={12} className="header-icon" /></span>
<span title={locale === 'zh' ? '锁定' : 'Lock'}><Lock size={12} className="header-icon" /></span>
</div>
)}
<div className="hierarchy-content scrollable" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
<div
className={`outliner-header-label ${sortColumn === 'name' ? 'sorted' : ''}`}
onClick={() => handleSortClick('name')}
>
<span>Item Label</span>
{sortColumn === 'name' && (
<span className="sort-indicator">{sortDirection === 'asc' ? '▲' : '▼'}</span>
)}
</div>
<div
className={`outliner-header-type ${sortColumn === 'type' ? 'sorted' : ''}`}
onClick={() => handleSortClick('type')}
>
<span>Type</span>
{sortColumn === 'type' && (
<span className="sort-indicator">{sortDirection === 'asc' ? '▲' : '▼'}</span>
)}
</div>
</div>
{/* Entity List */}
<div className="outliner-content" onContextMenu={(e) => !isShowingRemote && handleContextMenu(e, null)}>
{displayEntities.length === 0 ? (
<div className="empty-state">
<Box size={48} strokeWidth={1.5} className="empty-icon" />
<div className="empty-title">{t('hierarchy.empty')}</div>
<Box size={32} strokeWidth={1.5} className="empty-icon" />
<div className="empty-hint">
{isShowingRemote
? 'No entities in remote game'
: 'Create an entity to get started'}
? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game')
: (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
</div>
</div>
) : isShowingRemote ? (
<ul className="entity-list">
<div className="outliner-list">
{(displayEntities as RemoteEntity[]).map((entity) => (
<li
<div
key={entity.id}
className={`entity-item remote-entity ${selectedId === entity.id ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
title={`${entity.name} - ${entity.componentTypes.join(', ')}`}
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${!entity.enabled ? 'disabled' : ''}`}
onClick={() => handleRemoteEntityClick(entity)}
>
<Box size={14} className="entity-icon" />
<span className="entity-name">{entity.name}</span>
{entity.tag !== 0 && (
<span className="entity-tag" title={`Tag: ${entity.tag}`}>
#{entity.tag}
</span>
)}
{entity.componentCount > 0 && (
<span className="component-count">{entity.componentCount}</span>
)}
</li>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span className="outliner-item-expand" />
{getEntityIcon(entity.componentTypes?.[0] || 'Entity')}
<span className="outliner-item-name">{entity.name}</span>
</div>
<div className="outliner-item-type">
{entity.componentTypes?.[0] || 'Entity'}
</div>
</div>
))}
</ul>
</div>
) : (
<ul className="entity-list">
{entities.map((entity, index) => (
<li
key={entity.id}
className={`entity-item ${selectedId === entity.id ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
draggable
onClick={() => handleEntityClick(entity)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity);
handleContextMenu(e, entity.id);
}}
>
<Box size={14} className="entity-icon" />
<span className="entity-name">{entity.name || `Entity ${entity.id}`}</span>
</li>
))}
</ul>
<div className="outliner-list">
{/* World/Scene Root */}
<div
className={`outliner-item world-item ${expandedFolders.has(-1) ? 'expanded' : ''}`}
onClick={() => toggleFolderExpand(-1)}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span
className="outliner-item-expand"
onClick={(e) => { e.stopPropagation(); toggleFolderExpand(-1); }}
>
{expandedFolders.has(-1) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
<Mountain size={14} className="entity-type-icon world" />
<span className="outliner-item-name">{displaySceneName} (Editor)</span>
</div>
<div className="outliner-item-type">World</div>
</div>
{/* Entity Items */}
{expandedFolders.has(-1) && entities.map((entity, index) => {
const entityType = getEntityType(entity);
return (
<div
key={entity.id}
className={`outliner-item ${selectedIds.has(entity.id) ? 'selected' : ''} ${draggedEntityId === entity.id ? 'dragging' : ''} ${dropTargetIndex === index ? 'drop-target' : ''}`}
style={{ paddingLeft: '32px' }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
>
<div className="outliner-item-icons">
<Eye size={12} className="item-icon visibility" />
</div>
<div className="outliner-item-content">
<span className="outliner-item-expand" />
{getEntityIcon(entityType)}
<span className="outliner-item-name">{entity.name || `Entity ${entity.id}`}</span>
</div>
<div className="outliner-item-type">{entityType}</div>
</div>
);
})}
</div>
)}
</div>
{/* Status Bar */}
<div className="outliner-status">
<span>{totalCount} {locale === 'zh' ? '个对象' : 'actors'}</span>
{selectedCount > 0 && (
<span> ({selectedCount} {locale === 'zh' ? '个已选中' : 'selected'})</span>
)}
</div>
@@ -578,7 +703,6 @@ function ContextMenuWithSubmenu({
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
};
// 将模板按类别分组(所有模板现在都来自插件)
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
const cat = template.category || 'other';
if (!acc[cat]) acc[cat] = [];
@@ -586,7 +710,6 @@ function ContextMenuWithSubmenu({
return acc;
}, {} as Record<string, EntityCreationTemplate[]>);
// 按顺序排序每个类别内的模板
Object.values(templatesByCategory).forEach(templates => {
templates.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
});
@@ -597,7 +720,6 @@ function ContextMenuWithSubmenu({
setActiveSubmenu(category);
};
// 定义类别显示顺序
const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other'];
const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => {
const orderA = categoryOrder.indexOf(a);
@@ -618,7 +740,6 @@ function ContextMenuWithSubmenu({
{sortedCategories.length > 0 && <div className="context-menu-divider" />}
{/* 按类别渲染所有模板 */}
{sortedCategories.map(([category, templates]) => (
<div
key={category}

View File

@@ -1,5 +1,16 @@
import { useState, useEffect } from 'react';
import { X, Settings as SettingsIcon, ChevronRight } from 'lucide-react';
/**
* Settings Window - 设置窗口
* 重新设计以匹配编辑器设计稿
*/
import { useState, useEffect, useMemo } from 'react';
import {
X,
Search,
Settings as SettingsIcon,
ChevronDown,
ChevronRight
} from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { SettingsService } from '../services/SettingsService';
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager } from '@esengine/editor-core';
@@ -7,9 +18,16 @@ import { PluginListSetting } from './PluginListSetting';
import '../styles/SettingsWindow.css';
interface SettingsWindowProps {
onClose: () => void;
settingsRegistry: SettingsRegistry;
initialCategoryId?: string;
onClose: () => void;
settingsRegistry: SettingsRegistry;
initialCategoryId?: string;
}
// 主分类结构
interface MainCategory {
id: string;
title: string;
subCategories: SettingCategory[];
}
export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) {
@@ -17,14 +35,88 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(initialCategoryId || null);
const [values, setValues] = useState<Map<string, any>>(new Map());
const [errors, setErrors] = useState<Map<string, string>>(new Map());
const [searchTerm, setSearchTerm] = useState('');
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [expandedMainCategories, setExpandedMainCategories] = useState<Set<string>>(new Set(['通用']));
// 将分类组织成主分类和子分类
const mainCategories = useMemo((): MainCategory[] => {
const categoryMap = new Map<string, SettingCategory[]>();
// 定义主分类映射
const mainCategoryMapping: Record<string, string> = {
'appearance': '通用',
'general': '通用',
'project': '通用',
'plugins': '通用',
'editor': '通用',
'physics': '全局',
'rendering': '全局',
'audio': '全局',
'world': '世界分区',
'local': '世界分区(本地)',
'performance': '性能'
};
categories.forEach((cat) => {
const mainCatName = mainCategoryMapping[cat.id] || '其他';
if (!categoryMap.has(mainCatName)) {
categoryMap.set(mainCatName, []);
}
categoryMap.get(mainCatName)!.push(cat);
});
// 定义固定的主分类顺序
const orderedMainCategories = [
'通用',
'全局',
'世界分区',
'世界分区(本地)',
'性能',
'其他'
];
return orderedMainCategories
.filter((name) => categoryMap.has(name))
.map((name) => ({
id: name,
title: name,
subCategories: categoryMap.get(name)!
}));
}, [categories]);
// 获取显示的子分类标题
const subCategoryTitle = useMemo(() => {
if (!selectedCategoryId) return '';
const cat = categories.find((c) => c.id === selectedCategoryId);
return cat?.title || '';
}, [categories, selectedCategoryId]);
// 获取主分类标题
const mainCategoryTitle = useMemo(() => {
for (const main of mainCategories) {
if (main.subCategories.some((sub) => sub.id === selectedCategoryId)) {
return main.title;
}
}
return '';
}, [mainCategories, selectedCategoryId]);
useEffect(() => {
const allCategories = settingsRegistry.getAllCategories();
setCategories(allCategories);
// 默认展开所有section
const allSectionIds = new Set<string>();
allCategories.forEach((cat) => {
cat.sections.forEach((section) => {
allSectionIds.add(`${cat.id}-${section.id}`);
});
});
setExpandedSections(allSectionIds);
if (allCategories.length > 0 && !selectedCategoryId) {
// 如果有 initialCategoryId,尝试使用它
if (initialCategoryId && allCategories.some(c => c.id === initialCategoryId)) {
if (initialCategoryId && allCategories.some((c) => c.id === initialCategoryId)) {
setSelectedCategoryId(initialCategoryId);
} else {
const firstCategory = allCategories[0];
@@ -40,7 +132,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const initialValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
// Project-scoped settings are loaded from ProjectService
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
const resolution = projectService.getUIDesignResolution();
@@ -52,7 +143,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, `${resolution.width}x${resolution.height}`);
} else {
// For other project settings, use default
initialValues.set(key, descriptor.defaultValue);
}
} else {
@@ -62,7 +152,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
}
setValues(initialValues);
}, [settingsRegistry, selectedCategoryId]);
}, [settingsRegistry, initialCategoryId]);
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
const newValues = new Map(values);
@@ -71,7 +161,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const newErrors = new Map(errors);
if (!settingsRegistry.validateSetting(descriptor, value)) {
newErrors.set(key, descriptor.validator?.errorMessage || 'Invalid value');
newErrors.set(key, descriptor.validator?.errorMessage || '无效值');
} else {
newErrors.delete(key);
}
@@ -87,13 +177,11 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const changedSettings: Record<string, any> = {};
// Track UI resolution changes for batch saving
let uiResolutionChanged = false;
let newWidth = 1920;
let newHeight = 1080;
for (const [key, value] of values.entries()) {
// Project-scoped settings are saved to ProjectService
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
newWidth = value;
@@ -102,7 +190,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
newHeight = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.preset') {
// Preset changes width and height together
const [w, h] = value.split('x').map(Number);
if (w && h) {
newWidth = w;
@@ -117,7 +204,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
}
}
// Save UI resolution if changed
if (uiResolutionChanged && projectService) {
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
}
@@ -133,6 +219,76 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
onClose();
};
const handleResetToDefault = () => {
const allSettings = settingsRegistry.getAllSettings();
const defaultValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
defaultValues.set(key, descriptor.defaultValue);
}
setValues(defaultValues);
};
const handleExport = () => {
const exportData: Record<string, any> = {};
for (const [key, value] of values.entries()) {
exportData[key] = value;
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'editor-settings.json';
a.click();
URL.revokeObjectURL(url);
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const importData = JSON.parse(text);
const newValues = new Map(values);
for (const [key, value] of Object.entries(importData)) {
newValues.set(key, value);
}
setValues(newValues);
} catch (err) {
console.error('Failed to import settings:', err);
}
};
input.click();
};
const toggleSection = (sectionId: string) => {
setExpandedSections((prev) => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
};
const toggleMainCategory = (categoryId: string) => {
setExpandedMainCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
const renderSettingInput = (setting: SettingDescriptor) => {
const value = values.get(setting.key) ?? setting.defaultValue;
const error = errors.get(setting.key);
@@ -140,105 +296,109 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
switch (setting.type) {
case 'boolean':
return (
<div className="settings-field">
<label className="settings-label settings-label-checkbox">
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<ChevronRight size={12} className="settings-row-expand" />
)}
<span>{setting.label}</span>
</div>
<div className="settings-row-value">
<input
type="checkbox"
className="settings-checkbox"
checked={value}
onChange={(e) => handleValueChange(setting.key, e.target.checked, setting)}
/>
<span>{setting.label}</span>
{setting.description && (
<span className="settings-hint">{setting.description}</span>
)}
</label>
{error && <span className="settings-error">{error}</span>}
</div>
</div>
);
case 'number':
return (
<div className="settings-field">
<label className="settings-label">
{setting.label}
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<span className="settings-hint">{setting.description}</span>
<ChevronRight size={12} className="settings-row-expand" />
)}
</label>
<input
type="number"
className={`settings-input ${error ? 'settings-input-error' : ''}`}
value={value}
onChange={(e) => handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)}
placeholder={setting.placeholder}
min={setting.min}
max={setting.max}
step={setting.step}
/>
{error && <span className="settings-error">{error}</span>}
<span>{setting.label}</span>
</div>
<div className="settings-row-value">
<input
type="number"
className={`settings-number-input ${error ? 'error' : ''}`}
value={value}
onChange={(e) => handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)}
placeholder={setting.placeholder}
min={setting.min}
max={setting.max}
step={setting.step}
/>
</div>
</div>
);
case 'string':
return (
<div className="settings-field">
<label className="settings-label">
{setting.label}
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<span className="settings-hint">{setting.description}</span>
<ChevronRight size={12} className="settings-row-expand" />
)}
</label>
<input
type="text"
className={`settings-input ${error ? 'settings-input-error' : ''}`}
value={value}
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
placeholder={setting.placeholder}
/>
{error && <span className="settings-error">{error}</span>}
<span>{setting.label}</span>
</div>
<div className="settings-row-value">
<input
type="text"
className={`settings-text-input ${error ? 'error' : ''}`}
value={value}
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
placeholder={setting.placeholder}
/>
</div>
</div>
);
case 'select':
return (
<div className="settings-field">
<label className="settings-label">
{setting.label}
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<span className="settings-hint">{setting.description}</span>
<ChevronRight size={12} className="settings-row-expand" />
)}
</label>
<select
className={`settings-select ${error ? 'settings-input-error' : ''}`}
value={value}
onChange={(e) => {
const option = setting.options?.find((opt) => String(opt.value) === e.target.value);
if (option) {
handleValueChange(setting.key, option.value, setting);
}
}}
>
{setting.options?.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
{error && <span className="settings-error">{error}</span>}
<span>{setting.label}</span>
</div>
<div className="settings-row-value">
<select
className={`settings-select ${error ? 'error' : ''}`}
value={value}
onChange={(e) => {
const option = setting.options?.find((opt) => String(opt.value) === e.target.value);
if (option) {
handleValueChange(setting.key, option.value, setting);
}
}}
>
{setting.options?.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</div>
</div>
);
case 'range':
return (
<div className="settings-field">
<label className="settings-label">
{setting.label}
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<span className="settings-hint">{setting.description}</span>
<ChevronRight size={12} className="settings-row-expand" />
)}
</label>
<div className="settings-range-wrapper">
<span>{setting.label}</span>
</div>
<div className="settings-row-value">
<input
type="range"
className="settings-range"
@@ -250,26 +410,28 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
/>
<span className="settings-range-value">{value}</span>
</div>
{error && <span className="settings-error">{error}</span>}
</div>
);
case 'color':
return (
<div className="settings-field">
<label className="settings-label">
{setting.label}
<div className="settings-row">
<div className="settings-row-label">
{setting.description && (
<span className="settings-hint">{setting.description}</span>
<ChevronRight size={12} className="settings-row-expand" />
)}
</label>
<input
type="color"
className="settings-color-input"
value={value}
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
/>
{error && <span className="settings-error">{error}</span>}
<span>{setting.label}</span>
</div>
<div className="settings-row-value">
<div className="settings-color-bar" style={{ backgroundColor: value }}>
<input
type="color"
className="settings-color-input"
value={value}
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
/>
</div>
</div>
</div>
);
@@ -277,15 +439,30 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
if (!pluginManager) {
return (
<div className="settings-field settings-field-full">
<div className="settings-row">
<p className="settings-error">PluginManager </p>
</div>
);
}
return (
<div className="settings-field settings-field-full">
<div className="settings-plugin-list">
<PluginListSetting pluginManager={pluginManager} />
{error && <span className="settings-error">{error}</span>}
</div>
);
}
case 'collisionMatrix': {
const CustomRenderer = setting.customRenderer as React.ComponentType<any> | undefined;
if (CustomRenderer) {
return (
<div className="settings-custom-renderer">
<CustomRenderer />
</div>
);
}
return (
<div className="settings-row">
<p className="settings-hint"></p>
</div>
);
}
@@ -298,71 +475,149 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
return (
<div className="settings-overlay">
<div className="settings-window">
<div className="settings-header">
<div className="settings-title">
<SettingsIcon size={18} />
<h2></h2>
</div>
<button className="settings-close-btn" onClick={handleCancel}>
<X size={18} />
</button>
</div>
<div className="settings-body">
<div className="settings-sidebar">
{categories.map((category) => (
<button
key={category.id}
className={`settings-category-btn ${selectedCategoryId === category.id ? 'active' : ''}`}
onClick={() => setSelectedCategoryId(category.id)}
>
<span className="settings-category-title">{category.title}</span>
{category.description && (
<span className="settings-category-desc">{category.description}</span>
)}
<ChevronRight size={14} className="settings-category-arrow" />
</button>
))}
<div className="settings-overlay" onClick={handleCancel}>
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
{/* Left Sidebar */}
<div className="settings-sidebar-new">
<div className="settings-sidebar-header">
<SettingsIcon size={16} />
<span></span>
<button className="settings-sidebar-close" onClick={handleCancel}>
<X size={14} />
</button>
</div>
<div className="settings-content">
{selectedCategory && selectedCategory.sections.map((section) => (
<div key={section.id} className="settings-section">
<h3 className="settings-section-title">{section.title}</h3>
{section.description && (
<p className="settings-section-description">{section.description}</p>
)}
{section.settings.map((setting) => (
<div key={setting.key}>
{renderSettingInput(setting)}
<div className="settings-sidebar-search">
<span></span>
</div>
<div className="settings-sidebar-categories">
{mainCategories.map((mainCat) => (
<div key={mainCat.id} className="settings-main-category">
<div
className="settings-main-category-header"
onClick={() => toggleMainCategory(mainCat.id)}
>
{expandedMainCategories.has(mainCat.id) ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
<span>{mainCat.title}</span>
</div>
{expandedMainCategories.has(mainCat.id) && (
<div className="settings-sub-categories">
{mainCat.subCategories.map((subCat) => (
<button
key={subCat.id}
className={`settings-sub-category ${selectedCategoryId === subCat.id ? 'active' : ''}`}
onClick={() => setSelectedCategoryId(subCat.id)}
>
{subCat.title}
</button>
))}
</div>
))}
)}
</div>
))}
</div>
</div>
{/* Right Content */}
<div className="settings-content-new">
{/* Top Header */}
<div className="settings-content-header">
<div className="settings-search-bar">
<Search size={14} />
<input
type="text"
placeholder="搜索"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="settings-header-actions">
<button className="settings-icon-btn" title="设置">
<SettingsIcon size={14} />
</button>
<button className="settings-action-btn" onClick={handleExport}>
......
</button>
<button className="settings-action-btn" onClick={handleImport}>
......
</button>
</div>
</div>
{/* Category Title */}
<div className="settings-category-title-bar">
<div className="settings-category-breadcrumb">
<ChevronDown size={14} />
<span className="settings-breadcrumb-main">{mainCategoryTitle}</span>
<span className="settings-breadcrumb-separator">-</span>
<span className="settings-breadcrumb-sub">{subCategoryTitle}</span>
</div>
{selectedCategory?.description && (
<p className="settings-category-desc">{selectedCategory.description}</p>
)}
<div className="settings-category-actions">
<button className="settings-category-action-btn" onClick={handleResetToDefault}>
</button>
<button className="settings-category-action-btn" onClick={handleExport}>
......
</button>
<button className="settings-category-action-btn" onClick={handleImport}>
......
</button>
<button className="settings-category-action-btn" onClick={handleResetToDefault}>
</button>
</div>
</div>
{/* Settings Content */}
<div className="settings-sections-container">
{selectedCategory && selectedCategory.sections.map((section) => {
const sectionKey = `${selectedCategory.id}-${section.id}`;
const isExpanded = expandedSections.has(sectionKey);
return (
<div key={section.id} className="settings-section-new">
<div
className="settings-section-header-new"
onClick={() => toggleSection(sectionKey)}
>
{isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
<span>{section.title}</span>
</div>
{isExpanded && (
<div className="settings-section-content-new">
{section.settings.map((setting) => (
<div key={setting.key}>
{renderSettingInput(setting)}
</div>
))}
</div>
)}
</div>
);
})}
{!selectedCategory && (
<div className="settings-empty">
<div className="settings-empty-new">
<SettingsIcon size={48} />
<p></p>
</div>
)}
</div>
</div>
<div className="settings-footer">
<button className="settings-btn settings-btn-cancel" onClick={handleCancel}>
</button>
<button
className="settings-btn settings-btn-save"
onClick={handleSave}
disabled={errors.size > 0}
>
</button>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,326 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X } from 'lucide-react';
import type { MessageHub, LogService } from '@esengine/editor-core';
import { ContentBrowser } from './ContentBrowser';
import { OutputLogPanel } from './OutputLogPanel';
import '../styles/StatusBar.css';
interface StatusBarProps {
pluginCount?: number;
entityCount?: number;
messageHub?: MessageHub | null;
logService?: LogService | null;
locale?: string;
projectPath?: string | null;
onOpenScene?: (scenePath: string) => void;
}
type ActiveTab = 'output' | 'cmd';
export function StatusBar({
pluginCount = 0,
entityCount = 0,
messageHub,
logService,
locale = 'en',
projectPath,
onOpenScene
}: StatusBarProps) {
const [consoleInput, setConsoleInput] = useState('');
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
const [contentDrawerOpen, setContentDrawerOpen] = useState(false);
const [outputLogDrawerOpen, setOutputLogDrawerOpen] = useState(false);
const [contentDrawerHeight, setContentDrawerHeight] = useState(300);
const [outputLogDrawerHeight, setOutputLogDrawerHeight] = useState(300);
const [isResizingContent, setIsResizingContent] = useState(false);
const [isResizingOutputLog, setIsResizingOutputLog] = useState(false);
const [revealPath, setRevealPath] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const startY = useRef(0);
const startHeight = useRef(0);
// Subscribe to asset:reveal event
useEffect(() => {
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('asset:reveal', (payload: { path: string }) => {
if (payload.path) {
// Generate unique key to force re-trigger even with same path
setRevealPath(`${payload.path}?t=${Date.now()}`);
setContentDrawerOpen(true);
setOutputLogDrawerOpen(false);
}
});
return unsubscribe;
}, [messageHub]);
// Clear revealPath when drawer closes
useEffect(() => {
if (!contentDrawerOpen) {
setRevealPath(null);
}
}, [contentDrawerOpen]);
const handleSelectPanel = useCallback((panelId: string) => {
if (messageHub) {
messageHub.publish('panel:select', { panelId });
}
}, [messageHub]);
const handleContentDrawerClick = useCallback(() => {
setContentDrawerOpen(!contentDrawerOpen);
if (!contentDrawerOpen) {
setOutputLogDrawerOpen(false);
}
}, [contentDrawerOpen]);
const handleOutputLogClick = useCallback(() => {
setActiveTab('output');
setOutputLogDrawerOpen(!outputLogDrawerOpen);
if (!outputLogDrawerOpen) {
setContentDrawerOpen(false);
}
}, [outputLogDrawerOpen]);
const handleCmdClick = useCallback(() => {
setActiveTab('cmd');
handleSelectPanel('console');
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}, [handleSelectPanel]);
const handleConsoleSubmit = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && consoleInput.trim()) {
const command = consoleInput.trim();
console.info(`> ${command}`);
try {
if (command.startsWith('help')) {
console.info('Available commands: help, clear, echo <message>');
} else if (command === 'clear') {
logService?.clear();
} else if (command.startsWith('echo ')) {
console.info(command.substring(5));
} else {
console.warn(`Unknown command: ${command}`);
}
} catch (error) {
console.error(`Error executing command: ${error}`);
}
setConsoleInput('');
}
}, [consoleInput, logService]);
useEffect(() => {
if (activeTab === 'cmd') {
inputRef.current?.focus();
}
}, [activeTab]);
// Handle content drawer resize
const handleContentResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizingContent(true);
startY.current = e.clientY;
startHeight.current = contentDrawerHeight;
}, [contentDrawerHeight]);
// Handle output log drawer resize
const handleOutputLogResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizingOutputLog(true);
startY.current = e.clientY;
startHeight.current = outputLogDrawerHeight;
}, [outputLogDrawerHeight]);
useEffect(() => {
if (!isResizingContent && !isResizingOutputLog) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = startY.current - e.clientY;
const newHeight = Math.max(200, Math.min(startHeight.current + delta, window.innerHeight * 0.7));
if (isResizingContent) {
setContentDrawerHeight(newHeight);
} else if (isResizingOutputLog) {
setOutputLogDrawerHeight(newHeight);
}
};
const handleMouseUp = () => {
setIsResizingContent(false);
setIsResizingOutputLog(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizingContent, isResizingOutputLog]);
// Close drawer on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (contentDrawerOpen) {
setContentDrawerOpen(false);
}
if (outputLogDrawerOpen) {
setOutputLogDrawerOpen(false);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [contentDrawerOpen, outputLogDrawerOpen]);
return (
<>
{/* Drawer Backdrop */}
{(contentDrawerOpen || outputLogDrawerOpen) && (
<div
className="drawer-backdrop"
onClick={() => {
setContentDrawerOpen(false);
setOutputLogDrawerOpen(false);
}}
/>
)}
{/* Content Drawer Panel */}
<div
className={`drawer-panel content-drawer-panel ${contentDrawerOpen ? 'open' : ''}`}
style={{ height: contentDrawerOpen ? contentDrawerHeight : 0 }}
>
<div
className="drawer-resize-handle"
onMouseDown={handleContentResizeStart}
/>
<div className="drawer-header">
<span className="drawer-title">
<FolderOpen size={14} />
Content Browser
</span>
<button
className="drawer-close"
onClick={() => setContentDrawerOpen(false)}
>
<X size={14} />
</button>
</div>
<div className="drawer-body">
<ContentBrowser
projectPath={projectPath ?? null}
locale={locale}
onOpenScene={onOpenScene}
isDrawer={true}
revealPath={revealPath}
/>
</div>
</div>
{/* Output Log Drawer Panel */}
<div
className={`drawer-panel output-log-drawer-panel ${outputLogDrawerOpen ? 'open' : ''}`}
style={{ height: outputLogDrawerOpen ? outputLogDrawerHeight : 0 }}
>
<div
className="drawer-resize-handle"
onMouseDown={handleOutputLogResizeStart}
/>
<div className="drawer-body output-log-body">
{logService && (
<OutputLogPanel
logService={logService}
locale={locale}
onClose={() => setOutputLogDrawerOpen(false)}
/>
)}
</div>
</div>
{/* Status Bar */}
<div className="status-bar">
<div className="status-bar-left">
<button
className={`status-bar-btn drawer-toggle-btn ${contentDrawerOpen ? 'active' : ''}`}
onClick={handleContentDrawerClick}
>
<FolderOpen size={14} />
<span>{locale === 'zh' ? '内容侧滑菜单' : 'Content Drawer'}</span>
{contentDrawerOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
<div className="status-bar-divider" />
<button
className={`status-bar-tab ${outputLogDrawerOpen ? 'active' : ''}`}
onClick={handleOutputLogClick}
>
<FileText size={12} />
<span>{locale === 'zh' ? '输出日志' : 'Output Log'}</span>
</button>
<button
className={`status-bar-tab ${activeTab === 'cmd' ? 'active' : ''}`}
onClick={handleCmdClick}
>
<Terminal size={12} />
<span>Cmd</span>
<ChevronDown size={10} />
</button>
<div className="status-bar-console-input">
<span className="console-prompt">&gt;</span>
<input
ref={inputRef}
type="text"
placeholder={locale === 'zh' ? '输入控制台命令' : 'Enter Console Command'}
value={consoleInput}
onChange={(e) => setConsoleInput(e.target.value)}
onKeyDown={handleConsoleSubmit}
onFocus={() => setActiveTab('cmd')}
/>
</div>
</div>
<div className="status-bar-right">
<button className="status-bar-indicator">
<Activity size={12} />
<span>{locale === 'zh' ? '回追踪' : 'Trace'}</span>
<ChevronDown size={10} />
</button>
<div className="status-bar-divider" />
<div className="status-bar-icon-group">
<button className="status-bar-icon-btn" title={locale === 'zh' ? '网络' : 'Network'}>
<Wifi size={14} />
</button>
<button className="status-bar-icon-btn" title={locale === 'zh' ? '源代码管理' : 'Source Control'}>
<GitBranch size={14} />
</button>
</div>
<div className="status-bar-divider" />
<div className="status-bar-info">
<Save size={12} />
<span>{locale === 'zh' ? '所有已保存' : 'All Saved'}</span>
</div>
<div className="status-bar-info">
<span>{locale === 'zh' ? '版本控制' : 'Revision Control'}</span>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,360 @@
import { useState, useRef, useEffect } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import '../styles/TitleBar.css';
interface MenuItem {
label?: string;
shortcut?: string;
icon?: string;
disabled?: boolean;
separator?: boolean;
submenu?: MenuItem[];
onClick?: () => void;
}
interface TitleBarProps {
projectName?: string;
locale?: string;
uiRegistry?: UIRegistry;
messageHub?: MessageHub;
pluginManager?: PluginManager;
onNewScene?: () => void;
onOpenScene?: () => void;
onSaveScene?: () => void;
onSaveSceneAs?: () => void;
onOpenProject?: () => void;
onCloseProject?: () => void;
onExit?: () => void;
onOpenPluginManager?: () => void;
onOpenProfiler?: () => void;
onOpenPortManager?: () => void;
onOpenSettings?: () => void;
onToggleDevtools?: () => void;
onOpenAbout?: () => void;
onCreatePlugin?: () => void;
onReloadPlugins?: () => void;
}
export function TitleBar({
projectName = 'Untitled',
locale = 'en',
uiRegistry,
messageHub,
pluginManager,
onNewScene,
onOpenScene,
onSaveScene,
onSaveSceneAs,
onOpenProject,
onCloseProject,
onExit,
onOpenPluginManager,
onOpenProfiler: _onOpenProfiler,
onOpenPortManager,
onOpenSettings,
onToggleDevtools,
onOpenAbout,
onCreatePlugin,
onReloadPlugins
}: TitleBarProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
const [isMaximized, setIsMaximized] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const appWindow = getCurrentWindow();
const updateMenuItems = () => {
if (uiRegistry) {
const items = uiRegistry.getChildMenus('window');
setPluginMenuItems(items);
}
};
useEffect(() => {
updateMenuItems();
}, [uiRegistry, pluginManager]);
useEffect(() => {
if (messageHub) {
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
updateMenuItems();
});
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
updateMenuItems();
});
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
updateMenuItems();
});
return () => {
unsubscribeInstalled();
unsubscribeEnabled();
unsubscribeDisabled();
};
}
}, [messageHub, uiRegistry, pluginManager]);
useEffect(() => {
const checkMaximized = async () => {
const maximized = await appWindow.isMaximized();
setIsMaximized(maximized);
};
checkMaximized();
const unlisten = appWindow.onResized(async () => {
const maximized = await appWindow.isMaximized();
setIsMaximized(maximized);
});
return () => {
unlisten.then(fn => fn());
};
}, []);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
en: {
file: 'File',
newScene: 'New Scene',
openScene: 'Open Scene',
saveScene: 'Save Scene',
saveSceneAs: 'Save Scene As...',
openProject: 'Open Project',
closeProject: 'Close Project',
exit: 'Exit',
edit: 'Edit',
undo: 'Undo',
redo: 'Redo',
cut: 'Cut',
copy: 'Copy',
paste: 'Paste',
delete: 'Delete',
selectAll: 'Select All',
window: 'Window',
sceneHierarchy: 'Scene Hierarchy',
inspector: 'Inspector',
assets: 'Assets',
console: 'Console',
viewport: 'Viewport',
pluginManager: 'Plugin Manager',
tools: 'Tools',
createPlugin: 'Create Plugin',
reloadPlugins: 'Reload Plugins',
portManager: 'Port Manager',
settings: 'Settings',
help: 'Help',
documentation: 'Documentation',
checkForUpdates: 'Check for Updates',
about: 'About',
devtools: 'Developer Tools'
},
zh: {
file: '文件',
newScene: '新建场景',
openScene: '打开场景',
saveScene: '保存场景',
saveSceneAs: '场景另存为...',
openProject: '打开项目',
closeProject: '关闭项目',
exit: '退出',
edit: '编辑',
undo: '撤销',
redo: '重做',
cut: '剪切',
copy: '复制',
paste: '粘贴',
delete: '删除',
selectAll: '全选',
window: '窗口',
sceneHierarchy: '场景层级',
inspector: '检视器',
assets: '资产',
console: '控制台',
viewport: '视口',
pluginManager: '插件管理器',
tools: '工具',
createPlugin: '创建插件',
reloadPlugins: '重新加载插件',
portManager: '端口管理器',
settings: '设置',
help: '帮助',
documentation: '文档',
checkForUpdates: '检查更新',
about: '关于',
devtools: '开发者工具'
}
};
return translations[locale]?.[key] || key;
};
const menus: Record<string, MenuItem[]> = {
file: [
{ label: t('newScene'), shortcut: 'Ctrl+N', onClick: onNewScene },
{ label: t('openScene'), shortcut: 'Ctrl+O', onClick: onOpenScene },
{ separator: true },
{ label: t('saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
{ label: t('saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
{ separator: true },
{ label: t('openProject'), onClick: onOpenProject },
{ label: t('closeProject'), onClick: onCloseProject },
{ separator: true },
{ label: t('exit'), onClick: onExit }
],
edit: [
{ label: t('undo'), shortcut: 'Ctrl+Z', disabled: true },
{ label: t('redo'), shortcut: 'Ctrl+Y', disabled: true },
{ separator: true },
{ label: t('cut'), shortcut: 'Ctrl+X', disabled: true },
{ label: t('copy'), shortcut: 'Ctrl+C', disabled: true },
{ label: t('paste'), shortcut: 'Ctrl+V', disabled: true },
{ label: t('delete'), shortcut: 'Delete', disabled: true },
{ separator: true },
{ label: t('selectAll'), shortcut: 'Ctrl+A', disabled: true }
],
window: [
...pluginMenuItems.map((item) => ({
label: item.label || '',
icon: item.icon,
disabled: item.disabled,
onClick: item.onClick
})),
...(pluginMenuItems.length > 0 ? [{ separator: true } as MenuItem] : []),
{ label: t('pluginManager'), onClick: onOpenPluginManager },
{ separator: true },
{ label: t('devtools'), onClick: onToggleDevtools }
],
tools: [
{ label: t('createPlugin'), onClick: onCreatePlugin },
{ label: t('reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
{ separator: true },
{ label: t('portManager'), onClick: onOpenPortManager },
{ separator: true },
{ label: t('settings'), onClick: onOpenSettings }
],
help: [
{ label: t('documentation'), disabled: true },
{ separator: true },
{ label: t('about'), onClick: onOpenAbout }
]
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenu(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleMenuClick = (menuKey: string) => {
setOpenMenu(openMenu === menuKey ? null : menuKey);
};
const handleMenuItemClick = (item: MenuItem) => {
if (!item.disabled && !item.separator && item.onClick && item.label) {
item.onClick();
setOpenMenu(null);
}
};
const handleMinimize = async () => {
await appWindow.minimize();
};
const handleMaximize = async () => {
await appWindow.toggleMaximize();
};
const handleClose = async () => {
await appWindow.close();
};
return (
<div className="titlebar">
{/* Left: Logo and Menu */}
<div className="titlebar-left">
<div className="titlebar-logo">
<span className="titlebar-logo-text">ES</span>
</div>
<div className="titlebar-menus" ref={menuRef}>
{Object.keys(menus).map((menuKey) => (
<div key={menuKey} className="titlebar-menu-item">
<button
className={`titlebar-menu-button ${openMenu === menuKey ? 'active' : ''}`}
onClick={() => handleMenuClick(menuKey)}
>
{t(menuKey)}
</button>
{openMenu === menuKey && menus[menuKey] && (
<div className="titlebar-dropdown">
{menus[menuKey].map((item, index) => {
if (item.separator) {
return <div key={index} className="titlebar-dropdown-separator" />;
}
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
return (
<button
key={index}
className={`titlebar-dropdown-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => handleMenuItemClick(item)}
disabled={item.disabled}
>
<span className="titlebar-dropdown-item-content">
{IconComponent && <IconComponent size={14} />}
<span>{item.label || ''}</span>
</span>
{item.shortcut && <span className="titlebar-dropdown-shortcut">{item.shortcut}</span>}
</button>
);
})}
</div>
)}
</div>
))}
</div>
</div>
{/* Center: Draggable area */}
<div className="titlebar-center" data-tauri-drag-region />
{/* Right: Project name + Window controls */}
<div className="titlebar-right">
<span className="titlebar-project-name" data-tauri-drag-region>{projectName}</span>
<div className="titlebar-window-controls">
<button className="titlebar-button" onClick={handleMinimize} title="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor"/>
</svg>
</button>
<button className="titlebar-button" onClick={handleMaximize} title={isMaximized ? "Restore" : "Maximize"}>
{isMaximized ? (
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M2 0v2H0v8h8V8h2V0H2zm6 8H2V4h6v4z" fill="currentColor"/>
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
<rect width="10" height="10" fill="none" stroke="currentColor" strokeWidth="1"/>
</svg>
)}
</button>
<button className="titlebar-button titlebar-button-close" onClick={handleClose} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Play, Pause, Square, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown } from 'lucide-react';
import {
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
Magnet, ZoomIn
} from 'lucide-react';
import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
import { EngineService } from '../services/EngineService';
@@ -101,6 +105,18 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
const runMenuRef = useRef<HTMLDivElement>(null);
// Snap settings
const [snapEnabled, setSnapEnabled] = useState(true);
const [gridSnapValue, setGridSnapValue] = useState(10);
const [rotationSnapValue, setRotationSnapValue] = useState(15);
const [scaleSnapValue, setScaleSnapValue] = useState(0.25);
const [showGridSnapMenu, setShowGridSnapMenu] = useState(false);
const [showRotationSnapMenu, setShowRotationSnapMenu] = useState(false);
const [showScaleSnapMenu, setShowScaleSnapMenu] = useState(false);
const gridSnapMenuRef = useRef<HTMLDivElement>(null);
const rotationSnapMenuRef = useRef<HTMLDivElement>(null);
const scaleSnapMenuRef = useRef<HTMLDivElement>(null);
// Store editor camera state when entering play mode
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
const playStateRef = useRef<PlayState>('stopped');
@@ -130,6 +146,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const selectedEntityRef = useRef<Entity | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const transformModeRef = useRef<TransformMode>('select');
const snapEnabledRef = useRef(true);
const gridSnapRef = useRef(10);
const rotationSnapRef = useRef(15);
const scaleSnapRef = useRef(0.25);
// Keep refs in sync with state
useEffect(() => {
@@ -144,6 +164,40 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
transformModeRef.current = transformMode;
}, [transformMode]);
useEffect(() => {
snapEnabledRef.current = snapEnabled;
}, [snapEnabled]);
useEffect(() => {
gridSnapRef.current = gridSnapValue;
}, [gridSnapValue]);
useEffect(() => {
rotationSnapRef.current = rotationSnapValue;
}, [rotationSnapValue]);
useEffect(() => {
scaleSnapRef.current = scaleSnapValue;
}, [scaleSnapValue]);
// Snap helper functions
const snapToGrid = useCallback((value: number): number => {
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
return Math.round(value / gridSnapRef.current) * gridSnapRef.current;
}, []);
const snapRotation = useCallback((value: number): number => {
if (!snapEnabledRef.current || rotationSnapRef.current <= 0) return value;
const degrees = (value * 180) / Math.PI;
const snappedDegrees = Math.round(degrees / rotationSnapRef.current) * rotationSnapRef.current;
return (snappedDegrees * Math.PI) / 180;
}, []);
const snapScale = useCallback((value: number): number => {
if (!snapEnabledRef.current || scaleSnapRef.current <= 0) return value;
return Math.round(value / scaleSnapRef.current) * scaleSnapRef.current;
}, []);
// Screen to world coordinate conversion - uses refs to avoid re-registering event handlers
const screenToWorld = useCallback((screenX: number, screenY: number) => {
const canvas = canvasRef.current;
@@ -205,8 +259,17 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
resizeCanvas();
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
// Use requestAnimationFrame to avoid ResizeObserver loop errors
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
resizeCanvas();
rafId = null;
});
});
if (containerRef.current) {
@@ -349,8 +412,38 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
isDraggingTransformRef.current = false;
canvas.style.cursor = 'grab';
// Apply snap on mouse up
const entity = selectedEntityRef.current;
if (entity && snapEnabledRef.current) {
const mode = transformModeRef.current;
const transform = entity.getComponent(TransformComponent);
if (transform) {
if (mode === 'move') {
transform.position.x = snapToGrid(transform.position.x);
transform.position.y = snapToGrid(transform.position.y);
} else if (mode === 'rotate') {
transform.rotation.z = snapRotation(transform.rotation.z);
} else if (mode === 'scale') {
transform.scale.x = snapScale(transform.scale.x);
transform.scale.y = snapScale(transform.scale.y);
}
}
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x = snapToGrid(uiTransform.x);
uiTransform.y = snapToGrid(uiTransform.y);
} else if (mode === 'rotate') {
uiTransform.rotation = snapRotation(uiTransform.rotation);
} else if (mode === 'scale') {
uiTransform.scaleX = snapScale(uiTransform.scaleX);
uiTransform.scaleY = snapScale(uiTransform.scaleY);
}
}
}
// Notify Inspector to refresh after transform change
// 通知 Inspector 在变换更改后刷新
if (messageHubRef.current && selectedEntityRef.current) {
messageHubRef.current.publish('entity:selected', {
entity: selectedEntityRef.current
@@ -383,6 +476,9 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
document.addEventListener('mouseup', handleMouseUp);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
window.removeEventListener('resize', resizeCanvas);
resizeObserver.disconnect();
canvas.removeEventListener('mousedown', handleMouseDown);
@@ -538,6 +634,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
setCamera2DZoom(1);
};
// Store handlers in refs to avoid dependency issues
const handlePlayRef = useRef(handlePlay);
const handlePauseRef = useRef(handlePause);
const handleStopRef = useRef(handleStop);
const handleRunInBrowserRef = useRef<(() => void) | null>(null);
const handleRunOnDeviceRef = useRef<(() => void) | null>(null);
handlePlayRef.current = handlePlay;
handlePauseRef.current = handlePause;
handleStopRef.current = handleStop;
const handleRunInBrowser = async () => {
setShowRunMenu(false);
@@ -749,6 +855,51 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
};
// Update refs after function definitions
handleRunInBrowserRef.current = handleRunInBrowser;
handleRunOnDeviceRef.current = handleRunOnDevice;
// Subscribe to MainToolbar events
useEffect(() => {
if (!messageHub) return;
const unsubscribeStart = messageHub.subscribe('preview:start', () => {
handlePlayRef.current();
messageHub.publish('preview:started', {});
});
const unsubscribePause = messageHub.subscribe('preview:pause', () => {
handlePauseRef.current();
messageHub.publish('preview:paused', {});
});
const unsubscribeStop = messageHub.subscribe('preview:stop', () => {
handleStopRef.current();
messageHub.publish('preview:stopped', {});
});
const unsubscribeStep = messageHub.subscribe('preview:step', () => {
engine.step();
});
const unsubscribeRunBrowser = messageHub.subscribe('viewport:run-in-browser', () => {
handleRunInBrowserRef.current?.();
});
const unsubscribeRunDevice = messageHub.subscribe('viewport:run-on-device', () => {
handleRunOnDeviceRef.current?.();
});
return () => {
unsubscribeStart();
unsubscribePause();
unsubscribeStop();
unsubscribeStep();
unsubscribeRunBrowser();
unsubscribeRunDevice();
};
}, [messageHub]);
const handleFullscreen = () => {
if (containerRef.current) {
if (document.fullscreenElement) {
@@ -788,11 +939,44 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const gridSnapOptions = [1, 5, 10, 25, 50, 100];
const rotationSnapOptions = [5, 10, 15, 30, 45, 90];
const scaleSnapOptions = [0.1, 0.25, 0.5, 1];
const closeAllSnapMenus = useCallback(() => {
setShowGridSnapMenu(false);
setShowRotationSnapMenu(false);
setShowScaleSnapMenu(false);
setShowRunMenu(false);
}, []);
// Close menus when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (gridSnapMenuRef.current && !gridSnapMenuRef.current.contains(target)) {
setShowGridSnapMenu(false);
}
if (rotationSnapMenuRef.current && !rotationSnapMenuRef.current.contains(target)) {
setShowRotationSnapMenu(false);
}
if (scaleSnapMenuRef.current && !scaleSnapMenuRef.current.contains(target)) {
setShowScaleSnapMenu(false);
}
if (runMenuRef.current && !runMenuRef.current.contains(target)) {
setShowRunMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="viewport" ref={containerRef}>
<div className="viewport-toolbar">
<div className="viewport-toolbar-left">
{/* Transform tools group */}
{/* Internal Overlay Toolbar */}
<div className="viewport-internal-toolbar">
<div className="viewport-internal-toolbar-left">
{/* Transform tools */}
<div className="viewport-btn-group">
<button
className={`viewport-btn ${transformMode === 'select' ? 'active' : ''}`}
@@ -823,37 +1007,165 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
<Scaling size={14} />
</button>
</div>
<div className="viewport-divider" />
{/* View options group */}
<div className="viewport-btn-group">
{/* Snap toggle */}
<button
className={`viewport-btn ${snapEnabled ? 'active' : ''}`}
onClick={() => setSnapEnabled(!snapEnabled)}
title={locale === 'zh' ? '吸附开关' : 'Toggle Snap'}
>
<Magnet size={14} />
</button>
{/* Grid Snap Value */}
<div className="viewport-snap-dropdown" ref={gridSnapMenuRef}>
<button
className={`viewport-btn ${showGrid ? 'active' : ''}`}
onClick={() => setShowGrid(!showGrid)}
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowGridSnapMenu(!showGridSnapMenu); }}
title={locale === 'zh' ? '网格吸附' : 'Grid Snap'}
>
<Grid3x3 size={14} />
</button>
<button
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
onClick={() => setShowGizmos(!showGizmos)}
title={locale === 'zh' ? '显示辅助工具' : 'Show Gizmos'}
>
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
<Grid3x3 size={12} />
<span>{gridSnapValue}</span>
<ChevronDown size={10} />
</button>
{showGridSnapMenu && (
<div className="viewport-snap-menu">
{gridSnapOptions.map((val) => (
<button
key={val}
className={gridSnapValue === val ? 'active' : ''}
onClick={() => { setGridSnapValue(val); setShowGridSnapMenu(false); }}
>
{val}
</button>
))}
</div>
)}
</div>
<div className="viewport-divider" />
{/* Run options dropdown */}
<div className="viewport-dropdown" ref={runMenuRef}>
{/* Rotation Snap Value */}
<div className="viewport-snap-dropdown" ref={rotationSnapMenuRef}>
<button
className="viewport-btn"
onClick={() => setShowRunMenu(!showRunMenu)}
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowRotationSnapMenu(!showRotationSnapMenu); }}
title={locale === 'zh' ? '旋转吸附' : 'Rotation Snap'}
>
<RotateCw size={12} />
<span>{rotationSnapValue}°</span>
<ChevronDown size={10} />
</button>
{showRotationSnapMenu && (
<div className="viewport-snap-menu">
{rotationSnapOptions.map((val) => (
<button
key={val}
className={rotationSnapValue === val ? 'active' : ''}
onClick={() => { setRotationSnapValue(val); setShowRotationSnapMenu(false); }}
>
{val}°
</button>
))}
</div>
)}
</div>
{/* Scale Snap Value */}
<div className="viewport-snap-dropdown" ref={scaleSnapMenuRef}>
<button
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowScaleSnapMenu(!showScaleSnapMenu); }}
title={locale === 'zh' ? '缩放吸附' : 'Scale Snap'}
>
<Scaling size={12} />
<span>{scaleSnapValue}</span>
<ChevronDown size={10} />
</button>
{showScaleSnapMenu && (
<div className="viewport-snap-menu">
{scaleSnapOptions.map((val) => (
<button
key={val}
className={scaleSnapValue === val ? 'active' : ''}
onClick={() => { setScaleSnapValue(val); setShowScaleSnapMenu(false); }}
>
{val}
</button>
))}
</div>
)}
</div>
</div>
<div className="viewport-internal-toolbar-right">
{/* View options */}
<button
className={`viewport-btn ${showGrid ? 'active' : ''}`}
onClick={() => setShowGrid(!showGrid)}
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
>
<Grid3x3 size={14} />
</button>
<button
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
onClick={() => setShowGizmos(!showGizmos)}
title={locale === 'zh' ? '显示辅助线' : 'Show Gizmos'}
>
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<div className="viewport-divider" />
{/* Zoom display */}
<div className="viewport-zoom-display">
<ZoomIn size={12} />
<span>{Math.round(camera2DZoom * 100)}%</span>
</div>
<div className="viewport-divider" />
{/* Stats toggle */}
<button
className={`viewport-btn ${showStats ? 'active' : ''}`}
onClick={() => setShowStats(!showStats)}
title={locale === 'zh' ? '统计信息' : 'Stats'}
>
<Activity size={14} />
</button>
{/* Reset view */}
<button
className="viewport-btn"
onClick={handleReset}
title={locale === 'zh' ? '重置视图' : 'Reset View'}
>
<RotateCcw size={14} />
</button>
{/* Fullscreen */}
<button
className="viewport-btn"
onClick={handleFullscreen}
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
>
<Maximize2 size={14} />
</button>
<div className="viewport-divider" />
{/* Run options */}
<div className="viewport-snap-dropdown" ref={runMenuRef}>
<button
className="viewport-snap-btn"
onClick={() => { closeAllSnapMenus(); setShowRunMenu(!showRunMenu); }}
title={locale === 'zh' ? '运行选项' : 'Run Options'}
>
<Globe size={14} />
<ChevronDown size={10} />
</button>
{showRunMenu && (
<div className="viewport-dropdown-menu">
<div className="viewport-snap-menu viewport-snap-menu-right">
<button onClick={handleRunInBrowser}>
<Globe size={14} />
{locale === 'zh' ? '浏览器运行' : 'Run in Browser'}
@@ -866,62 +1178,10 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
)}
</div>
</div>
{/* Centered playback controls */}
<div className="viewport-toolbar-center">
<div className="viewport-playback">
<button
className={`viewport-btn play-btn ${playState === 'playing' ? 'active' : ''}`}
onClick={handlePlay}
disabled={playState === 'playing'}
title={locale === 'zh' ? '播放' : 'Play'}
>
<Play size={16} />
</button>
<button
className={`viewport-btn pause-btn ${playState === 'paused' ? 'active' : ''}`}
onClick={handlePause}
disabled={playState !== 'playing'}
title={locale === 'zh' ? '暂停' : 'Pause'}
>
<Pause size={16} />
</button>
<button
className="viewport-btn stop-btn"
onClick={handleStop}
disabled={playState === 'stopped'}
title={locale === 'zh' ? '停止' : 'Stop'}
>
<Square size={16} />
</button>
</div>
</div>
<div className="viewport-toolbar-right">
<span className="viewport-zoom">{Math.round(camera2DZoom * 100)}%</span>
<div className="viewport-divider" />
<button
className="viewport-btn"
onClick={handleReset}
title={locale === 'zh' ? '重置视图' : 'Reset View'}
>
<RotateCcw size={14} />
</button>
<button
className={`viewport-btn ${showStats ? 'active' : ''}`}
onClick={() => setShowStats(!showStats)}
title={locale === 'zh' ? '显示统计信息' : 'Show Stats'}
>
<Activity size={14} />
</button>
<button
className="viewport-btn"
onClick={handleFullscreen}
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
>
<Maximize2 size={14} />
</button>
</div>
</div>
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
{showStats && (
<div className="viewport-stats">
<div className="viewport-stat">

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect, useRef } from 'react';
import { Component } from '@esengine/ecs-framework';
import { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/ecs-components';
import { ChevronDown, Lock, Unlock } from 'lucide-react';
import '../../../styles/TransformInspector.css';
interface AxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
onChange: (value: number) => void;
suffix?: string;
}
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [inputValue, setInputValue] = useState(String(value ?? 0));
const dragStartRef = useRef({ x: 0, value: 0 });
useEffect(() => {
setInputValue(String(value ?? 0));
}, [value]);
const handleBarMouseDown = (e: React.MouseEvent) => {
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 : e.ctrlKey ? 1 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
const rounded = Math.round(newValue * 1000) / 1000;
onChange(rounded);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputBlur = () => {
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
onChange(parsed);
} else {
setInputValue(String(value ?? 0));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setInputValue(String(value ?? 0));
(e.target as HTMLInputElement).blur();
}
};
return (
<div className={`tf-axis-input ${isDragging ? 'dragging' : ''}`}>
<div
className={`tf-axis-bar tf-axis-${axis}`}
onMouseDown={handleBarMouseDown}
/>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
onFocus={(e) => e.target.select()}
/>
{suffix && <span className="tf-axis-suffix">{suffix}</span>}
</div>
);
}
// 双向箭头重置图标
function ResetIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 6H11M1 6L3 4M1 6L3 8M11 6L9 4M11 6L9 8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
interface TransformRowProps {
label: string;
value: { x: number; y: number; z: number };
showLock?: boolean;
isLocked?: boolean;
onLockChange?: (locked: boolean) => void;
onChange: (value: { x: number; y: number; z: number }) => void;
onReset?: () => void;
suffix?: string;
showDivider?: boolean;
}
function TransformRow({
label,
value,
showLock = false,
isLocked = false,
onLockChange,
onChange,
onReset,
suffix,
showDivider = true
}: TransformRowProps) {
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
if (isLocked && showLock) {
const oldVal = value[axis];
if (oldVal !== 0) {
const ratio = newValue / oldVal;
onChange({
x: axis === 'x' ? newValue : value.x * ratio,
y: axis === 'y' ? newValue : value.y * ratio,
z: axis === 'z' ? newValue : value.z * ratio
});
} else {
onChange({ ...value, [axis]: newValue });
}
} else {
onChange({ ...value, [axis]: newValue });
}
};
return (
<>
<div className="tf-row">
<button className="tf-label-btn">
{label}
<ChevronDown size={10} />
</button>
<div className="tf-inputs">
<AxisInput
axis="x"
value={value?.x ?? 0}
onChange={(v) => handleAxisChange('x', v)}
suffix={suffix}
/>
<AxisInput
axis="y"
value={value?.y ?? 0}
onChange={(v) => handleAxisChange('y', v)}
suffix={suffix}
/>
<AxisInput
axis="z"
value={value?.z ?? 0}
onChange={(v) => handleAxisChange('z', v)}
suffix={suffix}
/>
</div>
{showLock && (
<button
className={`tf-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => onLockChange?.(!isLocked)}
title={isLocked ? 'Unlock' : 'Lock'}
>
{isLocked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
)}
<button
className="tf-reset-btn"
onClick={onReset}
title="Reset"
>
<ResetIcon />
</button>
</div>
{showDivider && <div className="tf-divider" />}
</>
);
}
interface MobilityRowProps {
value: 'static' | 'stationary' | 'movable';
onChange: (value: 'static' | 'stationary' | 'movable') => void;
}
function MobilityRow({ value, onChange }: MobilityRowProps) {
return (
<div className="tf-mobility-row">
<span className="tf-mobility-label">Mobility</span>
<div className="tf-mobility-buttons">
<button
className={`tf-mobility-btn ${value === 'static' ? 'active' : ''}`}
onClick={() => onChange('static')}
>
Static
</button>
<button
className={`tf-mobility-btn ${value === 'stationary' ? 'active' : ''}`}
onClick={() => onChange('stationary')}
>
Stationary
</button>
<button
className={`tf-mobility-btn ${value === 'movable' ? 'active' : ''}`}
onClick={() => onChange('movable')}
>
Movable
</button>
</div>
</div>
);
}
function TransformInspectorContent({ context }: { context: ComponentInspectorContext }) {
const transform = context.component as TransformComponent;
const [isScaleLocked, setIsScaleLocked] = useState(false);
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
const [, forceUpdate] = useState({});
const handlePositionChange = (value: { x: number; y: number; z: number }) => {
transform.position = value;
context.onChange?.('position', value);
forceUpdate({});
};
const handleRotationChange = (value: { x: number; y: number; z: number }) => {
transform.rotation = value;
context.onChange?.('rotation', value);
forceUpdate({});
};
const handleScaleChange = (value: { x: number; y: number; z: number }) => {
transform.scale = value;
context.onChange?.('scale', value);
forceUpdate({});
};
return (
<div className="tf-inspector">
<TransformRow
label="Location"
value={transform.position}
onChange={handlePositionChange}
onReset={() => handlePositionChange({ x: 0, y: 0, z: 0 })}
/>
<TransformRow
label="Rotation"
value={transform.rotation}
onChange={handleRotationChange}
onReset={() => handleRotationChange({ x: 0, y: 0, z: 0 })}
suffix="°"
/>
<TransformRow
label="Scale"
value={transform.scale}
showLock
isLocked={isScaleLocked}
onLockChange={setIsScaleLocked}
onChange={handleScaleChange}
onReset={() => handleScaleChange({ x: 1, y: 1, z: 1 })}
showDivider={false}
/>
<div className="tf-divider" />
<MobilityRow value={mobility} onChange={setMobility} />
</div>
);
}
export class TransformComponentInspector implements IComponentInspector<TransformComponent> {
readonly id = 'transform-component-inspector';
readonly name = 'Transform Component Inspector';
readonly priority = 100;
readonly targetComponents = ['Transform', 'TransformComponent'];
canHandle(component: Component): component is TransformComponent {
return component instanceof TransformComponent ||
component.constructor.name === 'TransformComponent' ||
(component.constructor as any).componentName === 'Transform';
}
render(context: ComponentInspectorContext): React.ReactElement {
return React.createElement(TransformInspectorContent, {
context,
key: `transform-${context.version}`
});
}
}

View File

@@ -1,198 +1,147 @@
/* 资产选择框 */
/* Asset Field - Design System Style */
.asset-field {
margin-bottom: 6px;
min-width: 0; /* 允许在flex容器中收缩 */
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.asset-field__label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.asset-field__container {
/* Main content container */
.asset-field__content {
display: flex;
align-items: center;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
/* Thumbnail Preview */
.asset-field__thumbnail {
width: 44px;
height: 44px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
transition: all 0.15s ease;
position: relative;
min-width: 0; /* 允许在flex容器中收缩 */
overflow: hidden; /* 防止内容溢出 */
}
.asset-field__container.hovered {
border-color: #444;
}
.asset-field__container.dragging {
border-color: #4ade80;
background: #1a2a1a;
box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2);
}
/* 资产图标区域 */
.asset-field__icon {
border: 1px solid #3a3a3a;
border-radius: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: #262626;
border-right: 1px solid #333;
color: #888;
flex-shrink: 0; /* 图标不收缩 */
overflow: hidden;
transition: border-color 0.15s ease;
}
.asset-field__container.hovered .asset-field__icon {
color: #aaa;
.asset-field__thumbnail:hover {
border-color: #4a4a4a;
}
/* 资产输入区域 */
.asset-field__input {
.asset-field__thumbnail.dragging {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.asset-field__thumbnail img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.asset-field__thumbnail-icon {
color: #555;
}
/* Right side container */
.asset-field__right {
flex: 1;
padding: 0 8px;
height: 26px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
/* Dropdown selector */
.asset-field__dropdown {
display: flex;
align-items: center;
height: 22px;
padding: 0 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
cursor: pointer;
user-select: none;
min-width: 0; /* 关键允许flex项收缩到小于内容宽度 */
overflow: hidden; /* 配合min-width: 0防止溢出 */
transition: border-color 0.15s ease;
min-width: 0;
}
.asset-field__input:hover {
background: rgba(255, 255, 255, 0.02);
.asset-field__dropdown:hover {
border-color: #4a4a4a;
}
.asset-field__dropdown.dragging {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.asset-field__value {
flex: 1;
font-size: 11px;
color: #e0e0e0;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%; /* 确保不超出父容器 */
display: block; /* 让text-overflow生效 */
}
.asset-field__input.empty .asset-field__value {
color: #666;
font-style: italic;
}
/* 操作按钮组 */
.asset-field__dropdown.has-value .asset-field__value {
color: #ddd;
font-style: normal;
}
.asset-field__dropdown-arrow {
color: #666;
flex-shrink: 0;
margin-left: 4px;
}
/* Action buttons row */
.asset-field__actions {
display: flex;
align-items: center;
gap: 1px;
padding: 0 1px;
flex-shrink: 0; /* 操作按钮不收缩 */
gap: 2px;
}
.asset-field__button {
.asset-field__btn {
width: 20px;
height: 20px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: none;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 2px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.asset-field__button:hover {
background: #333;
color: #e0e0e0;
.asset-field__btn:hover {
background: #3a3a3a;
border-color: #4a4a4a;
color: #ccc;
}
.asset-field__button:active {
background: #444;
}
/* 清除按钮特殊样式 */
.asset-field__button--clear:hover {
.asset-field__btn--clear:hover {
background: #4a2020;
border-color: #5a3030;
color: #f87171;
}
/* 创建按钮特殊样式 */
.asset-field__button--create {
color: #4ade80;
}
.asset-field__button--create:hover {
background: #1a3a1a;
color: #4ade80;
}
/* 禁用状态 */
.asset-field__container[disabled] {
/* Disabled state */
.asset-field[disabled] {
opacity: 0.6;
pointer-events: none;
}
/* 下拉菜单样式(如果需要) */
.asset-field__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.asset-field__dropdown-item {
padding: 6px 8px;
font-size: 11px;
color: #e0e0e0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.asset-field__dropdown-item:hover {
background: #262626;
}
.asset-field__dropdown-item-icon {
color: #888;
}
/* 动画效果 */
@keyframes highlight {
0% {
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
}
100% {
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
}
}
.asset-field__container.dragging {
animation: highlight 0.5s ease;
}
/* 响应式调整 */
@media (max-width: 768px) {
.asset-field__button {
width: 20px;
height: 20px;
}
.asset-field__icon {
width: 26px;
}
}

View File

@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package, Plus } from 'lucide-react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Image, X, Navigation, ChevronDown, Copy } from 'lucide-react';
import { convertFileSrc } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import { ProjectService } from '@esengine/editor-core';
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
import './AssetField.css';
@@ -7,11 +10,11 @@ interface AssetFieldProps {
label?: string;
value: string | null;
onChange: (value: string | null) => void;
fileExtension?: string; // 例如: '.btree'
fileExtension?: string;
placeholder?: string;
readonly?: boolean;
onNavigate?: (path: string) => void; // 导航到资产
onCreate?: () => void; // 创建新资产
onNavigate?: (path: string) => void;
onCreate?: () => void;
}
export function AssetField({
@@ -25,10 +28,51 @@ export function AssetField({
onCreate
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const inputRef = useRef<HTMLDivElement>(null);
// 检测是否是图片资源
const isImageAsset = useCallback((path: string | null) => {
if (!path) return false;
return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].some(ext =>
path.toLowerCase().endsWith(ext)
);
}, []);
// 加载缩略图
useEffect(() => {
if (value && isImageAsset(value)) {
// 获取项目路径并构建完整路径
const projectService = Core.services.tryResolve(ProjectService);
const projectPath = projectService?.getCurrentProject()?.path;
if (projectPath) {
// 构建完整的文件路径
const fullPath = value.startsWith('/') || value.includes(':')
? value
: `${projectPath}/${value}`;
try {
const url = convertFileSrc(fullPath);
setThumbnailUrl(url);
} catch {
setThumbnailUrl(null);
}
} else {
// 没有项目路径时,尝试直接使用 value
try {
const url = convertFileSrc(value);
setThumbnailUrl(url);
} catch {
setThumbnailUrl(null);
}
}
} else {
setThumbnailUrl(null);
}
}, [value, isImageAsset]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -55,26 +99,22 @@ export function AssetField({
if (readonly) return;
// 处理从文件系统拖入的文件
const files = Array.from(e.dataTransfer.files);
const file = files.find((f) =>
!fileExtension || f.name.endsWith(fileExtension)
);
if (file) {
// Web File API 没有 path 属性,使用 name
onChange(file.name);
return;
}
// 处理从资产面板拖入的文件路径
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
onChange(assetPath);
return;
}
// 兼容纯文本拖拽
const text = e.dataTransfer.getData('text/plain');
if (text && (!fileExtension || text.endsWith(fileExtension))) {
onChange(text);
@@ -105,99 +145,85 @@ export function AssetField({
return (
<div className="asset-field">
{label && <label className="asset-field__label">{label}</label>}
<div
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 资产图标 */}
<div className="asset-field__icon">
{value ? (
fileExtension === '.btree' ?
<FileText size={14} /> :
<Package size={14} />
) : (
<Package size={14} style={{ opacity: 0.5 }} />
)}
</div>
{/* 资产选择框 */}
<div className="asset-field__content">
{/* 缩略图预览 */}
<div
ref={inputRef}
className={`asset-field__input ${value ? 'has-value' : 'empty'}`}
className={`asset-field__thumbnail ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={!readonly ? handleBrowse : undefined}
title={value || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
</span>
{thumbnailUrl ? (
<img src={thumbnailUrl} alt="" />
) : (
<Image size={18} className="asset-field__thumbnail-icon" />
)}
</div>
{/* 操作按钮组 */}
<div className="asset-field__actions">
{/* 创建按钮 */}
{onCreate && !readonly && !value && (
<button
className="asset-field__button asset-field__button--create"
onClick={(e) => {
e.stopPropagation();
onCreate();
}}
title="创建新资产"
>
<Plus size={12} />
</button>
)}
{/* 右侧区域 */}
<div className="asset-field__right">
{/* 下拉选择框 */}
<div
ref={inputRef}
className={`asset-field__dropdown ${value ? 'has-value' : ''} ${isDragging ? 'dragging' : ''}`}
onClick={!readonly ? handleBrowse : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
title={value || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
</span>
<ChevronDown size={12} className="asset-field__dropdown-arrow" />
</div>
{/* 浏览按钮 */}
{!readonly && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
handleBrowse();
}}
title="浏览..."
>
<Search size={12} />
</button>
)}
{/* 导航/定位按钮 */}
{onNavigate && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
if (value) {
{/* 操作按钮 */}
<div className="asset-field__actions">
{/* 定位按钮 */}
{value && onNavigate && (
<button
className="asset-field__btn"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
} else {
handleBrowse();
}
}}
title={value ? '在资产浏览器中显示' : '选择资产'}
>
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
</button>
)}
}}
title="Locate in Asset Browser"
>
<Navigation size={12} />
</button>
)}
{/* 清除按钮 */}
{value && !readonly && (
<button
className="asset-field__button asset-field__button--clear"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
title="清除"
>
<X size={12} />
</button>
)}
{/* 复制路径按钮 */}
{value && (
<button
className="asset-field__btn"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(value);
}}
title="Copy Path"
>
<Copy size={12} />
</button>
)}
{/* 清除按钮 */}
{value && !readonly && (
<button
className="asset-field__btn asset-field__btn--clear"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
title="Clear"
>
<X size={12} />
</button>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,344 @@
/**
* Collision Layer Field Component
* 碰撞层字段组件 - 支持 16 层选择
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
/**
* 碰撞层配置接口(用于获取自定义层名称)
*/
interface CollisionLayerConfigAPI {
getLayers(): Array<{ name: string }>;
addListener(callback: () => void): void;
removeListener(callback: () => void): void;
}
/**
* 默认层名称(当 CollisionLayerConfig 不可用时使用)
*/
const DEFAULT_LAYER_NAMES = [
'Default', 'Player', 'Enemy', 'Projectile',
'Ground', 'Platform', 'Trigger', 'Item',
'Layer8', 'Layer9', 'Layer10', 'Layer11',
'Layer12', 'Layer13', 'Layer14', 'Layer15',
];
let cachedConfig: CollisionLayerConfigAPI | null = null;
/**
* 尝试获取 CollisionLayerConfig 实例
*/
function getCollisionConfig(): CollisionLayerConfigAPI | null {
if (cachedConfig) return cachedConfig;
try {
// 动态导入以避免循环依赖
const physicsModule = (window as any).__PHYSICS_RAPIER2D__;
if (physicsModule?.CollisionLayerConfig) {
cachedConfig = physicsModule.CollisionLayerConfig.getInstance();
return cachedConfig;
}
} catch {
// 忽略错误
}
return null;
}
interface CollisionLayerFieldProps {
label: string;
value: number;
multiple?: boolean;
readOnly?: boolean;
onChange: (value: number) => void;
}
export const CollisionLayerField: React.FC<CollisionLayerFieldProps> = ({
label,
value,
multiple = false,
readOnly = false,
onChange
}) => {
const [isOpen, setIsOpen] = useState(false);
const [layerNames, setLayerNames] = useState<string[]>(DEFAULT_LAYER_NAMES);
const dropdownRef = useRef<HTMLDivElement>(null);
// 从配置服务获取层名称
useEffect(() => {
const config = getCollisionConfig();
if (config) {
const updateNames = () => {
const layers = config.getLayers();
setLayerNames(layers.map(l => l.name));
};
updateNames();
config.addListener(updateNames);
return () => config.removeListener(updateNames);
}
}, []);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getLayerIndex = useCallback((layerBit: number): number => {
for (let i = 0; i < 16; i++) {
if (layerBit === (1 << i)) {
return i;
}
}
for (let i = 0; i < 16; i++) {
if ((layerBit & (1 << i)) !== 0) {
return i;
}
}
return 0;
}, []);
const getSelectedCount = useCallback((): number => {
let count = 0;
for (let i = 0; i < 16; i++) {
if ((value & (1 << i)) !== 0) count++;
}
return count;
}, [value]);
const getSelectedLayerNames = useCallback((): string => {
if (!multiple) {
const index = getLayerIndex(value);
return `${index}: ${layerNames[index] ?? 'Unknown'}`;
}
const count = getSelectedCount();
if (count === 0) return 'None';
if (count === 16) return 'All (16)';
if (count > 3) return `${count} layers`;
const names: string[] = [];
for (let i = 0; i < 16; i++) {
if ((value & (1 << i)) !== 0) {
names.push(layerNames[i] ?? `Layer${i}`);
}
}
return names.join(', ');
}, [value, multiple, layerNames, getLayerIndex, getSelectedCount]);
const handleLayerToggle = (index: number) => {
if (readOnly) return;
if (multiple) {
const bit = 1 << index;
const newValue = (value & bit) ? (value & ~bit) : (value | bit);
onChange(newValue);
} else {
onChange(1 << index);
setIsOpen(false);
}
};
const handleSelectAll = () => {
if (!readOnly) onChange(0xFFFF);
};
const handleSelectNone = () => {
if (!readOnly) onChange(0);
};
return (
<div className="property-field clayer-field" ref={dropdownRef}>
<label className="property-label">{label}</label>
<div className="clayer-selector">
<button
className={`clayer-btn ${readOnly ? 'readonly' : ''}`}
onClick={() => !readOnly && setIsOpen(!isOpen)}
type="button"
disabled={readOnly}
>
<span className="clayer-text">{getSelectedLayerNames()}</span>
<span className="clayer-arrow">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && !readOnly && (
<div className="clayer-dropdown">
{multiple && (
<div className="clayer-actions">
<button onClick={handleSelectAll} type="button"></button>
<button onClick={handleSelectNone} type="button"></button>
</div>
)}
<div className="clayer-list">
{layerNames.map((layerName, index) => {
const isSelected = multiple
? (value & (1 << index)) !== 0
: getLayerIndex(value) === index;
return (
<div
key={index}
className={`clayer-item ${isSelected ? 'selected' : ''}`}
onClick={() => handleLayerToggle(index)}
>
{multiple && (
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="clayer-check"
/>
)}
<span className="clayer-idx">{index}</span>
<span className="clayer-name">{layerName}</span>
</div>
);
})}
</div>
</div>
)}
</div>
<style>{`
.clayer-field {
position: relative;
}
.clayer-selector {
position: relative;
flex: 1;
}
.clayer-btn {
width: 100%;
padding: 3px 6px;
border: 1px solid var(--input-border, #3c3c3c);
border-radius: 3px;
background: var(--input-bg, #1e1e1e);
color: var(--text-primary, #ccc);
font-size: 11px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
min-height: 22px;
}
.clayer-btn:hover:not(.readonly) {
border-color: var(--accent-color, #007acc);
}
.clayer-btn.readonly {
cursor: not-allowed;
opacity: 0.6;
}
.clayer-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clayer-arrow {
font-size: 7px;
margin-left: 6px;
color: var(--text-tertiary, #666);
}
.clayer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: var(--dropdown-bg, #252526);
border: 1px solid var(--border-color, #3c3c3c);
border-radius: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 1000;
max-height: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.clayer-actions {
display: flex;
gap: 4px;
padding: 4px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.clayer-actions button {
flex: 1;
padding: 2px 6px;
border: none;
border-radius: 2px;
background: var(--button-bg, #333);
color: var(--text-secondary, #aaa);
font-size: 10px;
cursor: pointer;
}
.clayer-actions button:hover {
background: var(--button-hover-bg, #444);
color: var(--text-primary, #fff);
}
.clayer-list {
overflow-y: auto;
max-height: 240px;
}
.clayer-item {
display: flex;
align-items: center;
padding: 3px 6px;
cursor: pointer;
gap: 6px;
font-size: 11px;
}
.clayer-item:hover {
background: var(--list-hover-bg, #2a2d2e);
}
.clayer-item.selected {
background: var(--list-active-bg, #094771);
}
.clayer-check {
width: 12px;
height: 12px;
accent-color: var(--accent-color, #007acc);
cursor: pointer;
}
.clayer-idx {
width: 14px;
font-size: 9px;
color: var(--text-tertiary, #666);
text-align: right;
}
.clayer-name {
flex: 1;
color: var(--text-primary, #ccc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`}</style>
</div>
);
};
export default CollisionLayerField;

View File

@@ -0,0 +1,348 @@
import { useState, useEffect, useRef } from 'react';
import { ChevronRight, Lock, Unlock, RotateCcw } from 'lucide-react';
interface TransformValue {
x: number;
y: number;
z?: number;
}
interface AxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
onChange: (value: number) => void;
suffix?: string;
}
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [inputValue, setInputValue] = useState(String(value ?? 0));
const dragStartRef = useRef({ x: 0, value: 0 });
useEffect(() => {
setInputValue(String(value ?? 0));
}, [value]);
const handleBarMouseDown = (e: React.MouseEvent) => {
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 : e.ctrlKey ? 1 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
const rounded = Math.round(newValue * 1000) / 1000;
onChange(rounded);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputBlur = () => {
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
onChange(parsed);
} else {
setInputValue(String(value ?? 0));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setInputValue(String(value ?? 0));
(e.target as HTMLInputElement).blur();
}
};
return (
<div className={`transform-axis-input ${isDragging ? 'dragging' : ''}`}>
<div
className={`transform-axis-bar ${axis}`}
onMouseDown={handleBarMouseDown}
/>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
onFocus={(e) => e.target.select()}
/>
{suffix && <span className="transform-axis-suffix">{suffix}</span>}
</div>
);
}
interface TransformRowProps {
label: string;
value: TransformValue;
showZ?: boolean;
showLock?: boolean;
isLocked?: boolean;
onLockChange?: (locked: boolean) => void;
onChange: (value: TransformValue) => void;
onReset?: () => void;
suffix?: string;
}
export function TransformRow({
label,
value,
showZ = false,
showLock = false,
isLocked = false,
onLockChange,
onChange,
onReset,
suffix
}: TransformRowProps) {
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
if (isLocked && showLock) {
const oldVal = axis === 'x' ? value.x : axis === 'y' ? value.y : (value.z ?? 0);
if (oldVal !== 0) {
const ratio = newValue / oldVal;
onChange({
x: axis === 'x' ? newValue : value.x * ratio,
y: axis === 'y' ? newValue : value.y * ratio,
z: showZ ? (axis === 'z' ? newValue : (value.z ?? 1) * ratio) : undefined
});
} else {
onChange({ ...value, [axis]: newValue });
}
} else {
onChange({ ...value, [axis]: newValue });
}
};
return (
<div className="transform-row">
<div className="transform-row-label">
<span className="transform-label-text">{label}</span>
</div>
<div className="transform-row-inputs">
<AxisInput
axis="x"
value={value?.x ?? 0}
onChange={(v) => handleAxisChange('x', v)}
suffix={suffix}
/>
<AxisInput
axis="y"
value={value?.y ?? 0}
onChange={(v) => handleAxisChange('y', v)}
suffix={suffix}
/>
{showZ && (
<AxisInput
axis="z"
value={value?.z ?? 0}
onChange={(v) => handleAxisChange('z', v)}
suffix={suffix}
/>
)}
</div>
{showLock && (
<button
className={`transform-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => onLockChange?.(!isLocked)}
title={isLocked ? 'Unlock' : 'Lock'}
>
{isLocked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
)}
<button
className="transform-reset-btn"
onClick={onReset}
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
);
}
interface RotationRowProps {
value: number | { x: number; y: number; z: number };
onChange: (value: number | { x: number; y: number; z: number }) => void;
onReset?: () => void;
is3D?: boolean;
}
export function RotationRow({ value, onChange, onReset, is3D = false }: RotationRowProps) {
if (is3D && typeof value === 'object') {
return (
<div className="transform-row">
<div className="transform-row-label">
<span className="transform-label-text">Rotation</span>
</div>
<div className="transform-row-inputs">
<AxisInput
axis="x"
value={value.x ?? 0}
onChange={(v) => onChange({ ...value, x: v })}
suffix="°"
/>
<AxisInput
axis="y"
value={value.y ?? 0}
onChange={(v) => onChange({ ...value, y: v })}
suffix="°"
/>
<AxisInput
axis="z"
value={value.z ?? 0}
onChange={(v) => onChange({ ...value, z: v })}
suffix="°"
/>
</div>
<button
className="transform-reset-btn"
onClick={() => onReset?.() || onChange({ x: 0, y: 0, z: 0 })}
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
);
}
const numericValue = typeof value === 'number' ? value : 0;
return (
<div className="transform-row">
<div className="transform-row-label">
<span className="transform-label-text">Rotation</span>
</div>
<div className="transform-row-inputs rotation-single">
<AxisInput
axis="z"
value={numericValue}
onChange={(v) => onChange(v)}
suffix="°"
/>
</div>
<button
className="transform-reset-btn"
onClick={() => onReset?.() || onChange(0)}
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
);
}
interface MobilityRowProps {
value: 'static' | 'stationary' | 'movable';
onChange: (value: 'static' | 'stationary' | 'movable') => void;
}
export function MobilityRow({ value, onChange }: MobilityRowProps) {
return (
<div className="transform-mobility-row">
<span className="transform-mobility-label">Mobility</span>
<div className="transform-mobility-buttons">
<button
className={`transform-mobility-btn ${value === 'static' ? 'active' : ''}`}
onClick={() => onChange('static')}
>
Static
</button>
<button
className={`transform-mobility-btn ${value === 'stationary' ? 'active' : ''}`}
onClick={() => onChange('stationary')}
>
Stationary
</button>
<button
className={`transform-mobility-btn ${value === 'movable' ? 'active' : ''}`}
onClick={() => onChange('movable')}
>
Movable
</button>
</div>
</div>
);
}
interface TransformSectionProps {
position: { x: number; y: number };
rotation: number;
scale: { x: number; y: number };
onPositionChange: (value: { x: number; y: number }) => void;
onRotationChange: (value: number) => void;
onScaleChange: (value: { x: number; y: number }) => void;
}
export function TransformSection({
position,
rotation,
scale,
onPositionChange,
onRotationChange,
onScaleChange
}: TransformSectionProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isScaleLocked, setIsScaleLocked] = useState(false);
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
return (
<div className="transform-section">
<div
className="transform-section-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className={`transform-section-expand ${isExpanded ? 'expanded' : ''}`}>
<ChevronRight size={14} />
</span>
<span className="transform-section-title">Transform</span>
</div>
{isExpanded && (
<div className="transform-section-content">
<TransformRow
label="Location"
value={position}
onChange={onPositionChange}
onReset={() => onPositionChange({ x: 0, y: 0 })}
/>
<RotationRow
value={rotation}
onChange={(v) => onRotationChange(typeof v === 'number' ? v : 0)}
onReset={() => onRotationChange(0)}
/>
<TransformRow
label="Scale"
value={scale}
showLock
isLocked={isScaleLocked}
onLockChange={setIsScaleLocked}
onChange={onScaleChange}
onReset={() => onScaleChange({ x: 1, y: 1 })}
/>
<MobilityRow value={mobility} onChange={setMobility} />
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search } from 'lucide-react';
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search, Lock, Unlock } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
@@ -8,6 +8,20 @@ import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } f
import '../../../styles/EntityInspector.css';
import * as LucideIcons from 'lucide-react';
type CategoryFilter = 'all' | 'general' | 'transform' | 'rendering' | 'physics' | 'audio' | 'other';
// 从 ComponentRegistry category 到 CategoryFilter 的映射
const categoryKeyMap: Record<string, CategoryFilter> = {
'components.category.core': 'general',
'components.category.rendering': 'rendering',
'components.category.physics': 'physics',
'components.category.audio': 'audio',
'components.category.ui': 'rendering',
'components.category.ui.core': 'rendering',
'components.category.ui.widgets': 'rendering',
'components.category.other': 'other',
};
interface ComponentInfo {
name: string;
type?: new () => Component;
@@ -24,12 +38,18 @@ interface EntityInspectorProps {
}
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
// 默认展开所有组件
return new Set(entity.components.map((_, index) => index));
});
const [showComponentMenu, setShowComponentMenu] = useState(false);
const [localVersion, setLocalVersion] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const [isLocked, setIsLocked] = useState(false);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [propertySearchQuery, setPropertySearchQuery] = useState('');
const addButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -38,6 +58,18 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
useEffect(() => {
setExpandedComponents((prev) => {
const newSet = new Set(prev);
// 添加所有当前组件的索引(保留已有的展开状态)
entity.components.forEach((_, index) => {
newSet.add(index);
});
return newSet;
});
}, [entity, entity.components.length, componentVersion]);
useEffect(() => {
if (showComponentMenu && addButtonRef.current) {
const rect = addButtonRef.current.getBoundingClientRect();
@@ -182,25 +214,89 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
}
};
const categoryTabs: { key: CategoryFilter; label: string }[] = [
{ key: 'general', label: 'General' },
{ key: 'transform', label: 'Transform' },
{ key: 'rendering', label: 'Rendering' },
{ key: 'physics', label: 'Physics' },
{ key: 'audio', label: 'Audio' },
{ key: 'other', label: 'Other' },
{ key: 'all', label: 'All' }
];
const getComponentCategory = useCallback((componentName: string): CategoryFilter => {
const componentInfo = componentRegistry?.getComponent(componentName);
if (componentInfo?.category) {
return categoryKeyMap[componentInfo.category] || 'general';
}
return 'general';
}, [componentRegistry]);
const filteredComponents = useMemo(() => {
return entity.components.filter((component: Component) => {
const componentName = getComponentInstanceTypeName(component);
if (categoryFilter !== 'all') {
const category = getComponentCategory(componentName);
if (category !== categoryFilter) {
return false;
}
}
if (propertySearchQuery.trim()) {
const query = propertySearchQuery.toLowerCase();
if (!componentName.toLowerCase().includes(query)) {
return false;
}
}
return true;
});
}, [entity.components, categoryFilter, propertySearchQuery, getComponentCategory]);
return (
<div className="entity-inspector">
{/* Header */}
<div className="inspector-header">
<Settings size={16} />
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
<div className="inspector-header-left">
<button
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => setIsLocked(!isLocked)}
title={isLocked ? '解锁检视器' : '锁定检视器'}
>
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<Settings size={14} color="#666" />
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
</div>
<span className="inspector-object-count">1 object</span>
</div>
{/* Search Box */}
<div className="inspector-search">
<Search size={14} />
<input
type="text"
placeholder="Search..."
value={propertySearchQuery}
onChange={(e) => setPropertySearchQuery(e.target.value)}
/>
</div>
{/* Category Tabs */}
<div className="inspector-category-tabs">
{categoryTabs.map((tab) => (
<button
key={tab.key}
className={`inspector-category-tab ${categoryFilter === tab.key ? 'active' : ''}`}
onClick={() => setCategoryFilter(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label">Entity ID</label>
<span className="property-value-text">{entity.id}</span>
</div>
<div className="property-field">
<label className="property-label">Enabled</label>
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
</div>
</div>
<div className="inspector-section">
<div className="section-title section-title-with-action">
@@ -273,11 +369,14 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
)}
</div>
</div>
{entity.components.length === 0 ? (
<div className="empty-state-small"></div>
{filteredComponents.length === 0 ? (
<div className="empty-state-small">
{entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'}
</div>
) : (
entity.components.map((component: Component, index: number) => {
const isExpanded = expandedComponents.has(index);
filteredComponents.map((component: Component) => {
const originalIndex = entity.components.indexOf(component);
const isExpanded = expandedComponents.has(originalIndex);
const componentName = getComponentInstanceTypeName(component);
const componentInfo = componentRegistry?.getComponent(componentName);
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
@@ -285,12 +384,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
return (
<div
key={`${componentName}-${index}`}
key={`${componentName}-${originalIndex}`}
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
>
<div
className="component-item-header"
onClick={() => toggleComponentExpanded(index)}
onClick={() => toggleComponentExpanded(originalIndex)}
>
<span className="component-expand-icon">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
@@ -311,7 +410,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
className="component-remove-btn"
onClick={(e) => {
e.stopPropagation();
handleRemoveComponent(index);
handleRemoveComponent(originalIndex);
}}
title="移除组件"
>

View File

@@ -27,6 +27,7 @@ export interface UseEngineReturn {
state: EngineState;
start: () => void;
stop: () => void;
step: () => void;
createSprite: (name: string, options?: {
x?: number;
y?: number;
@@ -186,6 +187,12 @@ export function useEngine(
setState((prev) => ({ ...prev, running: false }));
}, []);
// Step single frame (advance one frame when paused)
const step = useCallback(() => {
// Execute a single frame update via Core
Core.update(1 / 60); // Use fixed 60fps timestep for step
}, []);
// Create sprite entity
const createSprite = useCallback((name: string, options?: {
x?: number;
@@ -206,6 +213,7 @@ export function useEngine(
state,
start,
stop,
step,
createSprite,
loadTexture,
viewportId: options.viewportId

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
import { IFieldEditor, FieldEditorProps, MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { AssetField } from '../../components/inspectors/fields/AssetField';
@@ -23,24 +23,21 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
}
};
// 从 FileActionRegistry 获取资产创建消息映射
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
const creationMapping = fileActionRegistry?.getAssetCreationMapping(fileExtension);
const handleCreate = () => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
if (fileExtension === '.tilemap.json') {
messageHub.publish('tilemap:create-asset', {
entityId: context.metadata?.entityId,
onChange
});
} else if (fileExtension === '.btree') {
messageHub.publish('behavior-tree:create-asset', {
entityId: context.metadata?.entityId,
onChange
});
}
if (messageHub && creationMapping) {
messageHub.publish(creationMapping.createMessage, {
entityId: context.metadata?.entityId,
onChange
});
}
};
const canCreate = ['.tilemap.json', '.btree'].includes(fileExtension);
const canCreate = creationMapping !== null && creationMapping !== undefined;
return (
<AssetField

View File

@@ -9,3 +9,4 @@ export { ProfilerPlugin } from './ProfilerPlugin';
export { EditorAppearancePlugin } from './EditorAppearancePlugin';
export { PluginConfigPlugin } from './PluginConfigPlugin';
export { ProjectSettingsPlugin } from './ProjectSettingsPlugin';
export { BlueprintPlugin } from '@esengine/blueprint/editor';

View File

@@ -234,6 +234,7 @@ export class EngineService {
core: Core,
engineBridge: this.bridge,
renderSystem: this.renderSystem,
assetManager: this.assetManager,
isEditor: true
};
@@ -396,6 +397,9 @@ export class EngineService {
// 启用行为树系统用于预览
if (this.behaviorTreeSystem) {
this.behaviorTreeSystem.enabled = true;
// 启动所有自动启动的行为树(因为在编辑器模式下 onAdded 不会处理)
// Start all auto-start behavior trees (since onAdded doesn't handle them in editor mode)
this.behaviorTreeSystem.startAllAutoStartTrees();
}
// Enable physics system for preview
// 启用物理系统用于预览

View File

@@ -1,18 +1,29 @@
import type { IJsonModel, IJsonTabSetNode, IJsonRowNode } from 'flexlayout-react';
import type { FlexDockPanel } from './types';
// 固定宽度配置(像素)| Fixed width configuration (pixels)
const RIGHT_PANEL_WIDTH = 320;
const RIGHT_HIERARCHY_HEIGHT_RATIO = 40;
const RIGHT_INSPECTOR_HEIGHT_RATIO = 60;
export class LayoutBuilder {
static createDefaultLayout(panels: FlexDockPanel[], activePanelId?: string): IJsonModel {
const viewportPanels = panels.filter((p) => p.id === 'viewport');
const hierarchyPanels = panels.filter((p) => p.id.includes('hierarchy'));
const assetPanels = panels.filter((p) => p.id.includes('asset'));
const rightPanels = panels.filter((p) => p.id.includes('inspector'));
const bottomPanels = panels.filter((p) => p.id.includes('console'));
const centerPanels = panels.filter((p) =>
!hierarchyPanels.includes(p) && !assetPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
const inspectorPanels = panels.filter((p) => p.id.includes('inspector'));
const pluginPanels = panels.filter((p) =>
!viewportPanels.includes(p) &&
!hierarchyPanels.includes(p) &&
!inspectorPanels.includes(p)
);
const centerColumnChildren = this.buildCenterColumn(centerPanels, bottomPanels, activePanelId);
const mainRowChildren = this.buildMainRow(hierarchyPanels, assetPanels, centerColumnChildren, rightPanels);
const mainRowChildren = this.buildLayout(
viewportPanels,
pluginPanels,
hierarchyPanels,
inspectorPanels,
activePanelId
);
return {
global: {
@@ -21,7 +32,9 @@ export class LayoutBuilder {
tabSetEnableMaximize: true,
borderSize: 200,
tabSetMinWidth: 100,
tabSetMinHeight: 100
tabSetMinHeight: 100,
splitterSize: 6,
splitterExtra: 4
},
borders: [],
layout: {
@@ -32,28 +45,31 @@ export class LayoutBuilder {
};
}
private static buildCenterColumn(
centerPanels: FlexDockPanel[],
bottomPanels: FlexDockPanel[],
private static buildLayout(
viewportPanels: FlexDockPanel[],
pluginPanels: FlexDockPanel[],
hierarchyPanels: FlexDockPanel[],
inspectorPanels: FlexDockPanel[],
activePanelId?: string
): (IJsonTabSetNode | IJsonRowNode)[] {
const children: (IJsonTabSetNode | IJsonRowNode)[] = [];
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (centerPanels.length > 0) {
const leftPanels = [...viewportPanels, ...pluginPanels];
if (leftPanels.length > 0) {
let activeTabIndex = 0;
if (activePanelId) {
const index = centerPanels.findIndex((p) => p.id === activePanelId);
const index = leftPanels.findIndex((p) => p.id === activePanelId);
if (index !== -1) {
activeTabIndex = index;
}
}
children.push({
mainRowChildren.push({
type: 'tabset',
weight: 70,
weight: 100, // 占据剩余空间
selected: activeTabIndex,
enableMaximize: true,
children: centerPanels.map((p) => ({
children: leftPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
@@ -63,73 +79,12 @@ export class LayoutBuilder {
});
}
if (bottomPanels.length > 0) {
children.push({
type: 'tabset',
weight: 30,
enableMaximize: true,
children: bottomPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
return children;
}
private static buildMainRow(
hierarchyPanels: FlexDockPanel[],
assetPanels: FlexDockPanel[],
centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[],
rightPanels: FlexDockPanel[]
): (IJsonTabSetNode | IJsonRowNode)[] {
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (hierarchyPanels.length > 0 || assetPanels.length > 0) {
const leftColumnChildren = this.buildLeftColumn(hierarchyPanels, assetPanels);
mainRowChildren.push({
type: 'row',
weight: 20,
children: leftColumnChildren
});
}
if (centerColumnChildren.length > 0) {
this.addCenterColumn(mainRowChildren, centerColumnChildren);
}
if (rightPanels.length > 0) {
mainRowChildren.push({
type: 'tabset',
weight: 20,
enableMaximize: true,
children: rightPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
return mainRowChildren;
}
private static buildLeftColumn(
hierarchyPanels: FlexDockPanel[],
assetPanels: FlexDockPanel[]
): IJsonTabSetNode[] {
const leftColumnChildren: IJsonTabSetNode[] = [];
const rightColumnChildren: IJsonTabSetNode[] = [];
if (hierarchyPanels.length > 0) {
leftColumnChildren.push({
rightColumnChildren.push({
type: 'tabset',
weight: 50,
weight: RIGHT_HIERARCHY_HEIGHT_RATIO,
enableMaximize: true,
children: hierarchyPanels.map((p) => ({
type: 'tab',
@@ -141,12 +96,12 @@ export class LayoutBuilder {
});
}
if (assetPanels.length > 0) {
leftColumnChildren.push({
if (inspectorPanels.length > 0) {
rightColumnChildren.push({
type: 'tabset',
weight: 50,
weight: RIGHT_INSPECTOR_HEIGHT_RATIO,
enableMaximize: true,
children: assetPanels.map((p) => ({
children: inspectorPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
@@ -156,35 +111,14 @@ export class LayoutBuilder {
});
}
return leftColumnChildren;
}
private static addCenterColumn(
mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[],
centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[]
): void {
if (centerColumnChildren.length === 1) {
const centerChild = centerColumnChildren[0];
if (centerChild && centerChild.type === 'tabset') {
mainRowChildren.push({
type: 'tabset',
weight: 60,
enableMaximize: true,
children: centerChild.children
} as IJsonTabSetNode);
} else if (centerChild) {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerChild.children
} as IJsonRowNode);
}
} else {
if (rightColumnChildren.length > 0) {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerColumnChildren
});
width: RIGHT_PANEL_WIDTH, // 使用固定宽度而不是权重
children: rightColumnChildren
} as IJsonRowNode);
}
return mainRowChildren;
}
}

View File

@@ -24,213 +24,14 @@
color: var(--color-text-primary);
}
.editor-titlebar {
display: flex;
align-items: center;
justify-content: center;
height: 22px;
background: linear-gradient(to bottom, #3a3a3f, #2a2a2f);
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
flex-shrink: 0;
position: relative;
-webkit-app-region: drag;
}
.titlebar-project-name {
position: absolute;
left: 8px;
font-size: 11px;
font-weight: 500;
color: #fff;
}
.titlebar-app-name {
font-size: 11px;
color: #888;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0 8px;
background-color: #1a1a1f;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
transition: all 0.3s ease;
z-index: var(--z-index-dropdown);
position: relative;
}
.editor-header.remote-connected .status {
color: #4ade80;
}
.editor-header.remote-connected .status::before {
background-color: #4ade80;
}
.header-right {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
height: var(--size-button-sm);
padding: 0 var(--spacing-md);
background-color: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
user-select: none;
}
.toolbar-btn:hover:not(:disabled) {
background-color: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.toolbar-btn:active:not(:disabled) {
background-color: var(--color-primary-active);
transform: translateY(0);
box-shadow: var(--shadow-inner);
}
.toolbar-btn:disabled {
background-color: var(--color-bg-input);
color: var(--color-text-disabled);
cursor: not-allowed;
opacity: 0.6;
}
.toolbar-btn:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.toolbar-btn svg {
width: var(--size-icon-sm);
height: var(--size-icon-sm);
}
.locale-btn {
width: 22px;
height: 22px;
padding: 0;
background-color: transparent;
color: #888;
border: none;
border-radius: 2px;
}
.locale-btn:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.08);
color: #ccc;
border-color: transparent;
transform: none;
box-shadow: none;
}
.locale-dropdown {
position: relative;
}
.locale-btn {
display: flex;
align-items: center;
gap: 4px;
width: auto;
padding: 0 6px;
}
.locale-label {
font-size: 10px;
}
.locale-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 100px;
background: #252529;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
padding: 4px 0;
z-index: var(--z-index-dropdown);
}
.locale-menu-item {
display: block;
width: 100%;
padding: 6px 12px;
background: transparent;
border: none;
color: #ccc;
font-size: 11px;
text-align: left;
cursor: pointer;
transition: all 0.1s;
}
.locale-menu-item:hover {
background: #3b82f6;
color: #fff;
}
.locale-menu-item.active {
color: #3b82f6;
}
.locale-menu-item.active:hover {
color: #fff;
}
.editor-header .status {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #4ade80;
white-space: nowrap;
}
.editor-header .status::before {
content: '';
width: 5px;
height: 5px;
background-color: #4ade80;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.editor-content {
display: flex;
flex: 1;
overflow: hidden;
cursor: default;
/* Leave space for StatusBar */
padding-bottom: 24px;
}
.viewport {
@@ -238,7 +39,7 @@
flex-direction: column;
height: 100%;
background-color: var(--color-bg-base);
padding: var(--spacing-lg);
padding: 0;
}
.viewport h3 {
@@ -258,7 +59,7 @@
align-items: center;
justify-content: space-between;
height: var(--layout-footer-height);
padding: 0 var(--spacing-lg);
padding: 0 8px;
background-color: var(--color-primary);
color: var(--color-text-inverse);
font-size: var(--font-size-xs);

View File

@@ -9,9 +9,12 @@
}
.asset-browser-header {
padding: 12px 15px;
padding: 0 8px;
background: #252526;
border-bottom: 1px solid #333;
height: 26px;
display: flex;
align-items: center;
}
.asset-browser-header h3 {

View File

@@ -10,11 +10,12 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
padding: 0 8px;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-default);
flex-shrink: 0;
gap: 8px;
height: 26px;
}
.console-toolbar-left {

View File

@@ -0,0 +1,628 @@
/* ==================== Content Browser Styles ==================== */
.content-browser {
display: flex;
height: 100%;
background: #1e1e1e;
color: #e0e0e0;
font-size: 12px;
}
.content-browser.is-drawer {
border-top: 2px solid #3b82f6;
}
.content-browser-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #666;
}
/* ==================== Left Panel - Folder Tree ==================== */
.content-browser-left {
width: 200px;
min-width: 150px;
max-width: 300px;
display: flex;
flex-direction: column;
background: #252526;
border-right: 1px solid #3c3c3c;
overflow: hidden;
}
/* Section (Favorites, Collections) */
.cb-section {
border-bottom: 1px solid #3c3c3c;
}
.cb-section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
color: #999;
cursor: pointer;
user-select: none;
}
.cb-section-header:hover {
background: #2d2d30;
}
.cb-section-header span {
flex: 1;
}
.cb-section-actions {
display: flex;
gap: 2px;
}
.cb-section-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: transparent;
border: none;
border-radius: 3px;
color: #666;
cursor: pointer;
}
.cb-section-btn:hover {
background: #3c3c3c;
color: #999;
}
.cb-section-content {
padding: 4px 0;
}
.cb-section-empty {
padding: 8px 12px;
color: #555;
font-size: 11px;
font-style: italic;
}
/* Folder Tree */
.cb-folder-tree {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.folder-tree-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
transition: background 0.1s ease;
}
.folder-tree-item:hover {
background: #2d2d30;
}
.folder-tree-item.selected {
background: #094771;
}
.folder-tree-expand {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
color: #888;
flex-shrink: 0;
}
.folder-tree-icon {
display: flex;
align-items: center;
color: #dcb67a;
flex-shrink: 0;
}
.folder-tree-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
/* ==================== Right Panel - Content Area ==================== */
.content-browser-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #1e1e1e;
}
/* Top Toolbar */
.cb-toolbar {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 8px;
background: #2d2d30;
border-bottom: 1px solid #3c3c3c;
}
.cb-toolbar-left {
display: flex;
gap: 4px;
}
.cb-toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: transparent;
border: none;
border-radius: 3px;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: all 0.1s ease;
}
.cb-toolbar-btn:hover {
background: #3c3c3c;
}
.cb-toolbar-btn.primary {
background: #3b82f6;
color: #fff;
}
.cb-toolbar-btn.primary:hover {
background: #2563eb;
}
.cb-toolbar-btn.dock-btn {
background: #3c3c3c;
}
/* Breadcrumb */
.cb-breadcrumb {
flex: 1;
display: flex;
align-items: center;
gap: 2px;
padding: 0 8px;
overflow: hidden;
}
.cb-breadcrumb-item {
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.cb-breadcrumb-sep {
color: #666;
flex-shrink: 0;
}
.cb-breadcrumb-link {
padding: 2px 6px;
border-radius: 3px;
color: #999;
cursor: pointer;
transition: all 0.1s ease;
}
.cb-breadcrumb-link:hover {
background: #3c3c3c;
color: #fff;
}
.cb-toolbar-right {
display: flex;
gap: 4px;
}
/* Search Bar */
.cb-search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #252526;
border-bottom: 1px solid #3c3c3c;
}
.cb-filter-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
background: #3c3c3c;
border: none;
border-radius: 3px;
color: #999;
cursor: pointer;
}
.cb-filter-btn:hover {
background: #4c4c4c;
color: #ccc;
}
.cb-search-input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.cb-search-icon {
position: absolute;
left: 10px;
color: #666;
pointer-events: none;
}
.cb-search-input {
width: 100%;
padding: 6px 10px 6px 32px;
background: #3c3c3c;
border: 1px solid #4c4c4c;
border-radius: 3px;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.cb-search-input:focus {
border-color: #3b82f6;
}
.cb-search-input::placeholder {
color: #666;
}
.cb-view-options {
display: flex;
gap: 2px;
}
.cb-view-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 3px;
color: #666;
cursor: pointer;
}
.cb-view-btn:hover {
background: #3c3c3c;
color: #999;
}
.cb-view-btn.active {
background: #3c3c3c;
color: #e0e0e0;
}
/* ==================== Asset Grid ==================== */
.cb-asset-grid {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.cb-asset-grid.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
align-content: start;
}
.cb-asset-grid.list {
display: flex;
flex-direction: column;
gap: 2px;
}
.cb-loading,
.cb-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 13px;
}
/* Asset Item - Grid View */
.cb-asset-grid.grid .cb-asset-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px 8px;
background: transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.1s ease;
user-select: none;
}
.cb-asset-grid.grid .cb-asset-item:hover {
background: #2d2d30;
}
.cb-asset-grid.grid .cb-asset-item.selected {
background: #094771;
outline: 1px solid #3b82f6;
}
.cb-asset-grid.grid .cb-asset-thumbnail {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
margin-bottom: 8px;
}
.cb-asset-grid.grid .cb-asset-info {
width: 100%;
text-align: center;
}
.cb-asset-grid.grid .cb-asset-name {
font-size: 11px;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.cb-asset-grid.grid .cb-asset-type {
font-size: 10px;
color: #666;
}
/* Asset Item - List View */
.cb-asset-grid.list .cb-asset-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: transparent;
border-radius: 3px;
cursor: pointer;
transition: all 0.1s ease;
user-select: none;
}
.cb-asset-grid.list .cb-asset-item:hover {
background: #2d2d30;
}
.cb-asset-grid.list .cb-asset-item.selected {
background: #094771;
}
.cb-asset-grid.list .cb-asset-thumbnail {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cb-asset-grid.list .cb-asset-info {
flex: 1;
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.cb-asset-grid.list .cb-asset-name {
flex: 1;
font-size: 12px;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cb-asset-grid.list .cb-asset-type {
font-size: 11px;
color: #666;
white-space: nowrap;
}
/* Thumbnail Icons */
.asset-thumbnail-icon {
color: #888;
}
.asset-thumbnail-icon.folder {
color: #dcb67a;
}
.asset-thumbnail-icon.scene {
color: #66bb6a;
}
.asset-thumbnail-icon.btree {
color: #ab47bc;
}
.asset-thumbnail-icon.code {
color: #42a5f5;
}
.asset-thumbnail-icon.json {
color: #ffa726;
}
.asset-thumbnail-icon.image {
color: #ec407a;
}
/* ==================== Status Bar ==================== */
.cb-status-bar {
display: flex;
align-items: center;
padding: 4px 12px;
background: #252526;
border-top: 1px solid #3c3c3c;
font-size: 11px;
color: #888;
}
/* ==================== Dialogs ==================== */
.cb-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.cb-dialog {
min-width: 350px;
background: #2d2d30;
border: 1px solid #3c3c3c;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.cb-dialog-header {
padding: 12px 16px;
border-bottom: 1px solid #3c3c3c;
}
.cb-dialog-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.cb-dialog-body {
padding: 16px;
}
.cb-dialog-body input {
width: 100%;
padding: 8px 12px;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 4px;
color: #e0e0e0;
font-size: 13px;
outline: none;
}
.cb-dialog-body input:focus {
border-color: #3b82f6;
}
.cb-dialog-body p {
margin: 0;
color: #ccc;
font-size: 13px;
}
.cb-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #3c3c3c;
}
.cb-btn {
padding: 6px 16px;
background: #3c3c3c;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
transition: background 0.1s ease;
}
.cb-btn:hover {
background: #4c4c4c;
}
.cb-btn.primary {
background: #3b82f6;
color: #fff;
}
.cb-btn.primary:hover {
background: #2563eb;
}
.cb-btn.danger {
background: #dc2626;
color: #fff;
}
.cb-btn.danger:hover {
background: #b91c1c;
}
/* ==================== Scrollbar ==================== */
.content-browser-left::-webkit-scrollbar,
.cb-folder-tree::-webkit-scrollbar,
.cb-asset-grid::-webkit-scrollbar {
width: 8px;
}
.content-browser-left::-webkit-scrollbar-track,
.cb-folder-tree::-webkit-scrollbar-track,
.cb-asset-grid::-webkit-scrollbar-track {
background: transparent;
}
.content-browser-left::-webkit-scrollbar-thumb,
.cb-folder-tree::-webkit-scrollbar-thumb,
.cb-asset-grid::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 4px;
}
.content-browser-left::-webkit-scrollbar-thumb:hover,
.cb-folder-tree::-webkit-scrollbar-thumb:hover,
.cb-asset-grid::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}

View File

@@ -2,22 +2,57 @@
display: flex;
flex-direction: column;
height: 100%;
background-color: #1a1a1f;
color: var(--color-text-primary);
background-color: #262626;
color: #ccc;
position: relative;
font-size: 11px;
}
.inspector-header {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background-color: #252529;
gap: 6px;
height: 24px;
padding: 0 6px;
border-bottom: 1px solid #1a1a1a;
background-color: #2d2d2d;
flex-shrink: 0;
}
.inspector-header-left {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.inspector-lock-btn {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 0;
}
.inspector-lock-btn:hover {
color: #999;
}
.inspector-lock-btn.locked {
color: #f59e0b;
}
.inspector-object-count {
font-size: 11px;
color: #888;
margin-left: auto;
}
.inspector-header-icon {
color: var(--color-text-secondary);
flex-shrink: 0;
@@ -32,13 +67,84 @@
letter-spacing: 0.05em;
}
/* Search Box */
.inspector-search {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
}
.inspector-search svg {
color: #555;
flex-shrink: 0;
width: 12px;
height: 12px;
}
.inspector-search input {
flex: 1;
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 2px;
padding: 2px 6px;
font-size: 11px;
color: #ccc;
height: 18px;
}
.inspector-search input:focus {
outline: none;
border-color: #4a9eff;
}
.inspector-search input::placeholder {
color: #555;
}
/* Category Tabs */
.inspector-category-tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 2px 6px;
background: #262626;
border-bottom: 1px solid #1a1a1a;
}
.inspector-category-tab {
padding: 1px 6px;
font-size: 10px;
font-weight: 400;
color: #888;
background: transparent;
border: 1px solid #333;
border-radius: 2px;
cursor: pointer;
transition: all 0.1s ease;
height: 18px;
}
.inspector-category-tab:hover {
background: #333;
color: #ccc;
}
.inspector-category-tab.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.inspector-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: scroll;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
padding: 0;
min-height: 0;
}
@@ -65,16 +171,12 @@
}
.inspector-section {
margin-bottom: 8px;
margin-bottom: 0;
padding: 0;
background-color: transparent;
border-radius: 0;
}
.inspector-section:last-child {
margin-bottom: 0;
}
.section-header {
display: flex;
align-items: center;
@@ -122,7 +224,7 @@
}
.section-content {
padding: var(--spacing-sm) 0;
padding: 0;
}
.info-row {
@@ -319,10 +421,10 @@
color: #888;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 6px 0;
padding: 6px 8px;
margin: 0;
padding: 3px 6px;
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
border-radius: 0;
}
.property-field {
@@ -485,8 +587,8 @@
.add-component-trigger {
display: flex;
align-items: center;
gap: 3px;
padding: 3px 6px;
gap: 2px;
padding: 2px 5px;
background: #3b82f6;
border: none;
border-radius: 2px;
@@ -495,6 +597,7 @@
font-size: 10px;
font-weight: 500;
transition: all 0.1s ease;
height: 18px;
}
.add-component-trigger:hover {
@@ -688,67 +791,57 @@
/* 组件列表项样式 */
.component-item-card {
margin-bottom: 2px;
background: #2a2a2f;
margin-bottom: 0;
background: #262626;
border: none;
border-radius: 0;
border-bottom: 1px solid #1a1a1a;
overflow: visible;
transition: none;
border-left: 3px solid #4a4a50;
}
.component-item-card:hover {
background: #2e2e33;
}
.component-item-card.expanded {
border-left-color: #3b82f6;
background: #252529;
background: #262626;
}
.component-item-header {
display: flex;
align-items: center;
padding: 6px 8px;
background: transparent;
padding: 0 8px;
background: #2d2d2d;
cursor: pointer;
user-select: none;
transition: background 0.1s ease;
min-height: 28px;
height: 26px;
gap: 6px;
}
.component-item-header:hover {
background: rgba(255, 255, 255, 0.03);
background: #333;
}
.component-expand-icon {
display: flex;
align-items: center;
color: #666;
transition: color 0.1s ease;
width: 14px;
color: #888;
width: 12px;
height: 12px;
}
.component-item-card:hover .component-expand-icon,
.component-item-card.expanded .component-expand-icon {
color: #3b82f6;
color: #ccc;
}
.component-icon {
display: flex;
align-items: center;
color: #666;
margin-left: 4px;
margin-right: 6px;
color: #888;
}
.component-item-card.expanded .component-icon {
color: #3b82f6;
color: #ccc;
}
.component-item-name {
flex: 1;
font-size: 11px;
font-size: 12px;
font-weight: 500;
color: #ccc;
}
@@ -765,10 +858,8 @@
border: none;
color: #555;
cursor: pointer;
padding: 3px;
border-radius: 2px;
padding: 2px;
opacity: 0;
transition: all 0.1s ease;
}
.component-item-header:hover .component-remove-btn {
@@ -776,14 +867,12 @@
}
.component-item-card .component-remove-btn:hover {
background: #ef4444;
color: #fff;
color: #ef4444;
}
.component-item-content {
padding: 6px 8px 8px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
background: #1e1e23;
padding: 0;
background: #262626;
overflow: visible;
min-width: 0;
}
@@ -828,3 +917,288 @@
color: #888;
font-size: 12px;
}
/* ==================== Transform Component (UE5 Style) ==================== */
.transform-section {
background: #262626;
border-bottom: 1px solid #1a1a1a;
}
.transform-section-header {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
background: #2d2d2d;
cursor: pointer;
user-select: none;
height: 24px;
border-bottom: 1px solid #1a1a1a;
}
.transform-section-header:hover {
background: #333;
}
.transform-section-expand {
display: flex;
align-items: center;
justify-content: center;
color: #888;
transition: transform 0.15s ease;
width: 14px;
height: 14px;
}
.transform-section-expand.expanded {
transform: rotate(90deg);
}
.transform-section-title {
font-size: 11px;
font-weight: 600;
color: #ccc;
flex: 1;
}
.transform-section-content {
padding: 4px 0;
}
/* Transform Row */
.transform-row {
display: flex;
align-items: center;
padding: 3px 8px;
min-height: 22px;
gap: 4px;
}
.transform-row:hover {
background: rgba(255, 255, 255, 0.02);
}
/* Transform Row Label */
.transform-row-label {
display: flex;
align-items: center;
width: 64px;
min-width: 64px;
flex-shrink: 0;
}
.transform-label-text {
font-size: 11px;
color: #999;
font-weight: 400;
}
/* Transform Row Inputs Container */
.transform-row-inputs {
display: flex;
flex: 1;
gap: 3px;
min-width: 0;
}
.transform-row-inputs.rotation-single {
/* Single rotation input takes full width */
}
/* Transform Axis Input */
.transform-axis-input {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
position: relative;
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 2px;
height: 18px;
overflow: hidden;
}
.transform-axis-input:hover {
border-color: #4a4a4a;
background: #222;
}
.transform-axis-input:focus-within {
border-color: #4a9eff;
}
.transform-axis-input.dragging {
border-color: #4a9eff;
}
/* Color bar indicator inside input */
.transform-axis-bar {
width: 3px;
height: 100%;
flex-shrink: 0;
cursor: ew-resize;
transition: width 0.1s ease;
}
.transform-axis-bar:hover {
width: 4px;
}
.transform-axis-bar.x {
background: #c83232;
}
.transform-axis-bar.y {
background: #32a852;
}
.transform-axis-bar.z {
background: #3264c8;
}
.transform-axis-input input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 4px;
background: transparent;
border: none;
font-size: 10px;
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
color: #ddd;
text-align: left;
-moz-appearance: textfield;
}
.transform-axis-input input::-webkit-outer-spin-button,
.transform-axis-input input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.transform-axis-input input:focus {
outline: none;
background: rgba(74, 158, 255, 0.05);
}
/* Suffix (like degree symbol) */
.transform-axis-suffix {
font-size: 9px;
color: #666;
padding-right: 4px;
flex-shrink: 0;
}
/* Lock Button */
.transform-lock-btn {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: transparent;
border: none;
color: #555;
cursor: pointer;
padding: 0;
flex-shrink: 0;
border-radius: 2px;
}
.transform-lock-btn:hover {
color: #888;
background: rgba(255, 255, 255, 0.05);
}
.transform-lock-btn.locked {
color: #f59e0b;
}
/* Reset Button */
.transform-reset-btn {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: transparent;
border: none;
color: #444;
cursor: pointer;
padding: 0;
flex-shrink: 0;
border-radius: 2px;
opacity: 0;
transition: opacity 0.1s ease;
}
.transform-row:hover .transform-reset-btn {
opacity: 1;
}
.transform-reset-btn:hover {
color: #888;
background: rgba(255, 255, 255, 0.05);
}
/* Mobility Row */
.transform-mobility-row {
display: flex;
align-items: center;
padding: 3px 8px;
min-height: 22px;
gap: 4px;
margin-top: 4px;
border-top: 1px solid #333;
}
.transform-mobility-label {
font-size: 11px;
color: #999;
width: 64px;
min-width: 64px;
flex-shrink: 0;
}
.transform-mobility-buttons {
display: flex;
gap: 0;
flex: 1;
}
.transform-mobility-btn {
flex: 1;
padding: 0 6px;
font-size: 10px;
font-weight: 400;
color: #888;
background: #2a2a2a;
border: 1px solid #3a3a3a;
cursor: pointer;
height: 18px;
transition: all 0.1s ease;
}
.transform-mobility-btn:first-child {
border-radius: 2px 0 0 2px;
}
.transform-mobility-btn:last-child {
border-radius: 0 2px 2px 0;
}
.transform-mobility-btn:not(:first-child) {
border-left: none;
}
.transform-mobility-btn:hover {
background: #3a3a3a;
color: #ccc;
}
.transform-mobility-btn.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}

View File

@@ -1,9 +1,11 @@
.file-tree-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
padding: 0 8px;
background: #252526;
border-bottom: 1px solid #3e3e3e;
height: 26px;
}
.file-tree-toolbar-btn {

View File

@@ -1,372 +1,386 @@
/* ==================== Container ==================== */
.flexlayout-dock-container {
width: 100%;
height: 100%;
background: #1e1e1e;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
background: #1a1a1a;
position: relative;
}
.flexlayout__layout {
background: #1e1e1e;
position: absolute !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a1a;
position: absolute !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* ==================== Tabset (Panel Container) ==================== */
.flexlayout__tabset {
background: #252526;
}
.flexlayout__tabset_header {
background: #252526;
border-bottom: 1px solid #1e1e1e;
height: 28px;
min-height: 28px;
}
.flexlayout__tab {
background: transparent;
color: #969696;
border: none;
padding: 0 16px;
height: 28px;
line-height: 28px;
cursor: default;
transition: all 0.1s ease;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
position: relative;
}
.flexlayout__tab::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: transparent;
transition: background-color 0.1s ease;
}
.flexlayout__tab:hover {
background: rgba(255, 255, 255, 0.03);
color: #e0e0e0;
}
.flexlayout__tab_button {
background: transparent !important;
color: #969696;
border: none !important;
border-right: none !important;
padding: 0 16px;
height: 28px;
cursor: pointer;
transition: all 0.1s ease;
position: relative;
}
.flexlayout__tab_button::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: transparent;
transition: background-color 0.1s ease;
}
.flexlayout__tab_button:hover {
background: rgba(255, 255, 255, 0.03) !important;
color: #e0e0e0;
}
.flexlayout__tab_button--selected {
background: transparent !important;
color: #ffffff !important;
border-bottom: none !important;
}
.flexlayout__tab_button--selected::after {
background: #007acc;
}
.flexlayout__tab_button_leading {
display: flex;
align-items: center;
gap: 6px;
}
.flexlayout__tab_button_content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.3px;
}
.flexlayout__tab_button--selected .flexlayout__tab_button_content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
font-weight: 500;
}
.flexlayout__tab_button_trailing {
margin-left: 8px;
opacity: 0.6;
transition: opacity 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 3px;
}
.flexlayout__tab_button:hover .flexlayout__tab_button_trailing,
.flexlayout__tab:hover .flexlayout__tab_button_trailing {
opacity: 1;
}
.flexlayout__tab_button_trailing:hover {
background: rgba(255, 255, 255, 0.1);
}
.flexlayout__tab_button_trailing svg {
width: 12px;
height: 12px;
color: #cccccc;
}
.flexlayout__tab_button_trailing:hover svg {
color: #ffffff;
}
.flexlayout__tabset_tabbar_outer {
background: #2d2d30;
}
/* 标签栏滚动条样式 */
.flexlayout__tab_moveable::-webkit-scrollbar {
width: 14px;
height: 14px;
}
.flexlayout__tab_moveable::-webkit-scrollbar-track {
background: transparent;
}
.flexlayout__tab_moveable::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.4);
border-radius: 8px;
border: 3px solid transparent;
background-clip: padding-box;
}
.flexlayout__tab_moveable::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
background-clip: padding-box;
}
.flexlayout__tab_moveable::-webkit-scrollbar-corner {
background: transparent;
background: #242424;
border-radius: 0;
}
.flexlayout__tabset-selected {
background: #1e1e1e;
background: #242424;
}
.flexlayout__tabset_header {
background: #2a2a2a;
border-bottom: 1px solid #1a1a1a;
height: 26px;
min-height: 26px;
}
.flexlayout__tabset_tabbar_outer {
background: #2a2a2a;
}
/* ==================== Tab Buttons ==================== */
.flexlayout__tab {
background: transparent;
color: #888888;
border: none;
padding: 0 12px;
height: 26px;
line-height: 26px;
cursor: default;
transition: color 0.1s ease;
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
font-size: 11px;
position: relative;
}
.flexlayout__tab:hover {
color: #cccccc;
background: transparent;
}
.flexlayout__tab::after {
display: none;
}
.flexlayout__tab_button {
background: transparent !important;
color: #888888;
border: none !important;
border-right: none !important;
padding: 0 12px;
height: 26px;
cursor: pointer;
transition: color 0.1s ease;
position: relative;
font-size: 11px;
}
.flexlayout__tab_button::after {
display: none;
}
.flexlayout__tab_button:hover {
background: transparent !important;
color: #cccccc;
}
.flexlayout__tab_button--selected {
background: transparent !important;
color: #ffffff !important;
border-bottom: none !important;
}
.flexlayout__tab_button_leading {
display: flex;
align-items: center;
gap: 6px;
}
.flexlayout__tab_button_content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
font-size: 11px;
font-weight: 400;
}
.flexlayout__tab_button--selected .flexlayout__tab_button_content {
font-weight: 400;
}
/* Tab close button */
.flexlayout__tab_button_trailing {
margin-left: 6px;
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 2px;
}
.flexlayout__tab_button:hover .flexlayout__tab_button_trailing {
opacity: 0.6;
}
.flexlayout__tab_button_trailing:hover {
opacity: 1 !important;
background: rgba(255, 255, 255, 0.1);
}
.flexlayout__tab_button_trailing svg {
width: 10px;
height: 10px;
color: #999999;
}
.flexlayout__tab_button_trailing:hover svg {
color: #ffffff;
}
/* ==================== Splitter (Divider between panels) ==================== */
.flexlayout__splitter {
background: linear-gradient(to right, transparent, #3e3e42 40%, #3e3e42 60%, transparent);
transition: all 0.15s ease;
}
.flexlayout__splitter_horz {
background: linear-gradient(to bottom, transparent, #3e3e42 40%, #3e3e42 60%, transparent);
background: #1a1a1a !important;
transition: background 0.15s ease;
}
.flexlayout__splitter:hover {
background: #007acc;
background: #4a9eff !important;
}
.flexlayout__splitter_horz {
cursor: row-resize !important;
}
.flexlayout__splitter_vert {
cursor: col-resize !important;
}
.flexlayout__splitter_border {
background: #1e1e1e;
}
.flexlayout__outline_rect {
border: 2px solid #007acc;
box-shadow: 0 0 20px rgba(0, 122, 204, 0.5);
background: rgba(0, 122, 204, 0.1);
}
.flexlayout__edge_rect {
background: rgba(0, 122, 204, 0.3);
border: 2px solid #007acc;
}
.flexlayout__drag_rect {
border: 2px solid #007acc;
background: rgba(0, 122, 204, 0.2);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
background: #1a1a1a !important;
}
/* ==================== Panel Content ==================== */
.flexlayout__tabset_content {
background: #1e1e1e;
overflow: auto;
background: #242424;
overflow: auto;
}
.flexlayout__tabset_content * {
cursor: default !important;
cursor: default !important;
}
.flexlayout__tabset_content button,
.flexlayout__tabset_content a,
.flexlayout__tabset_content [role="button"] {
cursor: pointer !important;
.flexlayout__tabset_content [role="button"],
.flexlayout__tabset_content input,
.flexlayout__tabset_content select,
.flexlayout__tabset_content textarea {
cursor: pointer !important;
}
/* ==================== Drag & Drop ==================== */
.flexlayout__outline_rect {
border: 1px solid #4a9eff;
box-shadow: 0 0 12px rgba(74, 158, 255, 0.3);
background: rgba(74, 158, 255, 0.08);
border-radius: 2px;
}
.flexlayout__edge_rect {
background: rgba(74, 158, 255, 0.15);
border: 1px solid #4a9eff;
}
.flexlayout__drag_rect {
border: 1px solid #4a9eff;
background: rgba(74, 158, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border-radius: 2px;
}
/* ==================== Tab Toolbar ==================== */
.flexlayout__tab_toolbar {
display: flex !important;
align-items: center;
gap: 4px;
padding: 0 8px;
visibility: visible !important;
opacity: 1 !important;
display: flex !important;
align-items: center;
gap: 2px;
padding: 0 4px;
visibility: visible !important;
opacity: 1 !important;
}
.flexlayout__tab_toolbar_button {
background: transparent;
border: none;
color: #cccccc;
cursor: pointer;
padding: 4px;
border-radius: 3px;
transition: background-color 0.15s ease;
display: flex !important;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
color: #666666;
cursor: pointer;
padding: 3px;
border-radius: 2px;
transition: all 0.1s ease;
display: flex !important;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.flexlayout__tab_toolbar_button:hover {
background: #383838;
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
color: #cccccc;
}
.flexlayout__tab_toolbar_button svg {
width: 14px;
height: 14px;
width: 12px;
height: 12px;
}
/* 确保最小化和最大化按钮可见 */
.flexlayout__tab_toolbar_button-min,
.flexlayout__tab_toolbar_button-max {
display: flex !important;
visibility: visible !important;
display: flex !important;
visibility: visible !important;
}
/* Maximized tabset styling */
.flexlayout__tabset_maximized .flexlayout__tab_toolbar_button-max {
color: #4a9eff;
}
.flexlayout__tabset_maximized .flexlayout__tab_toolbar_button-max:hover {
background: rgba(74, 158, 255, 0.2);
color: #ffffff;
}
/* ==================== Popup Menu ==================== */
.flexlayout__popup_menu {
background: #252526;
border: 1px solid #454545;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
border-radius: 3px;
background: #2d2d2d;
border: 1px solid #3a3a3a;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
border-radius: 4px;
padding: 4px 0;
}
.flexlayout__popup_menu_item {
color: #cccccc;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.15s ease;
color: #cccccc;
padding: 6px 12px;
cursor: pointer;
transition: background 0.1s ease;
font-size: 11px;
}
.flexlayout__popup_menu_item:hover {
background: #2a2d2e;
color: #ffffff;
background: #3a3a3a;
color: #ffffff;
}
.flexlayout__popup_menu_item:active {
background: #094771;
background: #4a9eff;
}
/* ==================== Border Panels ==================== */
.flexlayout__border {
background: #252526;
border: 1px solid #1e1e1e;
background: #242424;
border: none;
}
.flexlayout__border_top,
.flexlayout__border_bottom {
border-left: none;
border-right: none;
border-left: none;
border-right: none;
}
.flexlayout__border_left,
.flexlayout__border_right {
border-top: none;
border-bottom: none;
border-top: none;
border-bottom: none;
}
.flexlayout__border_button {
background: transparent;
color: #969696;
border: none;
border-bottom: 1px solid #1e1e1e;
padding: 8px 12px;
cursor: pointer;
transition: all 0.1s ease;
position: relative;
background: transparent;
color: #888888;
border: none;
border-bottom: none;
padding: 6px 10px;
cursor: pointer;
transition: color 0.1s ease;
position: relative;
font-size: 11px;
}
.flexlayout__border_button::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: transparent;
transition: background-color 0.1s ease;
display: none;
}
.flexlayout__border_button:hover {
background: rgba(255, 255, 255, 0.03);
color: #e0e0e0;
background: transparent;
color: #cccccc;
}
.flexlayout__border_button--selected {
background: transparent;
color: #ffffff;
}
.flexlayout__border_button--selected::after {
background: #007acc;
background: transparent;
color: #ffffff;
}
/* ==================== Error Boundary ==================== */
.flexlayout__error_boundary_container {
background: #1e1e1e;
color: #f48771;
padding: 16px;
font-family: monospace;
background: #242424;
color: #f48771;
padding: 16px;
font-family: monospace;
}
.flexlayout__error_boundary_message {
margin-bottom: 8px;
font-weight: 600;
margin-bottom: 8px;
font-weight: 600;
}
/* 持久化面板占位符 */
/* ==================== Scrollbar ==================== */
.flexlayout__tabset_content::-webkit-scrollbar,
.flexlayout__tab_moveable::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.flexlayout__tabset_content::-webkit-scrollbar-track,
.flexlayout__tab_moveable::-webkit-scrollbar-track {
background: transparent;
}
.flexlayout__tabset_content::-webkit-scrollbar-thumb,
.flexlayout__tab_moveable::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.flexlayout__tabset_content::-webkit-scrollbar-thumb:hover,
.flexlayout__tab_moveable::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.flexlayout__tabset_content::-webkit-scrollbar-corner,
.flexlayout__tab_moveable::-webkit-scrollbar-corner {
background: transparent;
}
/* ==================== Persistent Panels ==================== */
.persistent-panel-placeholder {
width: 100%;
height: 100%;
background: transparent;
width: 100%;
height: 100%;
background: transparent;
}
/* 持久化面板容器 */
.persistent-panel-container {
background: #1e1e1e;
background: #242424;
}
/* 确保 tabset header 在 persistent panel 之上 */
.flexlayout__tabset_header,
.flexlayout__tabset_tabbar_outer {
position: relative;
z-index: 10;
}
/* 最大化时确保 tab bar 可见 */
.flexlayout__tabset_maximized .flexlayout__tabset_header,
.flexlayout__tabset_maximized .flexlayout__tabset_tabbar_outer {
z-index: 100;
}

View File

@@ -12,11 +12,12 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
padding: 0 8px;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-default);
flex-shrink: 0;
gap: 8px;
height: 26px;
z-index: var(--z-index-above);
}

View File

@@ -0,0 +1,260 @@
/* ==================== Main Toolbar Container ==================== */
.main-toolbar {
display: flex;
align-items: center;
height: 36px;
background: linear-gradient(180deg, #3a3a3f 0%, #2d2d32 100%);
border-bottom: 1px solid #1a1a1d;
padding: 0 8px;
gap: 4px;
user-select: none;
flex-shrink: 0;
position: relative;
z-index: var(--z-index-header);
}
/* ==================== Toolbar Groups ==================== */
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-center {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 2px 4px;
}
/* Absolutely centered play controls */
.toolbar-center-wrapper {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
pointer-events: none;
}
.toolbar-center-wrapper > * {
pointer-events: auto;
}
/* Right section for preview indicator */
.toolbar-right {
margin-left: auto;
display: flex;
align-items: center;
min-width: 100px;
justify-content: flex-end;
}
/* ==================== Toolbar Separator ==================== */
.toolbar-separator {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.1);
margin: 0 4px;
}
/* ==================== Tool Buttons ==================== */
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 3px;
color: #888888;
cursor: pointer;
transition: all 0.1s ease;
}
.toolbar-button:hover:not(.disabled) {
background: rgba(255, 255, 255, 0.08);
color: #cccccc;
}
.toolbar-button:active:not(.disabled) {
background: rgba(255, 255, 255, 0.05);
}
.toolbar-button.active {
background: rgba(74, 158, 255, 0.2);
color: #4a9eff;
}
.toolbar-button.active:hover {
background: rgba(74, 158, 255, 0.3);
}
.toolbar-button.disabled {
color: #444444;
cursor: not-allowed;
}
.toolbar-button svg {
flex-shrink: 0;
}
/* Play button special styling */
.toolbar-center .toolbar-button {
width: 32px;
height: 28px;
}
.toolbar-center .toolbar-button:first-child {
color: #4ade80;
}
.toolbar-center .toolbar-button:first-child:hover:not(.disabled) {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
.toolbar-center .toolbar-button:nth-child(2) {
color: #f87171;
}
.toolbar-center .toolbar-button:nth-child(2):hover:not(.disabled) {
background: rgba(248, 113, 113, 0.15);
}
.toolbar-center .toolbar-button:nth-child(2).disabled {
color: #444444;
}
/* ==================== Preview Mode Indicator ==================== */
.preview-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(74, 222, 128, 0.15);
border: 1px solid rgba(74, 222, 128, 0.3);
border-radius: 4px;
color: #4ade80;
font-size: 11px;
font-weight: 500;
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 4px rgba(74, 222, 128, 0.2);
}
50% {
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
}
.preview-indicator svg {
flex-shrink: 0;
}
/* ==================== Dropdown Menus (for future use) ==================== */
.toolbar-dropdown {
position: relative;
}
.toolbar-dropdown-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 3px;
color: #888888;
font-size: 11px;
cursor: pointer;
transition: all 0.1s ease;
}
.toolbar-dropdown-button:hover {
background: rgba(255, 255, 255, 0.08);
color: #cccccc;
}
.toolbar-dropdown-item {
display: block;
width: 100%;
padding: 6px 12px;
background: transparent;
border: none;
color: #c0c0c0;
font-size: 11px;
text-align: left;
cursor: pointer;
transition: background 0.1s ease;
}
.toolbar-dropdown-item:hover {
background: #3b82f6;
color: #ffffff;
}
/* ==================== Toolbar Dropdown ==================== */
.toolbar-dropdown {
position: relative;
}
.toolbar-dropdown-trigger {
display: flex !important;
align-items: center;
gap: 4px;
width: auto !important;
padding: 0 8px !important;
}
.toolbar-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 160px;
background: #252528;
border: 1px solid #3a3a3d;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
padding: 4px 0;
z-index: var(--z-index-popover);
animation: dropdown-fade-in 0.1s ease;
}
@keyframes dropdown-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.toolbar-dropdown-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: #c0c0c0;
font-size: 12px;
text-align: left;
cursor: pointer;
transition: background 0.1s ease;
}
.toolbar-dropdown-menu button:hover {
background: #3b82f6;
color: #ffffff;
}
.toolbar-dropdown-menu button svg {
flex-shrink: 0;
}

Some files were not shown because too many files have changed in this diff Show More