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:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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: |
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
* 批量加载资产
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
* 获取所有注册的加载器
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射节点类型
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 根节点没有需要重置的状态
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { RootExecutor } from './RootExecutor';
|
||||
export { SequenceExecutor } from './SequenceExecutor';
|
||||
export { SelectorExecutor } from './SelectorExecutor';
|
||||
export { ParallelExecutor } from './ParallelExecutor';
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Asset type constant for behavior tree
|
||||
// 行为树资产类型常量
|
||||
export const BehaviorTreeAssetType = 'behaviortree' as const;
|
||||
|
||||
// Types
|
||||
export * from './Types/TaskStatus';
|
||||
|
||||
|
||||
110
packages/behavior-tree/src/loaders/BehaviorTreeLoader.ts
Normal file
110
packages/behavior-tree/src/loaders/BehaviorTreeLoader.ts
Normal 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>;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
85
packages/blueprint/package.json
Normal file
85
packages/blueprint/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
packages/blueprint/plugin.json
Normal file
30
packages/blueprint/plugin.json
Normal 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"
|
||||
}
|
||||
180
packages/blueprint/src/editor/BlueprintPlugin.ts
Normal file
180
packages/blueprint/src/editor/BlueprintPlugin.ts
Normal 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()
|
||||
};
|
||||
383
packages/blueprint/src/editor/components/BlueprintCanvas.tsx
Normal file
383
packages/blueprint/src/editor/components/BlueprintCanvas.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
7
packages/blueprint/src/editor/components/index.ts
Normal file
7
packages/blueprint/src/editor/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Blueprint Editor Components
|
||||
* 蓝图编辑器组件
|
||||
*/
|
||||
|
||||
export * from './BlueprintCanvas';
|
||||
export * from './BlueprintEditorPanel';
|
||||
8
packages/blueprint/src/editor/index.ts
Normal file
8
packages/blueprint/src/editor/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Blueprint Editor Module
|
||||
* 蓝图编辑器模块
|
||||
*/
|
||||
|
||||
export * from './components';
|
||||
export * from './stores';
|
||||
export * from './BlueprintPlugin';
|
||||
256
packages/blueprint/src/editor/stores/blueprintEditorStore.ts
Normal file
256
packages/blueprint/src/editor/stores/blueprintEditorStore.ts
Normal 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 });
|
||||
}
|
||||
}));
|
||||
6
packages/blueprint/src/editor/stores/index.ts
Normal file
6
packages/blueprint/src/editor/stores/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Blueprint Editor Stores
|
||||
* 蓝图编辑器状态存储
|
||||
*/
|
||||
|
||||
export * from './blueprintEditorStore';
|
||||
31
packages/blueprint/src/index.ts
Normal file
31
packages/blueprint/src/index.ts
Normal 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';
|
||||
91
packages/blueprint/src/nodes/debug/Print.ts
Normal file
91
packages/blueprint/src/nodes/debug/Print.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
6
packages/blueprint/src/nodes/debug/index.ts
Normal file
6
packages/blueprint/src/nodes/debug/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Debug Nodes - Tools for debugging blueprints
|
||||
* 调试节点 - 蓝图调试工具
|
||||
*/
|
||||
|
||||
export * from './Print';
|
||||
44
packages/blueprint/src/nodes/events/EventBeginPlay.ts
Normal file
44
packages/blueprint/src/nodes/events/EventBeginPlay.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
42
packages/blueprint/src/nodes/events/EventEndPlay.ts
Normal file
42
packages/blueprint/src/nodes/events/EventEndPlay.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
50
packages/blueprint/src/nodes/events/EventTick.ts
Normal file
50
packages/blueprint/src/nodes/events/EventTick.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
8
packages/blueprint/src/nodes/events/index.ts
Normal file
8
packages/blueprint/src/nodes/events/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Event Nodes - Entry points for blueprint execution
|
||||
* 事件节点 - 蓝图执行的入口点
|
||||
*/
|
||||
|
||||
export * from './EventBeginPlay';
|
||||
export * from './EventTick';
|
||||
export * from './EventEndPlay';
|
||||
11
packages/blueprint/src/nodes/index.ts
Normal file
11
packages/blueprint/src/nodes/index.ts
Normal 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';
|
||||
122
packages/blueprint/src/nodes/math/MathOperations.ts
Normal file
122
packages/blueprint/src/nodes/math/MathOperations.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
6
packages/blueprint/src/nodes/math/index.ts
Normal file
6
packages/blueprint/src/nodes/math/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Math Nodes - Mathematical operation nodes
|
||||
* 数学节点 - 数学运算节点
|
||||
*/
|
||||
|
||||
export * from './MathOperations';
|
||||
57
packages/blueprint/src/nodes/time/Delay.ts
Normal file
57
packages/blueprint/src/nodes/time/Delay.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
45
packages/blueprint/src/nodes/time/GetDeltaTime.ts
Normal file
45
packages/blueprint/src/nodes/time/GetDeltaTime.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
45
packages/blueprint/src/nodes/time/GetTime.ts
Normal file
45
packages/blueprint/src/nodes/time/GetTime.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
8
packages/blueprint/src/nodes/time/index.ts
Normal file
8
packages/blueprint/src/nodes/time/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Time Nodes - Time-related utility nodes
|
||||
* 时间节点 - 时间相关的工具节点
|
||||
*/
|
||||
|
||||
export * from './GetDeltaTime';
|
||||
export * from './GetTime';
|
||||
export * from './Delay';
|
||||
116
packages/blueprint/src/runtime/BlueprintComponent.ts
Normal file
116
packages/blueprint/src/runtime/BlueprintComponent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
121
packages/blueprint/src/runtime/BlueprintSystem.ts
Normal file
121
packages/blueprint/src/runtime/BlueprintSystem.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
335
packages/blueprint/src/runtime/BlueprintVM.ts
Normal file
335
packages/blueprint/src/runtime/BlueprintVM.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
294
packages/blueprint/src/runtime/ExecutionContext.ts
Normal file
294
packages/blueprint/src/runtime/ExecutionContext.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
151
packages/blueprint/src/runtime/NodeRegistry.ts
Normal file
151
packages/blueprint/src/runtime/NodeRegistry.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
10
packages/blueprint/src/runtime/index.ts
Normal file
10
packages/blueprint/src/runtime/index.ts
Normal 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';
|
||||
125
packages/blueprint/src/types/blueprint.ts
Normal file
125
packages/blueprint/src/types/blueprint.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
3
packages/blueprint/src/types/index.ts
Normal file
3
packages/blueprint/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './pins';
|
||||
export * from './nodes';
|
||||
export * from './blueprint';
|
||||
138
packages/blueprint/src/types/nodes.ts
Normal file
138
packages/blueprint/src/types/nodes.ts
Normal 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;
|
||||
}
|
||||
135
packages/blueprint/src/types/pins.ts
Normal file
135
packages/blueprint/src/types/pins.ts
Normal 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;
|
||||
}
|
||||
28
packages/blueprint/tsconfig.json
Normal file
28
packages/blueprint/tsconfig.json
Normal 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"]
|
||||
}
|
||||
106
packages/blueprint/vite.config.ts
Normal file
106
packages/blueprint/vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
* 设置变换工具模式。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
957
packages/editor-app/src/components/ContentBrowser.tsx
Normal file
957
packages/editor-app/src/components/ContentBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
}}
|
||||
>
|
||||
|
||||
323
packages/editor-app/src/components/MainToolbar.tsx
Normal file
323
packages/editor-app/src/components/MainToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
474
packages/editor-app/src/components/OutputLogPanel.tsx
Normal file
474
packages/editor-app/src/components/OutputLogPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
326
packages/editor-app/src/components/StatusBar.tsx
Normal file
326
packages/editor-app/src/components/StatusBar.tsx
Normal 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">></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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
packages/editor-app/src/components/TitleBar.tsx
Normal file
360
packages/editor-app/src/components/TitleBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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="移除组件"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
// 启用物理系统用于预览
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
628
packages/editor-app/src/styles/ContentBrowser.css
Normal file
628
packages/editor-app/src/styles/ContentBrowser.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
260
packages/editor-app/src/styles/MainToolbar.css
Normal file
260
packages/editor-app/src/styles/MainToolbar.css
Normal 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
Reference in New Issue
Block a user