feat(modules): 添加module.json配置

This commit is contained in:
yhh
2025-12-03 16:20:48 +08:00
parent e1d494b415
commit 37ab494e4a
26 changed files with 2356 additions and 147 deletions

View 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"
}

View File

@@ -1,5 +1,5 @@
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework'; 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'; import { AudioSourceComponent } from './AudioSourceComponent';
class AudioRuntimeModule implements IRuntimeModule { class AudioRuntimeModule implements IRuntimeModule {
@@ -8,17 +8,21 @@ class AudioRuntimeModule implements IRuntimeModule {
} }
} }
const descriptor: PluginDescriptor = { const manifest: ModuleManifest = {
id: '@esengine/audio', id: 'audio',
name: 'Audio', name: '@esengine/audio',
displayName: 'Audio',
version: '1.0.0', version: '1.0.0',
description: '音频组件', description: '音频组件',
category: 'audio', category: 'Audio',
enabledByDefault: true, isCore: false,
isEnginePlugin: true defaultEnabled: true,
isEngineModule: true,
dependencies: ['core', 'asset-system'],
exports: { components: ['AudioSourceComponent'] }
}; };
export const AudioPlugin: IPlugin = { export const AudioPlugin: IPlugin = {
descriptor, manifest,
runtimeModule: new AudioRuntimeModule() runtimeModule: new AudioRuntimeModule()
}; };

View File

@@ -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', id: '@esengine/behavior-tree',
name: 'Behavior Tree System', name: '@esengine/behavior-tree',
displayName: 'Behavior Tree System',
version: '1.0.0', version: '1.0.0',
description: 'AI 行为树系统,支持可视化编辑和运行时执行', description: 'AI 行为树系统,支持可视化编辑和运行时执行',
category: 'ai', category: 'AI',
enabledByDefault: true, icon: 'GitBranch',
isCore: false,
defaultEnabled: true,
isEngineModule: false,
canContainContent: false, canContainContent: false,
isEnginePlugin: false, dependencies: ['engine-core'],
modules: [ exports: {
{ components: ['BehaviorTreeRuntimeComponent'],
name: 'BehaviorTreeRuntime', systems: ['BehaviorTreeExecutionSystem'],
type: 'runtime', loaders: ['BehaviorTreeLoader']
loadingPhase: 'default' }
},
{
name: 'BehaviorTreeEditor',
type: 'editor',
loadingPhase: 'default'
}
],
dependencies: [
{ id: '@esengine/engine-core', version: '>=1.0.0', optional: true }
],
icon: 'GitBranch'
}; };

View File

@@ -42,8 +42,8 @@ import { useBehaviorTreeDataStore } from './stores';
import { createRootNode } from './domain/constants/RootNode'; import { createRootNode } from './domain/constants/RootNode';
import { PluginContext } from './PluginContext'; import { PluginContext } from './PluginContext';
// Import descriptor from local file // Import manifest from local file
import { descriptor } from './BehaviorTreePlugin'; import { manifest } from './BehaviorTreePlugin';
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM // 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM
// Import editor CSS styles (automatically handled and injected by vite) // 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 // Create the complete plugin with editor module
export const BehaviorTreePlugin: IPluginLoader = { export const BehaviorTreePlugin: IPluginLoader = {
descriptor, manifest,
runtimeModule: new BehaviorTreeRuntimeModule(), runtimeModule: new BehaviorTreeRuntimeModule(),
editorModule: new BehaviorTreeEditorModule(), editorModule: new BehaviorTreeEditorModule(),
}; };

View 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"
}

View File

@@ -1,6 +1,6 @@
import type { IScene, ServiceContainer } from '@esengine/ecs-framework'; import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry, Core } 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 type { AssetManager } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent'; import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
@@ -39,7 +39,10 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
this._loaderRegistered = true; 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) { if (btContext.assetManager) {
behaviorTreeSystem.setAssetManager(btContext.assetManager); behaviorTreeSystem.setAssetManager(btContext.assetManager);
@@ -54,18 +57,25 @@ class BehaviorTreeRuntimeModule implements IRuntimeModule {
} }
} }
const descriptor: PluginDescriptor = { const manifest: ModuleManifest = {
id: '@esengine/behavior-tree', id: 'behavior-tree',
name: 'Behavior Tree', name: '@esengine/behavior-tree',
displayName: 'Behavior Tree',
version: '1.0.0', version: '1.0.0',
description: 'AI behavior tree system', description: 'AI behavior tree system',
category: 'ai', category: 'AI',
enabledByDefault: false, icon: 'GitBranch',
isEnginePlugin: true isCore: false,
defaultEnabled: false,
isEngineModule: true,
canContainContent: true,
dependencies: ['core'],
exports: { components: ['BehaviorTreeComponent'] },
editorPackage: '@esengine/behavior-tree-editor'
}; };
export const BehaviorTreePlugin: IPlugin = { export const BehaviorTreePlugin: IPlugin = {
descriptor, manifest,
runtimeModule: new BehaviorTreeRuntimeModule() runtimeModule: new BehaviorTreeRuntimeModule()
}; };

View File

@@ -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 type { AssetManager } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent'; import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager'; import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
@@ -6,6 +6,7 @@ import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor';
import { BehaviorTreeData, BehaviorNodeData } from './BehaviorTreeData'; import { BehaviorTreeData, BehaviorNodeData } from './BehaviorTreeData';
import { TaskStatus } from '../Types/TaskStatus'; import { TaskStatus } from '../Types/TaskStatus';
import { NodeMetadataRegistry } from './NodeMetadata'; import { NodeMetadataRegistry } from './NodeMetadata';
import type { IBehaviorTreeAsset } from '../loaders/BehaviorTreeLoader';
import './Executors'; import './Executors';
/** /**
@@ -17,14 +18,17 @@ import './Executors';
export class BehaviorTreeExecutionSystem extends EntitySystem { export class BehaviorTreeExecutionSystem extends EntitySystem {
private btAssetManager: BehaviorTreeAssetManager | null = null; private btAssetManager: BehaviorTreeAssetManager | null = null;
private executorRegistry: NodeExecutorRegistry; private executorRegistry: NodeExecutorRegistry;
private coreInstance: typeof Core | null = null; private _services: ServiceContainer | null = null;
/** 引用 asset-system 的 AssetManager由 BehaviorTreeRuntimeModule 设置) */ /** 引用 asset-system 的 AssetManager由 BehaviorTreeRuntimeModule 设置) */
private _assetManager: AssetManager | null = null; private _assetManager: AssetManager | null = null;
constructor(coreInstance?: typeof Core) { /** 已警告过的缺失资产,避免重复警告 */
private _warnedMissingAssets: Set<string> = new Set();
constructor(services?: ServiceContainer) {
super(Matcher.empty().all(BehaviorTreeRuntimeComponent)); super(Matcher.empty().all(BehaviorTreeRuntimeComponent));
this.coreInstance = coreInstance || null; this._services = services || null;
this.executorRegistry = new NodeExecutorRegistry(); this.executorRegistry = new NodeExecutorRegistry();
this.registerBuiltInExecutors(); this.registerBuiltInExecutors();
} }
@@ -121,12 +125,38 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
private getBTAssetManager(): BehaviorTreeAssetManager { private getBTAssetManager(): BehaviorTreeAssetManager {
if (!this.btAssetManager) { if (!this.btAssetManager) {
const core = this.coreInstance || Core; // 优先使用传入的 services否则回退到全局 Core.services
this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager); // 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; 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; continue;
} }
const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId); const treeData = this.getTreeData(runtime.treeAssetId);
if (!treeData) { 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; continue;
} }

View File

@@ -7,9 +7,9 @@
import type { import type {
IAssetLoader, IAssetLoader,
IAssetMetadata, IAssetParseContext,
IAssetLoadOptions, IAssetContent,
IAssetLoadResult AssetContentType
} from '@esengine/asset-system'; } from '@esengine/asset-system';
import { Core } from '@esengine/ecs-framework'; import { Core } from '@esengine/ecs-framework';
import { BehaviorTreeData } from '../execution/BehaviorTreeData'; import { BehaviorTreeData } from '../execution/BehaviorTreeData';
@@ -34,60 +34,38 @@ export interface IBehaviorTreeAsset {
export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> { export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
readonly supportedType = BehaviorTreeAssetType; readonly supportedType = BehaviorTreeAssetType;
readonly supportedExtensions = ['.btree']; readonly supportedExtensions = ['.btree'];
readonly contentType: AssetContentType = 'text';
/** /**
* 加载行为树资产 * 从内容解析行为树资产
* Load behavior tree asset * Parse behavior tree asset from content
*/ */
async load( async parse(content: IAssetContent, context: IAssetParseContext): Promise<IBehaviorTreeAsset> {
path: string, if (!content.text) {
metadata: IAssetMetadata, throw new Error('Behavior tree content is empty');
_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); const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
// 使用文件路径作为 ID // 使用文件路径作为 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); const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager) { if (btAssetManager) {
btAssetManager.loadAsset(treeData); btAssetManager.loadAsset(treeData);
} }
const asset: IBehaviorTreeAsset = {
data: treeData,
path
};
return { return {
asset, data: treeData,
handle: 0, // 由 AssetManager 分配 path: assetPath
metadata,
loadTime: 0
}; };
} }
/**
* 检查是否可以加载
* Check if can load this asset
*/
canLoad(path: string, _metadata: IAssetMetadata): boolean {
return path.endsWith('.btree');
}
/** /**
* 释放资产 * 释放资产
* Dispose asset * Dispose asset
@@ -100,11 +78,3 @@ export class BehaviorTreeLoader implements IAssetLoader<IBehaviorTreeAsset> {
} }
} }
} }
/**
* 文件系统接口(简化版,仅用于类型)
*/
interface IFileSystem {
readFile(path: string): Promise<string>;
exists(path: string): Promise<boolean>;
}

View File

@@ -4,7 +4,7 @@
*/ */
import { Core, type ServiceContainer } from '@esengine/ecs-framework'; 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 type { IEditorModuleLoader, PanelDescriptor, FileActionHandler, FileCreationTemplate } from '@esengine/editor-core';
import { MessageHub, PanelPosition } 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', id: '@esengine/blueprint',
name: 'Blueprint', name: '@esengine/blueprint',
displayName: 'Blueprint',
version: '1.0.0', version: '1.0.0',
description: 'Visual scripting system for ECS Framework', description: 'Visual scripting system for ECS Framework',
category: 'scripting', category: 'Other',
enabledByDefault: false, isCore: false,
isEnginePlugin: true, defaultEnabled: false,
isEngineModule: true,
canContainContent: true, canContainContent: true,
modules: [ dependencies: ['engine-core'],
{ name: 'Runtime', type: 'runtime', loadingPhase: 'default' }, exports: {
{ name: 'Editor', type: 'editor', loadingPhase: 'postDefault' } components: ['BlueprintComponent'],
] systems: ['BlueprintSystem'],
other: ['NodeRegistry', 'BlueprintVM']
}
}; };
/** /**
@@ -115,7 +119,7 @@ const descriptor: PluginDescriptor = {
* 完整的蓝图插件,包含运行时和编辑器模块 * 完整的蓝图插件,包含运行时和编辑器模块
*/ */
export const BlueprintPlugin: IPlugin = { export const BlueprintPlugin: IPlugin = {
descriptor, manifest,
editorModule: new BlueprintEditorModuleImpl() editorModule: new BlueprintEditorModuleImpl()
}; };

View 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"
}

View File

@@ -31,6 +31,7 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@esengine/ecs-framework": "workspace:*", "@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/build-config": "workspace:*", "@esengine/build-config": "workspace:*",
"@types/node": "^20.19.17", "@types/node": "^20.19.17",
"rimraf": "^5.0.0", "rimraf": "^5.0.0",

View 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()
};

View File

@@ -29,3 +29,6 @@ export {
triggerCustomBlueprintEvent triggerCustomBlueprintEvent
} from './runtime/BlueprintSystem'; } from './runtime/BlueprintSystem';
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint'; export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint';
// Plugin
export { BlueprintPlugin } from './BlueprintPlugin';

View 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"
}

View File

@@ -1,5 +1,5 @@
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework'; 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'; import { CameraComponent } from './CameraComponent';
class CameraRuntimeModule implements IRuntimeModule { class CameraRuntimeModule implements IRuntimeModule {
@@ -8,17 +8,21 @@ class CameraRuntimeModule implements IRuntimeModule {
} }
} }
const descriptor: PluginDescriptor = { const manifest: ModuleManifest = {
id: '@esengine/camera', id: 'camera',
name: 'Camera', name: '@esengine/camera',
displayName: 'Camera',
version: '1.0.0', version: '1.0.0',
description: '2D/3D 相机组件', description: '2D/3D 相机组件',
category: 'core', category: 'Rendering',
enabledByDefault: true, isCore: false,
isEnginePlugin: true defaultEnabled: true,
isEngineModule: true,
dependencies: ['core', 'math'],
exports: { components: ['CameraComponent'] }
}; };
export const CameraPlugin: IPlugin = { export const CameraPlugin: IPlugin = {
descriptor, manifest,
runtimeModule: new CameraRuntimeModule() runtimeModule: new CameraRuntimeModule()
}; };

View File

@@ -26,9 +26,11 @@
"@esengine/engine-core": "workspace:*", "@esengine/engine-core": "workspace:*",
"@esengine/sprite": "workspace:*", "@esengine/sprite": "workspace:*",
"@esengine/editor-core": "workspace:*", "@esengine/editor-core": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/build-config": "workspace:*", "@esengine/build-config": "workspace:*",
"react": "^18.3.1", "react": "^18.3.1",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"lucide-react": "^0.453.0",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"tsup": "^8.0.0", "tsup": "^8.0.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"

View 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;
}

View 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}`
});
}
}

View File

@@ -9,27 +9,43 @@ import type { Entity, ServiceContainer } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework'; import { Core } from '@esengine/ecs-framework';
import type { import type {
IEditorModuleLoader, IEditorModuleLoader,
EntityCreationTemplate EntityCreationTemplate,
IPlugin,
ModuleManifest
} from '@esengine/editor-core'; } from '@esengine/editor-core';
import { import {
EntityStoreService, EntityStoreService,
MessageHub, MessageHub,
ComponentRegistry ComponentRegistry,
ComponentInspectorRegistry
} from '@esengine/editor-core'; } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core'; import { TransformComponent } from '@esengine/engine-core';
// Runtime imports from @esengine/sprite // Runtime imports from @esengine/sprite
import { import {
SpriteComponent, SpriteComponent,
SpriteAnimatorComponent SpriteAnimatorComponent,
SpriteRuntimeModule
} from '@esengine/sprite'; } from '@esengine/sprite';
// Inspector
import { SpriteComponentInspector } from './SpriteComponentInspector';
// Export inspector
export { SpriteComponentInspector } from './SpriteComponentInspector';
/** /**
* 精灵编辑器模块 * 精灵编辑器模块
* Sprite Editor Module * Sprite Editor Module
*/ */
export class SpriteEditorModule implements IEditorModuleLoader { export class SpriteEditorModule implements IEditorModuleLoader {
async install(services: ServiceContainer): Promise<void> { 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 // 注册 Sprite 组件到编辑器组件注册表 | Register Sprite components to editor component registry
const componentRegistry = services.resolve(ComponentRegistry); const componentRegistry = services.resolve(ComponentRegistry);
if (componentRegistry) { if (componentRegistry) {
@@ -144,4 +160,35 @@ export class SpriteEditorModule implements IEditorModuleLoader {
export const spriteEditorModule = new SpriteEditorModule(); 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; export default spriteEditorModule;

View 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"
}

View File

@@ -1,12 +1,33 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
import type { AssetReference } from '@esengine/asset-system'; 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图像渲染 * 精灵组件 - 管理2D图像渲染
* Sprite component - manages 2D image rendering * Sprite component - manages 2D image rendering
*/ */
@ECSComponent('Sprite') @ECSComponent('Sprite')
@Serializable({ version: 2, typeId: 'Sprite' }) @Serializable({ version: 3, typeId: 'Sprite' })
export class SpriteComponent extends Component { export class SpriteComponent extends Component {
/** 纹理路径或资源ID | Texture path or asset ID */ /** 纹理路径或资源ID | Texture path or asset ID */
@Serialize() @Serialize()
@@ -129,6 +150,55 @@ export class SpriteComponent extends Component {
@Property({ type: 'integer', label: 'Sorting Order' }) @Property({ type: 'integer', label: 'Sorting Order' })
public sortingOrder: number = 0; 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 */ /** 锚点X (0-1) - 别名为originX | Anchor X (0-1) - alias for originX */
get anchorX(): number { get anchorX(): number {
return this.originX; return this.originX;
@@ -229,6 +299,167 @@ export class SpriteComponent extends Component {
return this.assetGuid || this.texture; 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 * Called when component is destroyed
@@ -239,5 +470,8 @@ export class SpriteComponent extends Component {
this._assetReference.release(); this._assetReference.release();
this._assetReference = undefined; this._assetReference = undefined;
} }
// 清理材质覆盖 / Clear material overrides
this.materialOverrides = {};
this._instanceMaterial = null;
} }
} }

View File

@@ -1,10 +1,10 @@
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework'; 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 { SpriteComponent } from './SpriteComponent';
import { SpriteAnimatorComponent } from './SpriteAnimatorComponent'; import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
import { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem'; 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 { class SpriteRuntimeModule implements IRuntimeModule {
registerComponents(registry: typeof ComponentRegistryType): void { registerComponents(registry: typeof ComponentRegistryType): void {
@@ -24,18 +24,26 @@ class SpriteRuntimeModule implements IRuntimeModule {
} }
} }
const descriptor: PluginDescriptor = { const manifest: ModuleManifest = {
id: '@esengine/sprite', id: 'sprite',
name: 'Sprite Components', name: '@esengine/sprite',
displayName: 'Sprite 2D',
version: '1.0.0', version: '1.0.0',
description: 'Sprite and SpriteAnimator components for 2D rendering', description: 'Sprite and SpriteAnimator components for 2D rendering',
category: 'rendering', category: 'Rendering',
enabledByDefault: true, icon: 'Image',
isEnginePlugin: true 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 = { export const SpritePlugin: IPlugin = {
descriptor, manifest,
runtimeModule: new SpriteRuntimeModule() runtimeModule: new SpriteRuntimeModule()
}; };

View File

@@ -1,4 +1,5 @@
export { SpriteComponent } from './SpriteComponent'; export { SpriteComponent } from './SpriteComponent';
export type { MaterialPropertyOverride, MaterialOverrides } from './SpriteComponent';
export { SpriteAnimatorComponent } from './SpriteAnimatorComponent'; export { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent'; export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem'; export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';

View File

@@ -404,15 +404,23 @@ export const uiEditorModule = new UIEditorModule();
// 从 @esengine/ui 导入运行时模块 // 从 @esengine/ui 导入运行时模块
import { UIRuntimeModule } from '@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', id: '@esengine/ui',
name: 'UI', name: '@esengine/ui',
displayName: 'UI',
version: '1.0.0', version: '1.0.0',
description: 'ECS-based UI system with editor support', description: 'ECS-based UI system with editor support',
category: 'ui', category: 'Rendering',
enabledByDefault: true 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) * Complete UI Plugin (runtime + editor)
*/ */
export const UIPlugin: IPlugin = { export const UIPlugin: IPlugin = {
descriptor, manifest,
runtimeModule: new UIRuntimeModule(), runtimeModule: new UIRuntimeModule(),
editorModule: uiEditorModule editorModule: uiEditorModule
}; };

43
packages/ui/module.json Normal file
View 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"
}

View File

@@ -1,6 +1,6 @@
import type { IScene } from '@esengine/ecs-framework'; import type { IScene } from '@esengine/ecs-framework';
import { ComponentRegistry } 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 { import {
UITransformComponent, UITransformComponent,
@@ -95,18 +95,25 @@ class UIRuntimeModule implements IRuntimeModule {
} }
} }
const descriptor: PluginDescriptor = { const manifest: ModuleManifest = {
id: '@esengine/ui', id: 'ui',
name: 'UI', name: '@esengine/ui',
displayName: 'UI System',
version: '1.0.0', version: '1.0.0',
description: 'ECS-based UI system', description: 'ECS-based UI system',
category: 'ui', category: 'Rendering',
enabledByDefault: true, icon: 'Layout',
isEnginePlugin: true isCore: false,
defaultEnabled: false,
isEngineModule: true,
canContainContent: true,
dependencies: ['core', 'math'],
exports: { components: ['UICanvasComponent'] },
editorPackage: '@esengine/ui-editor'
}; };
export const UIPlugin: IPlugin = { export const UIPlugin: IPlugin = {
descriptor, manifest,
runtimeModule: new UIRuntimeModule() runtimeModule: new UIRuntimeModule()
}; };