Feature/tilemap editor (#237)

* feat: 添加 Tilemap 编辑器插件和组件生命周期支持

* feat(editor-core): 添加声明式插件注册 API

* feat(editor-core): 改进tiledmap结构合并tileset进tiledmapeditor

* feat: 添加 editor-runtime SDK 和插件系统改进

* fix(ci): 修复SceneResourceManager里变量未使用问题
This commit is contained in:
YHH
2025-11-25 22:23:19 +08:00
committed by GitHub
parent 551ca7805d
commit 3fb6f919f8
166 changed files with 54691 additions and 8674 deletions

352
examples/core-demos/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,352 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@esengine/ecs-framework':
specifier: file:../../packages/core
version: file:../../packages/core
devDependencies:
typescript:
specifier: ^5.0.0
version: 5.9.3
vite:
specifier: ^4.0.0
version: 4.5.14
packages:
'@esbuild/android-arm64@0.18.20':
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.18.20':
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.18.20':
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.18.20':
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.18.20':
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.18.20':
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.18.20':
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.18.20':
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.18.20':
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.18.20':
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.18.20':
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.18.20':
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.18.20':
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.18.20':
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.18.20':
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.18.20':
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.18.20':
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.18.20':
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.18.20':
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.18.20':
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.18.20':
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.18.20':
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@esengine/ecs-framework@file:../../packages/core':
resolution: {directory: ../../packages/core, type: directory}
esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
rollup@3.29.5:
resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite@4.5.14:
resolution: {integrity: sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
lightningcss: ^1.21.0
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
snapshots:
'@esbuild/android-arm64@0.18.20':
optional: true
'@esbuild/android-arm@0.18.20':
optional: true
'@esbuild/android-x64@0.18.20':
optional: true
'@esbuild/darwin-arm64@0.18.20':
optional: true
'@esbuild/darwin-x64@0.18.20':
optional: true
'@esbuild/freebsd-arm64@0.18.20':
optional: true
'@esbuild/freebsd-x64@0.18.20':
optional: true
'@esbuild/linux-arm64@0.18.20':
optional: true
'@esbuild/linux-arm@0.18.20':
optional: true
'@esbuild/linux-ia32@0.18.20':
optional: true
'@esbuild/linux-loong64@0.18.20':
optional: true
'@esbuild/linux-mips64el@0.18.20':
optional: true
'@esbuild/linux-ppc64@0.18.20':
optional: true
'@esbuild/linux-riscv64@0.18.20':
optional: true
'@esbuild/linux-s390x@0.18.20':
optional: true
'@esbuild/linux-x64@0.18.20':
optional: true
'@esbuild/netbsd-x64@0.18.20':
optional: true
'@esbuild/openbsd-x64@0.18.20':
optional: true
'@esbuild/sunos-x64@0.18.20':
optional: true
'@esbuild/win32-arm64@0.18.20':
optional: true
'@esbuild/win32-ia32@0.18.20':
optional: true
'@esbuild/win32-x64@0.18.20':
optional: true
'@esengine/ecs-framework@file:../../packages/core':
dependencies:
tslib: 2.8.1
esbuild@0.18.20:
optionalDependencies:
'@esbuild/android-arm': 0.18.20
'@esbuild/android-arm64': 0.18.20
'@esbuild/android-x64': 0.18.20
'@esbuild/darwin-arm64': 0.18.20
'@esbuild/darwin-x64': 0.18.20
'@esbuild/freebsd-arm64': 0.18.20
'@esbuild/freebsd-x64': 0.18.20
'@esbuild/linux-arm': 0.18.20
'@esbuild/linux-arm64': 0.18.20
'@esbuild/linux-ia32': 0.18.20
'@esbuild/linux-loong64': 0.18.20
'@esbuild/linux-mips64el': 0.18.20
'@esbuild/linux-ppc64': 0.18.20
'@esbuild/linux-riscv64': 0.18.20
'@esbuild/linux-s390x': 0.18.20
'@esbuild/linux-x64': 0.18.20
'@esbuild/netbsd-x64': 0.18.20
'@esbuild/openbsd-x64': 0.18.20
'@esbuild/sunos-x64': 0.18.20
'@esbuild/win32-arm64': 0.18.20
'@esbuild/win32-ia32': 0.18.20
'@esbuild/win32-x64': 0.18.20
fsevents@2.3.3:
optional: true
nanoid@3.3.11: {}
picocolors@1.1.1: {}
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@3.29.5:
optionalDependencies:
fsevents: 2.3.3
source-map-js@1.2.1: {}
tslib@2.8.1: {}
typescript@5.9.3: {}
vite@4.5.14:
dependencies:
esbuild: 0.18.20
postcss: 8.5.6
rollup: 3.29.5
optionalDependencies:
fsevents: 2.3.3

View File

@@ -86,11 +86,9 @@ export class AssetPathResolver {
// 应用自定义转换器(如果提供)
if (this.config.pathTransformer) {
path = this.config.pathTransformer(path);
// Re-validate after transformation
const postTransform = PathValidator.validate(path);
if (!postTransform.valid) {
throw new Error(`Path transformer produced invalid path: ${postTransform.reason}`);
}
// Transformer output is trusted (may be absolute path or asset:// URL)
// 转换器输出是可信的(可能是绝对路径或 asset:// URL
return path;
}
// Platform-specific resolution

View File

@@ -9,6 +9,7 @@ export * from './types/AssetTypes';
// Interfaces
export * from './interfaces/IAssetLoader';
export * from './interfaces/IAssetManager';
export * from './interfaces/IResourceComponent';
// Core
export { AssetManager } from './core/AssetManager';
@@ -30,6 +31,13 @@ export { BinaryLoader } from './loaders/BinaryLoader';
export { EngineIntegration } from './integration/EngineIntegration';
export type { IEngineBridge } from './integration/EngineIntegration';
// Services
export { SceneResourceManager } from './services/SceneResourceManager';
export type { IResourceLoader } from './services/SceneResourceManager';
// Utils
export { UVHelper } from './utils/UVHelper';
// Default instance
import { AssetManager } from './core/AssetManager';

View File

@@ -6,6 +6,7 @@
import { AssetManager } from '../core/AssetManager';
import { AssetGUID } from '../types/AssetTypes';
import { ITextureAsset } from '../interfaces/IAssetLoader';
import { globalPathResolver } from '../core/AssetPathResolver';
/**
* Engine bridge interface
@@ -63,24 +64,35 @@ export class EngineIntegration {
/**
* Load texture for component
* 为组件加载纹理
*
* 统一的路径解析入口:相对路径会被转换为 Tauri 可用的 asset:// URL
* Unified path resolution entry: relative paths will be converted to Tauri-compatible asset:// URLs
*/
async loadTextureForComponent(texturePath: string): Promise<number> {
// 检查是否已有纹理ID / Check if texture ID exists
// 检查缓存(使用原始路径作为键)
// Check cache (using original path as key)
const existingId = this._pathToTextureId.get(texturePath);
if (existingId) {
return existingId;
}
// 通过资产系统加载 / Load through asset system
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
// 使用 globalPathResolver 转换路径
// Use globalPathResolver to transform the path
const resolvedPath = globalPathResolver.resolve(texturePath);
// 通过资产系统加载(使用解析后的路径)
// Load through asset system (using resolved path)
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(resolvedPath);
const textureAsset = result.asset;
// 如果有引擎桥接上传到GPU / Upload to GPU if bridge exists
// 如果有引擎桥接上传到GPU(使用解析后的路径)
// Upload to GPU if bridge exists (using resolved path)
if (this._engineBridge && textureAsset.data) {
await this._engineBridge.loadTexture(textureAsset.textureId, texturePath);
await this._engineBridge.loadTexture(textureAsset.textureId, resolvedPath);
}
// 缓存映射 / Cache mapping
// 缓存映射(使用原始路径作为键,避免重复解析)
// Cache mapping (using original path as key to avoid re-resolving)
this._pathToTextureId.set(texturePath, textureAsset.textureId);
return textureAsset.textureId;
@@ -150,6 +162,25 @@ export class EngineIntegration {
return results;
}
/**
* 批量加载资源(通用方法,支持 IResourceLoader 接口)
* Load resources in batch (generic method for IResourceLoader interface)
*
* @param paths 资源路径数组 / Array of resource paths
* @param type 资源类型 / Resource type
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
*/
async loadResourcesBatch(paths: string[], type: 'texture' | 'audio' | 'font' | 'data'): Promise<Map<string, number>> {
// 目前只支持纹理 / Currently only supports textures
if (type === 'texture') {
return this.loadTexturesBatch(paths);
}
// 其他资源类型暂未实现 / Other resource types not yet implemented
console.warn(`[EngineIntegration] Resource type '${type}' not yet supported`);
return new Map();
}
/**
* Unload texture
* 卸载纹理

View File

@@ -0,0 +1,62 @@
/**
* 资源组件接口 - 用于依赖运行时资源的组件(纹理、音频等)
* Interface for components that depend on runtime resources (textures, audio, etc.)
*
* 实现此接口的组件可以参与 SceneResourceManager 管理的集中式资源加载
* Components implementing this interface can participate in centralized resource loading managed by SceneResourceManager
*/
/**
* 资源引用 - 包含路径和运行时 ID
* Resource reference with path and runtime ID
*/
export interface ResourceReference {
/** 资源路径(例如 "assets/sprites/player.png"/ Asset path (e.g., "assets/sprites/player.png") */
path: string;
/** 引擎分配的运行时资源 ID例如 GPU 上的纹理 ID/ Runtime resource ID assigned by engine (e.g., texture ID on GPU) */
runtimeId?: number;
/** 资源类型标识符 / Resource type identifier */
type: 'texture' | 'audio' | 'font' | 'data';
}
/**
* 资源组件接口
* Resource component interface
*
* 实现此接口的组件可以在场景启动前由 SceneResourceManager 集中加载资源
* Components implementing this interface can have their resources loaded centrally by SceneResourceManager before the scene starts
*/
export interface IResourceComponent {
/**
* 获取此组件需要的所有资源引用
* Get all resource references needed by this component
*
* 在场景加载期间调用以收集资源路径
* Called during scene loading to collect resource paths
*/
getResourceReferences(): ResourceReference[];
/**
* 设置已加载资源的运行时 ID
* Set runtime IDs for loaded resources
*
* 在 SceneResourceManager 加载资源后调用
* Called after resources are loaded by SceneResourceManager
*
* @param pathToId 资源路径到运行时 ID 的映射 / Map of resource paths to runtime IDs
*/
setResourceIds(pathToId: Map<string, number>): void;
}
/**
* 类型守卫 - 检查组件是否实现了 IResourceComponent
* Type guard to check if a component implements IResourceComponent
*/
export function isResourceComponent(component: any): component is IResourceComponent {
return (
component !== null &&
typeof component === 'object' &&
typeof component.getResourceReferences === 'function' &&
typeof component.setResourceIds === 'function'
);
}

View File

@@ -0,0 +1,155 @@
/**
* 场景资源管理器 - 集中式场景资源加载
* SceneResourceManager - Centralized resource loading for scenes
*
* 扫描场景中所有组件,收集资源引用,批量加载资源,并将运行时 ID 分配回组件
* Scans all components in a scene, collects resource references, batch-loads them, and assigns runtime IDs back to components
*/
import type { Scene } from '@esengine/ecs-framework';
import { isResourceComponent, type ResourceReference } from '../interfaces/IResourceComponent';
/**
* 资源加载器接口
* Resource loader interface
*/
export interface IResourceLoader {
/**
* 批量加载资源并返回路径到 ID 的映射
* Load a batch of resources and return path-to-ID mapping
* @param paths 资源路径数组 / Array of resource paths
* @param type 资源类型 / Resource type
* @returns 路径到运行时 ID 的映射 / Map of paths to runtime IDs
*/
loadResourcesBatch(paths: string[], type: ResourceReference['type']): Promise<Map<string, number>>;
}
export class SceneResourceManager {
private resourceLoader: IResourceLoader | null = null;
/**
* 设置资源加载器实现
* Set the resource loader implementation
*
* 应由引擎集成层调用
* This should be called by the engine integration layer
*
* @param loader 资源加载器实例 / Resource loader instance
*/
setResourceLoader(loader: IResourceLoader): void {
this.resourceLoader = loader;
}
/**
* 加载场景所需的所有资源
* Load all resources required by a scene
*
* 流程 / Process:
* 1. 扫描所有实体并从 IResourceComponent 实现中收集资源引用
* Scan all entities and collect resource references from IResourceComponent implementations
* 2. 按类型分组资源(纹理、音频等)
* Group resources by type (texture, audio, etc.)
* 3. 批量加载每种资源类型
* Batch load each resource type
* 4. 将运行时 ID 分配回组件
* Assign runtime IDs back to components
*
* @param scene 要加载资源的场景 / The scene to load resources for
* @returns 当所有资源加载完成时解析的 Promise / Promise that resolves when all resources are loaded
*/
async loadSceneResources(scene: Scene): Promise<void> {
if (!this.resourceLoader) {
console.warn('[SceneResourceManager] No resource loader set, skipping resource loading');
return;
}
// 从组件收集所有资源引用 / Collect all resource references from components
const resourceRefs = this.collectResourceReferences(scene);
if (resourceRefs.length === 0) {
return;
}
// 按资源类型分组 / Group by resource type
const resourcesByType = new Map<ResourceReference['type'], Set<string>>();
for (const ref of resourceRefs) {
if (!resourcesByType.has(ref.type)) {
resourcesByType.set(ref.type, new Set());
}
resourcesByType.get(ref.type)!.add(ref.path);
}
// 批量加载每种资源类型 / Load each resource type in batch
const allResourceIds = new Map<string, number>();
for (const [type, paths] of resourcesByType) {
const pathsArray = Array.from(paths);
try {
const resourceIds = await this.resourceLoader.loadResourcesBatch(pathsArray, type);
// 合并到总映射表 / Merge into combined map
for (const [path, id] of resourceIds) {
allResourceIds.set(path, id);
}
} catch (error) {
console.error(`[SceneResourceManager] Failed to load ${type} resources:`, error);
}
}
// 将资源 ID 分配回组件 / Assign resource IDs back to components
this.assignResourceIds(scene, allResourceIds);
}
/**
* 从场景实体收集所有资源引用
* Collect all resource references from scene entities
*/
private collectResourceReferences(scene: Scene): ResourceReference[] {
const refs: ResourceReference[] = [];
for (const entity of scene.entities.buffer) {
for (const component of entity.components) {
if (isResourceComponent(component)) {
const componentRefs = component.getResourceReferences();
refs.push(...componentRefs);
}
}
}
return refs;
}
/**
* 将已加载的资源 ID 分配回组件
* Assign loaded resource IDs back to components
*
* @param scene 场景 / Scene
* @param pathToId 路径到 ID 的映射 / Path to ID mapping
*/
private assignResourceIds(scene: Scene, pathToId: Map<string, number>): void {
for (const entity of scene.entities.buffer) {
for (const component of entity.components) {
if (isResourceComponent(component)) {
component.setResourceIds(pathToId);
}
}
}
}
/**
* 卸载场景使用的所有资源
* Unload all resources used by a scene
*
* 在场景销毁时调用
* Called when a scene is being destroyed
*
* @param scene 要卸载资源的场景 / The scene to unload resources for
*/
async unloadSceneResources(_scene: Scene): Promise<void> {
// TODO: 实现资源卸载 / Implement resource unloading
// 需要跟踪资源引用计数,仅在不再使用时卸载
// Need to track resource reference counts and only unload when no longer used
console.log('[SceneResourceManager] Scene resource unloading not yet implemented');
}
}

View File

@@ -59,6 +59,10 @@ export enum AssetType {
AnimationClip = 'animation',
/** 行为树 */
BehaviorTree = 'behaviortree',
/** 瓦片地图 */
Tilemap = 'tilemap',
/** 瓦片集 */
Tileset = 'tileset',
/** JSON数据 */
Json = 'json',
/** 文本 */

View File

@@ -0,0 +1,81 @@
/**
* UV Coordinate Helper
* UV 坐标辅助工具
*
* 引擎使用图像坐标系:
* Engine uses image coordinate system:
* - 原点 (0, 0) 在左上角 | Origin at top-left
* - V 轴向下增长 | V-axis increases downward
* - UV 格式:[u0, v0, u1, v1] 其中 v0 < v1
*/
export class UVHelper {
/**
* Calculate UV coordinates for a texture region
* 计算纹理区域的 UV 坐标
*/
static calculateUV(
imageRect: { x: number; y: number; width: number; height: number },
textureSize: { width: number; height: number }
): [number, number, number, number] {
const { x, y, width, height } = imageRect;
const { width: tw, height: th } = textureSize;
return [
x / tw, // u0
y / th, // v0
(x + width) / tw, // u1
(y + height) / th // v1
];
}
/**
* Calculate UV coordinates for a tile in a tileset
* 计算 tileset 中某个 tile 的 UV 坐标
*/
static calculateTileUV(
tileIndex: number,
tilesetInfo: {
columns: number;
tileWidth: number;
tileHeight: number;
imageWidth: number;
imageHeight: number;
margin?: number;
spacing?: number;
}
): [number, number, number, number] | null {
if (tileIndex < 0) return null;
const {
columns,
tileWidth,
tileHeight,
imageWidth,
imageHeight,
margin = 0,
spacing = 0
} = tilesetInfo;
const col = tileIndex % columns;
const row = Math.floor(tileIndex / columns);
const x = margin + col * (tileWidth + spacing);
const y = margin + row * (tileHeight + spacing);
return this.calculateUV(
{ x, y, width: tileWidth, height: tileHeight },
{ width: imageWidth, height: imageHeight }
);
}
static validateUV(uv: [number, number, number, number]): boolean {
const [u0, v0, u1, v1] = uv;
return u0 >= 0 && u0 <= 1 && u1 >= 0 && u1 <= 1 &&
v0 >= 0 && v0 <= 1 && v1 >= 0 && v1 <= 1 &&
u0 < u1 && v0 < v1;
}
static debugPrint(uv: [number, number, number, number], label?: string): void {
const prefix = label ? `[${label}] ` : '';
console.log(`${prefix}UV: [${uv.map(n => n.toFixed(4)).join(', ')}]`);
}
}

View File

@@ -25,32 +25,21 @@
"license": "MIT",
"devDependencies": {
"@esengine/behavior-tree": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-replace": "^6.0.3",
"@types/react": "^18.3.18",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"rimraf": "^6.0.1",
"rollup": "^4.28.1",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-postcss": "^4.0.2",
"typescript": "^5.8.2",
"zustand": "^5.0.2"
"typescript": "^5.8.2"
},
"peerDependencies": {
"@esengine/behavior-tree": "*",
"@esengine/ecs-framework": "*",
"@esengine/editor-core": "*",
"@tauri-apps/api": "*",
"@tauri-apps/plugin-dialog": "*",
"@tauri-apps/plugin-http": "*",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"tsyringe": "*",
"zustand": "^5.0.2"
"@esengine/editor-runtime": "*"
},
"dependencies": {
"mobx": "^6.15.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,12 @@
const resolve = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const replace = require('@rollup/plugin-replace');
const dts = require('rollup-plugin-dts').default;
const postcss = require('rollup-plugin-postcss');
const external = [
'react',
'react/jsx-runtime',
'zustand',
'zustand/middleware',
'lucide-react',
'@esengine/ecs-framework',
'@esengine/editor-core',
'@esengine/editor-runtime',
'@esengine/behavior-tree',
'tsyringe',
'@tauri-apps/api/core',
'@tauri-apps/plugin-dialog'
];
module.exports = [
@@ -28,6 +20,10 @@ module.exports = [
inlineDynamicImports: true
},
plugins: [
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production')
}),
resolve({
extensions: ['.js', '.jsx']
}),
@@ -60,7 +56,12 @@ module.exports = [
],
external: [
...external,
/\.css$/
/\.css$/,
// 排除 React 相关类型,避免 rollup-plugin-dts 解析问题
'react',
'react-dom',
/^@types\//,
/^@esengine\//
]
}
];

View File

@@ -1,25 +1,32 @@
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
import {
IEditorPlugin,
type Core,
type ServiceContainer,
type IService,
type ServiceType,
type IEditorPlugin,
EditorPluginCategory,
CompilerRegistry,
ICompilerRegistry,
InspectorRegistry,
IInspectorRegistry,
PanelPosition,
type FileCreationTemplate,
type FileActionHandler,
type PanelDescriptor
} from '@esengine/editor-core';
type PanelDescriptor,
createElement,
Icons,
createLogger,
} from '@esengine/editor-runtime';
import { BehaviorTreeService } from './services/BehaviorTreeService';
import { FileSystemService } from './services/FileSystemService';
import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler';
import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider';
import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
import { useBehaviorTreeDataStore } from './stores';
import { createElement } from 'react';
import { GitBranch } from 'lucide-react';
import { createRootNode } from './domain/constants/RootNode';
import type { IService, ServiceType } from '@esengine/ecs-framework';
import { createLogger } from '@esengine/ecs-framework';
import { PluginContext } from './PluginContext';
const { GitBranch } = Icons;
const logger = createLogger('BehaviorTreePlugin');
@@ -38,6 +45,8 @@ export class BehaviorTreePlugin implements IEditorPlugin {
async install(core: Core, services: ServiceContainer): Promise<void> {
this.services = services;
// 设置插件上下文,让内部服务可以访问服务容器
PluginContext.setServices(services);
this.registerServices(services);
this.registerCompilers(services);
this.registerInspectors(services);
@@ -53,6 +62,7 @@ export class BehaviorTreePlugin implements IEditorPlugin {
this.registeredServices.clear();
useBehaviorTreeDataStore.getState().reset();
PluginContext.clear();
this.services = undefined;
}
@@ -88,7 +98,7 @@ export class BehaviorTreePlugin implements IEditorPlugin {
private registerCompilers(services: ServiceContainer): void {
try {
const compilerRegistry = services.resolve(CompilerRegistry);
const compilerRegistry = services.resolve<CompilerRegistry>(ICompilerRegistry);
const compiler = new BehaviorTreeCompiler();
compilerRegistry.register(compiler);
logger.info('Successfully registered BehaviorTreeCompiler');
@@ -98,11 +108,15 @@ export class BehaviorTreePlugin implements IEditorPlugin {
}
private registerInspectors(services: ServiceContainer): void {
const inspectorRegistry = services.resolve(InspectorRegistry);
try {
const inspectorRegistry = services.resolve<InspectorRegistry>(IInspectorRegistry);
if (inspectorRegistry) {
const provider = new BehaviorTreeNodeInspectorProvider();
inspectorRegistry.register(provider);
}
} catch (error) {
logger.error('Failed to register inspector:', error);
}
}
private registerFileActions(services: ServiceContainer): void {

View File

@@ -0,0 +1,26 @@
import type { ServiceContainer } from '@esengine/editor-runtime';
/**
* 插件上下文
* 存储插件安装时传入的服务容器引用
*/
class PluginContextClass {
private _services: ServiceContainer | null = null;
setServices(services: ServiceContainer): void {
this._services = services;
}
getServices(): ServiceContainer {
if (!this._services) {
throw new Error('PluginContext not initialized. Make sure the plugin is properly installed.');
}
return this._services;
}
clear(): void {
this._services = null;
}
}
export const PluginContext = new PluginContextClass();

View File

@@ -1,4 +1,6 @@
import { create } from 'zustand';
import { createStore } from '@esengine/editor-runtime';
const create = createStore;
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import { Node } from '../../domain/models/Node';

View File

@@ -1,10 +1,19 @@
import React, { useState, useEffect } from 'react';
import { ICompiler, CompileResult, CompilerContext, IFileSystem } from '@esengine/editor-core';
import { File, FolderTree, FolderOpen } from 'lucide-react';
import {
React,
useState,
useEffect,
type ICompiler,
type CompileResult,
type CompilerContext,
type IFileSystem,
Icons,
createLogger,
} from '@esengine/editor-runtime';
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
import { createLogger } from '@esengine/ecs-framework';
const { File, FolderTree, FolderOpen } = Icons;
const logger = createLogger('BehaviorTreeCompiler');

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { React, useEffect, useMemo, useRef, useState, useCallback } from '@esengine/editor-runtime';
import { NodeTemplate, BlackboardValueType } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
import { useUIStore } from '../stores';

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } from 'lucide-react';
import { React, useState, Icons } from '@esengine/editor-runtime';
const { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } = Icons;
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';

View File

@@ -1,4 +1,4 @@
import React, { useRef, useCallback, forwardRef, useState, useEffect } from 'react';
import { React, useRef, useCallback, forwardRef, useState, useEffect } from '@esengine/editor-runtime';
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
import { EditorConfig } from '../../types';
import { GridBackground } from './GridBackground';

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { React, useMemo } from '@esengine/editor-runtime';
interface GridBackgroundProps {
canvasOffset: { x: number; y: number };

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { GripVertical } from 'lucide-react';
import { React, useState, useRef, useEffect, type ReactNode, Icons } from '@esengine/editor-runtime';
const { GripVertical } = Icons;
interface DraggablePanelProps {
title: string | ReactNode;

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { React, useMemo } from '@esengine/editor-runtime';
import { ConnectionRenderer } from './ConnectionRenderer';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { React, useMemo } from '@esengine/editor-runtime';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Trash2, Replace, Plus } from 'lucide-react';
import { React, Icons } from '@esengine/editor-runtime';
const { Trash2, Replace, Plus } = Icons;
interface NodeContextMenuProps {
visible: boolean;

View File

@@ -1,8 +1,10 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { React, useRef, useEffect, useState, useMemo, Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree';
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
const { Search, X, ChevronDown, ChevronRight } = Icons;
interface QuickCreateMenuProps {
visible: boolean;
position: { x: number; y: number };

View File

@@ -1,19 +1,17 @@
import React from 'react';
import {
TreePine,
Database,
AlertTriangle,
AlertCircle,
LucideIcon
} from 'lucide-react';
import { React, Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { PropertyDefinition } from '@esengine/behavior-tree';
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection';
import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
import type { NodeExecutionStatus } from '../../stores';
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard';
const { TreePine, Database, AlertTriangle, AlertCircle } = Icons;
type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeNodeProps {

View File

@@ -1,8 +1,10 @@
import React, { useMemo } from 'react';
import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { React, useMemo, Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { NodeViewData } from '../../types';
const LucideIcons = Icons;
/**
* 图标映射
*/

View File

@@ -1,16 +1,25 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Core, createLogger } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { open, save } from '@tauri-apps/plugin-dialog';
import {
React,
useState,
useCallback,
useEffect,
Core,
createLogger,
MessageHub,
open,
save,
Icons,
} from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore } from '../../stores';
import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
import { showToast } from '../../services/NotificationService';
import { FolderOpen } from 'lucide-react';
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import './BehaviorTreeEditorPanel.css';
const { FolderOpen } = Icons;
const logger = createLogger('BehaviorTreeEditorPanel');
/**

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } from 'lucide-react';
import { React, Icons } from '@esengine/editor-runtime';
const { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } = Icons;
type ExecutionMode = 'idle' | 'running' | 'paused';

View File

@@ -1,12 +1,14 @@
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
import {
import { Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
const {
List, GitBranch, Layers, Shuffle, RotateCcw,
Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings,
Database, TreePine,
LucideIcon
} from 'lucide-react';
Database, TreePine
} = Icons;
export const ICON_MAP: Record<string, LucideIcon> = {
List,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, React } from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore, useUIStore } from '../stores';
/**

View File

@@ -1,4 +1,4 @@
import { RefObject, useEffect, useRef } from 'react';
import { type RefObject, useEffect, useRef, React } from '@esengine/editor-runtime';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
interface QuickCreateMenuState {

View File

@@ -1,5 +1,4 @@
import { useRef, useCallback, useMemo, useEffect } from 'react';
import { CommandManager } from '@esengine/editor-core';
import { useRef, useCallback, useMemo, useEffect, CommandManager } from '@esengine/editor-runtime';
/**
* 撤销/重做功能 Hook

View File

@@ -1,11 +1,9 @@
import { useCallback, useMemo } from 'react';
import { CommandManager } from '@esengine/editor-core';
import { useCallback, useMemo, CommandManager, createLogger } from '@esengine/editor-runtime';
import { ConnectionType } from '../domain/models/Connection';
import { IValidator } from '../domain/interfaces/IValidator';
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
import { AddConnectionUseCase } from '../application/use-cases/AddConnectionUseCase';
import { RemoveConnectionUseCase } from '../application/use-cases/RemoveConnectionUseCase';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useConnectionOperations');

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, React } from '@esengine/editor-runtime';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
interface ContextMenuState {

View File

@@ -1,8 +1,7 @@
import { useState, RefObject } from 'react';
import { useState, type RefObject, React, createLogger } from '@esengine/editor-runtime';
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useDropHandler');

View File

@@ -1,5 +1,4 @@
import { useCallback } from 'react';
import { ask } from '@tauri-apps/plugin-dialog';
import { useCallback, React, ask } from '@esengine/editor-runtime';
import { BehaviorTreeNode } from '../stores';
interface UseEditorHandlersParams {

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useRef, useState } from '@esengine/editor-runtime';
import { BehaviorTreeExecutor } from '../utils/BehaviorTreeExecutor';
export function useEditorState() {

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useRef, createLogger } from '@esengine/editor-runtime';
import { ExecutionController, ExecutionMode } from '../application/services/ExecutionController';
import { BlackboardManager } from '../application/services/BlackboardManager';
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
import { ExecutionLog } from '../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../domain/models/Blackboard';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useExecutionController');

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect } from '@esengine/editor-runtime';
import { Connection, ROOT_NODE_ID } from '../stores';
import { useNodeOperations } from './useNodeOperations';
import { useConnectionOperations } from './useConnectionOperations';

View File

@@ -1,4 +1,4 @@
import { useRef, useCallback, RefObject } from 'react';
import { useRef, useCallback, type RefObject, React } from '@esengine/editor-runtime';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations';

View File

@@ -1,6 +1,5 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, CommandManager } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree';
import { CommandManager } from '@esengine/editor-core';
import { Position } from '../domain/value-objects/Position';
import { INodeFactory } from '../domain/interfaces/INodeFactory';
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef } from '@esengine/editor-runtime';
import { BehaviorTreeNode } from '../stores';
import { ExecutionMode } from '../application/services/ExecutionController';

View File

@@ -1,4 +1,4 @@
import { RefObject } from 'react';
import { type RefObject, React } from '@esengine/editor-runtime';
import { BehaviorTreeNode, Connection, ROOT_NODE_ID, useUIStore } from '../stores';
import { PropertyDefinition } from '@esengine/behavior-tree';
import { useConnectionOperations } from './useConnectionOperations';

View File

@@ -1,4 +1,4 @@
import { useState, RefObject } from 'react';
import { useState, type RefObject } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
import { Node } from '../domain/models/Node';

View File

@@ -3,6 +3,7 @@ import { BehaviorTreePlugin } from './BehaviorTreePlugin';
export default new BehaviorTreePlugin();
export { BehaviorTreePlugin } from './BehaviorTreePlugin';
export { PluginContext } from './PluginContext';
export { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
export * from './BehaviorTreeModule';
export * from './services/BehaviorTreeService';

View File

@@ -1,8 +1,7 @@
import { React, createLogger } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree';
import { Node as BehaviorTreeNode } from '../domain/models/Node';
import { LucideIcon } from 'lucide-react';
import React from 'react';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('IEditorExtensions');

View File

@@ -1,6 +1,14 @@
import React, { useState, useCallback } from 'react';
import { IInspectorProvider, InspectorContext, MessageHub, FieldEditorRegistry, FieldEditorContext } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import {
React,
useState,
useCallback,
type IInspectorProvider,
type InspectorContext,
MessageHub,
FieldEditorRegistry,
type FieldEditorContext,
Core,
} from '@esengine/editor-runtime';
import { Node as BehaviorTreeNode } from '../domain/models/Node';
import { PropertyDefinition } from '@esengine/behavior-tree';

View File

@@ -1,9 +1,14 @@
import { singleton } from 'tsyringe';
import { Core, IService, createLogger } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import {
singleton,
type IService,
createLogger,
MessageHub,
IMessageHub,
} from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
import type { BehaviorTree } from '../domain/models/BehaviorTree';
import { FileSystemService } from './FileSystemService';
import { PluginContext } from '../PluginContext';
const logger = createLogger('BehaviorTreeService');
@@ -15,8 +20,10 @@ export class BehaviorTreeService implements IService {
async loadFromFile(filePath: string): Promise<void> {
try {
const services = PluginContext.getServices();
// 运行时解析 FileSystemService
const fileSystem = Core.services.resolve(FileSystemService);
const fileSystem = services.resolve(FileSystemService);
if (!fileSystem) {
throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.');
}
@@ -29,7 +36,7 @@ export class BehaviorTreeService implements IService {
// 在 store 中保存文件信息Panel 挂载时读取
store.setCurrentFile(filePath, fileName);
const messageHub = Core.services.resolve(MessageHub);
const messageHub = services.resolve<MessageHub>(IMessageHub);
if (messageHub) {
messageHub.publish('dynamic-panel:open', {
panelId: 'behavior-tree-editor',
@@ -50,8 +57,10 @@ export class BehaviorTreeService implements IService {
async saveToFile(filePath: string, metadata?: { name: string; description: string }): Promise<void> {
try {
const services = PluginContext.getServices();
// 运行时解析 FileSystemService
const fileSystem = Core.services.resolve(FileSystemService);
const fileSystem = services.resolve(FileSystemService);
if (!fileSystem) {
throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.');
}

View File

@@ -1,6 +1,4 @@
import { singleton } from 'tsyringe';
import { invoke } from '@tauri-apps/api/core';
import { IService } from '@esengine/ecs-framework';
import { singleton, invoke, type IService } from '@esengine/editor-runtime';
/**
* 文件系统服务

View File

@@ -1,5 +1,4 @@
import { Core, createLogger } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { Core, createLogger, MessageHub } from '@esengine/editor-runtime';
const logger = createLogger('NotificationService');

View File

@@ -1,4 +1,6 @@
import { create } from 'zustand';
import { createStore } from '@esengine/editor-runtime';
const create = createStore;
/**
* 节点执行统计信息

View File

@@ -1,4 +1,6 @@
import { create } from 'zustand';
import { createStore } from '@esengine/editor-runtime';
const create = createStore;
/**
* UI 状态 Store

View File

@@ -1,6 +1,5 @@
import { RefObject } from 'react';
import { type RefObject, createLogger } from '@esengine/editor-runtime';
import { Node as BehaviorTreeNode } from '../domain/models/Node';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('portUtils');

View File

@@ -29,6 +29,7 @@
"build:ts": "tsc",
"prebuild": "npm run clean",
"build": "npm run build:ts",
"build:esm": "vite build",
"build:watch": "tsc --watch",
"rebuild": "npm run clean && npm run build",
"build:npm": "npm run build && node build-rollup.cjs",
@@ -56,7 +57,8 @@
"rollup": "^4.42.0",
"rollup-plugin-dts": "^6.2.1",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vite": "^6.0.7"
},
"dependencies": {
"tslib": "^2.8.1"

4312
packages/behavior-tree/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
export { BehaviorTreeData, BehaviorNodeData, NodeRuntimeState, createDefaultRuntimeState } from './BehaviorTreeData';
export type { BehaviorTreeData, BehaviorNodeData, NodeRuntimeState } from './BehaviorTreeData';
export { createDefaultRuntimeState } from './BehaviorTreeData';
export { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
export { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager';
export { INodeExecutor, NodeExecutionContext, NodeExecutorRegistry, BindingHelper } from './NodeExecutor';
export type { INodeExecutor, NodeExecutionContext } from './NodeExecutor';
export { NodeExecutorRegistry, BindingHelper } from './NodeExecutor';
export { BehaviorTreeExecutionSystem } from './BehaviorTreeExecutionSystem';
export { NodeMetadata, ConfigFieldDefinition, NodeMetadataRegistry, NodeExecutorMetadata } from './NodeMetadata';
export type { NodeMetadata, ConfigFieldDefinition, NodeExecutorMetadata } from './NodeMetadata';
export { NodeMetadataRegistry } from './NodeMetadata';
export * from './Executors';

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: () => 'behavior-tree.js'
},
rollupOptions: {
output: {
exports: 'named',
inlineDynamicImports: true
}
},
outDir: 'dist',
target: 'es2020',
minify: false,
sourcemap: true
}
});

5550
packages/core/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,13 @@ export interface IService {
*/
export type ServiceType<T extends IService> = new (...args: any[]) => T;
/**
* 服务标识符
*
* 支持类构造函数或 Symbol 作为服务标识符
*/
export type ServiceIdentifier<T extends IService = IService> = ServiceType<T> | symbol;
/**
* 服务生命周期
*/
@@ -43,9 +50,14 @@ export enum ServiceLifetime {
*/
interface ServiceRegistration<T extends IService> {
/**
* 服务类型
* 服务标识符
*/
type: ServiceType<T>;
identifier: ServiceIdentifier<T>;
/**
* 服务类型(用于构造实例)
*/
type?: ServiceType<T>;
/**
* 服务实例(单例模式)
@@ -96,12 +108,12 @@ export class ServiceContainer {
/**
* 服务注册表
*/
private _services: Map<ServiceType<IService>, ServiceRegistration<IService>> = new Map();
private _services: Map<ServiceIdentifier, ServiceRegistration<IService>> = new Map();
/**
* 正在解析的服务栈(用于循环依赖检测)
*/
private _resolving: Set<ServiceType<IService>> = new Set();
private _resolving: Set<ServiceIdentifier> = new Set();
/**
* 可更新的服务列表
@@ -132,12 +144,13 @@ export class ServiceContainer {
type: ServiceType<T>,
factory?: (container: ServiceContainer) => T
): void {
if (this._services.has(type as ServiceType<IService>)) {
if (this._services.has(type as ServiceIdentifier)) {
logger.warn(`Service ${type.name} is already registered`);
return;
}
this._services.set(type as ServiceType<IService>, {
this._services.set(type as ServiceIdentifier, {
identifier: type as ServiceIdentifier,
type: type as ServiceType<IService>,
...(factory && { factory: factory as (container: ServiceContainer) => IService }),
lifetime: ServiceLifetime.Singleton
@@ -164,12 +177,13 @@ export class ServiceContainer {
type: ServiceType<T>,
factory?: (container: ServiceContainer) => T
): void {
if (this._services.has(type as ServiceType<IService>)) {
if (this._services.has(type as ServiceIdentifier)) {
logger.warn(`Service ${type.name} is already registered`);
return;
}
this._services.set(type as ServiceType<IService>, {
this._services.set(type as ServiceIdentifier, {
identifier: type as ServiceIdentifier,
type: type as ServiceType<IService>,
...(factory && { factory: factory as (container: ServiceContainer) => IService }),
lifetime: ServiceLifetime.Transient
@@ -183,65 +197,77 @@ export class ServiceContainer {
*
* 直接注册已创建的实例,自动视为单例。
*
* @param type - 服务类型(构造函数,仅用作标识
* @param identifier - 服务标识符(构造函数或 Symbol
* @param instance - 服务实例
*
* @example
* ```typescript
* const config = new Config();
* container.registerInstance(Config, config);
*
* // 使用 Symbol 作为标识符(适用于接口)
* const IFileSystem = Symbol('IFileSystem');
* container.registerInstance(IFileSystem, new TauriFileSystem());
* ```
*/
public registerInstance<T extends IService>(type: ServiceType<T>, instance: T): void {
if (this._services.has(type as ServiceType<IService>)) {
logger.warn(`Service ${type.name} is already registered`);
public registerInstance<T extends IService>(identifier: ServiceIdentifier<T>, instance: T): void {
if (this._services.has(identifier)) {
const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
logger.warn(`Service ${name} is already registered`);
return;
}
this._services.set(type as ServiceType<IService>, {
type: type as ServiceType<IService>,
this._services.set(identifier, {
identifier,
instance: instance as IService,
lifetime: ServiceLifetime.Singleton
});
// 如果使用了@Updatable装饰器添加到可更新列表
if (checkUpdatable(type)) {
const metadata = getUpdatableMetadata(type);
if (typeof identifier !== 'symbol' && checkUpdatable(identifier)) {
const metadata = getUpdatableMetadata(identifier);
const priority = metadata?.priority ?? 0;
this._updatableServices.push({ instance, priority });
// 按优先级排序(数值越小越先执行)
this._updatableServices.sort((a, b) => a.priority - b.priority);
logger.debug(`Service ${type.name} is updatable (priority: ${priority}), added to update list`);
logger.debug(`Service ${identifier.name} is updatable (priority: ${priority}), added to update list`);
}
logger.debug(`Registered service instance: ${type.name}`);
const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
logger.debug(`Registered service instance: ${name}`);
}
/**
* 解析服务
*
* @param type - 服务类型
* @param identifier - 服务标识符(构造函数或 Symbol
* @returns 服务实例
* @throws 如果服务未注册或存在循环依赖
*
* @example
* ```typescript
* const timer = container.resolve(TimerManager);
*
* // 使用 Symbol
* const fileSystem = container.resolve(IFileSystem);
* ```
*/
public resolve<T extends IService>(type: ServiceType<T>): T {
const registration = this._services.get(type as ServiceType<IService>);
public resolve<T extends IService>(identifier: ServiceIdentifier<T>): T {
const registration = this._services.get(identifier);
const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
if (!registration) {
throw new Error(`Service ${type.name} is not registered`);
throw new Error(`Service ${name} is not registered`);
}
// 检测循环依赖
if (this._resolving.has(type as ServiceType<IService>)) {
const chain = Array.from(this._resolving).map((t) => t.name).join(' -> ');
throw new Error(`Circular dependency detected: ${chain} -> ${type.name}`);
if (this._resolving.has(identifier)) {
const chain = Array.from(this._resolving).map((t) =>
typeof t === 'symbol' ? t.description : t.name
).join(' -> ');
throw new Error(`Circular dependency detected: ${chain} -> ${name}`);
}
// 如果是单例且已经有实例,直接返回
@@ -250,7 +276,7 @@ export class ServiceContainer {
}
// 添加到解析栈
this._resolving.add(type as ServiceType<IService>);
this._resolving.add(identifier);
try {
// 创建实例
@@ -259,9 +285,11 @@ export class ServiceContainer {
if (registration.factory) {
// 使用工厂函数
instance = registration.factory(this);
} else {
} else if (registration.type) {
// 直接构造
instance = new (registration.type)();
} else {
throw new Error(`Service ${name} has no factory or type to construct`);
}
// 如果是单例,缓存实例
@@ -269,7 +297,7 @@ export class ServiceContainer {
registration.instance = instance;
// 如果使用了@Updatable装饰器添加到可更新列表
if (checkUpdatable(registration.type)) {
if (registration.type && checkUpdatable(registration.type)) {
const metadata = getUpdatableMetadata(registration.type);
const priority = metadata?.priority ?? 0;
this._updatableServices.push({ instance, priority });
@@ -277,14 +305,14 @@ export class ServiceContainer {
// 按优先级排序(数值越小越先执行)
this._updatableServices.sort((a, b) => a.priority - b.priority);
logger.debug(`Service ${type.name} is updatable (priority: ${priority}), added to update list`);
logger.debug(`Service ${name} is updatable (priority: ${priority}), added to update list`);
}
}
return instance as T;
} finally {
// 从解析栈移除
this._resolving.delete(type as ServiceType<IService>);
this._resolving.delete(identifier);
}
}
@@ -293,7 +321,7 @@ export class ServiceContainer {
*
* 如果服务未注册返回null而不是抛出异常。
*
* @param type - 服务类型
* @param identifier - 服务标识符(构造函数或 Symbol
* @returns 服务实例或null
*
* @example
@@ -304,9 +332,9 @@ export class ServiceContainer {
* }
* ```
*/
public tryResolve<T extends IService>(type: ServiceType<T>): T | null {
public tryResolve<T extends IService>(identifier: ServiceIdentifier<T>): T | null {
try {
return this.resolve(type);
return this.resolve(identifier);
} catch {
return null;
}
@@ -315,21 +343,21 @@ export class ServiceContainer {
/**
* 检查服务是否已注册
*
* @param type - 服务类型
* @param identifier - 服务标识符(构造函数或 Symbol
* @returns 是否已注册
*/
public isRegistered<T extends IService>(type: ServiceType<T>): boolean {
return this._services.has(type as ServiceType<IService>);
public isRegistered<T extends IService>(identifier: ServiceIdentifier<T>): boolean {
return this._services.has(identifier);
}
/**
* 注销服务
*
* @param type - 服务类型
* @param identifier - 服务标识符(构造函数或 Symbol
* @returns 是否成功注销
*/
public unregister<T extends IService>(type: ServiceType<T>): boolean {
const registration = this._services.get(type as ServiceType<IService>);
public unregister<T extends IService>(identifier: ServiceIdentifier<T>): boolean {
const registration = this._services.get(identifier);
if (!registration) {
return false;
}
@@ -345,8 +373,9 @@ export class ServiceContainer {
registration.instance.dispose();
}
this._services.delete(type as ServiceType<IService>);
logger.debug(`Unregistered service: ${type.name}`);
this._services.delete(identifier);
const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
logger.debug(`Unregistered service: ${name}`);
return true;
}
@@ -367,11 +396,11 @@ export class ServiceContainer {
}
/**
* 获取所有已注册的服务类型
* 获取所有已注册的服务标识符
*
* @returns 服务类型数组
* @returns 服务标识符数组
*/
public getRegisteredServices(): ServiceType<IService>[] {
public getRegisteredServices(): ServiceIdentifier[] {
return Array.from(this._services.keys());
}

View File

@@ -84,4 +84,31 @@ export abstract class Component implements IComponent {
* 虽然保留此方法,但建议将复杂的清理逻辑放在 System 中处理。
*/
public onRemovedFromEntity(): void {}
/**
* 组件反序列化后的回调
*
* 当组件从场景文件加载或快照恢复后调用,可以在此方法中恢复运行时数据。
*
* @remarks
* 这是一个生命周期钩子,用于恢复无法序列化的运行时数据。
* 例如:从图片路径重新加载图片尺寸信息,重建缓存等。
*
* @example
* ```typescript
* class TilemapComponent extends Component {
* public tilesetImage: string = '';
* private _tilesetData: TilesetData | undefined;
*
* public async onDeserialized(): Promise<void> {
* if (this.tilesetImage) {
* // 重新加载 tileset 图片并恢复运行时数据
* const img = await loadImage(this.tilesetImage);
* this.setTilesetInfo(img.width, img.height, ...);
* }
* }
* }
* ```
*/
public onDeserialized(): void | Promise<void> {}
}

View File

@@ -281,6 +281,40 @@ export class SceneSerializer {
if (serializedScene.sceneData) {
this.deserializeSceneData(serializedScene.sceneData, scene.sceneData);
}
// 调用所有组件的 onDeserialized 生命周期方法
// Call onDeserialized lifecycle method on all components
const deserializedPromises: Promise<void>[] = [];
for (const entity of entities) {
this.callOnDeserializedRecursively(entity, deserializedPromises);
}
// 如果有异步的 onDeserialized在后台执行
if (deserializedPromises.length > 0) {
Promise.all(deserializedPromises).catch(error => {
console.error('Error in onDeserialized:', error);
});
}
}
/**
* 递归调用实体及其子实体所有组件的 onDeserialized 方法
*/
private static callOnDeserializedRecursively(entity: Entity, promises: Promise<void>[]): void {
for (const component of entity.components) {
try {
const result = component.onDeserialized();
if (result instanceof Promise) {
promises.push(result);
}
} catch (error) {
console.error(`Error calling onDeserialized on component ${component.constructor.name}:`, error);
}
}
for (const child of entity.children) {
this.callOnDeserializedRecursively(child, promises);
}
}
/**

View File

@@ -24,6 +24,8 @@ export interface IComponent {
onAddedToEntity(): void;
/** 组件从实体移除时的回调 */
onRemovedFromEntity(): void;
/** 组件反序列化后的回调 */
onDeserialized(): void | Promise<void>;
}
/**

View File

@@ -6,7 +6,7 @@
// 核心模块
export { Core } from './Core';
export { ServiceContainer, ServiceLifetime } from './Core/ServiceContainer';
export type { IService, ServiceType } from './Core/ServiceContainer';
export type { IService, ServiceType, ServiceIdentifier } from './Core/ServiceContainer';
// 插件系统
export { PluginManager } from './Core/PluginManager';

304
packages/ecs-engine-bindgen/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,304 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@esengine/ecs-framework':
specifier: workspace:*
version: link:../core
es-engine:
specifier: workspace:*
version: link:../engine/pkg
devDependencies:
rimraf:
specifier: ^5.0.0
version: 5.0.10
typescript:
specifier: ^5.8.0
version: 5.9.3
packages:
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
rimraf@5.0.10:
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
snapshots:
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@pkgjs/parseargs@0.11.0':
optional: true
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
balanced-match@1.0.2: {}
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
eastasianwidth@0.2.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
is-fullwidth-code-point@3.0.0: {}
isexe@2.0.0: {}
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
lru-cache@10.4.3: {}
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
minipass@7.1.2: {}
package-json-from-dist@1.0.1: {}
path-key@3.1.1: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
rimraf@5.0.10:
dependencies:
glob: 10.5.0
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@4.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
typescript@5.9.3: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2

View File

@@ -8,6 +8,6 @@
export { EngineBridge, EngineBridgeConfig } from './core/EngineBridge';
export { RenderBatcher } from './core/RenderBatcher';
export { SpriteRenderHelper, ITransformComponent } from './core/SpriteRenderHelper';
export { EngineRenderSystem, type TransformComponentType } from './systems/EngineRenderSystem';
export { EngineRenderSystem, type TransformComponentType, type IRenderDataProvider, type GizmoDataProviderFn, type HasGizmoProviderFn } from './systems/EngineRenderSystem';
export { CameraSystem } from './systems/CameraSystem';
export * from './types';

View File

@@ -10,6 +10,87 @@ import { RenderBatcher } from '../core/RenderBatcher';
import type { SpriteRenderData } from '../types';
import type { ITransformComponent } from '../core/SpriteRenderHelper';
/**
* Render data from a provider
* 提供者的渲染数据
*/
export interface ProviderRenderData {
transforms: Float32Array;
textureIds: Uint32Array;
uvs: Float32Array;
colors: Uint32Array;
tileCount: number;
/** Sorting order for render ordering | 渲染排序顺序 */
sortingOrder: number;
/** Texture path for loading (optional, used if textureId is 0) */
texturePath?: string;
}
/**
* Interface for additional render data providers (e.g., tilemap)
* 额外渲染数据提供者接口(如瓦片地图)
*/
export interface IRenderDataProvider {
getRenderData(): readonly ProviderRenderData[];
}
/**
* Internal gizmo color interface (duck-typed, compatible with editor-core GizmoColor)
* 内部 gizmo 颜色接口(鸭子类型,与 editor-core GizmoColor 兼容)
* @internal
*/
interface GizmoColorInternal {
r: number;
g: number;
b: number;
a: number;
}
/**
* Internal gizmo render data type (duck-typed, compatible with editor-core types)
* 内部 gizmo 渲染数据类型(鸭子类型,与 editor-core 类型兼容)
* @internal
*/
interface GizmoRenderDataInternal {
type: 'rect' | 'circle' | 'line' | 'grid';
color: GizmoColorInternal;
// Rect specific
x?: number;
y?: number;
width?: number;
height?: number;
rotation?: number;
originX?: number;
originY?: number;
showHandles?: boolean;
// Circle specific
radius?: number;
// Line specific
points?: Array<{ x: number; y: number }>;
closed?: boolean;
// Grid specific
cols?: number;
rows?: number;
}
/**
* Function type for getting gizmo data from a component.
* Used to inject GizmoRegistry functionality from editor layer.
* 从组件获取 gizmo 数据的函数类型。
* 用于从编辑器层注入 GizmoRegistry 功能。
*/
export type GizmoDataProviderFn = (
component: Component,
entity: Entity,
isSelected: boolean
) => GizmoRenderDataInternal[];
/**
* Function type for checking if a component has gizmo provider.
* 检查组件是否有 gizmo 提供者的函数类型。
*/
export type HasGizmoProviderFn = (component: Component) => boolean;
/**
* Type for transform component constructor.
* 变换组件构造函数类型。
@@ -55,6 +136,15 @@ export class EngineRenderSystem extends EntitySystem {
// 可重用的映射以避免每帧分配
private entityRenderMap: Map<number, SpriteRenderData> = new Map();
// Additional render data providers (e.g., tilemap)
// 额外的渲染数据提供者(如瓦片地图)
private renderDataProviders: IRenderDataProvider[] = [];
// Gizmo registry functions (injected from editor layer)
// Gizmo 注册表函数(从编辑器层注入)
private gizmoDataProvider: GizmoDataProviderFn | null = null;
private hasGizmoProvider: HasGizmoProviderFn | null = null;
/**
* Create a new engine render system.
* 创建新的引擎渲染系统。
@@ -86,7 +176,6 @@ export class EngineRenderSystem extends EntitySystem {
* 处理实体之前调用。
*/
protected override onBegin(): void {
// Clear the batch | 清空批处理
this.batcher.clear();
@@ -108,6 +197,12 @@ export class EngineRenderSystem extends EntitySystem {
// 清空并重用映射用于绘制gizmo
this.entityRenderMap.clear();
// Collect all render items with sorting order
// 收集所有渲染项及其排序顺序
const renderItems: Array<{ sortingOrder: number; sprites: SpriteRenderData[] }> = [];
// Collect sprites from entities
// 收集实体的 sprites
for (const entity of entities) {
const sprite = entity.getComponent(SpriteComponent);
const transform = entity.getComponent(this.transformType) as unknown as ITransformComponent | null;
@@ -159,35 +254,76 @@ export class EngineRenderSystem extends EntitySystem {
color
};
this.batcher.addSprite(renderData);
renderItems.push({ sortingOrder: sprite.sortingOrder, sprites: [renderData] });
this.entityRenderMap.set(entity.id, renderData);
}
// Submit batch and render at the end of process | 在process结束时提交批处理并渲染
// Collect render data from providers (e.g., tilemap)
// 收集来自提供者的渲染数据(如瓦片地图)
for (const provider of this.renderDataProviders) {
const renderDataList = provider.getRenderData();
for (const data of renderDataList) {
// Get texture ID - load from path if needed
let textureId = data.textureIds[0] || 0;
if (textureId === 0 && data.texturePath) {
textureId = this.bridge.getOrLoadTextureByPath(data.texturePath);
}
// Convert tilemap render data to sprites
const tilemapSprites: SpriteRenderData[] = [];
for (let i = 0; i < data.tileCount; i++) {
const tOffset = i * 7;
const uvOffset = i * 4;
const renderData: SpriteRenderData = {
x: data.transforms[tOffset],
y: data.transforms[tOffset + 1],
rotation: data.transforms[tOffset + 2],
scaleX: data.transforms[tOffset + 3],
scaleY: data.transforms[tOffset + 4],
originX: data.transforms[tOffset + 5],
originY: data.transforms[tOffset + 6],
textureId,
uv: [data.uvs[uvOffset], data.uvs[uvOffset + 1], data.uvs[uvOffset + 2], data.uvs[uvOffset + 3]],
color: data.colors[i]
};
tilemapSprites.push(renderData);
}
if (tilemapSprites.length > 0) {
renderItems.push({ sortingOrder: data.sortingOrder, sprites: tilemapSprites });
}
}
}
// Sort by sortingOrder (lower values render first, appear behind)
// 按 sortingOrder 排序(值越小越先渲染,显示在后面)
renderItems.sort((a, b) => a.sortingOrder - b.sortingOrder);
// Submit all sprites in sorted order
// 按排序顺序提交所有 sprites
for (const item of renderItems) {
for (const sprite of item.sprites) {
this.batcher.addSprite(sprite);
}
}
if (!this.batcher.isEmpty) {
const sprites = this.batcher.getSprites();
this.bridge.submitSprites(sprites);
}
// Draw gizmos for all entities with IGizmoProvider components
// 为所有具有 IGizmoProvider 组件的实体绘制 Gizmo
if (this.showGizmos) {
this.drawComponentGizmos();
}
// Draw gizmos for selected entities (always, even if no sprites)
// 为选中的实体绘制Gizmo始终绘制即使没有精灵
if (this.showGizmos && this.selectedEntityIds.size > 0) {
for (const entityId of this.selectedEntityIds) {
const renderData = this.entityRenderMap.get(entityId);
if (renderData) {
this.bridge.addGizmoRect(
renderData.x,
renderData.y,
renderData.scaleX,
renderData.scaleY,
renderData.rotation,
renderData.originX,
renderData.originY,
0.0, 1.0, 0.5, 1.0, // Green color | 绿色
true // Show transform handles for selection gizmo
);
}
}
this.drawSelectedEntityGizmos();
}
// Draw camera frustum gizmos
@@ -199,6 +335,296 @@ export class EngineRenderSystem extends EntitySystem {
this.bridge.render();
}
/**
* Draw gizmos from components that have registered gizmo providers.
* 绘制已注册 gizmo 提供者的组件的 gizmo。
*/
private drawComponentGizmos(): void {
const scene = Core.scene;
if (!scene || !this.gizmoDataProvider || !this.hasGizmoProvider) return;
// Iterate all entities in the scene
// 遍历场景中的所有实体
for (const entity of scene.entities.buffer) {
const isSelected = this.selectedEntityIds.has(entity.id);
// Check each component for gizmo provider
// 检查每个组件是否有 gizmo 提供者
for (const component of entity.components) {
if (this.hasGizmoProvider(component)) {
try {
const gizmoDataArray = this.gizmoDataProvider(component, entity, isSelected);
for (const gizmoData of gizmoDataArray) {
this.renderGizmoData(gizmoData);
}
} catch (e) {
// Silently ignore errors from gizmo providers
// 静默忽略 gizmo 提供者的错误
}
}
}
}
}
/**
* Render a single gizmo data item.
* 渲染单个 gizmo 数据项。
*/
private renderGizmoData(data: GizmoRenderDataInternal): void {
const { r, g, b, a } = data.color;
switch (data.type) {
case 'rect':
if (data.x !== undefined && data.y !== undefined &&
data.width !== undefined && data.height !== undefined) {
this.bridge.addGizmoRect(
data.x,
data.y,
data.width,
data.height,
data.rotation ?? 0,
data.originX ?? 0.5,
data.originY ?? 0.5,
r, g, b, a,
data.showHandles ?? false
);
}
break;
case 'grid':
// Render grid as multiple line segments
// 将网格渲染为多条线段
if (data.x !== undefined && data.y !== undefined &&
data.width !== undefined && data.height !== undefined &&
data.cols !== undefined && data.rows !== undefined) {
this.renderGridGizmo(data.x, data.y, data.width, data.height, data.cols, data.rows, r, g, b, a);
}
break;
case 'line':
// Lines are rendered as connected rect segments (thin)
// 线条渲染为连接的细矩形段
if (data.points && data.points.length >= 2) {
this.renderLineGizmo(data.points, data.closed ?? false, r, g, b, a);
}
break;
case 'circle':
// Circle rendered as polygon approximation
// 圆形渲染为多边形近似
if (data.x !== undefined && data.y !== undefined && data.radius !== undefined) {
this.renderCircleGizmo(data.x, data.y, data.radius, r, g, b, a);
}
break;
}
}
/**
* Render a grid gizmo using line segments.
* 使用线段渲染网格 gizmo。
*/
private renderGridGizmo(
x: number, y: number, width: number, height: number,
cols: number, rows: number,
r: number, g: number, b: number, a: number
): void {
const cellWidth = width / cols;
const cellHeight = height / rows;
const lineThickness = 1;
// Vertical lines | 垂直线
for (let col = 0; col <= cols; col++) {
const lineX = x + col * cellWidth;
this.bridge.addGizmoRect(
lineX, y + height / 2,
lineThickness, height,
0, 0.5, 0.5,
r, g, b, a,
false
);
}
// Horizontal lines | 水平线
for (let row = 0; row <= rows; row++) {
const lineY = y + row * cellHeight;
this.bridge.addGizmoRect(
x + width / 2, lineY,
width, lineThickness,
0, 0.5, 0.5,
r, g, b, a,
false
);
}
}
/**
* Render a line gizmo.
* 渲染线条 gizmo。
*/
private renderLineGizmo(
points: Array<{ x: number; y: number }>,
closed: boolean,
r: number, g: number, b: number, a: number
): void {
const lineThickness = 2;
const count = closed ? points.length : points.length - 1;
for (let i = 0; i < count; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
// Draw line segment as thin rect
// 将线段绘制为细矩形
this.bridge.addGizmoRect(
(p1.x + p2.x) / 2,
(p1.y + p2.y) / 2,
length, lineThickness,
angle, 0.5, 0.5,
r, g, b, a,
false
);
}
}
/**
* Render a circle gizmo as polygon.
* 将圆形 gizmo 渲染为多边形。
*/
private renderCircleGizmo(
x: number, y: number, radius: number,
r: number, g: number, b: number, a: number
): void {
const segments = 32;
const points: Array<{ x: number; y: number }> = [];
for (let i = 0; i < segments; i++) {
const angle = (i / segments) * Math.PI * 2;
points.push({
x: x + Math.cos(angle) * radius,
y: y + Math.sin(angle) * radius
});
}
this.renderLineGizmo(points, true, r, g, b, a);
}
/**
* Draw gizmos for selected entities with transform handles.
* 为选中的实体绘制带有变换手柄的 gizmo。
*
* This method ensures that selected entities show transform handles
* regardless of whether they have sprite data in entityRenderMap.
* 此方法确保选中的实体显示变换手柄,无论它们是否在 entityRenderMap 中有精灵数据。
*/
private drawSelectedEntityGizmos(): void {
const scene = Core.scene;
if (!scene) return;
// Determine if we should show handles based on transform mode
// 根据变换模式确定是否显示手柄
const shouldShowHandles = this.transformMode !== 'select';
for (const entityId of this.selectedEntityIds) {
// Find the entity
// 查找实体
const entity = scene.entities.findEntityById(entityId);
if (!entity) continue;
// Get transform component
// 获取变换组件
const transform = entity.getComponent(TransformComponent);
if (!transform) continue;
// First check if we have sprite data from entityRenderMap
// 首先检查是否有来自 entityRenderMap 的精灵数据
const spriteData = this.entityRenderMap.get(entityId);
if (spriteData) {
// Use sprite data for selection gizmo
// 使用精灵数据绘制选择 gizmo
this.bridge.addGizmoRect(
spriteData.x,
spriteData.y,
spriteData.scaleX,
spriteData.scaleY,
spriteData.rotation,
spriteData.originX,
spriteData.originY,
0.0, 0.8, 1.0, 1.0, // Selection color (cyan)
shouldShowHandles
);
continue;
}
// For entities without sprite data, try to get gizmo data from components via registry
// 对于没有精灵数据的实体,尝试通过注册表从组件获取 gizmo 数据
let foundGizmo = false;
if (this.gizmoDataProvider && this.hasGizmoProvider) {
for (const component of entity.components) {
if (this.hasGizmoProvider(component)) {
try {
const gizmoDataArray = this.gizmoDataProvider(component, entity, true);
// Use the first rect gizmo for selection handles
// 使用第一个矩形 gizmo 来绘制选择手柄
for (const gizmoData of gizmoDataArray) {
if (gizmoData.type === 'rect' &&
gizmoData.x !== undefined && gizmoData.y !== undefined &&
gizmoData.width !== undefined && gizmoData.height !== undefined) {
// Draw selection gizmo with handles
// 绘制带手柄的选择 gizmo
this.bridge.addGizmoRect(
gizmoData.x,
gizmoData.y,
gizmoData.width,
gizmoData.height,
gizmoData.rotation ?? 0,
gizmoData.originX ?? 0.5,
gizmoData.originY ?? 0.5,
0.0, 0.8, 1.0, 1.0, // Selection color (cyan)
shouldShowHandles
);
foundGizmo = true;
break;
}
}
if (foundGizmo) break;
} catch (e) {
// Silently ignore errors
// 静默忽略错误
}
}
}
}
// If no gizmo provider found, draw a default gizmo at transform position
// 如果没有找到 gizmo 提供者,在变换位置绘制默认 gizmo
if (!foundGizmo) {
const rotation = typeof transform.rotation === 'number'
? transform.rotation
: transform.rotation.z;
// Draw a small default gizmo at entity position
// 在实体位置绘制一个小的默认 gizmo
this.bridge.addGizmoRect(
transform.position.x,
transform.position.y,
32, // Default size
32,
rotation,
0.5,
0.5,
0.0, 0.8, 1.0, 1.0, // Selection color (cyan)
shouldShowHandles
);
}
}
}
/**
* Draw camera frustum gizmos for all cameras in scene.
* 为场景中所有相机绘制视锥体 gizmo。
@@ -249,6 +675,26 @@ export class EngineRenderSystem extends EntitySystem {
}
}
/**
* Set gizmo registry functions.
* 设置 gizmo 注册表函数。
*
* This allows the editor layer to inject GizmoRegistry functionality
* without creating a direct dependency from engine to editor.
* 这允许编辑器层注入 GizmoRegistry 功能,
* 而不会创建从引擎到编辑器的直接依赖。
*
* @param provider - Function to get gizmo data from a component
* @param hasProvider - Function to check if a component has a gizmo provider
*/
setGizmoRegistry(
provider: GizmoDataProviderFn,
hasProvider: HasGizmoProviderFn
): void {
this.gizmoDataProvider = provider;
this.hasGizmoProvider = hasProvider;
}
/**
* Set gizmo visibility.
* 设置Gizmo可见性。
@@ -331,6 +777,27 @@ export class EngineRenderSystem extends EntitySystem {
}
/**
* Register a render data provider.
* 注册渲染数据提供者。
*/
addRenderDataProvider(provider: IRenderDataProvider): void {
if (!this.renderDataProviders.includes(provider)) {
this.renderDataProviders.push(provider);
}
}
/**
* Remove a render data provider.
* 移除渲染数据提供者。
*/
removeRenderDataProvider(provider: IRenderDataProvider): void {
const index = this.renderDataProviders.indexOf(provider);
if (index >= 0) {
this.renderDataProviders.splice(index, 1);
}
}
/**
* Get the number of sprites rendered.
* 获取渲染的精灵数量。

View File

@@ -0,0 +1,310 @@
/* tslint:disable */
/* eslint-disable */
/**
* Initialize panic hook for better error messages in console.
* 初始化panic hook以在控制台显示更好的错误信息。
*/
export function init(): void;
/**
* Game engine main interface exposed to JavaScript.
* 暴露给JavaScript的游戏引擎主接口。
*
* This is the primary entry point for the engine from TypeScript/JavaScript.
* 这是从TypeScript/JavaScript访问引擎的主要入口点。
*/
export class GameEngine {
free(): void;
[Symbol.dispose](): void;
/**
* Get camera state.
* 获取相机状态。
*
* # Returns | 返回
* Array of [x, y, zoom, rotation] | 数组 [x, y, zoom, rotation]
*/
getCamera(): Float32Array;
/**
* Set camera position, zoom, and rotation.
* 设置相机位置、缩放和旋转。
*
* # Arguments | 参数
* * `x` - Camera X position | 相机X位置
* * `y` - Camera Y position | 相机Y位置
* * `zoom` - Zoom level | 缩放级别
* * `rotation` - Rotation in radians | 旋转角度(弧度)
*/
setCamera(x: number, y: number, zoom: number, rotation: number): void;
/**
* Check if a key is currently pressed.
* 检查某个键是否当前被按下。
*
* # Arguments | 参数
* * `key_code` - The key code to check | 要检查的键码
*/
isKeyDown(key_code: string): boolean;
/**
* Load a texture from URL.
* 从URL加载纹理。
*
* # Arguments | 参数
* * `id` - Unique texture identifier | 唯一纹理标识符
* * `url` - Image URL to load | 要加载的图片URL
*/
loadTexture(id: number, url: string): void;
/**
* Update input state. Should be called once per frame.
* 更新输入状态。应该每帧调用一次。
*/
updateInput(): void;
/**
* Create a new game engine from external WebGL context.
* 从外部 WebGL 上下文创建引擎。
*
* This is designed for WeChat MiniGame and similar environments.
* 适用于微信小游戏等环境。
*/
static fromExternal(gl_context: any, width: number, height: number): GameEngine;
/**
* Set grid visibility.
* 设置网格可见性。
*/
setShowGrid(show: boolean): void;
/**
* Add a rectangle gizmo outline.
* 添加矩形Gizmo边框。
*
* # Arguments | 参数
* * `x` - Center X position | 中心X位置
* * `y` - Center Y position | 中心Y位置
* * `width` - Rectangle width | 矩形宽度
* * `height` - Rectangle height | 矩形高度
* * `rotation` - Rotation in radians | 旋转角度(弧度)
* * `origin_x` - Origin X (0-1) | 原点X (0-1)
* * `origin_y` - Origin Y (0-1) | 原点Y (0-1)
* * `r`, `g`, `b`, `a` - Color (0.0-1.0) | 颜色
* * `show_handles` - Whether to show transform handles | 是否显示变换手柄
*/
addGizmoRect(x: number, y: number, width: number, height: number, rotation: number, origin_x: number, origin_y: number, r: number, g: number, b: number, a: number, show_handles: boolean): void;
/**
* Resize a specific viewport.
* 调整特定视口大小。
*/
resizeViewport(viewport_id: string, width: number, height: number): void;
/**
* Set clear color (background color).
* 设置清除颜色(背景颜色)。
*
* # Arguments | 参数
* * `r`, `g`, `b`, `a` - Color components (0.0-1.0) | 颜色分量 (0.0-1.0)
*/
setClearColor(r: number, g: number, b: number, a: number): void;
/**
* Set gizmo visibility.
* 设置辅助工具可见性。
*/
setShowGizmos(show: boolean): void;
/**
* Get all registered viewport IDs.
* 获取所有已注册的视口ID。
*/
getViewportIds(): string[];
/**
* Register a new viewport.
* 注册新视口。
*
* # Arguments | 参数
* * `id` - Unique viewport identifier | 唯一视口标识符
* * `canvas_id` - HTML canvas element ID | HTML canvas元素ID
*/
registerViewport(id: string, canvas_id: string): void;
/**
* Render to a specific viewport.
* 渲染到特定视口。
*/
renderToViewport(viewport_id: string): void;
/**
* Set transform tool mode.
* 设置变换工具模式。
*
* # Arguments | 参数
* * `mode` - 0=Select, 1=Move, 2=Rotate, 3=Scale
*/
setTransformMode(mode: number): void;
/**
* Get or load texture by path.
* 按路径获取或加载纹理。
*
* # Arguments | 参数
* * `path` - Image path/URL | 图片路径/URL
*/
getOrLoadTextureByPath(path: string): number;
/**
* Get camera for a specific viewport.
* 获取特定视口的相机。
*/
getViewportCamera(viewport_id: string): Float32Array | undefined;
/**
* Set the active viewport.
* 设置活动视口。
*/
setActiveViewport(id: string): boolean;
/**
* Set camera for a specific viewport.
* 为特定视口设置相机。
*/
setViewportCamera(viewport_id: string, x: number, y: number, zoom: number, rotation: number): void;
/**
* Set viewport configuration.
* 设置视口配置。
*/
setViewportConfig(viewport_id: string, show_grid: boolean, show_gizmos: boolean): void;
/**
* Submit sprite batch data for rendering.
* 提交精灵批次数据进行渲染。
*
* # Arguments | 参数
* * `transforms` - Float32Array [x, y, rotation, scaleX, scaleY, originX, originY] per sprite
* 每个精灵的变换数据
* * `texture_ids` - Uint32Array of texture IDs | 纹理ID数组
* * `uvs` - Float32Array [u0, v0, u1, v1] per sprite | 每个精灵的UV坐标
* * `colors` - Uint32Array of packed RGBA colors | 打包的RGBA颜色数组
*/
submitSpriteBatch(transforms: Float32Array, texture_ids: Uint32Array, uvs: Float32Array, colors: Uint32Array): void;
/**
* Unregister a viewport.
* 注销视口。
*/
unregisterViewport(id: string): void;
/**
* Load texture by path, returning texture ID.
* 按路径加载纹理返回纹理ID。
*
* # Arguments | 参数
* * `path` - Image path/URL to load | 要加载的图片路径/URL
*/
loadTextureByPath(path: string): number;
/**
* Get texture ID by path.
* 按路径获取纹理ID。
*
* # Arguments | 参数
* * `path` - Image path to lookup | 要查找的图片路径
*/
getTextureIdByPath(path: string): number | undefined;
/**
* Create a new game engine instance.
* 创建新的游戏引擎实例。
*
* # Arguments | 参数
* * `canvas_id` - The HTML canvas element ID | HTML canvas元素ID
*
* # Returns | 返回
* A new GameEngine instance or an error | 新的GameEngine实例或错误
*/
constructor(canvas_id: string);
/**
* Clear the screen with specified color.
* 使用指定颜色清除屏幕。
*
* # Arguments | 参数
* * `r` - Red component (0.0-1.0) | 红色分量
* * `g` - Green component (0.0-1.0) | 绿色分量
* * `b` - Blue component (0.0-1.0) | 蓝色分量
* * `a` - Alpha component (0.0-1.0) | 透明度分量
*/
clear(r: number, g: number, b: number, a: number): void;
/**
* Render the current frame.
* 渲染当前帧。
*/
render(): void;
/**
* Resize viewport.
* 调整视口大小。
*
* # Arguments | 参数
* * `width` - New viewport width | 新视口宽度
* * `height` - New viewport height | 新视口高度
*/
resize(width: number, height: number): void;
/**
* Get canvas width.
* 获取画布宽度。
*/
readonly width: number;
/**
* Get canvas height.
* 获取画布高度。
*/
readonly height: number;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly __wbg_gameengine_free: (a: number, b: number) => void;
readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void;
readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
readonly gameengine_getCamera: (a: number) => [number, number];
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number;
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
readonly gameengine_getViewportIds: (a: number) => [number, number];
readonly gameengine_height: (a: number) => number;
readonly gameengine_isKeyDown: (a: number, b: number, c: number) => number;
readonly gameengine_loadTexture: (a: number, b: number, c: number, d: number) => [number, number];
readonly gameengine_loadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
readonly gameengine_new: (a: number, b: number) => [number, number, number];
readonly gameengine_registerViewport: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly gameengine_render: (a: number) => [number, number];
readonly gameengine_renderToViewport: (a: number, b: number, c: number) => [number, number];
readonly gameengine_resize: (a: number, b: number, c: number) => void;
readonly gameengine_resizeViewport: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_setActiveViewport: (a: number, b: number, c: number) => number;
readonly gameengine_setCamera: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_setClearColor: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_setShowGizmos: (a: number, b: number) => void;
readonly gameengine_setShowGrid: (a: number, b: number) => void;
readonly gameengine_setTransformMode: (a: number, b: number) => void;
readonly gameengine_setViewportCamera: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
readonly gameengine_setViewportConfig: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number];
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
readonly gameengine_updateInput: (a: number) => void;
readonly gameengine_width: (a: number) => number;
readonly init: () => void;
readonly wasm_bindgen__convert__closures_____invoke__hdbeb4a641c76f980: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h201da39d82f7cf6e: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __externref_table_dealloc: (a: number) => void;
readonly __externref_drop_slice: (a: number, b: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View File

@@ -9,6 +9,7 @@
"outDir": "./bin",
"rootDir": "./src",
"strict": true,
"composite": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,

View File

@@ -4,9 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ECS Framework Editor</title>
<!-- ES Module Shims: 为不支持 Import Maps 的浏览器提供 polyfill -->
<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
</head>
<body>
<div id="root"></div>
<!-- Import Map 将由 PluginLoader 在运行时动态注入 -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -5,20 +5,21 @@
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"build:sdk": "cd ../editor-runtime && pnpm build",
"build": "npm run build:sdk && tsc && vite build",
"build:watch": "vite build --watch",
"tauri": "tauri",
"kill-dev": "node scripts/kill-dev-server.js",
"tauri:dev": "npm run kill-dev && tauri dev",
"tauri:dev": "npm run build:sdk && tauri dev",
"bundle:runtime": "node scripts/bundle-runtime.mjs",
"tauri:build": "npm run bundle:runtime && tauri build",
"tauri:build": "npm run build:sdk && npm run bundle:runtime && tauri build",
"version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
},
"dependencies": {
"@esengine/asset-system": "workspace:*",
"@esengine/behavior-tree": "workspace:*",
"@esengine/ecs-components": "workspace:*",
"@esengine/tilemap": "workspace:*",
"@esengine/tilemap-editor": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*",

3127
packages/editor-app/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
// React DOM shim - 从全局变量导出 ReactDOM
const ReactDOM = window.ReactDOM;
export default ReactDOM;
export const {
createPortal,
flushSync,
hydrate,
render,
unmountComponentAtNode,
unstable_batchedUpdates,
unstable_renderSubtreeIntoContainer,
version,
createRoot,
hydrateRoot
} = ReactDOM;

View File

@@ -0,0 +1,4 @@
// React JSX Runtime shim - 从全局变量导出
const ReactJSXRuntime = window.ReactJSXRuntime;
export const { jsx, jsxs, Fragment } = ReactJSXRuntime;
export default ReactJSXRuntime;

View File

@@ -0,0 +1,40 @@
// React shim - 从全局变量导出 React
// 这个文件用于 Import Map让插件的 import 'react' 能正确解析到主应用的 React
const React = window.React;
export default React;
export const {
Children,
Component,
Fragment,
Profiler,
PureComponent,
StrictMode,
Suspense,
cloneElement,
createContext,
createElement,
createFactory,
createRef,
forwardRef,
isValidElement,
lazy,
memo,
startTransition,
unstable_act,
useCallback,
useContext,
useDebugValue,
useDeferredValue,
useEffect,
useId,
useImperativeHandle,
useInsertionEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
useTransition,
version
} = React;

View File

@@ -36,10 +36,10 @@ pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<Strin
.map_err(|e| format!("Failed to create .build-cache directory: {}", e))?;
}
let npm_command = if cfg!(target_os = "windows") {
"npm.cmd"
let pnpm_command = if cfg!(target_os = "windows") {
"pnpm.cmd"
} else {
"npm"
"pnpm"
};
// Step 1: Install dependencies
@@ -52,15 +52,15 @@ pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<Strin
)
.ok();
let install_output = Command::new(npm_command)
let install_output = Command::new(&pnpm_command)
.args(["install"])
.current_dir(&plugin_folder)
.output()
.map_err(|e| format!("Failed to run npm install: {}", e))?;
.map_err(|e| format!("Failed to run pnpm install: {}", e))?;
if !install_output.status.success() {
return Err(format!(
"npm install failed: {}",
"pnpm install failed: {}",
String::from_utf8_lossy(&install_output.stderr)
));
}
@@ -75,15 +75,15 @@ pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<Strin
)
.ok();
let build_output = Command::new(npm_command)
let build_output = Command::new(&pnpm_command)
.args(["run", "build"])
.current_dir(&plugin_folder)
.output()
.map_err(|e| format!("Failed to run npm run build: {}", e))?;
.map_err(|e| format!("Failed to run pnpm run build: {}", e))?;
if !build_output.status.success() {
return Err(format!(
"npm run build failed: {}",
"pnpm run build failed: {}",
String::from_utf8_lossy(&build_output.stderr)
));
}

View File

@@ -101,6 +101,10 @@ fn handle_project_protocol(
let uri = request.uri();
let path = uri.path();
// Debug logging
println!("[project://] Full URI: {}", uri);
println!("[project://] Path: {}", path);
let file_path = {
let paths = match project_paths.lock() {
Ok(p) => p,

View File

@@ -3,8 +3,7 @@
"version": "1.0.8",
"identifier": "com.esengine.editor",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run build:watch",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
@@ -67,7 +66,7 @@
}
],
"security": {
"csp": null,
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' tauri: project: asset: https: http: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' tauri: project: asset: https: http: blob:; style-src 'self' 'unsafe-inline' tauri: https: http:; img-src 'self' tauri: project: asset: https: http: data: blob:; connect-src 'self' tauri: project: asset: https: http: ws: wss:",
"assetProtocol": {
"enable": true,
"scope": {

View File

@@ -1,6 +1,14 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import { Core, createLogger, Scene } from '@esengine/ecs-framework';
import * as ECSFramework from '@esengine/ecs-framework';
// 将 React 暴露到全局,供动态加载的插件使用
// editor-runtime.js 将 React 设为 external需要从全局获取
(window as any).React = React;
(window as any).ReactDOM = ReactDOM;
(window as any).ReactJSXRuntime = ReactJSXRuntime;
import {
EditorPluginManager,
UIRegistry,
@@ -13,6 +21,7 @@ import {
SceneManagerService,
ProjectService,
CompilerRegistry,
ICompilerRegistry,
InspectorRegistry,
INotification,
CommandManager
@@ -64,6 +73,11 @@ Core.services.registerInstance(LocaleService, localeService);
Core.services.registerSingleton(GlobalBlackboardService);
Core.services.registerSingleton(CompilerRegistry);
// 在 CompilerRegistry 实例化后,也用 Symbol 注册,用于跨包插件访问
// 注意registerSingleton 会延迟实例化,所以需要在第一次使用后再注册 Symbol
const compilerRegistryInstance = Core.services.resolve(CompilerRegistry);
Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance);
const logger = createLogger('App');
function App() {
@@ -368,34 +382,17 @@ function App() {
await projectService.openProject(projectPath);
await fetch('/@user-project-set-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectPath })
});
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
await TauriAPI.setProjectBasePath(projectPath);
setStatus(t('header.status.projectOpened'));
setLoadingMessage(locale === 'zh' ? '步骤 2/2: 加载场景...' : 'Step 2/2: Loading scene...');
setLoadingMessage(locale === 'zh' ? '步骤 2/2: 初始化场景...' : 'Step 2/2: Initializing scene...');
const sceneManagerService = Core.services.resolve(SceneManagerService);
const scenesPath = projectService.getScenesPath();
if (scenesPath && sceneManagerService) {
try {
const sceneFiles = await TauriAPI.scanDirectory(scenesPath, '*.ecs');
if (sceneFiles.length > 0) {
const defaultScenePath = projectService.getDefaultScenePath();
const sceneToLoad = sceneFiles.find((f) => f === defaultScenePath) || sceneFiles[0];
await sceneManagerService.openScene(sceneToLoad);
} else {
if (sceneManagerService) {
await sceneManagerService.newScene();
}
} catch {
await sceneManagerService.newScene();
}
}
const settings = SettingsService.getInstance();
settings.addRecentProject(projectPath);

View File

@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
/**
* 文件过滤器定义
@@ -298,6 +298,19 @@ export class TauriAPI {
static async generateQRCode(text: string): Promise<string> {
return await invoke<string>('generate_qrcode', { text });
}
/**
* 将本地文件路径转换为 Tauri 可访问的 asset URL
* @param filePath 本地文件路径
* @param protocol 协议类型 (默认: 'asset')
* @returns 转换后的 URL可用于 img src、audio src 等
* @example
* const url = TauriAPI.convertFileSrc('C:\\Users\\...\\image.png');
* // 返回: 'https://asset.localhost/C:/Users/.../image.png'
*/
static convertFileSrc(filePath: string, protocol?: string): string {
return convertFileSrc(filePath, protocol);
}
}
export interface DirectoryEntry {

View File

@@ -2,13 +2,17 @@ import type { EditorPluginManager } from '@esengine/editor-core';
import { SceneInspectorPlugin } from '../../plugins/SceneInspectorPlugin';
import { ProfilerPlugin } from '../../plugins/ProfilerPlugin';
import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin';
import { GizmoPlugin } from '../../plugins/GizmoPlugin';
import { TilemapEditorPlugin } from '@esengine/tilemap-editor';
export class PluginInstaller {
async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> {
const plugins = [
new GizmoPlugin(),
new SceneInspectorPlugin(),
new ProfilerPlugin(),
new EditorAppearancePlugin()
new EditorAppearancePlugin(),
new TilemapEditorPlugin()
];
for (const plugin of plugins) {

View File

@@ -2,6 +2,7 @@ import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-
import {
UIRegistry,
MessageHub,
IMessageHub,
SerializerRegistry,
EntityStoreService,
ComponentRegistry,
@@ -12,10 +13,17 @@ import {
SettingsRegistry,
SceneManagerService,
FileActionRegistry,
EntityCreationRegistry,
EditorPluginManager,
InspectorRegistry,
IInspectorRegistry,
PropertyRendererRegistry,
FieldEditorRegistry
FieldEditorRegistry,
ComponentActionRegistry,
IDialogService,
IFileSystemService,
CompilerRegistry,
ICompilerRegistry
} from '@esengine/editor-core';
import {
TransformComponent,
@@ -128,9 +136,12 @@ export class ServiceRegistry {
const settingsRegistry = new SettingsRegistry();
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService, entityStore);
const fileActionRegistry = new FileActionRegistry();
const entityCreationRegistry = new EntityCreationRegistry();
const componentActionRegistry = new ComponentActionRegistry();
Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub);
Core.services.registerInstance(IMessageHub, messageHub); // Symbol 注册用于跨包插件访问
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
Core.services.registerInstance(EntityStoreService, entityStore);
Core.services.registerInstance(ComponentRegistry, componentRegistry);
@@ -141,6 +152,8 @@ export class ServiceRegistry {
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
Core.services.registerInstance(SceneManagerService, sceneManager);
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
const pluginManager = new EditorPluginManager();
pluginManager.initialize(coreInstance, Core.services);
@@ -155,10 +168,12 @@ export class ServiceRegistry {
const dialog = new TauriDialogService();
const notification = new NotificationService();
Core.services.registerInstance(NotificationService, notification);
Core.services.registerInstance(IDialogService, dialog);
Core.services.registerInstance(IFileSystemService, fileSystem);
const inspectorRegistry = new InspectorRegistry();
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
const propertyRendererRegistry = new PropertyRendererRegistry();
Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry);

View File

@@ -0,0 +1,113 @@
import { Core, Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/ecs-components';
import { TilemapComponent } from '@esengine/tilemap';
import { BaseCommand } from '../BaseCommand';
/**
* Tilemap创建选项
*/
export interface TilemapCreationOptions {
/** 地图宽度瓦片数默认10 */
width?: number;
/** 地图高度瓦片数默认10 */
height?: number;
/** 瓦片宽度像素默认32 */
tileWidth?: number;
/** 瓦片高度像素默认32 */
tileHeight?: number;
/** 渲染层级默认0 */
sortingOrder?: number;
/** 初始Tileset源路径 */
tilesetSource?: string;
}
/**
* 创建带Tilemap组件的实体命令
*/
export class CreateTilemapEntityCommand extends BaseCommand {
private entity: Entity | null = null;
private entityId: number | null = null;
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private entityName: string,
private parentEntity?: Entity,
private options: TilemapCreationOptions = {}
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化');
}
this.entity = scene.createEntity(this.entityName);
this.entityId = this.entity.id;
// 添加Transform组件
this.entity.addComponent(new TransformComponent());
// 创建并配置Tilemap组件
const tilemapComponent = new TilemapComponent();
// 应用配置选项
const {
width = 10,
height = 10,
tileWidth = 32,
tileHeight = 32,
sortingOrder = 0,
tilesetSource
} = this.options;
tilemapComponent.tileWidth = tileWidth;
tilemapComponent.tileHeight = tileHeight;
tilemapComponent.sortingOrder = sortingOrder;
// 初始化空白地图
tilemapComponent.initializeEmpty(width, height);
// 添加初始 Tileset
if (tilesetSource) {
tilemapComponent.addTileset(tilesetSource);
}
this.entity.addComponent(tilemapComponent);
if (this.parentEntity) {
this.parentEntity.addChild(this.entity);
}
this.entityStore.addEntity(this.entity, this.parentEntity);
this.entityStore.selectEntity(this.entity);
this.messageHub.publish('entity:added', { entity: this.entity });
this.messageHub.publish('tilemap:created', {
entity: this.entity,
component: tilemapComponent
});
}
undo(): void {
if (!this.entity) return;
this.entityStore.removeEntity(this.entity);
this.entity.destroy();
this.messageHub.publish('entity:removed', { entityId: this.entityId });
this.entity = null;
}
getDescription(): string {
return `创建Tilemap实体: ${this.entityName}`;
}
getCreatedEntity(): Entity | null {
return this.entity;
}
}

View File

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

View File

@@ -95,27 +95,40 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
const filePath = data.path;
if (filePath) {
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
if (!filePath || !projectPath) return;
// Convert relative path to absolute path if needed
let absoluteFilePath = filePath;
if (!filePath.includes(':') && !filePath.startsWith('/')) {
absoluteFilePath = `${projectPath}/${filePath}`.replace(/\\/g, '/');
}
const lastSlashIndex = Math.max(absoluteFilePath.lastIndexOf('/'), absoluteFilePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? absoluteFilePath.substring(0, lastSlashIndex) : null;
if (dirPath) {
try {
const dirExists = await TauriAPI.pathExists(dirPath);
if (!dirExists) return;
setCurrentPath(dirPath);
// Load assets first, then set selection after list is populated
await loadAssets(dirPath);
setSelectedPaths(new Set([filePath]));
setSelectedPaths(new Set([absoluteFilePath]));
// Expand tree to reveal the file
if (showDetailView) {
detailViewFileTreeRef.current?.revealPath(filePath);
detailViewFileTreeRef.current?.revealPath(absoluteFilePath);
} else {
treeOnlyViewFileTreeRef.current?.revealPath(filePath);
treeOnlyViewFileTreeRef.current?.revealPath(absoluteFilePath);
}
} catch (error) {
console.error(`[AssetBrowser] Failed to reveal asset: ${absoluteFilePath}`, error);
}
}
});
return () => unsubscribe();
}, [showDetailView]);
}, [showDetailView, projectPath]);
const loadAssets = async (path: string) => {
setLoading(true);

View File

@@ -3,7 +3,7 @@ import { Core, IService, ServiceType } from '@esengine/ecs-framework';
import { CompilerRegistry, ICompiler, CompilerContext, CompileResult, IFileSystem, IDialog, FileEntry } from '@esengine/editor-core';
import { X, Play, Loader2 } from 'lucide-react';
import { open as tauriOpen, save as tauriSave, message as tauriMessage, confirm as tauriConfirm } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import '../styles/CompilerConfigDialog.css';
interface DirectoryEntry {
@@ -98,7 +98,11 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
return entries
.filter((e) => !e.is_dir && e.name.endsWith(ext))
.map((e) => e.name.replace(ext, ''));
}
},
convertToAssetUrl: (filePath: string) => {
return convertFileSrc(filePath);
},
dispose: () => {}
});
const createDialog = (): IDialog => ({
@@ -124,7 +128,8 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
},
showConfirm: async (title: string, message: string) => {
return await tauriConfirm(message, { title });
}
},
dispose: () => {}
});
const createContext = (): CompilerContext => ({

View File

@@ -91,7 +91,11 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
};
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
if (!selectedEntity) return;
console.log('[EntityInspector] handlePropertyChange called:', propertyName, value);
if (!selectedEntity) {
console.log('[EntityInspector] No selectedEntity, returning');
return;
}
// Actually update the component property
// 实际更新组件属性
@@ -103,6 +107,10 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
propertyName,
value
});
// Also publish scene:modified so other panels can react
console.log('[EntityInspector] Publishing scene:modified');
messageHub.publish('scene:modified', {});
};
const renderRemoteProperty = (key: string, value: any) => {

View File

@@ -92,10 +92,16 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
// Expand tree to reveal a specific file path
const revealPath = async (targetPath: string) => {
if (!rootPath || !targetPath.startsWith(rootPath)) return;
if (!rootPath) return;
// Normalize paths to use forward slashes for comparison
const normalizedTargetPath = targetPath.replace(/\\/g, '/');
const normalizedRootPath = rootPath.replace(/\\/g, '/');
if (!normalizedTargetPath.startsWith(normalizedRootPath)) return;
// Get path segments between root and target
const relativePath = targetPath.substring(rootPath.length).replace(/^[/\\]/, '');
const relativePath = normalizedTargetPath.substring(normalizedRootPath.length).replace(/^[/\\]/, '');
const segments = relativePath.split(/[/\\]/);
// Build list of folder paths to expand
@@ -748,9 +754,20 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
};
const renderNode = (node: TreeNode, level: number = 0) => {
const isSelected = selectedPaths
? selectedPaths.has(node.path)
: (internalSelectedPath || selectedPath) === node.path;
// Normalize paths for comparison (handle forward/backward slashes)
const normalizedNodePath = node.path.replace(/\\/g, '/');
const normalizedInternalPath = internalSelectedPath?.replace(/\\/g, '/');
const normalizedSelectedPath = selectedPath?.replace(/\\/g, '/');
// Check if this node is selected, normalizing paths for comparison
let isSelected = false;
if (selectedPaths) {
// Check both original path and normalized path in selectedPaths set
isSelected = selectedPaths.has(node.path) || selectedPaths.has(normalizedNodePath);
} else {
isSelected = (normalizedInternalPath || normalizedSelectedPath) === normalizedNodePath;
}
const isRenaming = renamingNode === node.path;
const indent = level * 16;

View File

@@ -215,6 +215,8 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
propertyName,
value
});
// Also publish scene:modified so other panels can react to changes
messageHub.publish('scene:modified', {});
};
const renderRemoteProperty = (key: string, value: any) => {

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, IFileSystemService } from '@esengine/editor-core';
import type { IFileSystem } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetSaveDialog } from './dialogs/AssetSaveDialog';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
@@ -80,6 +82,12 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
if (onChange) {
onChange(propertyName, value);
}
// Always publish scene:modified so other panels can react to changes
const messageHub = Core.services.resolve(MessageHub);
if (messageHub) {
messageHub.publish('scene:modified', {});
}
};
// Read value directly from component to avoid state sync issues
@@ -187,6 +195,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
fileExtension={metadata.fileExtension}
readOnly={metadata.readOnly || !!controlledBy}
controlledBy={controlledBy}
entityId={entity?.id?.toString()}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
@@ -469,6 +478,7 @@ function ColorField({ label, value, readOnly, onChange }: ColorFieldProps) {
const v = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
const newColor = hsvToHex(hsv.h, s, v);
setTempColor(newColor);
onChange(newColor); // Real-time update
};
const handleHueChange = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -476,6 +486,7 @@ function ColorField({ label, value, readOnly, onChange }: ColorFieldProps) {
const h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / rect.width) * 360));
const newColor = hsvToHex(h, hsv.s, hsv.v);
setTempColor(newColor);
onChange(newColor); // Real-time update
};
return (
@@ -857,11 +868,90 @@ interface AssetDropFieldProps {
fileExtension?: string;
readOnly?: boolean;
controlledBy?: string;
entityId?: string;
onChange: (value: string) => void;
}
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, onChange }: AssetDropFieldProps) {
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const canCreate = fileExtension && ['.tilemap.json', '.btree'].includes(fileExtension);
const handleCreate = () => {
setShowSaveDialog(true);
};
const handleSaveAsset = async (relativePath: string) => {
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
const messageHub = Core.services.tryResolve(MessageHub);
if (!fileSystem) {
console.error('[AssetDropField] FileSystem service not available');
return;
}
try {
// Get absolute path from project
const projectService = Core.services.tryResolve(
(await import('@esengine/editor-core')).ProjectService
);
const currentProject = projectService?.getCurrentProject();
if (!currentProject) {
console.error('[AssetDropField] No project loaded');
return;
}
const absolutePath = `${currentProject.path}/${relativePath}`.replace(/\\/g, '/');
// Create default content based on file type
let defaultContent = '';
if (fileExtension === '.tilemap.json') {
defaultContent = JSON.stringify({
name: 'New Tilemap',
version: 2,
width: 20,
height: 15,
tileWidth: 16,
tileHeight: 16,
layers: [
{
id: 'default',
name: 'Layer 0',
visible: true,
opacity: 1,
data: new Array(20 * 15).fill(0)
}
],
tilesets: []
}, null, 2);
} else if (fileExtension === '.btree') {
defaultContent = JSON.stringify({
name: 'New Behavior Tree',
version: 1,
nodes: [],
connections: []
}, null, 2);
}
// Write file
await fileSystem.writeFile(absolutePath, defaultContent);
// Update component with relative path
onChange(relativePath);
// Open editor panel if tilemap
if (messageHub && fileExtension === '.tilemap.json' && entityId) {
const { useTilemapEditorStore } = await import('@esengine/tilemap-editor');
useTilemapEditorStore.getState().setEntityId(entityId);
messageHub.publish('dynamic-panel:open', { panelId: 'tilemap-editor', title: 'Tilemap Editor' });
}
console.log('[AssetDropField] Created asset:', relativePath);
} catch (error) {
console.error('[AssetDropField] Failed to create asset:', error);
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
@@ -890,8 +980,14 @@ function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, o
if (assetPath) {
if (fileExtension) {
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
const fileExt = assetPath.toLowerCase().split('.').pop();
if (fileExt && extensions.some((ext) => ext === `.${fileExt}` || ext === fileExt)) {
const lowerPath = assetPath.toLowerCase();
// Check if the path ends with any of the specified extensions
// This handles both simple extensions (.json) and compound extensions (.tilemap.json)
const isValidExtension = extensions.some((ext) => {
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
return lowerPath.endsWith(normalizedExt);
});
if (isValidExtension) {
onChange(assetPath);
}
} else {
@@ -943,6 +1039,18 @@ function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, o
{value ? getFileName(value) : 'None'}
</span>
<div className="property-asset-actions">
{canCreate && !readOnly && !value && (
<button
className="property-asset-btn property-asset-btn-create"
onClick={(e) => {
e.stopPropagation();
handleCreate();
}}
title="创建新资产"
>
+
</button>
)}
{value && (
<button
className="property-asset-btn"
@@ -957,6 +1065,16 @@ function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, o
)}
</div>
</div>
{/* Save Dialog */}
<AssetSaveDialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
onSave={handleSaveAsset}
title={fileExtension === '.tilemap.json' ? '创建 Tilemap 资产' : '创建资产'}
defaultFileName={fileExtension === '.tilemap.json' ? 'new-tilemap' : 'new-asset'}
fileExtension={fileExtension}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Entity, Core } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager } from '@esengine/editor-core';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
@@ -31,10 +31,32 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
const { t, locale } = useLocale();
const isShowingRemote = viewMode === 'remote' && isRemoteConnected;
// Get entity creation templates from plugins
useEffect(() => {
const updateTemplates = () => {
const registry = Core.services.resolve(EntityCreationRegistry);
if (registry) {
setPluginTemplates(registry.getAll());
}
};
updateTemplates();
// Update when plugins are installed
const unsubInstalled = messageHub.subscribe('plugin:installed', updateTemplates);
const unsubUninstalled = messageHub.subscribe('plugin:uninstalled', updateTemplates);
return () => {
unsubInstalled();
unsubUninstalled();
};
}, [messageHub]);
// Subscribe to scene changes
useEffect(() => {
const sceneManager = Core.services.resolve(SceneManagerService);
@@ -535,6 +557,23 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
<Camera size={12} />
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
</button>
{pluginTemplates.length > 0 && (
<>
<div className="context-menu-divider" />
{pluginTemplates.map((template) => (
<button
key={template.id}
onClick={async () => {
await template.create(contextMenu.entityId ?? undefined);
closeContextMenu();
}}
>
{template.icon || <Plus size={12} />}
<span>{template.label}</span>
</button>
))}
</>
)}
{contextMenu.entityId && (
<>
<div className="context-menu-divider" />

View File

@@ -43,7 +43,7 @@ function generateRuntimeHtml(): string {
<canvas id="runtime-canvas"></canvas>
<script src="/runtime.browser.js"></script>
<script type="module">
import * as esEngine from '/engine.js';
import * as esEngine from '/es_engine.js';
(async function() {
try {
// Set canvas size before creating runtime
@@ -361,7 +361,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
};
}, []);
// Sync camera state to engine
// Sync camera state to engine and publish camera:updated event
// 同步相机状态到引擎并发布 camera:updated 事件
useEffect(() => {
if (engine.state.initialized) {
EngineService.getInstance().setCamera({
@@ -370,6 +371,17 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
zoom: camera2DZoom,
rotation: 0
});
// Publish camera update event for other systems
// 发布相机更新事件供其他系统使用
const hub = messageHubRef.current;
if (hub) {
hub.publish('camera:updated', {
x: camera2DOffset.x,
y: camera2DOffset.y,
zoom: camera2DZoom
});
}
}
}, [camera2DOffset, camera2DZoom, engine.state.initialized]);
@@ -473,11 +485,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
};
const handleStop = () => {
const handleStop = async () => {
setPlayState('stopped');
engine.stop();
// Restore scene snapshot
EngineService.getInstance().restoreSceneSnapshot();
await EngineService.getInstance().restoreSceneSnapshot();
// Restore editor camera state
setCamera2DOffset({ x: editorCameraRef.current.x, y: editorCameraRef.current.y });
setCamera2DZoom(editorCameraRef.current.zoom);

View File

@@ -197,3 +197,119 @@
color: #666;
cursor: not-allowed;
}
/* Asset Save Dialog specific styles */
.asset-save-filename {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-save-filename label {
font-size: 12px;
color: #888;
white-space: nowrap;
}
.asset-save-filename input {
flex: 1;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 8px;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-save-filename input:focus {
border-color: #1976d2;
}
.asset-save-extension {
font-size: 11px;
color: #666;
white-space: nowrap;
}
/* New folder styles */
.asset-save-new-folder-btn {
padding: 8px 16px;
border-top: 1px solid #333;
}
.asset-save-new-folder-btn button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
}
.asset-save-new-folder-btn button:hover {
background: #444;
}
.asset-save-new-folder {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid #333;
background: #252525;
}
.asset-save-new-folder input {
flex: 1;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 8px;
color: #e0e0e0;
font-size: 12px;
outline: none;
}
.asset-save-new-folder input:focus {
border-color: #1976d2;
}
.asset-save-new-folder button {
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
}
.asset-save-new-folder button:first-of-type {
background: #1976d2;
color: white;
}
.asset-save-new-folder button:first-of-type:hover {
background: #1565c0;
}
.asset-save-new-folder button:first-of-type:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}
.asset-save-new-folder button:last-child {
background: #333;
color: #e0e0e0;
}
.asset-save-new-folder button:last-child:hover {
background: #444;
}

View File

@@ -149,19 +149,34 @@ export function AssetPickerDialog({
}
}, [toggleFolder]);
// Convert absolute path to relative path based on project root
const toRelativePath = useCallback((absolutePath: string): string => {
const projectService = Core.services.tryResolve(ProjectService);
const currentProject = projectService?.getCurrentProject();
if (currentProject) {
const projectPath = currentProject.path.replace(/\\/g, '/');
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
if (normalizedAbsolute.startsWith(projectPath)) {
// Return relative path from project root
return normalizedAbsolute.substring(projectPath.length + 1);
}
}
return absolutePath;
}, []);
const handleConfirm = useCallback(() => {
if (selectedPath) {
onSelect(selectedPath);
onSelect(toRelativePath(selectedPath));
onClose();
}
}, [selectedPath, onSelect, onClose]);
}, [selectedPath, onSelect, onClose, toRelativePath]);
const handleDoubleClick = useCallback((node: FileNode) => {
if (!node.isDirectory) {
onSelect(node.path);
onSelect(toRelativePath(node.path));
onClose();
}
}, [onSelect, onClose]);
}, [onSelect, onClose, toRelativePath]);
const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase();

View File

@@ -0,0 +1,374 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Search, Folder, FolderOpen, FolderPlus } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { ProjectService, IFileSystemService } from '@esengine/editor-core';
import type { IFileSystem } from '@esengine/editor-core';
import './AssetPickerDialog.css';
interface AssetSaveDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (path: string) => void;
title?: string;
defaultFileName?: string;
fileExtension?: string; // e.g., '.tilemap.json'
placeholder?: string;
}
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
export function AssetSaveDialog({
isOpen,
onClose,
onSave,
title = 'Save Asset',
defaultFileName = 'new-asset',
fileExtension = '',
placeholder = 'Search folders...'
}: AssetSaveDialogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [fileName, setFileName] = useState(defaultFileName);
const [folders, setFolders] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [projectPath, setProjectPath] = useState('');
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
// Load project folders
useEffect(() => {
if (!isOpen) return;
const loadFolders = async () => {
setLoading(true);
try {
const projectService = Core.services.tryResolve(ProjectService);
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
const currentProject = projectService?.getCurrentProject();
if (projectService && currentProject && fileSystem) {
const projPath = currentProject.path;
setProjectPath(projPath);
const assetsPath = `${projPath}/assets`;
// Set default selected folder to assets
setSelectedFolder(assetsPath);
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
const entries = await fileSystem.listDirectory(dirPath);
const nodes: FileNode[] = [];
for (const entry of entries) {
// Only include directories
if (entry.isDirectory) {
const node: FileNode = {
name: entry.name,
path: entry.path,
isDirectory: true
};
try {
node.children = await buildTree(entry.path);
} catch {
node.children = [];
}
nodes.push(node);
}
}
// Sort alphabetically
return nodes.sort((a, b) => a.name.localeCompare(b.name));
};
const tree = await buildTree(assetsPath);
// Add root assets folder
const rootNode: FileNode = {
name: 'assets',
path: assetsPath,
isDirectory: true,
children: tree
};
setFolders([rootNode]);
setExpandedFolders(new Set([assetsPath]));
}
} catch (error) {
console.error('Failed to load folders:', error);
} finally {
setLoading(false);
}
};
loadFolders();
setFileName(defaultFileName);
setSearchTerm('');
}, [isOpen, defaultFileName]);
// Filter folders based on search
const filteredFolders = useMemo(() => {
if (!searchTerm) return folders;
const filterNode = (node: FileNode): FileNode | null => {
const matchesSearch = node.name.toLowerCase().includes(searchTerm.toLowerCase());
if (node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileNode => n !== null);
if (filteredChildren.length > 0 || matchesSearch) {
return { ...node, children: filteredChildren };
}
}
return matchesSearch ? node : null;
};
return folders
.map(filterNode)
.filter((n): n is FileNode => n !== null);
}, [folders, searchTerm]);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleSelectFolder = useCallback((node: FileNode) => {
setSelectedFolder(node.path);
if (!expandedFolders.has(node.path)) {
toggleFolder(node.path);
}
}, [expandedFolders, toggleFolder]);
// Convert absolute path to relative path based on project root
const toRelativePath = useCallback((absolutePath: string): string => {
if (projectPath) {
const normalizedProject = projectPath.replace(/\\/g, '/');
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
if (normalizedAbsolute.startsWith(normalizedProject)) {
return normalizedAbsolute.substring(normalizedProject.length + 1);
}
}
return absolutePath;
}, [projectPath]);
const handleSave = useCallback(() => {
if (selectedFolder && fileName) {
// Ensure file has correct extension
let finalFileName = fileName;
if (fileExtension && !finalFileName.endsWith(fileExtension)) {
finalFileName += fileExtension;
}
const fullPath = `${selectedFolder}/${finalFileName}`.replace(/\\/g, '/');
onSave(toRelativePath(fullPath));
onClose();
}
}, [selectedFolder, fileName, fileExtension, onSave, onClose, toRelativePath]);
const handleCreateFolder = useCallback(async () => {
if (!selectedFolder || !newFolderName.trim()) return;
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
if (!fileSystem) return;
try {
const newFolderPath = `${selectedFolder}/${newFolderName.trim()}`.replace(/\\/g, '/');
await fileSystem.createDirectory(newFolderPath);
// Add new folder to tree
const addFolderToTree = (nodes: FileNode[]): FileNode[] => {
return nodes.map(node => {
if (node.path === selectedFolder) {
const newNode: FileNode = {
name: newFolderName.trim(),
path: newFolderPath,
isDirectory: true,
children: []
};
return {
...node,
children: [...(node.children || []), newNode].sort((a, b) => a.name.localeCompare(b.name))
};
}
if (node.children) {
return { ...node, children: addFolderToTree(node.children) };
}
return node;
});
};
setFolders(addFolderToTree(folders));
setSelectedFolder(newFolderPath);
setExpandedFolders(prev => new Set([...prev, selectedFolder]));
setShowNewFolderInput(false);
setNewFolderName('');
} catch (error) {
console.error('Failed to create folder:', error);
}
}, [selectedFolder, newFolderName, folders]);
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFolder === node.path;
return (
<div key={node.path}>
<div
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => handleSelectFolder(node)}
onDoubleClick={() => toggleFolder(node.path)}
>
<span className="asset-picker-item__icon">
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
</span>
<span className="asset-picker-item__name">{node.name}</span>
</div>
{isExpanded && node.children && (
<div className="asset-picker-children">
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
const getDisplayPath = () => {
if (!selectedFolder) return '';
const relativePath = toRelativePath(selectedFolder);
let finalFileName = fileName;
if (fileExtension && !finalFileName.endsWith(fileExtension)) {
finalFileName += fileExtension;
}
return `${relativePath}/${finalFileName}`;
};
if (!isOpen) return null;
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="asset-picker-search">
<Search size={14} />
<input
type="text"
placeholder={placeholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">Loading folders...</div>
) : filteredFolders.length === 0 ? (
<div className="asset-picker-empty">No folders found</div>
) : (
<div className="asset-picker-tree">
{filteredFolders.map((node) => renderNode(node))}
</div>
)}
</div>
{/* New folder input */}
{showNewFolderInput && (
<div className="asset-save-new-folder">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
/>
<button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
Create
</button>
<button onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}>
Cancel
</button>
</div>
)}
{/* New folder button */}
{!showNewFolderInput && selectedFolder && (
<div className="asset-save-new-folder-btn">
<button onClick={() => setShowNewFolderInput(true)}>
<FolderPlus size={14} />
New Folder
</button>
</div>
)}
<div className="asset-save-filename">
<label>File name:</label>
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="Enter file name"
autoFocus
/>
{fileExtension && (
<span className="asset-save-extension">{fileExtension}</span>
)}
</div>
<div className="asset-picker-footer">
<div className="asset-picker-selected">
{selectedFolder ? (
<span title={getDisplayPath()}>
{getDisplayPath()}
</span>
) : (
<span className="placeholder">Select a folder</span>
)}
</div>
<div className="asset-picker-actions">
<button className="btn-cancel" onClick={onClose}>
Cancel
</button>
<button
className="btn-confirm"
onClick={handleSave}
disabled={!selectedFolder || !fileName}
>
Save
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -113,6 +113,16 @@
color: #f87171;
}
/* 创建按钮特殊样式 */
.asset-field__button--create {
color: #4ade80;
}
.asset-field__button--create:hover {
background: #1a3a1a;
color: #4ade80;
}
/* 禁用状态 */
.asset-field__container[disabled] {
opacity: 0.6;

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package, Plus } from 'lucide-react';
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
import './AssetField.css';
@@ -11,6 +11,7 @@ interface AssetFieldProps {
placeholder?: string;
readonly?: boolean;
onNavigate?: (path: string) => void; // 导航到资产
onCreate?: () => void; // 创建新资产
}
export function AssetField({
@@ -20,7 +21,8 @@ export function AssetField({
fileExtension = '',
placeholder = 'None',
readonly = false,
onNavigate
onNavigate,
onCreate
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -137,6 +139,20 @@ export function AssetField({
{/* 操作按钮组 */}
<div className="asset-field__actions">
{/* 创建按钮 */}
{onCreate && !readonly && !value && (
<button
className="asset-field__button asset-field__button--create"
onClick={(e) => {
e.stopPropagation();
onCreate();
}}
title="创建新资产"
>
<Plus size={12} />
</button>
)}
{/* 浏览按钮 */}
{!readonly && (
<button

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
import { NotificationService } from '../../../services/NotificationService';
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
@@ -21,6 +21,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const [localVersion, setLocalVersion] = useState(0);
const componentRegistry = Core.services.resolve(ComponentRegistry);
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
const availableComponents = componentRegistry?.getAllComponents() || [];
const toggleComponentExpanded = (index: number) => {
@@ -252,6 +253,32 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
}
onAction={handlePropertyAction}
/>
{/* Dynamic component actions from plugins */}
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => (
<button
key={action.id}
className="component-action-btn"
onClick={() => action.execute(component, entity)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
width: '100%',
marginTop: '8px',
border: 'none',
borderRadius: '4px',
background: 'var(--accent-color, #0078d4)',
color: 'white',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 500,
}}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
</div>

View File

@@ -0,0 +1,81 @@
/**
* Sprite Gizmo Implementation
* 精灵 Gizmo 实现
*
* Registers gizmo provider for SpriteComponent using the GizmoRegistry.
* Rendered via Rust WebGL engine for optimal performance.
* 使用 GizmoRegistry 为 SpriteComponent 注册 gizmo 提供者。
* 通过 Rust WebGL 引擎渲染以获得最佳性能。
*/
import type { Entity } from '@esengine/ecs-framework';
import type { IGizmoRenderData, IRectGizmoData, GizmoColor } from '@esengine/editor-core';
import { GizmoColors, GizmoRegistry } from '@esengine/editor-core';
import { SpriteComponent, TransformComponent } from '@esengine/ecs-components';
/**
* Gizmo provider function for SpriteComponent.
* SpriteComponent 的 gizmo 提供者函数。
*/
function spriteGizmoProvider(
sprite: SpriteComponent,
entity: Entity,
isSelected: boolean
): IGizmoRenderData[] {
const transform = entity.getComponent(TransformComponent);
if (!transform) {
return [];
}
// Calculate world-space dimensions
// 计算世界空间尺寸
const width = sprite.width * transform.scale.x;
const height = sprite.height * transform.scale.y;
// Get rotation (handle both number and Vector3)
// 获取旋转(处理数字和 Vector3 两种情况)
const rotation = typeof transform.rotation === 'number'
? transform.rotation
: transform.rotation.z;
// Use predefined colors based on selection state
// 根据选择状态使用预定义颜色
const color: GizmoColor = isSelected
? GizmoColors.selected
: GizmoColors.unselected;
const gizmo: IRectGizmoData = {
type: 'rect',
x: transform.position.x,
y: transform.position.y,
width,
height,
rotation,
originX: sprite.anchorX,
originY: sprite.anchorY,
color,
showHandles: false // Selection handles are drawn separately by EngineRenderSystem
};
return [gizmo];
}
/**
* Register gizmo provider for SpriteComponent.
* 为 SpriteComponent 注册 gizmo 提供者。
*
* Uses the GizmoRegistry pattern for clean separation between
* game components and editor functionality.
* 使用 GizmoRegistry 模式实现游戏组件和编辑器功能的清晰分离。
*/
export function registerSpriteGizmo(): void {
GizmoRegistry.register(SpriteComponent, spriteGizmoProvider);
}
/**
* Unregister gizmo provider for SpriteComponent.
* 取消注册 SpriteComponent 的 gizmo 提供者。
*/
export function unregisterSpriteGizmo(): void {
GizmoRegistry.unregister(SpriteComponent);
}

View File

@@ -0,0 +1,9 @@
/**
* Editor Gizmos
* 编辑器 Gizmos
*
* Gizmo implementations for built-in components.
* 内置组件的 Gizmo 实现。
*/
export { registerSpriteGizmo } from './SpriteGizmo';

View File

@@ -23,6 +23,25 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
}
};
const handleCreate = () => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
if (fileExtension === '.tilemap.json') {
messageHub.publish('tilemap:create-asset', {
entityId: context.metadata?.entityId,
onChange
});
} else if (fileExtension === '.btree') {
messageHub.publish('behavior-tree:create-asset', {
entityId: context.metadata?.entityId,
onChange
});
}
}
};
const canCreate = ['.tilemap.json', '.btree'].includes(fileExtension);
return (
<AssetField
label={label}
@@ -32,6 +51,7 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
placeholder={placeholder}
readonly={context.readonly}
onNavigate={handleNavigate}
onCreate={canCreate ? handleCreate : undefined}
/>
);
}

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