feat(modules): 添加module.json配置
This commit is contained in:
43
packages/audio/module.json
Normal file
43
packages/audio/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "@esengine/audio",
|
||||
"displayName": "Audio",
|
||||
"description": "Audio playback and sound effects | 音频播放和音效",
|
||||
"version": "1.0.0",
|
||||
"category": "Audio",
|
||||
"icon": "Volume2",
|
||||
"tags": [
|
||||
"audio",
|
||||
"sound",
|
||||
"music"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"asset-system"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"AudioSourceComponent",
|
||||
"AudioListenerComponent"
|
||||
],
|
||||
"systems": [
|
||||
"AudioSystem"
|
||||
],
|
||||
"other": [
|
||||
"AudioClip",
|
||||
"AudioMixer"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "AudioPlugin"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { AudioSourceComponent } from './AudioSourceComponent';
|
||||
|
||||
class AudioRuntimeModule implements IRuntimeModule {
|
||||
@@ -8,17 +8,21 @@ class AudioRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/audio',
|
||||
name: 'Audio',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'audio',
|
||||
name: '@esengine/audio',
|
||||
displayName: 'Audio',
|
||||
version: '1.0.0',
|
||||
description: '音频组件',
|
||||
category: 'audio',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Audio',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core', 'asset-system'],
|
||||
exports: { components: ['AudioSourceComponent'] }
|
||||
};
|
||||
|
||||
export const AudioPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new AudioRuntimeModule()
|
||||
};
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
/**
|
||||
* Behavior Tree Plugin Descriptor
|
||||
* 行为树插件描述符
|
||||
* Behavior Tree Plugin Manifest
|
||||
* 行为树插件清单
|
||||
*/
|
||||
|
||||
import type { PluginDescriptor } from '@esengine/editor-runtime';
|
||||
import type { ModuleManifest } from '@esengine/editor-runtime';
|
||||
|
||||
/**
|
||||
* 插件描述符
|
||||
* 插件清单
|
||||
*/
|
||||
export const descriptor: PluginDescriptor = {
|
||||
export const manifest: ModuleManifest = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree System',
|
||||
name: '@esengine/behavior-tree',
|
||||
displayName: 'Behavior Tree System',
|
||||
version: '1.0.0',
|
||||
description: 'AI 行为树系统,支持可视化编辑和运行时执行',
|
||||
category: 'ai',
|
||||
enabledByDefault: true,
|
||||
category: 'AI',
|
||||
icon: 'GitBranch',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: false,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: false,
|
||||
modules: [
|
||||
{
|
||||
name: 'BehaviorTreeRuntime',
|
||||
type: 'runtime',
|
||||
loadingPhase: 'default'
|
||||
},
|
||||
{
|
||||
name: 'BehaviorTreeEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{ id: '@esengine/engine-core', version: '>=1.0.0', optional: true }
|
||||
],
|
||||
icon: 'GitBranch'
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['BehaviorTreeRuntimeComponent'],
|
||||
systems: ['BehaviorTreeExecutionSystem'],
|
||||
loaders: ['BehaviorTreeLoader']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,8 +42,8 @@ import { useBehaviorTreeDataStore } from './stores';
|
||||
import { createRootNode } from './domain/constants/RootNode';
|
||||
import { PluginContext } from './PluginContext';
|
||||
|
||||
// Import descriptor from local file
|
||||
import { descriptor } from './BehaviorTreePlugin';
|
||||
// Import manifest from local file
|
||||
import { manifest } from './BehaviorTreePlugin';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
@@ -340,7 +340,7 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
|
||||
|
||||
// Create the complete plugin with editor module
|
||||
export const BehaviorTreePlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new BehaviorTreeRuntimeModule(),
|
||||
editorModule: new BehaviorTreeEditorModule(),
|
||||
};
|
||||
|
||||
45
packages/behavior-tree/module.json
Normal file
45
packages/behavior-tree/module.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "behavior-tree",
|
||||
"name": "@esengine/behavior-tree",
|
||||
"displayName": "Behavior Tree",
|
||||
"description": "AI behavior tree system | AI 行为树系统",
|
||||
"version": "1.0.0",
|
||||
"category": "AI",
|
||||
"icon": "GitBranch",
|
||||
"tags": [
|
||||
"ai",
|
||||
"behavior",
|
||||
"tree"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"BehaviorTreeComponent"
|
||||
],
|
||||
"systems": [
|
||||
"BehaviorTreeSystem"
|
||||
],
|
||||
"other": [
|
||||
"BehaviorTree",
|
||||
"BTNode",
|
||||
"Selector",
|
||||
"Sequence",
|
||||
"Condition",
|
||||
"Action"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/behavior-tree-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "BehaviorTreePlugin"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry, Core } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
|
||||
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
|
||||
@@ -39,7 +39,10 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
this._loaderRegistered = true;
|
||||
}
|
||||
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core);
|
||||
// 使用 context 中的 services,确保与调用方使用同一个 ServiceContainer 实例
|
||||
// Use services from context to ensure same ServiceContainer instance as caller
|
||||
const services = (btContext as any).services || Core.services;
|
||||
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(services);
|
||||
|
||||
if (btContext.assetManager) {
|
||||
behaviorTreeSystem.setAssetManager(btContext.assetManager);
|
||||
@@ -54,18 +57,25 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/behavior-tree',
|
||||
name: 'Behavior Tree',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'behavior-tree',
|
||||
name: '@esengine/behavior-tree',
|
||||
displayName: 'Behavior Tree',
|
||||
version: '1.0.0',
|
||||
description: 'AI behavior tree system',
|
||||
category: 'ai',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true
|
||||
category: 'AI',
|
||||
icon: 'GitBranch',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['core'],
|
||||
exports: { components: ['BehaviorTreeComponent'] },
|
||||
editorPackage: '@esengine/behavior-tree-editor'
|
||||
};
|
||||
|
||||
export const BehaviorTreePlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new BehaviorTreeRuntimeModule()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
|
||||
import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
|
||||
@@ -6,6 +6,7 @@ import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor';
|
||||
import { BehaviorTreeData, BehaviorNodeData } from './BehaviorTreeData';
|
||||
import { TaskStatus } from '../Types/TaskStatus';
|
||||
import { NodeMetadataRegistry } from './NodeMetadata';
|
||||
import type { IBehaviorTreeAsset } from '../loaders/BehaviorTreeLoader';
|
||||
import './Executors';
|
||||
|
||||
/**
|
||||
@@ -17,14 +18,17 @@ import './Executors';
|
||||
export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
private btAssetManager: BehaviorTreeAssetManager | null = null;
|
||||
private executorRegistry: NodeExecutorRegistry;
|
||||
private coreInstance: typeof Core | null = null;
|
||||
private _services: ServiceContainer | null = null;
|
||||
|
||||
/** 引用 asset-system 的 AssetManager(由 BehaviorTreeRuntimeModule 设置) */
|
||||
private _assetManager: AssetManager | null = null;
|
||||
|
||||
constructor(coreInstance?: typeof Core) {
|
||||
/** 已警告过的缺失资产,避免重复警告 */
|
||||
private _warnedMissingAssets: Set<string> = new Set();
|
||||
|
||||
constructor(services?: ServiceContainer) {
|
||||
super(Matcher.empty().all(BehaviorTreeRuntimeComponent));
|
||||
this.coreInstance = coreInstance || null;
|
||||
this._services = services || null;
|
||||
this.executorRegistry = new NodeExecutorRegistry();
|
||||
this.registerBuiltInExecutors();
|
||||
}
|
||||
@@ -121,12 +125,38 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
|
||||
private getBTAssetManager(): BehaviorTreeAssetManager {
|
||||
if (!this.btAssetManager) {
|
||||
const core = this.coreInstance || Core;
|
||||
this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager);
|
||||
// 优先使用传入的 services,否则回退到全局 Core.services
|
||||
// Prefer passed services, fallback to global Core.services
|
||||
const services = this._services || Core.services;
|
||||
if (!services) {
|
||||
throw new Error('ServiceContainer is not available. Ensure Core.create() was called.');
|
||||
}
|
||||
this.btAssetManager = services.resolve(BehaviorTreeAssetManager);
|
||||
}
|
||||
return this.btAssetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取行为树数据
|
||||
* Get behavior tree data from AssetManager or BehaviorTreeAssetManager
|
||||
*
|
||||
* 优先从 AssetManager 获取(新方式),如果没有再从 BehaviorTreeAssetManager 获取(兼容旧方式)
|
||||
*/
|
||||
private getTreeData(assetIdOrPath: string): BehaviorTreeData | undefined {
|
||||
// 1. 优先从 AssetManager 获取(如果已加载)
|
||||
// First try AssetManager (preferred way)
|
||||
if (this._assetManager) {
|
||||
const cachedAsset = this._assetManager.getAssetByPath<IBehaviorTreeAsset>(assetIdOrPath);
|
||||
if (cachedAsset?.data) {
|
||||
return cachedAsset.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退到 BehaviorTreeAssetManager(兼容旧方式)
|
||||
// Fallback to BehaviorTreeAssetManager (legacy support)
|
||||
return this.getBTAssetManager().getAsset(assetIdOrPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册所有执行器(包括内置和插件提供的)
|
||||
*/
|
||||
@@ -158,9 +188,14 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
||||
continue;
|
||||
}
|
||||
|
||||
const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId);
|
||||
const treeData = this.getTreeData(runtime.treeAssetId);
|
||||
if (!treeData) {
|
||||
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
|
||||
// 只警告一次,避免每帧重复输出
|
||||
// Only warn once to avoid repeated output every frame
|
||||
if (!this._warnedMissingAssets.has(runtime.treeAssetId)) {
|
||||
this._warnedMissingAssets.add(runtime.treeAssetId);
|
||||
this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetMetadata,
|
||||
IAssetLoadOptions,
|
||||
IAssetLoadResult
|
||||
IAssetParseContext,
|
||||
IAssetContent,
|
||||
AssetContentType
|
||||
} from '@esengine/asset-system';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BehaviorTreeData } from '../execution/BehaviorTreeData';
|
||||
@@ -34,60 +34,38 @@ export interface IBehaviorTreeAsset {
|
||||
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
|
||||
readonly supportedType = BehaviorTreeAssetType;
|
||||
readonly supportedExtensions = ['.btree'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* 加载行为树资产
|
||||
* Load behavior tree asset
|
||||
* 从内容解析行为树资产
|
||||
* Parse behavior tree asset from content
|
||||
*/
|
||||
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');
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IBehaviorTreeAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Behavior tree content is empty');
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const content = await fileSystem.readFile(path);
|
||||
|
||||
// 转换为运行时数据
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content);
|
||||
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
|
||||
|
||||
// 使用文件路径作为 ID
|
||||
treeData.id = path;
|
||||
const assetPath = context.metadata.path;
|
||||
treeData.id = assetPath;
|
||||
|
||||
// 注册到 BehaviorTreeAssetManager(保持兼容性)
|
||||
// 同时注册到 BehaviorTreeAssetManager
|
||||
// Also register to BehaviorTreeAssetManager for legacy code that uses it directly
|
||||
// (e.g., loadFromEditorJSON, or code that doesn't use AssetManager)
|
||||
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
|
||||
data: treeData,
|
||||
path: assetPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以加载
|
||||
* Check if can load this asset
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
return path.endsWith('.btree');
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资产
|
||||
* Dispose asset
|
||||
@@ -100,11 +78,3 @@ export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件系统接口(简化版,仅用于类型)
|
||||
*/
|
||||
interface IFileSystem {
|
||||
readFile(path: string): Promise<string>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Core, type ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type { IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import type { IEditorModuleLoader, PanelDescriptor, FileActionHandler, FileCreationTemplate } from '@esengine/editor-core';
|
||||
import { MessageHub, PanelPosition } from '@esengine/editor-core';
|
||||
|
||||
@@ -95,19 +95,23 @@ class BlueprintEditorModuleImpl implements IEditorModuleLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/blueprint',
|
||||
name: 'Blueprint',
|
||||
name: '@esengine/blueprint',
|
||||
displayName: 'Blueprint',
|
||||
version: '1.0.0',
|
||||
description: 'Visual scripting system for ECS Framework',
|
||||
category: 'scripting',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true,
|
||||
category: 'Other',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
modules: [
|
||||
{ name: 'Runtime', type: 'runtime', loadingPhase: 'default' },
|
||||
{ name: 'Editor', type: 'editor', loadingPhase: 'postDefault' }
|
||||
]
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['BlueprintComponent'],
|
||||
systems: ['BlueprintSystem'],
|
||||
other: ['NodeRegistry', 'BlueprintVM']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,7 +119,7 @@ const descriptor: PluginDescriptor = {
|
||||
* 完整的蓝图插件,包含运行时和编辑器模块
|
||||
*/
|
||||
export const BlueprintPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
editorModule: new BlueprintEditorModuleImpl()
|
||||
};
|
||||
|
||||
|
||||
43
packages/blueprint/module.json
Normal file
43
packages/blueprint/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "blueprint",
|
||||
"name": "@esengine/blueprint",
|
||||
"displayName": "Blueprint",
|
||||
"description": "Visual scripting system | 可视化脚本系统",
|
||||
"version": "1.0.0",
|
||||
"category": "AI",
|
||||
"icon": "Workflow",
|
||||
"tags": [
|
||||
"visual",
|
||||
"scripting",
|
||||
"blueprint",
|
||||
"nodes"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"BlueprintComponent"
|
||||
],
|
||||
"systems": [
|
||||
"BlueprintSystem"
|
||||
],
|
||||
"other": [
|
||||
"Blueprint",
|
||||
"BlueprintNode",
|
||||
"BlueprintGraph"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/blueprint-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "BlueprintPlugin"
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"@types/node": "^20.19.17",
|
||||
"rimraf": "^5.0.0",
|
||||
|
||||
60
packages/blueprint/src/BlueprintPlugin.ts
Normal file
60
packages/blueprint/src/BlueprintPlugin.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Blueprint Plugin for ES Engine.
|
||||
* ES引擎的蓝图插件。
|
||||
*
|
||||
* Provides visual scripting runtime support.
|
||||
* 提供可视化脚本运行时支持。
|
||||
*/
|
||||
|
||||
import type { IPlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
|
||||
|
||||
/**
|
||||
* Blueprint Runtime Module.
|
||||
* 蓝图运行时模块。
|
||||
*
|
||||
* Note: Blueprint uses a custom system (IBlueprintSystem) instead of EntitySystem,
|
||||
* so createSystems is not implemented here. Blueprint systems should be created
|
||||
* manually using createBlueprintSystem(scene).
|
||||
*/
|
||||
class BlueprintRuntimeModule implements IRuntimeModule {
|
||||
async onInitialize(): Promise<void> {
|
||||
// Blueprint system initialization
|
||||
// Blueprint uses IBlueprintSystem which is different from EntitySystem
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
// Cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manifest for Blueprint.
|
||||
* 蓝图的插件清单。
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'blueprint',
|
||||
name: '@esengine/blueprint',
|
||||
displayName: 'Blueprint',
|
||||
version: '1.0.0',
|
||||
description: '可视化脚本系统',
|
||||
category: 'AI',
|
||||
icon: 'Workflow',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core'],
|
||||
exports: {
|
||||
components: ['BlueprintComponent'],
|
||||
systems: ['BlueprintSystem']
|
||||
},
|
||||
requiresWasm: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Blueprint Plugin.
|
||||
* 蓝图插件。
|
||||
*/
|
||||
export const BlueprintPlugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new BlueprintRuntimeModule()
|
||||
};
|
||||
@@ -29,3 +29,6 @@ export {
|
||||
triggerCustomBlueprintEvent
|
||||
} from './runtime/BlueprintSystem';
|
||||
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';
|
||||
|
||||
// Plugin
|
||||
export { BlueprintPlugin } from './BlueprintPlugin';
|
||||
|
||||
38
packages/camera/module.json
Normal file
38
packages/camera/module.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "camera",
|
||||
"name": "@esengine/camera",
|
||||
"displayName": "Camera",
|
||||
"description": "Camera and viewport management | 相机和视口管理",
|
||||
"version": "1.0.0",
|
||||
"category": "Rendering",
|
||||
"icon": "Video",
|
||||
"tags": [
|
||||
"camera",
|
||||
"viewport",
|
||||
"rendering"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"math"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"CameraComponent"
|
||||
],
|
||||
"systems": [
|
||||
"CameraSystem"
|
||||
]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "CameraPlugin"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
|
||||
import { CameraComponent } from './CameraComponent';
|
||||
|
||||
class CameraRuntimeModule implements IRuntimeModule {
|
||||
@@ -8,17 +8,21 @@ class CameraRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/camera',
|
||||
name: 'Camera',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'camera',
|
||||
name: '@esengine/camera',
|
||||
displayName: 'Camera',
|
||||
version: '1.0.0',
|
||||
description: '2D/3D 相机组件',
|
||||
category: 'core',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['core', 'math'],
|
||||
exports: { components: ['CameraComponent'] }
|
||||
};
|
||||
|
||||
export const CameraPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new CameraRuntimeModule()
|
||||
};
|
||||
|
||||
@@ -26,9 +26,11 @@
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
585
packages/sprite-editor/src/SpriteComponentInspector.css
Normal file
585
packages/sprite-editor/src/SpriteComponentInspector.css
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* Sprite Component Inspector Styles.
|
||||
* 精灵组件检查器样式。
|
||||
*/
|
||||
|
||||
.sprite-component-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Material Override Section */
|
||||
.material-override-section {
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
background: var(--bg-secondary, #252526);
|
||||
}
|
||||
|
||||
.material-override-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.material-override-header:hover {
|
||||
background: var(--bg-hover, #2a2a2a);
|
||||
}
|
||||
|
||||
.material-override-expand {
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.material-override-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.material-override-count {
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-color, #0078d4);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.material-override-content {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
/* Override Items */
|
||||
.material-override-item {
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary, #1e1e1e);
|
||||
}
|
||||
|
||||
.material-override-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.material-override-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.material-override-name {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.material-override-type {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.material-override-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.material-override-remove:hover {
|
||||
background: var(--error-bg, #3a2020);
|
||||
color: var(--error-color, #f87171);
|
||||
}
|
||||
|
||||
/* Override Inputs */
|
||||
.override-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 3px;
|
||||
background: var(--input-bg, #333);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.override-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.override-input-number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Vector Inputs */
|
||||
.override-vector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.override-vector-4 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.override-vector-axis {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.override-axis-label {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.override-axis-x { background: #e06666; }
|
||||
.override-axis-y { background: #93c47d; }
|
||||
.override-axis-z { background: #6fa8dc; }
|
||||
.override-axis-w { background: #b4a7d6; }
|
||||
|
||||
.override-vector-axis .override-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Color Input */
|
||||
.override-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.override-color-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.override-color-input {
|
||||
width: 60px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.override-color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.override-color-input::-webkit-color-swatch {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.override-alpha {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Add Override */
|
||||
.material-override-add-container {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.material-override-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.material-override-add-btn:hover {
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
color: var(--accent-color, #0078d4);
|
||||
background: var(--accent-bg, rgba(0, 120, 212, 0.1));
|
||||
}
|
||||
|
||||
.material-override-add-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
background: var(--dropdown-bg, #2d2d2d);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.material-override-add-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.material-override-add-item:hover {
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
}
|
||||
|
||||
.material-override-type-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.material-override-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.override-unsupported {
|
||||
color: var(--text-tertiary, #666);
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Inline Material Editor
|
||||
============================================ */
|
||||
|
||||
.inline-material-editor {
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
background: var(--bg-secondary, #252526);
|
||||
}
|
||||
|
||||
.inline-material-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.inline-material-header:hover {
|
||||
background: var(--bg-hover, #2a2a2a);
|
||||
}
|
||||
|
||||
.inline-material-expand {
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.inline-material-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.inline-material-dirty {
|
||||
color: var(--warning-color, #fbbf24);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.inline-material-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-material-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: var(--button-bg, #333);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.inline-material-btn:hover:not(:disabled) {
|
||||
background: var(--button-hover-bg, #444);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.inline-material-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.inline-material-content {
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.inline-material-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.inline-material-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.inline-material-row label {
|
||||
flex: 0 0 80px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.inline-material-row select {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 3px;
|
||||
background: var(--input-bg, #333);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.inline-material-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
/* Shader select with browse button */
|
||||
.inline-material-shader-select {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inline-material-shader-select select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inline-material-refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 3px;
|
||||
background: var(--button-bg, #333);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.inline-material-refresh-btn:hover:not(:disabled) {
|
||||
background: var(--button-hover-bg, #444);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.inline-material-refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.inline-material-refresh-btn.loading svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.inline-material-uniforms {
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.inline-material-uniforms-header {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.inline-material-uniform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.inline-material-uniform:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.inline-material-uniform label {
|
||||
flex: 0 0 80px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #888);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Uniform Value Editor (shared)
|
||||
============================================ */
|
||||
|
||||
.uniform-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 3px;
|
||||
background: var(--input-bg, #333);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.uniform-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.uniform-input-number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.uniform-vector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.uniform-vector-4 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.uniform-vector-axis {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 45px;
|
||||
}
|
||||
|
||||
.uniform-axis-label {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uniform-axis-x { background: #e06666; }
|
||||
.uniform-axis-y { background: #93c47d; }
|
||||
.uniform-axis-z { background: #6fa8dc; }
|
||||
.uniform-axis-w { background: #b4a7d6; }
|
||||
|
||||
.uniform-vector-axis .uniform-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.uniform-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.uniform-color-preview {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uniform-color-input {
|
||||
width: 50px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.uniform-color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.uniform-color-input::-webkit-color-swatch {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.uniform-alpha {
|
||||
width: 45px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.uniform-unsupported {
|
||||
color: var(--text-tertiary, #666);
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
983
packages/sprite-editor/src/SpriteComponentInspector.tsx
Normal file
983
packages/sprite-editor/src/SpriteComponentInspector.tsx
Normal file
@@ -0,0 +1,983 @@
|
||||
/**
|
||||
* Sprite Component Inspector.
|
||||
* 精灵组件检查器。
|
||||
*
|
||||
* Provides custom inspector UI for SpriteComponent with material override support.
|
||||
* 为 SpriteComponent 提供带材质覆盖支持的自定义检查器 UI。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { IComponentInspector, ComponentInspectorContext, MessageHub, IFileSystemService, IFileSystem, ProjectService } from '@esengine/editor-core';
|
||||
import { SpriteComponent, MaterialOverrides, MaterialPropertyOverride } from '@esengine/sprite';
|
||||
import { getMaterialManager, Material, BlendMode, BuiltInShaders, UniformType } from '@esengine/material-system';
|
||||
import { ChevronDown, ChevronRight, X, Plus, Save, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import './SpriteComponentInspector.css';
|
||||
|
||||
/**
|
||||
* Blend mode options.
|
||||
* 混合模式选项。
|
||||
*/
|
||||
const BLEND_MODE_OPTIONS = [
|
||||
{ value: BlendMode.None, label: 'None (Opaque)' },
|
||||
{ value: BlendMode.Alpha, label: 'Alpha Blend' },
|
||||
{ value: BlendMode.Additive, label: 'Additive' },
|
||||
{ value: BlendMode.Multiply, label: 'Multiply' },
|
||||
{ value: BlendMode.Screen, label: 'Screen' },
|
||||
{ value: BlendMode.PremultipliedAlpha, label: 'Premultiplied Alpha' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Built-in shader options.
|
||||
* 内置着色器选项。
|
||||
*/
|
||||
const BUILT_IN_SHADER_OPTIONS = [
|
||||
{ value: BuiltInShaders.DefaultSprite, label: 'Default Sprite' },
|
||||
{ value: BuiltInShaders.Grayscale, label: 'Grayscale' },
|
||||
{ value: BuiltInShaders.Tint, label: 'Tint' },
|
||||
{ value: BuiltInShaders.Flash, label: 'Flash' },
|
||||
{ value: BuiltInShaders.Outline, label: 'Outline' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Shader option with path info.
|
||||
* 带路径信息的着色器选项。
|
||||
*/
|
||||
interface ShaderOption {
|
||||
value: number;
|
||||
label: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available shaders (built-in + custom loaded).
|
||||
* 获取所有可用着色器(内置 + 自定义加载的)。
|
||||
*/
|
||||
function getAvailableShaders(): ShaderOption[] {
|
||||
const materialManager = getMaterialManager();
|
||||
if (!materialManager) {
|
||||
return BUILT_IN_SHADER_OPTIONS;
|
||||
}
|
||||
|
||||
const shaderIds = materialManager.getShaderIds();
|
||||
const options: ShaderOption[] = [];
|
||||
|
||||
for (const id of shaderIds) {
|
||||
const shader = materialManager.getShader(id);
|
||||
if (shader) {
|
||||
// Check if it's a built-in shader.
|
||||
// 检查是否是内置着色器。
|
||||
const builtIn = BUILT_IN_SHADER_OPTIONS.find(opt => opt.value === id);
|
||||
options.push({
|
||||
value: id,
|
||||
label: builtIn ? builtIn.label : shader.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan and load all shader files from project.
|
||||
* 扫描并加载项目中所有的着色器文件。
|
||||
*/
|
||||
async function scanAndLoadProjectShaders(): Promise<ShaderOption[]> {
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const materialManager = getMaterialManager();
|
||||
|
||||
if (!fileSystem || !projectService || !materialManager) {
|
||||
return getAvailableShaders();
|
||||
}
|
||||
|
||||
const currentProject = projectService.getCurrentProject();
|
||||
if (!currentProject) {
|
||||
return getAvailableShaders();
|
||||
}
|
||||
|
||||
try {
|
||||
// Scan for .shader files in project.
|
||||
// 扫描项目中的 .shader 文件。
|
||||
const shaderFiles = await fileSystem.scanFiles(currentProject.path, '**/*.shader');
|
||||
|
||||
// Load each shader.
|
||||
// 加载每个着色器。
|
||||
for (const shaderPath of shaderFiles) {
|
||||
// Skip if already loaded.
|
||||
// 如果已加载则跳过。
|
||||
if (materialManager.hasShaderByPath(shaderPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await materialManager.loadShaderFromPath(shaderPath);
|
||||
} catch (error) {
|
||||
console.warn('[SpriteComponentInspector] Failed to load shader:', shaderPath, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[SpriteComponentInspector] Failed to scan shader files:', error);
|
||||
}
|
||||
|
||||
return getAvailableShaders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniform type display names.
|
||||
* Uniform 类型显示名称。
|
||||
*/
|
||||
const UNIFORM_TYPE_LABELS: Record<string, string> = {
|
||||
'float': 'Float',
|
||||
'vec2': 'Vec2',
|
||||
'vec3': 'Vec3',
|
||||
'vec4': 'Vec4',
|
||||
'color': 'Color',
|
||||
'int': 'Int',
|
||||
'mat3': 'Mat3',
|
||||
'mat4': 'Mat4',
|
||||
'sampler': 'Sampler',
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline material editor props.
|
||||
* 内联材质编辑器属性。
|
||||
*/
|
||||
interface InlineMaterialEditorProps {
|
||||
material: Material;
|
||||
materialPath: string;
|
||||
onMaterialChange: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline material editor component.
|
||||
* 内联材质编辑器组件。
|
||||
*
|
||||
* Allows editing material properties directly in the sprite inspector.
|
||||
* 允许直接在精灵检查器中编辑材质属性。
|
||||
*/
|
||||
function InlineMaterialEditor({ material, materialPath, onMaterialChange }: InlineMaterialEditorProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isLoadingShaders, setIsLoadingShaders] = useState(false);
|
||||
const [shaderOptions, setShaderOptions] = useState<ShaderOption[]>(() => getAvailableShaders());
|
||||
const [localMaterial, setLocalMaterial] = useState(() => ({
|
||||
name: material.name,
|
||||
shader: material.shaderId,
|
||||
blendMode: material.blendMode,
|
||||
uniforms: Object.fromEntries(material.getUniforms())
|
||||
}));
|
||||
|
||||
// Scan and load project shaders on mount.
|
||||
// 挂载时扫描并加载项目着色器。
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setIsLoadingShaders(true);
|
||||
|
||||
scanAndLoadProjectShaders().then(options => {
|
||||
if (mounted) {
|
||||
setShaderOptions(options);
|
||||
setIsLoadingShaders(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Sync with material changes.
|
||||
// 同步材质变化。
|
||||
useEffect(() => {
|
||||
setLocalMaterial({
|
||||
name: material.name,
|
||||
shader: material.shaderId,
|
||||
blendMode: material.blendMode,
|
||||
uniforms: Object.fromEntries(material.getUniforms())
|
||||
});
|
||||
setIsDirty(false);
|
||||
}, [material]);
|
||||
|
||||
const handleShaderChange = (shaderId: number) => {
|
||||
material.shaderId = shaderId;
|
||||
setLocalMaterial(prev => ({ ...prev, shader: shaderId }));
|
||||
setIsDirty(true);
|
||||
onMaterialChange();
|
||||
};
|
||||
|
||||
const handleRefreshShaders = async () => {
|
||||
// Re-scan project shaders.
|
||||
// 重新扫描项目着色器。
|
||||
setIsLoadingShaders(true);
|
||||
const options = await scanAndLoadProjectShaders();
|
||||
setShaderOptions(options);
|
||||
setIsLoadingShaders(false);
|
||||
};
|
||||
|
||||
const handleBlendModeChange = (blendMode: BlendMode) => {
|
||||
material.blendMode = blendMode;
|
||||
setLocalMaterial(prev => ({ ...prev, blendMode }));
|
||||
setIsDirty(true);
|
||||
onMaterialChange();
|
||||
};
|
||||
|
||||
const handleUniformChange = (name: string, value: number | number[]) => {
|
||||
// Get the uniform type from current material.
|
||||
// 从当前材质获取 uniform 类型。
|
||||
const currentUniform = material.getUniform(name);
|
||||
if (!currentUniform) return;
|
||||
|
||||
// Set uniform based on type.
|
||||
// 根据类型设置 uniform。
|
||||
switch (currentUniform.type) {
|
||||
case UniformType.Float:
|
||||
if (typeof value === 'number') {
|
||||
material.setFloat(name, value);
|
||||
}
|
||||
break;
|
||||
case UniformType.Int:
|
||||
if (typeof value === 'number') {
|
||||
material.setInt(name, value);
|
||||
}
|
||||
break;
|
||||
case UniformType.Vec2:
|
||||
if (Array.isArray(value) && value.length >= 2) {
|
||||
material.setVec2(name, value[0], value[1]);
|
||||
}
|
||||
break;
|
||||
case UniformType.Vec3:
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
material.setVec3(name, value[0], value[1], value[2]);
|
||||
}
|
||||
break;
|
||||
case UniformType.Vec4:
|
||||
if (Array.isArray(value) && value.length >= 4) {
|
||||
material.setVec4(name, value[0], value[1], value[2], value[3]);
|
||||
}
|
||||
break;
|
||||
case UniformType.Color:
|
||||
if (Array.isArray(value) && value.length >= 4) {
|
||||
material.setColor(name, value[0], value[1], value[2], value[3]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setLocalMaterial(prev => ({
|
||||
...prev,
|
||||
uniforms: { ...prev.uniforms, [name]: { ...prev.uniforms[name], value } }
|
||||
}));
|
||||
setIsDirty(true);
|
||||
onMaterialChange();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!materialPath) return;
|
||||
|
||||
try {
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
if (!fileSystem) {
|
||||
console.error('[InlineMaterialEditor] FileSystem service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build material data.
|
||||
// 构建材质数据。
|
||||
const materialData = {
|
||||
name: material.name,
|
||||
shader: material.shaderId,
|
||||
blendMode: material.blendMode,
|
||||
uniforms: Object.fromEntries(
|
||||
Array.from(material.getUniforms().entries()).map(([k, v]) => [k, { type: v.type, value: v.value }])
|
||||
)
|
||||
};
|
||||
|
||||
await fileSystem.writeFile(materialPath, JSON.stringify(materialData, null, 2));
|
||||
setIsDirty(false);
|
||||
|
||||
// Notify
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('material:saved', { filePath: materialPath });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[InlineMaterialEditor] Failed to save material:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInEditor = () => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub && materialPath) {
|
||||
messageHub.publish('asset:open', { filePath: materialPath, type: 'material' });
|
||||
}
|
||||
};
|
||||
|
||||
const uniforms = Array.from(material.getUniforms().entries());
|
||||
|
||||
return (
|
||||
<div className="inline-material-editor">
|
||||
<div
|
||||
className="inline-material-header"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="inline-material-expand">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="inline-material-title">
|
||||
Material: {material.name}
|
||||
{isDirty && <span className="inline-material-dirty">*</span>}
|
||||
</span>
|
||||
<div className="inline-material-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="inline-material-btn"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
title="Save Material"
|
||||
>
|
||||
<Save size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-material-btn"
|
||||
onClick={handleOpenInEditor}
|
||||
title="Open in Material Editor"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="inline-material-content">
|
||||
{/* Shader */}
|
||||
<div className="inline-material-row">
|
||||
<label>Shader</label>
|
||||
<div className="inline-material-shader-select">
|
||||
<select
|
||||
value={localMaterial.shader}
|
||||
onChange={e => handleShaderChange(Number(e.target.value))}
|
||||
disabled={isLoadingShaders}
|
||||
>
|
||||
{shaderOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={`inline-material-refresh-btn ${isLoadingShaders ? 'loading' : ''}`}
|
||||
onClick={handleRefreshShaders}
|
||||
disabled={isLoadingShaders}
|
||||
title="Refresh shader list"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blend Mode */}
|
||||
<div className="inline-material-row">
|
||||
<label>Blend Mode</label>
|
||||
<select
|
||||
value={localMaterial.blendMode}
|
||||
onChange={e => handleBlendModeChange(Number(e.target.value) as BlendMode)}
|
||||
>
|
||||
{BLEND_MODE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Uniforms */}
|
||||
{uniforms.length > 0 && (
|
||||
<div className="inline-material-uniforms">
|
||||
<div className="inline-material-uniforms-header">Uniforms</div>
|
||||
{uniforms.map(([name, uniform]) => (
|
||||
<div key={name} className="inline-material-uniform">
|
||||
<label>{name}</label>
|
||||
<UniformValueEditor
|
||||
type={uniform.type as MaterialPropertyOverride['type']}
|
||||
value={uniform.value as number | number[]}
|
||||
onChange={v => handleUniformChange(name, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniform value editor component (reused for both material and overrides).
|
||||
* Uniform 值编辑器组件(用于材质和覆盖)。
|
||||
*/
|
||||
function UniformValueEditor({ type, value, onChange }: {
|
||||
type: MaterialPropertyOverride['type'];
|
||||
value: number | number[];
|
||||
onChange: (value: number | number[]) => void;
|
||||
}) {
|
||||
switch (type) {
|
||||
case 'float':
|
||||
case 'int':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="uniform-input uniform-input-number"
|
||||
value={typeof value === 'number' ? value : 0}
|
||||
step={type === 'int' ? 1 : 0.1}
|
||||
onChange={(e) => {
|
||||
const v = type === 'int'
|
||||
? Math.floor(parseFloat(e.target.value) || 0)
|
||||
: parseFloat(e.target.value) || 0;
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vec2':
|
||||
return (
|
||||
<div className="uniform-vector">
|
||||
{['X', 'Y'].map((axis, i) => (
|
||||
<div key={axis} className="uniform-vector-axis">
|
||||
<span className={`uniform-axis-label uniform-axis-${axis.toLowerCase()}`}>{axis}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="uniform-input"
|
||||
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const arr = Array.isArray(value) ? [...value] : [0, 0];
|
||||
arr[i] = parseFloat(e.target.value) || 0;
|
||||
onChange(arr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vec3':
|
||||
return (
|
||||
<div className="uniform-vector">
|
||||
{['X', 'Y', 'Z'].map((axis, i) => (
|
||||
<div key={axis} className="uniform-vector-axis">
|
||||
<span className={`uniform-axis-label uniform-axis-${axis.toLowerCase()}`}>{axis}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="uniform-input"
|
||||
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const arr = Array.isArray(value) ? [...value] : [0, 0, 0];
|
||||
arr[i] = parseFloat(e.target.value) || 0;
|
||||
onChange(arr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vec4':
|
||||
return (
|
||||
<div className="uniform-vector uniform-vector-4">
|
||||
{['X', 'Y', 'Z', 'W'].map((axis, i) => (
|
||||
<div key={axis} className="uniform-vector-axis">
|
||||
<span className={`uniform-axis-label uniform-axis-${axis.toLowerCase()}`}>{axis}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="uniform-input"
|
||||
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const arr = Array.isArray(value) ? [...value] : [0, 0, 0, 0];
|
||||
arr[i] = parseFloat(e.target.value) || 0;
|
||||
onChange(arr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'color': {
|
||||
const colorArray = Array.isArray(value) ? value : [1, 1, 1, 1];
|
||||
const r = Math.round((colorArray[0] ?? 1) * 255);
|
||||
const g = Math.round((colorArray[1] ?? 1) * 255);
|
||||
const b = Math.round((colorArray[2] ?? 1) * 255);
|
||||
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div className="uniform-color">
|
||||
<div
|
||||
className="uniform-color-preview"
|
||||
style={{ backgroundColor: hexColor }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
className="uniform-color-input"
|
||||
value={hexColor}
|
||||
onChange={(e) => {
|
||||
const hex = e.target.value;
|
||||
const newR = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const newG = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const newB = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
onChange([newR, newG, newB, colorArray[3] ?? 1]);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="uniform-input uniform-alpha"
|
||||
value={colorArray[3] ?? 1}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
title="Alpha"
|
||||
onChange={(e) => {
|
||||
const alpha = Math.max(0, Math.min(1, parseFloat(e.target.value) || 0));
|
||||
onChange([colorArray[0] ?? 1, colorArray[1] ?? 1, colorArray[2] ?? 1, alpha]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className="uniform-unsupported">Unsupported type</span>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material override editor props.
|
||||
* 材质覆盖编辑器属性。
|
||||
*/
|
||||
interface MaterialOverrideEditorProps {
|
||||
sprite: SpriteComponent;
|
||||
material: Material | null;
|
||||
onChange: (propertyName: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Material override editor component.
|
||||
* 材质覆盖编辑器组件。
|
||||
*/
|
||||
function MaterialOverrideEditor({ sprite, material, onChange }: MaterialOverrideEditorProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
|
||||
// Get available uniforms from material.
|
||||
// 从材质获取可用的 uniforms。
|
||||
const availableUniforms = useMemo(() => {
|
||||
if (!material) return [];
|
||||
const uniforms = material.getUniforms();
|
||||
return Array.from(uniforms.entries()).map(([name, value]) => ({
|
||||
name,
|
||||
type: value.type,
|
||||
defaultValue: value.value
|
||||
}));
|
||||
}, [material]);
|
||||
|
||||
// Get current overrides.
|
||||
// 获取当前覆盖。
|
||||
const currentOverrides = sprite.materialOverrides || {};
|
||||
const overrideKeys = Object.keys(currentOverrides);
|
||||
|
||||
// Get uniforms not yet overridden.
|
||||
// 获取尚未覆盖的 uniforms。
|
||||
const unoverriddenUniforms = availableUniforms.filter(
|
||||
u => !overrideKeys.includes(u.name)
|
||||
);
|
||||
|
||||
const handleAddOverride = (uniformName: string) => {
|
||||
const uniform = availableUniforms.find(u => u.name === uniformName);
|
||||
if (!uniform) return;
|
||||
|
||||
// Convert defaultValue to appropriate type
|
||||
let value: number | number[];
|
||||
if (typeof uniform.defaultValue === 'number') {
|
||||
value = uniform.defaultValue;
|
||||
} else if (Array.isArray(uniform.defaultValue)) {
|
||||
value = uniform.defaultValue as number[];
|
||||
} else {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
const newOverride: MaterialPropertyOverride = {
|
||||
type: uniform.type as MaterialPropertyOverride['type'],
|
||||
value
|
||||
};
|
||||
|
||||
const newOverrides = { ...currentOverrides, [uniformName]: newOverride };
|
||||
onChange('materialOverrides', newOverrides);
|
||||
setShowAddMenu(false);
|
||||
};
|
||||
|
||||
const handleRemoveOverride = (uniformName: string) => {
|
||||
const newOverrides = { ...currentOverrides };
|
||||
delete newOverrides[uniformName];
|
||||
onChange('materialOverrides', newOverrides);
|
||||
};
|
||||
|
||||
const handleOverrideChange = (uniformName: string, value: number | number[]) => {
|
||||
const current = currentOverrides[uniformName];
|
||||
if (!current) return;
|
||||
|
||||
const newOverrides = {
|
||||
...currentOverrides,
|
||||
[uniformName]: { ...current, value }
|
||||
};
|
||||
onChange('materialOverrides', newOverrides);
|
||||
};
|
||||
|
||||
if (!sprite.material) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="material-override-section">
|
||||
<div
|
||||
className="material-override-header"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="material-override-expand">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="material-override-title">Material Overrides</span>
|
||||
{overrideKeys.length > 0 && (
|
||||
<span className="material-override-count">{overrideKeys.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="material-override-content">
|
||||
{/* Existing overrides */}
|
||||
{overrideKeys.map(key => {
|
||||
const override = currentOverrides[key];
|
||||
if (!override) return null;
|
||||
return (
|
||||
<div key={key} className="material-override-item">
|
||||
<div className="material-override-item-header">
|
||||
<span className="material-override-name">{key}</span>
|
||||
<span className="material-override-type">
|
||||
{UNIFORM_TYPE_LABELS[override.type] || override.type}
|
||||
</span>
|
||||
<button
|
||||
className="material-override-remove"
|
||||
onClick={() => handleRemoveOverride(key)}
|
||||
title="Remove override"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<OverrideValueEditor
|
||||
type={override.type}
|
||||
value={override.value}
|
||||
onChange={(v) => handleOverrideChange(key, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add override button */}
|
||||
{unoverriddenUniforms.length > 0 && (
|
||||
<div className="material-override-add-container">
|
||||
<button
|
||||
className="material-override-add-btn"
|
||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add Override</span>
|
||||
</button>
|
||||
{showAddMenu && (
|
||||
<div className="material-override-add-menu">
|
||||
{unoverriddenUniforms.map(u => (
|
||||
<button
|
||||
key={u.name}
|
||||
className="material-override-add-item"
|
||||
onClick={() => handleAddOverride(u.name)}
|
||||
>
|
||||
<span>{u.name}</span>
|
||||
<span className="material-override-type-hint">
|
||||
{UNIFORM_TYPE_LABELS[u.type] || u.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{overrideKeys.length === 0 && unoverriddenUniforms.length === 0 && (
|
||||
<div className="material-override-empty">
|
||||
{material ? 'No parameters available' : 'Select a material first'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override value editor props.
|
||||
* 覆盖值编辑器属性。
|
||||
*/
|
||||
interface OverrideValueEditorProps {
|
||||
type: MaterialPropertyOverride['type'];
|
||||
value: number | number[];
|
||||
onChange: (value: number | number[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override value editor component.
|
||||
* 覆盖值编辑器组件。
|
||||
*/
|
||||
function OverrideValueEditor({ type, value, onChange }: OverrideValueEditorProps) {
|
||||
switch (type) {
|
||||
case 'float':
|
||||
case 'int':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="override-input override-input-number"
|
||||
value={typeof value === 'number' ? value : 0}
|
||||
step={type === 'int' ? 1 : 0.1}
|
||||
onChange={(e) => {
|
||||
const v = type === 'int'
|
||||
? Math.floor(parseFloat(e.target.value) || 0)
|
||||
: parseFloat(e.target.value) || 0;
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vec2':
|
||||
return (
|
||||
<div className="override-vector">
|
||||
{['X', 'Y'].map((axis, i) => (
|
||||
<div key={axis} className="override-vector-axis">
|
||||
<span className={`override-axis-label override-axis-${axis.toLowerCase()}`}>{axis}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="override-input"
|
||||
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const arr = Array.isArray(value) ? [...value] : [0, 0];
|
||||
arr[i] = parseFloat(e.target.value) || 0;
|
||||
onChange(arr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vec3':
|
||||
return (
|
||||
<div className="override-vector">
|
||||
{['X', 'Y', 'Z'].map((axis, i) => (
|
||||
<div key={axis} className="override-vector-axis">
|
||||
<span className={`override-axis-label override-axis-${axis.toLowerCase()}`}>{axis}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="override-input"
|
||||
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const arr = Array.isArray(value) ? [...value] : [0, 0, 0];
|
||||
arr[i] = parseFloat(e.target.value) || 0;
|
||||
onChange(arr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'vec4':
|
||||
return (
|
||||
<div className="override-vector override-vector-4">
|
||||
{['X', 'Y', 'Z', 'W'].map((axis, i) => (
|
||||
<div key={axis} className="override-vector-axis">
|
||||
<span className={`override-axis-label override-axis-${axis.toLowerCase()}`}>{axis}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="override-input"
|
||||
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const arr = Array.isArray(value) ? [...value] : [0, 0, 0, 0];
|
||||
arr[i] = parseFloat(e.target.value) || 0;
|
||||
onChange(arr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'color': {
|
||||
const colorArray = Array.isArray(value) ? value : [1, 1, 1, 1];
|
||||
const r = Math.round((colorArray[0] ?? 1) * 255);
|
||||
const g = Math.round((colorArray[1] ?? 1) * 255);
|
||||
const b = Math.round((colorArray[2] ?? 1) * 255);
|
||||
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<div className="override-color">
|
||||
<div
|
||||
className="override-color-preview"
|
||||
style={{ backgroundColor: hexColor }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
className="override-color-input"
|
||||
value={hexColor}
|
||||
onChange={(e) => {
|
||||
const hex = e.target.value;
|
||||
const newR = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const newG = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const newB = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
onChange([newR, newG, newB, colorArray[3] ?? 1]);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="override-input override-alpha"
|
||||
value={colorArray[3] ?? 1}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
title="Alpha"
|
||||
onChange={(e) => {
|
||||
const alpha = Math.max(0, Math.min(1, parseFloat(e.target.value) || 0));
|
||||
onChange([colorArray[0] ?? 1, colorArray[1] ?? 1, colorArray[2] ?? 1, alpha]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className="override-unsupported">Unsupported type</span>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite inspector content component.
|
||||
* 精灵检查器内容组件。
|
||||
*/
|
||||
function SpriteInspectorContent({ context }: { context: ComponentInspectorContext }) {
|
||||
const sprite = context.component as SpriteComponent;
|
||||
const [material, setMaterial] = useState<Material | null>(null);
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
// Load material when sprite.material changes.
|
||||
// 当 sprite.material 变化时加载材质。
|
||||
useEffect(() => {
|
||||
if (!sprite.material) {
|
||||
setMaterial(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const materialManager = getMaterialManager();
|
||||
if (!materialManager) {
|
||||
setMaterial(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get cached material by ID.
|
||||
// 尝试通过 ID 获取缓存的材质。
|
||||
const materialId = materialManager.getMaterialIdByPath(sprite.material);
|
||||
if (materialId > 0) {
|
||||
const mat = materialManager.getMaterial(materialId);
|
||||
setMaterial(mat || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load material asynchronously.
|
||||
// 异步加载材质。
|
||||
materialManager.loadMaterialFromPath(sprite.material)
|
||||
.then(matId => {
|
||||
const mat = materialManager.getMaterial(matId);
|
||||
setMaterial(mat || null);
|
||||
})
|
||||
.catch(() => {
|
||||
setMaterial(null);
|
||||
});
|
||||
}, [sprite.material]);
|
||||
|
||||
const handleChange = useCallback((propertyName: string, value: unknown) => {
|
||||
(sprite as unknown as Record<string, unknown>)[propertyName] = value;
|
||||
context.onChange?.(propertyName, value);
|
||||
forceUpdate({});
|
||||
|
||||
// Publish scene:modified.
|
||||
// 发布 scene:modified。
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('scene:modified', {});
|
||||
}
|
||||
}, [sprite, context]);
|
||||
|
||||
const handleMaterialChange = useCallback(() => {
|
||||
forceUpdate({});
|
||||
// Publish scene:modified for material changes.
|
||||
// 发布 scene:modified 用于材质变更。
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('scene:modified', {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// No material selected
|
||||
if (!sprite.material) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sprite-component-inspector">
|
||||
{/* Inline material editor */}
|
||||
{material && (
|
||||
<InlineMaterialEditor
|
||||
material={material}
|
||||
materialPath={sprite.material}
|
||||
onMaterialChange={handleMaterialChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Material override section */}
|
||||
<MaterialOverrideEditor
|
||||
sprite={sprite}
|
||||
material={material}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite component inspector implementation.
|
||||
* 精灵组件检查器实现。
|
||||
*
|
||||
* Uses 'append' mode to show material overrides after the default PropertyInspector.
|
||||
* 使用 'append' 模式在默认 PropertyInspector 后显示材质覆盖。
|
||||
*/
|
||||
export class SpriteComponentInspector implements IComponentInspector<SpriteComponent> {
|
||||
readonly id = 'sprite-component-inspector';
|
||||
readonly name = 'Sprite Component Inspector';
|
||||
readonly priority = 100;
|
||||
readonly targetComponents = ['Sprite', 'SpriteComponent'];
|
||||
readonly renderMode = 'append' as const;
|
||||
|
||||
canHandle(component: Component): component is SpriteComponent {
|
||||
const typeName = getComponentInstanceTypeName(component);
|
||||
return typeName === 'Sprite' || typeName === 'SpriteComponent';
|
||||
}
|
||||
|
||||
render(context: ComponentInspectorContext): React.ReactElement {
|
||||
return React.createElement(SpriteInspectorContent, {
|
||||
context,
|
||||
key: `sprite-${context.version}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,27 +9,43 @@ import type { Entity, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
EntityCreationTemplate
|
||||
EntityCreationTemplate,
|
||||
IPlugin,
|
||||
ModuleManifest
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
ComponentRegistry
|
||||
ComponentRegistry,
|
||||
ComponentInspectorRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
|
||||
// Runtime imports from @esengine/sprite
|
||||
import {
|
||||
SpriteComponent,
|
||||
SpriteAnimatorComponent
|
||||
SpriteAnimatorComponent,
|
||||
SpriteRuntimeModule
|
||||
} from '@esengine/sprite';
|
||||
|
||||
// Inspector
|
||||
import { SpriteComponentInspector } from './SpriteComponentInspector';
|
||||
|
||||
// Export inspector
|
||||
export { SpriteComponentInspector } from './SpriteComponentInspector';
|
||||
|
||||
/**
|
||||
* 精灵编辑器模块
|
||||
* Sprite Editor Module
|
||||
*/
|
||||
export class SpriteEditorModule implements IEditorModuleLoader {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// 注册组件检查器 | Register component inspectors
|
||||
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
|
||||
if (componentInspectorRegistry) {
|
||||
componentInspectorRegistry.register(new SpriteComponentInspector());
|
||||
}
|
||||
|
||||
// 注册 Sprite 组件到编辑器组件注册表 | Register Sprite components to editor component registry
|
||||
const componentRegistry = services.resolve(ComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
@@ -144,4 +160,35 @@ export class SpriteEditorModule implements IEditorModuleLoader {
|
||||
|
||||
export const spriteEditorModule = new SpriteEditorModule();
|
||||
|
||||
/**
|
||||
* Sprite 插件清单
|
||||
* Sprite Plugin Manifest
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/sprite',
|
||||
name: '@esengine/sprite',
|
||||
displayName: 'Sprite',
|
||||
version: '1.0.0',
|
||||
description: 'Sprite and animation components for 2D rendering',
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['SpriteComponent', 'SpriteAnimatorComponent'],
|
||||
systems: ['SpriteRenderSystem']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的 Sprite 插件(运行时 + 编辑器)
|
||||
* Complete Sprite Plugin (runtime + editor)
|
||||
*/
|
||||
export const SpritePlugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new SpriteRuntimeModule(),
|
||||
editorModule: spriteEditorModule
|
||||
};
|
||||
|
||||
export default spriteEditorModule;
|
||||
|
||||
38
packages/sprite/module.json
Normal file
38
packages/sprite/module.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "sprite",
|
||||
"name": "@esengine/sprite",
|
||||
"displayName": "Sprite 2D",
|
||||
"description": "2D sprite rendering | 2D 精灵渲染",
|
||||
"version": "1.0.0",
|
||||
"category": "Rendering",
|
||||
"icon": "Image",
|
||||
"tags": [
|
||||
"2d",
|
||||
"sprite",
|
||||
"rendering"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"math"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"SpriteComponent"
|
||||
],
|
||||
"systems": [
|
||||
"SpriteRenderSystem"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/sprite-editor",
|
||||
"requiresWasm": true,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "SpritePlugin"
|
||||
}
|
||||
@@ -1,12 +1,33 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
import type { AssetReference } from '@esengine/asset-system';
|
||||
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* Material property override value.
|
||||
* 材质属性覆盖值。
|
||||
*
|
||||
* Used to override specific uniform parameters on a per-instance basis
|
||||
* without creating a new material instance.
|
||||
* 用于在每个实例上覆盖特定的 uniform 参数,而无需创建新的材质实例。
|
||||
*/
|
||||
export interface MaterialPropertyOverride {
|
||||
/** Uniform type. | Uniform 类型。 */
|
||||
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||
/** Uniform value. | Uniform 值。 */
|
||||
value: number | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Material property overrides map.
|
||||
* 材质属性覆盖映射。
|
||||
*/
|
||||
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
||||
|
||||
/**
|
||||
* 精灵组件 - 管理2D图像渲染
|
||||
* Sprite component - manages 2D image rendering
|
||||
*/
|
||||
@ECSComponent('Sprite')
|
||||
@Serializable({ version: 2, typeId: 'Sprite' })
|
||||
@Serializable({ version: 3, typeId: 'Sprite' })
|
||||
export class SpriteComponent extends Component {
|
||||
/** 纹理路径或资源ID | Texture path or asset ID */
|
||||
@Serialize()
|
||||
@@ -129,6 +150,55 @@ export class SpriteComponent extends Component {
|
||||
@Property({ type: 'integer', label: 'Sorting Order' })
|
||||
public sortingOrder: number = 0;
|
||||
|
||||
/**
|
||||
* 材质资产路径(共享材质)
|
||||
* Material asset path (shared material)
|
||||
*
|
||||
* Multiple sprites can reference the same material file.
|
||||
* 多个精灵可以引用同一个材质文件。
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Material', extensions: ['.mat'] })
|
||||
public material: string = '';
|
||||
|
||||
/**
|
||||
* 材质属性覆盖(实例级别)
|
||||
* Material property overrides (instance level)
|
||||
*
|
||||
* Override specific uniform parameters without creating a new material.
|
||||
* 覆盖特定的 uniform 参数,无需创建新材质。
|
||||
*/
|
||||
@Serialize()
|
||||
public materialOverrides: MaterialOverrides = {};
|
||||
|
||||
/**
|
||||
* 是否使用独立材质实例
|
||||
* Whether to use an independent material instance
|
||||
*
|
||||
* When true, a copy of the shared material is created for this sprite.
|
||||
* Changes to this material won't affect other sprites using the same source.
|
||||
* 当为 true 时,会为此精灵创建共享材质的副本。
|
||||
* 对此材质的更改不会影响使用相同源的其他精灵。
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Use Instance Material' })
|
||||
public useInstanceMaterial: boolean = false;
|
||||
|
||||
/**
|
||||
* 运行时材质ID(缓存)
|
||||
* Runtime material ID (cached)
|
||||
*
|
||||
* Cached material ID for rendering. Updated when material path changes.
|
||||
* 用于渲染的缓存材质ID。当材质路径更改时更新。
|
||||
*/
|
||||
private _materialId: number = 0;
|
||||
|
||||
/**
|
||||
* 独立材质实例(如果 useInstanceMaterial 为 true)
|
||||
* Independent material instance (if useInstanceMaterial is true)
|
||||
*/
|
||||
private _instanceMaterial: unknown = null;
|
||||
|
||||
/** 锚点X (0-1) - 别名为originX | Anchor X (0-1) - alias for originX */
|
||||
get anchorX(): number {
|
||||
return this.originX;
|
||||
@@ -229,6 +299,167 @@ export class SpriteComponent extends Component {
|
||||
return this.assetGuid || this.texture;
|
||||
}
|
||||
|
||||
// ============= Material Override Methods =============
|
||||
// ============= 材质覆盖方法 =============
|
||||
|
||||
/**
|
||||
* 获取材质ID
|
||||
* Get material ID
|
||||
*
|
||||
* # Returns | 返回
|
||||
* The cached material ID for rendering.
|
||||
* 用于渲染的缓存材质ID。
|
||||
*/
|
||||
getMaterialId(): number {
|
||||
return this._materialId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置材质ID
|
||||
* Set material ID
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `id` - Material ID from MaterialManager. | 来自 MaterialManager 的材质ID。
|
||||
*/
|
||||
setMaterialId(id: number): void {
|
||||
this._materialId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置浮点覆盖值
|
||||
* Set float override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
* * `value` - Float value. | 浮点值。
|
||||
*/
|
||||
setOverrideFloat(name: string, value: number): this {
|
||||
this.materialOverrides[name] = { type: 'float', value };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 vec2 覆盖值
|
||||
* Set vec2 override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
* * `x` - X component. | X 分量。
|
||||
* * `y` - Y component. | Y 分量。
|
||||
*/
|
||||
setOverrideVec2(name: string, x: number, y: number): this {
|
||||
this.materialOverrides[name] = { type: 'vec2', value: [x, y] };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 vec3 覆盖值
|
||||
* Set vec3 override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
* * `x` - X component. | X 分量。
|
||||
* * `y` - Y component. | Y 分量。
|
||||
* * `z` - Z component. | Z 分量。
|
||||
*/
|
||||
setOverrideVec3(name: string, x: number, y: number, z: number): this {
|
||||
this.materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 vec4 覆盖值
|
||||
* Set vec4 override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
* * `x` - X component. | X 分量。
|
||||
* * `y` - Y component. | Y 分量。
|
||||
* * `z` - Z component. | Z 分量。
|
||||
* * `w` - W component. | W 分量。
|
||||
*/
|
||||
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
|
||||
this.materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置颜色覆盖值
|
||||
* Set color override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
* * `r` - Red component (0-1). | 红色分量 (0-1)。
|
||||
* * `g` - Green component (0-1). | 绿色分量 (0-1)。
|
||||
* * `b` - Blue component (0-1). | 蓝色分量 (0-1)。
|
||||
* * `a` - Alpha component (0-1). | 透明度分量 (0-1)。
|
||||
*/
|
||||
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
|
||||
this.materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置整数覆盖值
|
||||
* Set integer override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
* * `value` - Integer value. | 整数值。
|
||||
*/
|
||||
setOverrideInt(name: string, value: number): this {
|
||||
this.materialOverrides[name] = { type: 'int', value: Math.floor(value) };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取覆盖值
|
||||
* Get override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name. | Uniform 名称。
|
||||
*
|
||||
* # Returns | 返回
|
||||
* Override value or undefined if not set.
|
||||
* 覆盖值,如果未设置则返回 undefined。
|
||||
*/
|
||||
getOverride(name: string): MaterialPropertyOverride | undefined {
|
||||
return this.materialOverrides[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除覆盖值
|
||||
* Remove override value
|
||||
*
|
||||
* # Arguments | 参数
|
||||
* * `name` - Uniform name to remove. | 要移除的 Uniform 名称。
|
||||
*/
|
||||
removeOverride(name: string): this {
|
||||
delete this.materialOverrides[name];
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有覆盖值
|
||||
* Clear all override values
|
||||
*/
|
||||
clearOverrides(): this {
|
||||
this.materialOverrides = {};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有覆盖值
|
||||
* Check if there are any overrides
|
||||
*
|
||||
* # Returns | 返回
|
||||
* True if there are any material overrides.
|
||||
* 如果有任何材质覆盖则返回 true。
|
||||
*/
|
||||
hasOverrides(): boolean {
|
||||
return Object.keys(this.materialOverrides).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件销毁时调用
|
||||
* Called when component is destroyed
|
||||
@@ -239,5 +470,8 @@ export class SpriteComponent extends Component {
|
||||
this._assetReference.release();
|
||||
this._assetReference = undefined;
|
||||
}
|
||||
// 清理材质覆盖 / Clear material overrides
|
||||
this.materialOverrides = {};
|
||||
this._instanceMaterial = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from './SpriteComponent';
|
||||
import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
||||
import { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
|
||||
|
||||
export type { SystemContext, PluginDescriptor, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader };
|
||||
export type { SystemContext, ModuleManifest, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader };
|
||||
|
||||
class SpriteRuntimeModule implements IRuntimeModule {
|
||||
registerComponents(registry: typeof ComponentRegistryType): void {
|
||||
@@ -24,18 +24,26 @@ class SpriteRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/sprite',
|
||||
name: 'Sprite Components',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'sprite',
|
||||
name: '@esengine/sprite',
|
||||
displayName: 'Sprite 2D',
|
||||
version: '1.0.0',
|
||||
description: 'Sprite and SpriteAnimator components for 2D rendering',
|
||||
category: 'rendering',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Rendering',
|
||||
icon: 'Image',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['core', 'math'],
|
||||
exports: { components: ['SpriteComponent', 'SpriteAnimatorComponent'] },
|
||||
editorPackage: '@esengine/sprite-editor',
|
||||
requiresWasm: true
|
||||
};
|
||||
|
||||
export const SpritePlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new SpriteRuntimeModule()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { SpriteComponent } from './SpriteComponent';
|
||||
export type { MaterialPropertyOverride, MaterialOverrides } from './SpriteComponent';
|
||||
export { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
||||
export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
|
||||
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
|
||||
|
||||
@@ -404,15 +404,23 @@ export const uiEditorModule = new UIEditorModule();
|
||||
|
||||
// 从 @esengine/ui 导入运行时模块
|
||||
import { UIRuntimeModule } from '@esengine/ui';
|
||||
import type { IPlugin, PluginDescriptor } from '@esengine/editor-core';
|
||||
import type { IPlugin, ModuleManifest } from '@esengine/editor-core';
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/ui',
|
||||
name: 'UI',
|
||||
name: '@esengine/ui',
|
||||
displayName: 'UI',
|
||||
version: '1.0.0',
|
||||
description: 'ECS-based UI system with editor support',
|
||||
category: 'ui',
|
||||
enabledByDefault: true
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['UITransformComponent', 'UIRenderComponent', 'UITextComponent', 'UIButtonComponent'],
|
||||
systems: ['UIRenderSystem', 'UILayoutSystem', 'UIInteractionSystem']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -420,7 +428,7 @@ const descriptor: PluginDescriptor = {
|
||||
* Complete UI Plugin (runtime + editor)
|
||||
*/
|
||||
export const UIPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new UIRuntimeModule(),
|
||||
editorModule: uiEditorModule
|
||||
};
|
||||
|
||||
43
packages/ui/module.json
Normal file
43
packages/ui/module.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "ui",
|
||||
"name": "@esengine/ui",
|
||||
"displayName": "UI System",
|
||||
"description": "User interface components and layout | 用户界面组件和布局",
|
||||
"version": "1.0.0",
|
||||
"category": "Rendering",
|
||||
"icon": "Layout",
|
||||
"tags": [
|
||||
"ui",
|
||||
"interface",
|
||||
"layout"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop",
|
||||
"mobile"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"math"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"UICanvasComponent",
|
||||
"UITextComponent",
|
||||
"UIImageComponent",
|
||||
"UIButtonComponent"
|
||||
],
|
||||
"systems": [
|
||||
"UIRenderSystem",
|
||||
"UIEventSystem"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/ui-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "UIPlugin"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
|
||||
import {
|
||||
UITransformComponent,
|
||||
@@ -95,18 +95,25 @@ class UIRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/ui',
|
||||
name: 'UI',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'ui',
|
||||
name: '@esengine/ui',
|
||||
displayName: 'UI System',
|
||||
version: '1.0.0',
|
||||
description: 'ECS-based UI system',
|
||||
category: 'ui',
|
||||
enabledByDefault: true,
|
||||
isEnginePlugin: true
|
||||
category: 'Rendering',
|
||||
icon: 'Layout',
|
||||
isCore: false,
|
||||
defaultEnabled: false,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['core', 'math'],
|
||||
exports: { components: ['UICanvasComponent'] },
|
||||
editorPackage: '@esengine/ui-editor'
|
||||
};
|
||||
|
||||
export const UIPlugin: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new UIRuntimeModule()
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user