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
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
@@ -86,11 +86,9 @@ export class AssetPathResolver {
// 应用自定义转换器(如果提供) // 应用自定义转换器(如果提供)
if (this.config.pathTransformer) { if (this.config.pathTransformer) {
path = this.config.pathTransformer(path); path = this.config.pathTransformer(path);
// Re-validate after transformation // Transformer output is trusted (may be absolute path or asset:// URL)
const postTransform = PathValidator.validate(path); // 转换器输出是可信的(可能是绝对路径或 asset:// URL
if (!postTransform.valid) { return path;
throw new Error(`Path transformer produced invalid path: ${postTransform.reason}`);
}
} }
// Platform-specific resolution // Platform-specific resolution
+8
View File
@@ -9,6 +9,7 @@ export * from './types/AssetTypes';
// Interfaces // Interfaces
export * from './interfaces/IAssetLoader'; export * from './interfaces/IAssetLoader';
export * from './interfaces/IAssetManager'; export * from './interfaces/IAssetManager';
export * from './interfaces/IResourceComponent';
// Core // Core
export { AssetManager } from './core/AssetManager'; export { AssetManager } from './core/AssetManager';
@@ -30,6 +31,13 @@ export { BinaryLoader } from './loaders/BinaryLoader';
export { EngineIntegration } from './integration/EngineIntegration'; export { EngineIntegration } from './integration/EngineIntegration';
export type { IEngineBridge } 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 // Default instance
import { AssetManager } from './core/AssetManager'; import { AssetManager } from './core/AssetManager';
@@ -6,6 +6,7 @@
import { AssetManager } from '../core/AssetManager'; import { AssetManager } from '../core/AssetManager';
import { AssetGUID } from '../types/AssetTypes'; import { AssetGUID } from '../types/AssetTypes';
import { ITextureAsset } from '../interfaces/IAssetLoader'; import { ITextureAsset } from '../interfaces/IAssetLoader';
import { globalPathResolver } from '../core/AssetPathResolver';
/** /**
* Engine bridge interface * Engine bridge interface
@@ -63,24 +64,35 @@ export class EngineIntegration {
/** /**
* Load texture for component * 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> { 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); const existingId = this._pathToTextureId.get(texturePath);
if (existingId) { if (existingId) {
return existingId; return existingId;
} }
// 通过资产系统加载 / Load through asset system // 使用 globalPathResolver 转换路径
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath); // 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; 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) { 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); this._pathToTextureId.set(texturePath, textureAsset.textureId);
return textureAsset.textureId; return textureAsset.textureId;
@@ -150,6 +162,25 @@ export class EngineIntegration {
return results; 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 * Unload texture
* 卸载纹理 * 卸载纹理
@@ -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'
);
}
@@ -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');
}
}
@@ -59,6 +59,10 @@ export enum AssetType {
AnimationClip = 'animation', AnimationClip = 'animation',
/** 行为树 */ /** 行为树 */
BehaviorTree = 'behaviortree', BehaviorTree = 'behaviortree',
/** 瓦片地图 */
Tilemap = 'tilemap',
/** 瓦片集 */
Tileset = 'tileset',
/** JSON数据 */ /** JSON数据 */
Json = 'json', Json = 'json',
/** 文本 */ /** 文本 */
@@ -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(', ')}]`);
}
}
+4 -15
View File
@@ -25,32 +25,21 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@esengine/behavior-tree": "workspace:*", "@esengine/behavior-tree": "workspace:*",
"@esengine/ecs-framework": "workspace:*", "@esengine/editor-runtime": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-replace": "^6.0.3",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rollup": "^4.28.1", "rollup": "^4.28.1",
"rollup-plugin-copy": "^3.5.0", "rollup-plugin-copy": "^3.5.0",
"rollup-plugin-dts": "^6.1.1", "rollup-plugin-dts": "^6.1.1",
"rollup-plugin-postcss": "^4.0.2", "rollup-plugin-postcss": "^4.0.2",
"typescript": "^5.8.2", "typescript": "^5.8.2"
"zustand": "^5.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"@esengine/behavior-tree": "*", "@esengine/behavior-tree": "*",
"@esengine/ecs-framework": "*", "@esengine/editor-runtime": "*"
"@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"
}, },
"dependencies": { "dependencies": {
"mobx": "^6.15.0", "mobx": "^6.15.0",
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -1,20 +1,12 @@
const resolve = require('@rollup/plugin-node-resolve'); const resolve = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs'); const commonjs = require('@rollup/plugin-commonjs');
const replace = require('@rollup/plugin-replace');
const dts = require('rollup-plugin-dts').default; const dts = require('rollup-plugin-dts').default;
const postcss = require('rollup-plugin-postcss'); const postcss = require('rollup-plugin-postcss');
const external = [ const external = [
'react', '@esengine/editor-runtime',
'react/jsx-runtime',
'zustand',
'zustand/middleware',
'lucide-react',
'@esengine/ecs-framework',
'@esengine/editor-core',
'@esengine/behavior-tree', '@esengine/behavior-tree',
'tsyringe',
'@tauri-apps/api/core',
'@tauri-apps/plugin-dialog'
]; ];
module.exports = [ module.exports = [
@@ -28,6 +20,10 @@ module.exports = [
inlineDynamicImports: true inlineDynamicImports: true
}, },
plugins: [ plugins: [
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production')
}),
resolve({ resolve({
extensions: ['.js', '.jsx'] extensions: ['.js', '.jsx']
}), }),
@@ -60,7 +56,12 @@ module.exports = [
], ],
external: [ external: [
...external, ...external,
/\.css$/ /\.css$/,
// 排除 React 相关类型,避免 rollup-plugin-dts 解析问题
'react',
'react-dom',
/^@types\//,
/^@esengine\//
] ]
} }
]; ];
@@ -1,25 +1,32 @@
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
import { import {
IEditorPlugin, type Core,
type ServiceContainer,
type IService,
type ServiceType,
type IEditorPlugin,
EditorPluginCategory, EditorPluginCategory,
CompilerRegistry, CompilerRegistry,
ICompilerRegistry,
InspectorRegistry, InspectorRegistry,
IInspectorRegistry,
PanelPosition, PanelPosition,
type FileCreationTemplate, type FileCreationTemplate,
type FileActionHandler, type FileActionHandler,
type PanelDescriptor type PanelDescriptor,
} from '@esengine/editor-core'; createElement,
Icons,
createLogger,
} from '@esengine/editor-runtime';
import { BehaviorTreeService } from './services/BehaviorTreeService'; import { BehaviorTreeService } from './services/BehaviorTreeService';
import { FileSystemService } from './services/FileSystemService'; import { FileSystemService } from './services/FileSystemService';
import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler'; import { BehaviorTreeCompiler } from './compiler/BehaviorTreeCompiler';
import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider'; import { BehaviorTreeNodeInspectorProvider } from './providers/BehaviorTreeNodeInspectorProvider';
import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel'; import { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
import { useBehaviorTreeDataStore } from './stores'; import { useBehaviorTreeDataStore } from './stores';
import { createElement } from 'react';
import { GitBranch } from 'lucide-react';
import { createRootNode } from './domain/constants/RootNode'; import { createRootNode } from './domain/constants/RootNode';
import type { IService, ServiceType } from '@esengine/ecs-framework'; import { PluginContext } from './PluginContext';
import { createLogger } from '@esengine/ecs-framework';
const { GitBranch } = Icons;
const logger = createLogger('BehaviorTreePlugin'); const logger = createLogger('BehaviorTreePlugin');
@@ -38,6 +45,8 @@ export class BehaviorTreePlugin implements IEditorPlugin {
async install(core: Core, services: ServiceContainer): Promise<void> { async install(core: Core, services: ServiceContainer): Promise<void> {
this.services = services; this.services = services;
// 设置插件上下文,让内部服务可以访问服务容器
PluginContext.setServices(services);
this.registerServices(services); this.registerServices(services);
this.registerCompilers(services); this.registerCompilers(services);
this.registerInspectors(services); this.registerInspectors(services);
@@ -53,6 +62,7 @@ export class BehaviorTreePlugin implements IEditorPlugin {
this.registeredServices.clear(); this.registeredServices.clear();
useBehaviorTreeDataStore.getState().reset(); useBehaviorTreeDataStore.getState().reset();
PluginContext.clear();
this.services = undefined; this.services = undefined;
} }
@@ -88,7 +98,7 @@ export class BehaviorTreePlugin implements IEditorPlugin {
private registerCompilers(services: ServiceContainer): void { private registerCompilers(services: ServiceContainer): void {
try { try {
const compilerRegistry = services.resolve(CompilerRegistry); const compilerRegistry = services.resolve<CompilerRegistry>(ICompilerRegistry);
const compiler = new BehaviorTreeCompiler(); const compiler = new BehaviorTreeCompiler();
compilerRegistry.register(compiler); compilerRegistry.register(compiler);
logger.info('Successfully registered BehaviorTreeCompiler'); logger.info('Successfully registered BehaviorTreeCompiler');
@@ -98,10 +108,14 @@ export class BehaviorTreePlugin implements IEditorPlugin {
} }
private registerInspectors(services: ServiceContainer): void { private registerInspectors(services: ServiceContainer): void {
const inspectorRegistry = services.resolve(InspectorRegistry); try {
if (inspectorRegistry) { const inspectorRegistry = services.resolve<InspectorRegistry>(IInspectorRegistry);
const provider = new BehaviorTreeNodeInspectorProvider(); if (inspectorRegistry) {
inspectorRegistry.register(provider); const provider = new BehaviorTreeNodeInspectorProvider();
inspectorRegistry.register(provider);
}
} catch (error) {
logger.error('Failed to register inspector:', error);
} }
} }
@@ -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();
@@ -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 { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTree } from '../../domain/models/BehaviorTree'; import { BehaviorTree } from '../../domain/models/BehaviorTree';
import { Node } from '../../domain/models/Node'; import { Node } from '../../domain/models/Node';
@@ -1,10 +1,19 @@
import React, { useState, useEffect } from 'react'; import {
import { ICompiler, CompileResult, CompilerContext, IFileSystem } from '@esengine/editor-core'; React,
import { File, FolderTree, FolderOpen } from 'lucide-react'; useState,
useEffect,
type ICompiler,
type CompileResult,
type CompilerContext,
type IFileSystem,
Icons,
createLogger,
} from '@esengine/editor-runtime';
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator'; import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree'; import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore'; import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
import { createLogger } from '@esengine/ecs-framework';
const { File, FolderTree, FolderOpen } = Icons;
const logger = createLogger('BehaviorTreeCompiler'); const logger = createLogger('BehaviorTreeCompiler');
@@ -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 { NodeTemplate, BlackboardValueType } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores'; import { useBehaviorTreeDataStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
import { useUIStore } from '../stores'; import { useUIStore } from '../stores';
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import { React, useState, Icons } from '@esengine/editor-runtime';
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } from 'lucide-react';
const { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } = Icons;
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object'; type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
@@ -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 { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
import { EditorConfig } from '../../types'; import { EditorConfig } from '../../types';
import { GridBackground } from './GridBackground'; import { GridBackground } from './GridBackground';
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import { React, useMemo } from '@esengine/editor-runtime';
interface GridBackgroundProps { interface GridBackgroundProps {
canvasOffset: { x: number; y: number }; canvasOffset: { x: number; y: number };
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, ReactNode } from 'react'; import { React, useState, useRef, useEffect, type ReactNode, Icons } from '@esengine/editor-runtime';
import { GripVertical } from 'lucide-react';
const { GripVertical } = Icons;
interface DraggablePanelProps { interface DraggablePanelProps {
title: string | ReactNode; title: string | ReactNode;
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import { React, useMemo } from '@esengine/editor-runtime';
import { ConnectionRenderer } from './ConnectionRenderer'; import { ConnectionRenderer } from './ConnectionRenderer';
import { ConnectionViewData } from '../../types'; import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node'; import { Node } from '../../domain/models/Node';
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import { React, useMemo } from '@esengine/editor-runtime';
import { ConnectionViewData } from '../../types'; import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node'; import { Node } from '../../domain/models/Node';
@@ -1,5 +1,6 @@
import React from 'react'; import { React, Icons } from '@esengine/editor-runtime';
import { Trash2, Replace, Plus } from 'lucide-react';
const { Trash2, Replace, Plus } = Icons;
interface NodeContextMenuProps { interface NodeContextMenuProps {
visible: boolean; visible: boolean;
@@ -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 { NodeTemplate } from '@esengine/behavior-tree';
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
import { NodeFactory } from '../../infrastructure/factories/NodeFactory'; import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
const { Search, X, ChevronDown, ChevronRight } = Icons;
interface QuickCreateMenuProps { interface QuickCreateMenuProps {
visible: boolean; visible: boolean;
position: { x: number; y: number }; position: { x: number; y: number };
@@ -1,19 +1,17 @@
import React from 'react'; import { React, Icons } from '@esengine/editor-runtime';
import { import type { LucideIcon } from '@esengine/editor-runtime';
TreePine,
Database,
AlertTriangle,
AlertCircle,
LucideIcon
} from 'lucide-react';
import { PropertyDefinition } from '@esengine/behavior-tree'; import { PropertyDefinition } from '@esengine/behavior-tree';
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node'; import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection'; import { Connection } from '../../domain/models/Connection';
import { ROOT_NODE_ID } from '../../domain/constants/RootNode'; import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
import type { NodeExecutionStatus } from '../../stores'; import type { NodeExecutionStatus } from '../../stores';
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor'; import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard'; import { BlackboardValue } from '../../domain/models/Blackboard';
const { TreePine, Database, AlertTriangle, AlertCircle } = Icons;
type BlackboardVariables = Record<string, BlackboardValue>; type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeNodeProps { interface BehaviorTreeNodeProps {
@@ -1,8 +1,10 @@
import React, { useMemo } from 'react'; import { React, useMemo, Icons } from '@esengine/editor-runtime';
import * as LucideIcons from 'lucide-react'; import type { LucideIcon } from '@esengine/editor-runtime';
import type { LucideIcon } from 'lucide-react';
import { NodeViewData } from '../../types'; import { NodeViewData } from '../../types';
const LucideIcons = Icons;
/** /**
* *
*/ */
@@ -1,16 +1,25 @@
import React, { useState, useCallback, useEffect } from 'react'; import {
import { Core, createLogger } from '@esengine/ecs-framework'; React,
import { MessageHub } from '@esengine/editor-core'; useState,
import { open, save } from '@tauri-apps/plugin-dialog'; useCallback,
useEffect,
Core,
createLogger,
MessageHub,
open,
save,
Icons,
} from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore } from '../../stores'; import { useBehaviorTreeDataStore } from '../../stores';
import { BehaviorTreeEditor } from '../BehaviorTreeEditor'; import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
import { BehaviorTreeService } from '../../services/BehaviorTreeService'; import { BehaviorTreeService } from '../../services/BehaviorTreeService';
import { showToast } from '../../services/NotificationService'; import { showToast } from '../../services/NotificationService';
import { FolderOpen } from 'lucide-react';
import { Node as BehaviorTreeNode } from '../../domain/models/Node'; import { Node as BehaviorTreeNode } from '../../domain/models/Node';
import { BehaviorTree } from '../../domain/models/BehaviorTree'; import { BehaviorTree } from '../../domain/models/BehaviorTree';
import './BehaviorTreeEditorPanel.css'; import './BehaviorTreeEditorPanel.css';
const { FolderOpen } = Icons;
const logger = createLogger('BehaviorTreeEditorPanel'); const logger = createLogger('BehaviorTreeEditorPanel');
/** /**
@@ -1,5 +1,6 @@
import React from 'react'; import { React, Icons } from '@esengine/editor-runtime';
import { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } from 'lucide-react';
const { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } = Icons;
type ExecutionMode = 'idle' | 'running' | 'paused'; type ExecutionMode = 'idle' | 'running' | 'paused';
@@ -1,12 +1,14 @@
import { NodeTemplate, NodeType } from '@esengine/behavior-tree'; 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, List, GitBranch, Layers, Shuffle, RotateCcw,
Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code, Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings, Equal, Dices, Settings,
Database, TreePine, Database, TreePine
LucideIcon } = Icons;
} from 'lucide-react';
export const ICON_MAP: Record<string, LucideIcon> = { export const ICON_MAP: Record<string, LucideIcon> = {
List, List,
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, React } from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore, useUIStore } from '../stores'; import { useBehaviorTreeDataStore, useUIStore } from '../stores';
/** /**
@@ -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'; import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
interface QuickCreateMenuState { interface QuickCreateMenuState {
@@ -1,5 +1,4 @@
import { useRef, useCallback, useMemo, useEffect } from 'react'; import { useRef, useCallback, useMemo, useEffect, CommandManager } from '@esengine/editor-runtime';
import { CommandManager } from '@esengine/editor-core';
/** /**
* / Hook * / Hook
@@ -1,11 +1,9 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, CommandManager, createLogger } from '@esengine/editor-runtime';
import { CommandManager } from '@esengine/editor-core';
import { ConnectionType } from '../domain/models/Connection'; import { ConnectionType } from '../domain/models/Connection';
import { IValidator } from '../domain/interfaces/IValidator'; import { IValidator } from '../domain/interfaces/IValidator';
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore'; import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
import { AddConnectionUseCase } from '../application/use-cases/AddConnectionUseCase'; import { AddConnectionUseCase } from '../application/use-cases/AddConnectionUseCase';
import { RemoveConnectionUseCase } from '../application/use-cases/RemoveConnectionUseCase'; import { RemoveConnectionUseCase } from '../application/use-cases/RemoveConnectionUseCase';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useConnectionOperations'); const logger = createLogger('useConnectionOperations');
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, React } from '@esengine/editor-runtime';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores'; import { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
interface ContextMenuState { interface ContextMenuState {
@@ -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 { NodeTemplate, NodeType } from '@esengine/behavior-tree';
import { Position } from '../domain/value-objects/Position'; import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations'; import { useNodeOperations } from './useNodeOperations';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useDropHandler'); const logger = createLogger('useDropHandler');
@@ -1,5 +1,4 @@
import { useCallback } from 'react'; import { useCallback, React, ask } from '@esengine/editor-runtime';
import { ask } from '@tauri-apps/plugin-dialog';
import { BehaviorTreeNode } from '../stores'; import { BehaviorTreeNode } from '../stores';
interface UseEditorHandlersParams { interface UseEditorHandlersParams {
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useRef, useState } from '@esengine/editor-runtime';
import { BehaviorTreeExecutor } from '../utils/BehaviorTreeExecutor'; import { BehaviorTreeExecutor } from '../utils/BehaviorTreeExecutor';
export function useEditorState() { export function useEditorState() {
@@ -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 { ExecutionController, ExecutionMode } from '../application/services/ExecutionController';
import { BlackboardManager } from '../application/services/BlackboardManager'; import { BlackboardManager } from '../application/services/BlackboardManager';
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores'; import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
import { ExecutionLog } from '../utils/BehaviorTreeExecutor'; import { ExecutionLog } from '../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../domain/models/Blackboard'; import { BlackboardValue } from '../domain/models/Blackboard';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('useExecutionController'); const logger = createLogger('useExecutionController');
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect } from '@esengine/editor-runtime';
import { Connection, ROOT_NODE_ID } from '../stores'; import { Connection, ROOT_NODE_ID } from '../stores';
import { useNodeOperations } from './useNodeOperations'; import { useNodeOperations } from './useNodeOperations';
import { useConnectionOperations } from './useConnectionOperations'; import { useConnectionOperations } from './useConnectionOperations';
@@ -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 { BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
import { Position } from '../domain/value-objects/Position'; import { Position } from '../domain/value-objects/Position';
import { useNodeOperations } from './useNodeOperations'; import { useNodeOperations } from './useNodeOperations';
@@ -1,6 +1,5 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, CommandManager } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree'; import { NodeTemplate } from '@esengine/behavior-tree';
import { CommandManager } from '@esengine/editor-core';
import { Position } from '../domain/value-objects/Position'; import { Position } from '../domain/value-objects/Position';
import { INodeFactory } from '../domain/interfaces/INodeFactory'; import { INodeFactory } from '../domain/interfaces/INodeFactory';
import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore'; import { TreeStateAdapter } from '../application/state/BehaviorTreeDataStore';
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from '@esengine/editor-runtime';
import { BehaviorTreeNode } from '../stores'; import { BehaviorTreeNode } from '../stores';
import { ExecutionMode } from '../application/services/ExecutionController'; import { ExecutionMode } from '../application/services/ExecutionController';
@@ -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 { BehaviorTreeNode, Connection, ROOT_NODE_ID, useUIStore } from '../stores';
import { PropertyDefinition } from '@esengine/behavior-tree'; import { PropertyDefinition } from '@esengine/behavior-tree';
import { useConnectionOperations } from './useConnectionOperations'; import { useConnectionOperations } from './useConnectionOperations';
@@ -1,4 +1,4 @@
import { useState, RefObject } from 'react'; import { useState, type RefObject } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree'; import { NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores'; import { BehaviorTreeNode, Connection, useBehaviorTreeDataStore } from '../stores';
import { Node } from '../domain/models/Node'; import { Node } from '../domain/models/Node';
@@ -3,6 +3,7 @@ import { BehaviorTreePlugin } from './BehaviorTreePlugin';
export default new BehaviorTreePlugin(); export default new BehaviorTreePlugin();
export { BehaviorTreePlugin } from './BehaviorTreePlugin'; export { BehaviorTreePlugin } from './BehaviorTreePlugin';
export { PluginContext } from './PluginContext';
export { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel'; export { BehaviorTreeEditorPanel } from './components/panels/BehaviorTreeEditorPanel';
export * from './BehaviorTreeModule'; export * from './BehaviorTreeModule';
export * from './services/BehaviorTreeService'; export * from './services/BehaviorTreeService';
@@ -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 { NodeTemplate } from '@esengine/behavior-tree';
import { Node as BehaviorTreeNode } from '../domain/models/Node'; 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'); const logger = createLogger('IEditorExtensions');
@@ -1,6 +1,14 @@
import React, { useState, useCallback } from 'react'; import {
import { IInspectorProvider, InspectorContext, MessageHub, FieldEditorRegistry, FieldEditorContext } from '@esengine/editor-core'; React,
import { Core } from '@esengine/ecs-framework'; useState,
useCallback,
type IInspectorProvider,
type InspectorContext,
MessageHub,
FieldEditorRegistry,
type FieldEditorContext,
Core,
} from '@esengine/editor-runtime';
import { Node as BehaviorTreeNode } from '../domain/models/Node'; import { Node as BehaviorTreeNode } from '../domain/models/Node';
import { PropertyDefinition } from '@esengine/behavior-tree'; import { PropertyDefinition } from '@esengine/behavior-tree';
@@ -1,9 +1,14 @@
import { singleton } from 'tsyringe'; import {
import { Core, IService, createLogger } from '@esengine/ecs-framework'; singleton,
import { MessageHub } from '@esengine/editor-core'; type IService,
createLogger,
MessageHub,
IMessageHub,
} from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore'; import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
import type { BehaviorTree } from '../domain/models/BehaviorTree'; import type { BehaviorTree } from '../domain/models/BehaviorTree';
import { FileSystemService } from './FileSystemService'; import { FileSystemService } from './FileSystemService';
import { PluginContext } from '../PluginContext';
const logger = createLogger('BehaviorTreeService'); const logger = createLogger('BehaviorTreeService');
@@ -15,8 +20,10 @@ export class BehaviorTreeService implements IService {
async loadFromFile(filePath: string): Promise<void> { async loadFromFile(filePath: string): Promise<void> {
try { try {
const services = PluginContext.getServices();
// 运行时解析 FileSystemService // 运行时解析 FileSystemService
const fileSystem = Core.services.resolve(FileSystemService); const fileSystem = services.resolve(FileSystemService);
if (!fileSystem) { if (!fileSystem) {
throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.'); 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 中保存文件信息,Panel 挂载时读取
store.setCurrentFile(filePath, fileName); store.setCurrentFile(filePath, fileName);
const messageHub = Core.services.resolve(MessageHub); const messageHub = services.resolve<MessageHub>(IMessageHub);
if (messageHub) { if (messageHub) {
messageHub.publish('dynamic-panel:open', { messageHub.publish('dynamic-panel:open', {
panelId: 'behavior-tree-editor', panelId: 'behavior-tree-editor',
@@ -50,8 +57,10 @@ export class BehaviorTreeService implements IService {
async saveToFile(filePath: string, metadata?: { name: string; description: string }): Promise<void> { async saveToFile(filePath: string, metadata?: { name: string; description: string }): Promise<void> {
try { try {
const services = PluginContext.getServices();
// 运行时解析 FileSystemService // 运行时解析 FileSystemService
const fileSystem = Core.services.resolve(FileSystemService); const fileSystem = services.resolve(FileSystemService);
if (!fileSystem) { if (!fileSystem) {
throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.'); throw new Error('FileSystemService not found. Please ensure the BehaviorTreePlugin is properly installed.');
} }
@@ -1,6 +1,4 @@
import { singleton } from 'tsyringe'; import { singleton, invoke, type IService } from '@esengine/editor-runtime';
import { invoke } from '@tauri-apps/api/core';
import { IService } from '@esengine/ecs-framework';
/** /**
* *
@@ -1,5 +1,4 @@
import { Core, createLogger } from '@esengine/ecs-framework'; import { Core, createLogger, MessageHub } from '@esengine/editor-runtime';
import { MessageHub } from '@esengine/editor-core';
const logger = createLogger('NotificationService'); const logger = createLogger('NotificationService');
@@ -1,4 +1,6 @@
import { create } from 'zustand'; import { createStore } from '@esengine/editor-runtime';
const create = createStore;
/** /**
* *
@@ -1,4 +1,6 @@
import { create } from 'zustand'; import { createStore } from '@esengine/editor-runtime';
const create = createStore;
/** /**
* UI Store * UI Store
@@ -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 { Node as BehaviorTreeNode } from '../domain/models/Node';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('portUtils'); const logger = createLogger('portUtils');
+3 -1
View File
@@ -29,6 +29,7 @@
"build:ts": "tsc", "build:ts": "tsc",
"prebuild": "npm run clean", "prebuild": "npm run clean",
"build": "npm run build:ts", "build": "npm run build:ts",
"build:esm": "vite build",
"build:watch": "tsc --watch", "build:watch": "tsc --watch",
"rebuild": "npm run clean && npm run build", "rebuild": "npm run clean && npm run build",
"build:npm": "npm run build && node build-rollup.cjs", "build:npm": "npm run build && node build-rollup.cjs",
@@ -56,7 +57,8 @@
"rollup": "^4.42.0", "rollup": "^4.42.0",
"rollup-plugin-dts": "^6.2.1", "rollup-plugin-dts": "^6.2.1",
"ts-jest": "^29.4.0", "ts-jest": "^29.4.0",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"vite": "^6.0.7"
}, },
"dependencies": { "dependencies": {
"tslib": "^2.8.1" "tslib": "^2.8.1"
File diff suppressed because it is too large Load Diff
+6 -3
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 { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent';
export { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager'; 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 { BehaviorTreeExecutionSystem } from './BehaviorTreeExecutionSystem';
export { NodeMetadata, ConfigFieldDefinition, NodeMetadataRegistry, NodeExecutorMetadata } from './NodeMetadata'; export type { NodeMetadata, ConfigFieldDefinition, NodeExecutorMetadata } from './NodeMetadata';
export { NodeMetadataRegistry } from './NodeMetadata';
export * from './Executors'; export * from './Executors';
+22
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
}
});
+1 -1
View File
@@ -16,4 +16,4 @@ export { BoxColliderComponent } from './BoxColliderComponent';
export { CircleColliderComponent } from './CircleColliderComponent'; export { CircleColliderComponent } from './CircleColliderComponent';
// 音频 // 音频
export { AudioSourceComponent } from './AudioSourceComponent'; export { AudioSourceComponent } from './AudioSourceComponent';
+5550
View File
File diff suppressed because it is too large Load Diff
+73 -44
View File
@@ -23,6 +23,13 @@ export interface IService {
*/ */
export type ServiceType<T extends IService> = new (...args: any[]) => T; 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> { 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>, type: ServiceType<T>,
factory?: (container: ServiceContainer) => T factory?: (container: ServiceContainer) => T
): void { ): void {
if (this._services.has(type as ServiceType<IService>)) { if (this._services.has(type as ServiceIdentifier)) {
logger.warn(`Service ${type.name} is already registered`); logger.warn(`Service ${type.name} is already registered`);
return; return;
} }
this._services.set(type as ServiceType<IService>, { this._services.set(type as ServiceIdentifier, {
identifier: type as ServiceIdentifier,
type: type as ServiceType<IService>, type: type as ServiceType<IService>,
...(factory && { factory: factory as (container: ServiceContainer) => IService }), ...(factory && { factory: factory as (container: ServiceContainer) => IService }),
lifetime: ServiceLifetime.Singleton lifetime: ServiceLifetime.Singleton
@@ -164,12 +177,13 @@ export class ServiceContainer {
type: ServiceType<T>, type: ServiceType<T>,
factory?: (container: ServiceContainer) => T factory?: (container: ServiceContainer) => T
): void { ): void {
if (this._services.has(type as ServiceType<IService>)) { if (this._services.has(type as ServiceIdentifier)) {
logger.warn(`Service ${type.name} is already registered`); logger.warn(`Service ${type.name} is already registered`);
return; return;
} }
this._services.set(type as ServiceType<IService>, { this._services.set(type as ServiceIdentifier, {
identifier: type as ServiceIdentifier,
type: type as ServiceType<IService>, type: type as ServiceType<IService>,
...(factory && { factory: factory as (container: ServiceContainer) => IService }), ...(factory && { factory: factory as (container: ServiceContainer) => IService }),
lifetime: ServiceLifetime.Transient lifetime: ServiceLifetime.Transient
@@ -183,65 +197,77 @@ export class ServiceContainer {
* *
* *
* *
* @param type - * @param identifier - Symbol
* @param instance - * @param instance -
* *
* @example * @example
* ```typescript * ```typescript
* const config = new Config(); * const config = new Config();
* container.registerInstance(Config, 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 { public registerInstance<T extends IService>(identifier: ServiceIdentifier<T>, instance: T): void {
if (this._services.has(type as ServiceType<IService>)) { if (this._services.has(identifier)) {
logger.warn(`Service ${type.name} is already registered`); const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
logger.warn(`Service ${name} is already registered`);
return; return;
} }
this._services.set(type as ServiceType<IService>, { this._services.set(identifier, {
type: type as ServiceType<IService>, identifier,
instance: instance as IService, instance: instance as IService,
lifetime: ServiceLifetime.Singleton lifetime: ServiceLifetime.Singleton
}); });
// 如果使用了@Updatable装饰器,添加到可更新列表 // 如果使用了@Updatable装饰器,添加到可更新列表
if (checkUpdatable(type)) { if (typeof identifier !== 'symbol' && checkUpdatable(identifier)) {
const metadata = getUpdatableMetadata(type); const metadata = getUpdatableMetadata(identifier);
const priority = metadata?.priority ?? 0; const priority = metadata?.priority ?? 0;
this._updatableServices.push({ instance, priority }); this._updatableServices.push({ instance, priority });
// 按优先级排序(数值越小越先执行) // 按优先级排序(数值越小越先执行)
this._updatableServices.sort((a, b) => a.priority - b.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 * @returns
* @throws * @throws
* *
* @example * @example
* ```typescript * ```typescript
* const timer = container.resolve(TimerManager); * const timer = container.resolve(TimerManager);
*
* // 使用 Symbol
* const fileSystem = container.resolve(IFileSystem);
* ``` * ```
*/ */
public resolve<T extends IService>(type: ServiceType<T>): T { public resolve<T extends IService>(identifier: ServiceIdentifier<T>): T {
const registration = this._services.get(type as ServiceType<IService>); const registration = this._services.get(identifier);
const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
if (!registration) { 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>)) { if (this._resolving.has(identifier)) {
const chain = Array.from(this._resolving).map((t) => t.name).join(' -> '); const chain = Array.from(this._resolving).map((t) =>
throw new Error(`Circular dependency detected: ${chain} -> ${type.name}`); 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 { try {
// 创建实例 // 创建实例
@@ -259,9 +285,11 @@ export class ServiceContainer {
if (registration.factory) { if (registration.factory) {
// 使用工厂函数 // 使用工厂函数
instance = registration.factory(this); instance = registration.factory(this);
} else { } else if (registration.type) {
// 直接构造 // 直接构造
instance = new (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; registration.instance = instance;
// 如果使用了@Updatable装饰器,添加到可更新列表 // 如果使用了@Updatable装饰器,添加到可更新列表
if (checkUpdatable(registration.type)) { if (registration.type && checkUpdatable(registration.type)) {
const metadata = getUpdatableMetadata(registration.type); const metadata = getUpdatableMetadata(registration.type);
const priority = metadata?.priority ?? 0; const priority = metadata?.priority ?? 0;
this._updatableServices.push({ instance, priority }); this._updatableServices.push({ instance, priority });
@@ -277,14 +305,14 @@ export class ServiceContainer {
// 按优先级排序(数值越小越先执行) // 按优先级排序(数值越小越先执行)
this._updatableServices.sort((a, b) => a.priority - b.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 ${name} is updatable (priority: ${priority}), added to update list`);
} }
} }
return instance as T; return instance as T;
} finally { } finally {
// 从解析栈移除 // 从解析栈移除
this._resolving.delete(type as ServiceType<IService>); this._resolving.delete(identifier);
} }
} }
@@ -293,7 +321,7 @@ export class ServiceContainer {
* *
* null而不是抛出异常 * null而不是抛出异常
* *
* @param type - * @param identifier - Symbol
* @returns null * @returns null
* *
* @example * @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 { try {
return this.resolve(type); return this.resolve(identifier);
} catch { } catch {
return null; return null;
} }
@@ -315,21 +343,21 @@ export class ServiceContainer {
/** /**
* *
* *
* @param type - * @param identifier - Symbol
* @returns * @returns
*/ */
public isRegistered<T extends IService>(type: ServiceType<T>): boolean { public isRegistered<T extends IService>(identifier: ServiceIdentifier<T>): boolean {
return this._services.has(type as ServiceType<IService>); return this._services.has(identifier);
} }
/** /**
* *
* *
* @param type - * @param identifier - Symbol
* @returns * @returns
*/ */
public unregister<T extends IService>(type: ServiceType<T>): boolean { public unregister<T extends IService>(identifier: ServiceIdentifier<T>): boolean {
const registration = this._services.get(type as ServiceType<IService>); const registration = this._services.get(identifier);
if (!registration) { if (!registration) {
return false; return false;
} }
@@ -345,8 +373,9 @@ export class ServiceContainer {
registration.instance.dispose(); registration.instance.dispose();
} }
this._services.delete(type as ServiceType<IService>); this._services.delete(identifier);
logger.debug(`Unregistered service: ${type.name}`); const name = typeof identifier === 'symbol' ? identifier.description : identifier.name;
logger.debug(`Unregistered service: ${name}`);
return true; return true;
} }
@@ -367,11 +396,11 @@ export class ServiceContainer {
} }
/** /**
* *
* *
* @returns * @returns
*/ */
public getRegisteredServices(): ServiceType<IService>[] { public getRegisteredServices(): ServiceIdentifier[] {
return Array.from(this._services.keys()); return Array.from(this._services.keys());
} }
+27
View File
@@ -84,4 +84,31 @@ export abstract class Component implements IComponent {
* System * System
*/ */
public onRemovedFromEntity(): void {} 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> {}
} }
@@ -281,6 +281,40 @@ export class SceneSerializer {
if (serializedScene.sceneData) { if (serializedScene.sceneData) {
this.deserializeSceneData(serializedScene.sceneData, scene.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);
}
} }
/** /**
+2
View File
@@ -24,6 +24,8 @@ export interface IComponent {
onAddedToEntity(): void; onAddedToEntity(): void;
/** 组件从实体移除时的回调 */ /** 组件从实体移除时的回调 */
onRemovedFromEntity(): void; onRemovedFromEntity(): void;
/** 组件反序列化后的回调 */
onDeserialized(): void | Promise<void>;
} }
/** /**
+1 -1
View File
@@ -6,7 +6,7 @@
// 核心模块 // 核心模块
export { Core } from './Core'; export { Core } from './Core';
export { ServiceContainer, ServiceLifetime } from './Core/ServiceContainer'; 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'; export { PluginManager } from './Core/PluginManager';
+304
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
+1 -1
View File
@@ -8,6 +8,6 @@
export { EngineBridge, EngineBridgeConfig } from './core/EngineBridge'; export { EngineBridge, EngineBridgeConfig } from './core/EngineBridge';
export { RenderBatcher } from './core/RenderBatcher'; export { RenderBatcher } from './core/RenderBatcher';
export { SpriteRenderHelper, ITransformComponent } from './core/SpriteRenderHelper'; 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 { CameraSystem } from './systems/CameraSystem';
export * from './types'; export * from './types';
@@ -10,6 +10,87 @@ import { RenderBatcher } from '../core/RenderBatcher';
import type { SpriteRenderData } from '../types'; import type { SpriteRenderData } from '../types';
import type { ITransformComponent } from '../core/SpriteRenderHelper'; 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. * Type for transform component constructor.
* *
@@ -55,6 +136,15 @@ export class EngineRenderSystem extends EntitySystem {
// 可重用的映射以避免每帧分配 // 可重用的映射以避免每帧分配
private entityRenderMap: Map<number, SpriteRenderData> = new Map(); 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. * Create a new engine render system.
* *
@@ -86,7 +176,6 @@ export class EngineRenderSystem extends EntitySystem {
* *
*/ */
protected override onBegin(): void { protected override onBegin(): void {
// Clear the batch | 清空批处理 // Clear the batch | 清空批处理
this.batcher.clear(); this.batcher.clear();
@@ -108,6 +197,12 @@ export class EngineRenderSystem extends EntitySystem {
// 清空并重用映射用于绘制gizmo // 清空并重用映射用于绘制gizmo
this.entityRenderMap.clear(); 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) { for (const entity of entities) {
const sprite = entity.getComponent(SpriteComponent); const sprite = entity.getComponent(SpriteComponent);
const transform = entity.getComponent(this.transformType) as unknown as ITransformComponent | null; const transform = entity.getComponent(this.transformType) as unknown as ITransformComponent | null;
@@ -159,35 +254,76 @@ export class EngineRenderSystem extends EntitySystem {
color color
}; };
this.batcher.addSprite(renderData); renderItems.push({ sortingOrder: sprite.sortingOrder, sprites: [renderData] });
this.entityRenderMap.set(entity.id, 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) { if (!this.batcher.isEmpty) {
const sprites = this.batcher.getSprites(); const sprites = this.batcher.getSprites();
this.bridge.submitSprites(sprites); 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) // Draw gizmos for selected entities (always, even if no sprites)
// 为选中的实体绘制Gizmo(始终绘制,即使没有精灵) // 为选中的实体绘制Gizmo(始终绘制,即使没有精灵)
if (this.showGizmos && this.selectedEntityIds.size > 0) { if (this.showGizmos && this.selectedEntityIds.size > 0) {
for (const entityId of this.selectedEntityIds) { this.drawSelectedEntityGizmos();
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
);
}
}
} }
// Draw camera frustum gizmos // Draw camera frustum gizmos
@@ -199,6 +335,296 @@ export class EngineRenderSystem extends EntitySystem {
this.bridge.render(); 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. * Draw camera frustum gizmos for all cameras in scene.
* gizmo * 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. * Set gizmo visibility.
* Gizmo可见性 * 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. * Get the number of sprites rendered.
* *
+310
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>;
@@ -9,6 +9,7 @@
"outDir": "./bin", "outDir": "./bin",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,
"composite": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
+3
View File
@@ -4,9 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ECS Framework Editor</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- Import Map 将由 PluginLoader 在运行时动态注入 -->
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+7 -6
View File
@@ -5,20 +5,21 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "build:sdk": "cd ../editor-runtime && pnpm build",
"build": "tsc && vite build", "build": "npm run build:sdk && tsc && vite build",
"preview": "vite preview", "build:watch": "vite build --watch",
"tauri": "tauri", "tauri": "tauri",
"kill-dev": "node scripts/kill-dev-server.js", "tauri:dev": "npm run build:sdk && tauri dev",
"tauri:dev": "npm run kill-dev && tauri dev",
"bundle:runtime": "node scripts/bundle-runtime.mjs", "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" "version": "node scripts/sync-version.js && git add src-tauri/tauri.conf.json"
}, },
"dependencies": { "dependencies": {
"@esengine/asset-system": "workspace:*", "@esengine/asset-system": "workspace:*",
"@esengine/behavior-tree": "workspace:*", "@esengine/behavior-tree": "workspace:*",
"@esengine/ecs-components": "workspace:*", "@esengine/ecs-components": "workspace:*",
"@esengine/tilemap": "workspace:*",
"@esengine/tilemap-editor": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*", "@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/ecs-framework": "workspace:*", "@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*", "@esengine/editor-core": "workspace:*",
+3127
View File
File diff suppressed because it is too large Load Diff
+15
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;
@@ -0,0 +1,4 @@
// React JSX Runtime shim - 从全局变量导出
const ReactJSXRuntime = window.ReactJSXRuntime;
export const { jsx, jsxs, Fragment } = ReactJSXRuntime;
export default ReactJSXRuntime;
+40
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;
@@ -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))?; .map_err(|e| format!("Failed to create .build-cache directory: {}", e))?;
} }
let npm_command = if cfg!(target_os = "windows") { let pnpm_command = if cfg!(target_os = "windows") {
"npm.cmd" "pnpm.cmd"
} else { } else {
"npm" "pnpm"
}; };
// Step 1: Install dependencies // Step 1: Install dependencies
@@ -52,15 +52,15 @@ pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<Strin
) )
.ok(); .ok();
let install_output = Command::new(npm_command) let install_output = Command::new(&pnpm_command)
.args(["install"]) .args(["install"])
.current_dir(&plugin_folder) .current_dir(&plugin_folder)
.output() .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() { if !install_output.status.success() {
return Err(format!( return Err(format!(
"npm install failed: {}", "pnpm install failed: {}",
String::from_utf8_lossy(&install_output.stderr) String::from_utf8_lossy(&install_output.stderr)
)); ));
} }
@@ -75,15 +75,15 @@ pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<Strin
) )
.ok(); .ok();
let build_output = Command::new(npm_command) let build_output = Command::new(&pnpm_command)
.args(["run", "build"]) .args(["run", "build"])
.current_dir(&plugin_folder) .current_dir(&plugin_folder)
.output() .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() { if !build_output.status.success() {
return Err(format!( return Err(format!(
"npm run build failed: {}", "pnpm run build failed: {}",
String::from_utf8_lossy(&build_output.stderr) String::from_utf8_lossy(&build_output.stderr)
)); ));
} }
@@ -101,6 +101,10 @@ fn handle_project_protocol(
let uri = request.uri(); let uri = request.uri();
let path = uri.path(); let path = uri.path();
// Debug logging
println!("[project://] Full URI: {}", uri);
println!("[project://] Path: {}", path);
let file_path = { let file_path = {
let paths = match project_paths.lock() { let paths = match project_paths.lock() {
Ok(p) => p, Ok(p) => p,
@@ -3,8 +3,7 @@
"version": "1.0.8", "version": "1.0.8",
"identifier": "com.esengine.editor", "identifier": "com.esengine.editor",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run build:watch",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
@@ -67,7 +66,7 @@
} }
], ],
"security": { "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": { "assetProtocol": {
"enable": true, "enable": true,
"scope": { "scope": {
+20 -23
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 { Core, createLogger, Scene } from '@esengine/ecs-framework';
import * as ECSFramework 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 { import {
EditorPluginManager, EditorPluginManager,
UIRegistry, UIRegistry,
@@ -13,6 +21,7 @@ import {
SceneManagerService, SceneManagerService,
ProjectService, ProjectService,
CompilerRegistry, CompilerRegistry,
ICompilerRegistry,
InspectorRegistry, InspectorRegistry,
INotification, INotification,
CommandManager CommandManager
@@ -64,6 +73,11 @@ Core.services.registerInstance(LocaleService, localeService);
Core.services.registerSingleton(GlobalBlackboardService); Core.services.registerSingleton(GlobalBlackboardService);
Core.services.registerSingleton(CompilerRegistry); Core.services.registerSingleton(CompilerRegistry);
// 在 CompilerRegistry 实例化后,也用 Symbol 注册,用于跨包插件访问
// 注意:registerSingleton 会延迟实例化,所以需要在第一次使用后再注册 Symbol
const compilerRegistryInstance = Core.services.resolve(CompilerRegistry);
Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance);
const logger = createLogger('App'); const logger = createLogger('App');
function App() { function App() {
@@ -368,33 +382,16 @@ function App() {
await projectService.openProject(projectPath); await projectService.openProject(projectPath);
await fetch('/@user-project-set-path', { // 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
method: 'POST', await TauriAPI.setProjectBasePath(projectPath);
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectPath })
});
setStatus(t('header.status.projectOpened')); 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 sceneManagerService = Core.services.resolve(SceneManagerService);
const scenesPath = projectService.getScenesPath(); if (sceneManagerService) {
if (scenesPath && sceneManagerService) { await sceneManagerService.newScene();
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 {
await sceneManagerService.newScene();
}
} catch {
await sceneManagerService.newScene();
}
} }
const settings = SettingsService.getInstance(); const settings = SettingsService.getInstance();
+14 -1
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> { static async generateQRCode(text: string): Promise<string> {
return await invoke<string>('generate_qrcode', { text }); return await invoke<string>('generate_qrcode', { text });
} }
/**
* Tauri 访 asset URL
* @param filePath
* @param protocol (: 'asset')
* @returns URL img srcaudio 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 { export interface DirectoryEntry {
@@ -2,13 +2,17 @@ import type { EditorPluginManager } from '@esengine/editor-core';
import { SceneInspectorPlugin } from '../../plugins/SceneInspectorPlugin'; import { SceneInspectorPlugin } from '../../plugins/SceneInspectorPlugin';
import { ProfilerPlugin } from '../../plugins/ProfilerPlugin'; import { ProfilerPlugin } from '../../plugins/ProfilerPlugin';
import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin'; import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin';
import { GizmoPlugin } from '../../plugins/GizmoPlugin';
import { TilemapEditorPlugin } from '@esengine/tilemap-editor';
export class PluginInstaller { export class PluginInstaller {
async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> { async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> {
const plugins = [ const plugins = [
new GizmoPlugin(),
new SceneInspectorPlugin(), new SceneInspectorPlugin(),
new ProfilerPlugin(), new ProfilerPlugin(),
new EditorAppearancePlugin() new EditorAppearancePlugin(),
new TilemapEditorPlugin()
]; ];
for (const plugin of plugins) { for (const plugin of plugins) {
@@ -19,4 +23,4 @@ export class PluginInstaller {
} }
} }
} }
} }
@@ -2,6 +2,7 @@ import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-
import { import {
UIRegistry, UIRegistry,
MessageHub, MessageHub,
IMessageHub,
SerializerRegistry, SerializerRegistry,
EntityStoreService, EntityStoreService,
ComponentRegistry, ComponentRegistry,
@@ -12,10 +13,17 @@ import {
SettingsRegistry, SettingsRegistry,
SceneManagerService, SceneManagerService,
FileActionRegistry, FileActionRegistry,
EntityCreationRegistry,
EditorPluginManager, EditorPluginManager,
InspectorRegistry, InspectorRegistry,
IInspectorRegistry,
PropertyRendererRegistry, PropertyRendererRegistry,
FieldEditorRegistry FieldEditorRegistry,
ComponentActionRegistry,
IDialogService,
IFileSystemService,
CompilerRegistry,
ICompilerRegistry
} from '@esengine/editor-core'; } from '@esengine/editor-core';
import { import {
TransformComponent, TransformComponent,
@@ -128,9 +136,12 @@ export class ServiceRegistry {
const settingsRegistry = new SettingsRegistry(); const settingsRegistry = new SettingsRegistry();
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService, entityStore); const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService, entityStore);
const fileActionRegistry = new FileActionRegistry(); const fileActionRegistry = new FileActionRegistry();
const entityCreationRegistry = new EntityCreationRegistry();
const componentActionRegistry = new ComponentActionRegistry();
Core.services.registerInstance(UIRegistry, uiRegistry); Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub); Core.services.registerInstance(MessageHub, messageHub);
Core.services.registerInstance(IMessageHub, messageHub); // Symbol 注册用于跨包插件访问
Core.services.registerInstance(SerializerRegistry, serializerRegistry); Core.services.registerInstance(SerializerRegistry, serializerRegistry);
Core.services.registerInstance(EntityStoreService, entityStore); Core.services.registerInstance(EntityStoreService, entityStore);
Core.services.registerInstance(ComponentRegistry, componentRegistry); Core.services.registerInstance(ComponentRegistry, componentRegistry);
@@ -141,6 +152,8 @@ export class ServiceRegistry {
Core.services.registerInstance(SettingsRegistry, settingsRegistry); Core.services.registerInstance(SettingsRegistry, settingsRegistry);
Core.services.registerInstance(SceneManagerService, sceneManager); Core.services.registerInstance(SceneManagerService, sceneManager);
Core.services.registerInstance(FileActionRegistry, fileActionRegistry); Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
const pluginManager = new EditorPluginManager(); const pluginManager = new EditorPluginManager();
pluginManager.initialize(coreInstance, Core.services); pluginManager.initialize(coreInstance, Core.services);
@@ -155,10 +168,12 @@ export class ServiceRegistry {
const dialog = new TauriDialogService(); const dialog = new TauriDialogService();
const notification = new NotificationService(); const notification = new NotificationService();
Core.services.registerInstance(NotificationService, notification); Core.services.registerInstance(NotificationService, notification);
Core.services.registerInstance(IDialogService, dialog);
Core.services.registerInstance(IFileSystemService, fileSystem);
const inspectorRegistry = new InspectorRegistry(); const inspectorRegistry = new InspectorRegistry();
Core.services.registerInstance(InspectorRegistry, inspectorRegistry); Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
const propertyRendererRegistry = new PropertyRendererRegistry(); const propertyRendererRegistry = new PropertyRendererRegistry();
Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry); Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry);
@@ -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;
}
}
@@ -2,5 +2,6 @@ export { CreateEntityCommand } from './CreateEntityCommand';
export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand'; export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand';
export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand'; export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand';
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand'; export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
export { CreateTilemapEntityCommand } from './CreateTilemapEntityCommand';
export { DeleteEntityCommand } from './DeleteEntityCommand'; export { DeleteEntityCommand } from './DeleteEntityCommand';
@@ -95,27 +95,40 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => { const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => {
const filePath = data.path; const filePath = data.path;
if (filePath) { if (!filePath || !projectPath) return;
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null; // Convert relative path to absolute path if needed
if (dirPath) { 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); setCurrentPath(dirPath);
// Load assets first, then set selection after list is populated
await loadAssets(dirPath); await loadAssets(dirPath);
setSelectedPaths(new Set([filePath])); setSelectedPaths(new Set([absoluteFilePath]));
// Expand tree to reveal the file // Expand tree to reveal the file
if (showDetailView) { if (showDetailView) {
detailViewFileTreeRef.current?.revealPath(filePath); detailViewFileTreeRef.current?.revealPath(absoluteFilePath);
} else { } else {
treeOnlyViewFileTreeRef.current?.revealPath(filePath); treeOnlyViewFileTreeRef.current?.revealPath(absoluteFilePath);
} }
} catch (error) {
console.error(`[AssetBrowser] Failed to reveal asset: ${absoluteFilePath}`, error);
} }
} }
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [showDetailView]); }, [showDetailView, projectPath]);
const loadAssets = async (path: string) => { const loadAssets = async (path: string) => {
setLoading(true); setLoading(true);
@@ -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 { CompilerRegistry, ICompiler, CompilerContext, CompileResult, IFileSystem, IDialog, FileEntry } from '@esengine/editor-core';
import { X, Play, Loader2 } from 'lucide-react'; 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 { 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'; import '../styles/CompilerConfigDialog.css';
interface DirectoryEntry { interface DirectoryEntry {
@@ -98,7 +98,11 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
return entries return entries
.filter((e) => !e.is_dir && e.name.endsWith(ext)) .filter((e) => !e.is_dir && e.name.endsWith(ext))
.map((e) => e.name.replace(ext, '')); .map((e) => e.name.replace(ext, ''));
} },
convertToAssetUrl: (filePath: string) => {
return convertFileSrc(filePath);
},
dispose: () => {}
}); });
const createDialog = (): IDialog => ({ const createDialog = (): IDialog => ({
@@ -124,7 +128,8 @@ export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
}, },
showConfirm: async (title: string, message: string) => { showConfirm: async (title: string, message: string) => {
return await tauriConfirm(message, { title }); return await tauriConfirm(message, { title });
} },
dispose: () => {}
}); });
const createContext = (): CompilerContext => ({ const createContext = (): CompilerContext => ({
@@ -91,7 +91,11 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
}; };
const handlePropertyChange = (component: any, propertyName: string, value: any) => { 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 // Actually update the component property
// 实际更新组件属性 // 实际更新组件属性
@@ -103,6 +107,10 @@ export function EntityInspector({ entityStore: _entityStore, messageHub }: Entit
propertyName, propertyName,
value 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) => { const renderRemoteProperty = (key: string, value: any) => {
@@ -92,10 +92,16 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
// Expand tree to reveal a specific file path // Expand tree to reveal a specific file path
const revealPath = async (targetPath: string) => { 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 // 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(/[/\\]/); const segments = relativePath.split(/[/\\]/);
// Build list of folder paths to expand // 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 renderNode = (node: TreeNode, level: number = 0) => {
const isSelected = selectedPaths // Normalize paths for comparison (handle forward/backward slashes)
? selectedPaths.has(node.path) const normalizedNodePath = node.path.replace(/\\/g, '/');
: (internalSelectedPath || selectedPath) === node.path; 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 isRenaming = renamingNode === node.path;
const indent = level * 16; const indent = level * 16;
@@ -215,6 +215,8 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
propertyName, propertyName,
value value
}); });
// Also publish scene:modified so other panels can react to changes
messageHub.publish('scene:modified', {});
}; };
const renderRemoteProperty = (key: string, value: any) => { const renderRemoteProperty = (key: string, value: any) => {
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Component, Core } from '@esengine/ecs-framework'; 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 { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor'; import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetSaveDialog } from './dialogs/AssetSaveDialog';
import '../styles/PropertyInspector.css'; import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor(); const animationClipsEditor = new AnimationClipsFieldEditor();
@@ -80,6 +82,12 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
if (onChange) { if (onChange) {
onChange(propertyName, value); 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 // 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} fileExtension={metadata.fileExtension}
readOnly={metadata.readOnly || !!controlledBy} readOnly={metadata.readOnly || !!controlledBy}
controlledBy={controlledBy} controlledBy={controlledBy}
entityId={entity?.id?.toString()}
onChange={(newValue) => handleChange(propertyName, newValue)} 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 v = Math.max(0, Math.min(100, 100 - ((e.clientY - rect.top) / rect.height) * 100));
const newColor = hsvToHex(hsv.h, s, v); const newColor = hsvToHex(hsv.h, s, v);
setTempColor(newColor); setTempColor(newColor);
onChange(newColor); // Real-time update
}; };
const handleHueChange = (e: React.MouseEvent<HTMLDivElement>) => { 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 h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / rect.width) * 360));
const newColor = hsvToHex(h, hsv.s, hsv.v); const newColor = hsvToHex(h, hsv.s, hsv.v);
setTempColor(newColor); setTempColor(newColor);
onChange(newColor); // Real-time update
}; };
return ( return (
@@ -857,11 +868,90 @@ interface AssetDropFieldProps {
fileExtension?: string; fileExtension?: string;
readOnly?: boolean; readOnly?: boolean;
controlledBy?: string; controlledBy?: string;
entityId?: string;
onChange: (value: string) => void; 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 [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) => { const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -890,8 +980,14 @@ function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, o
if (assetPath) { if (assetPath) {
if (fileExtension) { if (fileExtension) {
const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase()); const extensions = fileExtension.split(',').map((ext) => ext.trim().toLowerCase());
const fileExt = assetPath.toLowerCase().split('.').pop(); const lowerPath = assetPath.toLowerCase();
if (fileExt && extensions.some((ext) => ext === `.${fileExt}` || ext === fileExt)) { // 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); onChange(assetPath);
} }
} else { } else {
@@ -943,6 +1039,18 @@ function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, o
{value ? getFileName(value) : 'None'} {value ? getFileName(value) : 'None'}
</span> </span>
<div className="property-asset-actions"> <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 && ( {value && (
<button <button
className="property-asset-btn" className="property-asset-btn"
@@ -957,6 +1065,16 @@ function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, o
)} )}
</div> </div>
</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> </div>
); );
} }
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Entity, Core } from '@esengine/ecs-framework'; 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 { useLocale } from '../hooks/useLocale';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react'; import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService'; 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 [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null);
const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null); const [draggedEntityId, setDraggedEntityId] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null); const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const [pluginTemplates, setPluginTemplates] = useState<EntityCreationTemplate[]>([]);
const { t, locale } = useLocale(); const { t, locale } = useLocale();
const isShowingRemote = viewMode === 'remote' && isRemoteConnected; 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 // Subscribe to scene changes
useEffect(() => { useEffect(() => {
const sceneManager = Core.services.resolve(SceneManagerService); const sceneManager = Core.services.resolve(SceneManagerService);
@@ -535,6 +557,23 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
<Camera size={12} /> <Camera size={12} />
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span> <span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
</button> </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 && ( {contextMenu.entityId && (
<> <>
<div className="context-menu-divider" /> <div className="context-menu-divider" />
@@ -43,7 +43,7 @@ function generateRuntimeHtml(): string {
<canvas id="runtime-canvas"></canvas> <canvas id="runtime-canvas"></canvas>
<script src="/runtime.browser.js"></script> <script src="/runtime.browser.js"></script>
<script type="module"> <script type="module">
import * as esEngine from '/engine.js'; import * as esEngine from '/es_engine.js';
(async function() { (async function() {
try { try {
// Set canvas size before creating runtime // 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(() => { useEffect(() => {
if (engine.state.initialized) { if (engine.state.initialized) {
EngineService.getInstance().setCamera({ EngineService.getInstance().setCamera({
@@ -370,6 +371,17 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
zoom: camera2DZoom, zoom: camera2DZoom,
rotation: 0 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]); }, [camera2DOffset, camera2DZoom, engine.state.initialized]);
@@ -473,11 +485,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
} }
}; };
const handleStop = () => { const handleStop = async () => {
setPlayState('stopped'); setPlayState('stopped');
engine.stop(); engine.stop();
// Restore scene snapshot // Restore scene snapshot
EngineService.getInstance().restoreSceneSnapshot(); await EngineService.getInstance().restoreSceneSnapshot();
// Restore editor camera state // Restore editor camera state
setCamera2DOffset({ x: editorCameraRef.current.x, y: editorCameraRef.current.y }); setCamera2DOffset({ x: editorCameraRef.current.x, y: editorCameraRef.current.y });
setCamera2DZoom(editorCameraRef.current.zoom); setCamera2DZoom(editorCameraRef.current.zoom);
@@ -197,3 +197,119 @@
color: #666; color: #666;
cursor: not-allowed; 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;
}
@@ -149,19 +149,34 @@ export function AssetPickerDialog({
} }
}, [toggleFolder]); }, [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(() => { const handleConfirm = useCallback(() => {
if (selectedPath) { if (selectedPath) {
onSelect(selectedPath); onSelect(toRelativePath(selectedPath));
onClose(); onClose();
} }
}, [selectedPath, onSelect, onClose]); }, [selectedPath, onSelect, onClose, toRelativePath]);
const handleDoubleClick = useCallback((node: FileNode) => { const handleDoubleClick = useCallback((node: FileNode) => {
if (!node.isDirectory) { if (!node.isDirectory) {
onSelect(node.path); onSelect(toRelativePath(node.path));
onClose(); onClose();
} }
}, [onSelect, onClose]); }, [onSelect, onClose, toRelativePath]);
const getFileIcon = (name: string) => { const getFileIcon = (name: string) => {
const ext = name.split('.').pop()?.toLowerCase(); const ext = name.split('.').pop()?.toLowerCase();
@@ -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>
);
}
@@ -113,6 +113,16 @@
color: #f87171; color: #f87171;
} }
/* 创建按钮特殊样式 */
.asset-field__button--create {
color: #4ade80;
}
.asset-field__button--create:hover {
background: #1a3a1a;
color: #4ade80;
}
/* 禁用状态 */ /* 禁用状态 */
.asset-field__container[disabled] { .asset-field__container[disabled] {
opacity: 0.6; opacity: 0.6;
@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback } from 'react'; 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 { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
import './AssetField.css'; import './AssetField.css';
@@ -11,6 +11,7 @@ interface AssetFieldProps {
placeholder?: string; placeholder?: string;
readonly?: boolean; readonly?: boolean;
onNavigate?: (path: string) => void; // 导航到资产 onNavigate?: (path: string) => void; // 导航到资产
onCreate?: () => void; // 创建新资产
} }
export function AssetField({ export function AssetField({
@@ -20,7 +21,8 @@ export function AssetField({
fileExtension = '', fileExtension = '',
placeholder = 'None', placeholder = 'None',
readonly = false, readonly = false,
onNavigate onNavigate,
onCreate
}: AssetFieldProps) { }: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -137,6 +139,20 @@ export function AssetField({
{/* 操作按钮组 */} {/* 操作按钮组 */}
<div className="asset-field__actions"> <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 && ( {!readonly && (
<button <button
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react'; import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework'; 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 { PropertyInspector } from '../../PropertyInspector';
import { NotificationService } from '../../../services/NotificationService'; import { NotificationService } from '../../../services/NotificationService';
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component'; 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 [localVersion, setLocalVersion] = useState(0);
const componentRegistry = Core.services.resolve(ComponentRegistry); const componentRegistry = Core.services.resolve(ComponentRegistry);
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
const availableComponents = componentRegistry?.getAllComponents() || []; const availableComponents = componentRegistry?.getAllComponents() || [];
const toggleComponentExpanded = (index: number) => { const toggleComponentExpanded = (index: number) => {
@@ -252,6 +253,32 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
} }
onAction={handlePropertyAction} 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>
)} )}
</div> </div>
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Editor Gizmos
* Gizmos
*
* Gizmo implementations for built-in components.
* Gizmo
*/
export { registerSpriteGizmo } from './SpriteGizmo';

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