* feat(prefab): 实现预制体系统和编辑器 UX 改进 ## 预制体系统 - 新增 PrefabSerializer: 预制体序列化/反序列化 - 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改 - 新增 PrefabService: 预制体核心服务 - 新增 PrefabLoader: 预制体资产加载器 - 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink ## 预制体编辑模式 - 支持双击 .prefab 文件进入编辑模式 - 预制体编辑模式工具栏 (保存/退出) - 预制体实例指示器和操作菜单 ## 编辑器 UX 改进 - SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航 - 支持双击实体名称内联编辑 - 删除实体时显示子节点数量警告 - 右键菜单添加重命名/复制选项及快捷键提示 - 布局持久化和重置功能 ## Bug 修复 - 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题 - 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见 - 修复 Inspector 资源字段高度不正确问题 * feat(editor): 改进编辑器 UX 交互体验 - ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计 - SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮 - PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理 - EntityInspector: 组件折叠状态持久化、属性搜索清除按钮 - Viewport: 变换操作实时数值显示 - 国际化: 添加相关文本 (en/zh) * fix(build): 修复 Web 构建资产加载和编辑器 UX 改进 构建系统修复: - 修复 asset-catalog.json 字段名不匹配 (entries vs assets) - 修复 BrowserFileSystemService 支持两种目录格式 - 修复 bundle 策略检测逻辑 (空对象判断) - 修复 module.json 中 assetExtensions 声明和类型推断 行为树修复: - 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath - 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree) 编辑器 UX 改进: - 构建完成对话框添加"打开文件夹"按钮 - 构建完成对话框样式优化 (圆形图标背景、按钮布局) - SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列) - SceneHierarchy 隐藏滚动条 错误追踪: - 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log) - 添加 append_to_log Tauri 命令 * feat(render): 修复 UI 渲染和点击特效系统 ## UI 渲染修复 - 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数 - 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap - Web 运行时添加 assetPathResolver 支持 GUID 解析 - UIInteractableComponent.blockEvents 默认值改为 false ## 点击特效系统 - 新增 ClickFxComponent 和 ClickFxSystem - 支持在点击位置播放粒子效果 - 支持多种触发模式和粒子轮换 ## Camera 系统重构 - CameraSystem 从 ecs-engine-bindgen 移至 camera 包 - 新增 CameraManager 统一管理相机 ## 编辑器改进 - 改进属性面板 UI 交互 - 粒子编辑器面板优化 - Transform 命令系统 * feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层 - 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay) - 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性 - 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染 - 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer - 更新粒子编辑器面板支持新的排序属性 - 优化 UI 渲染系统使用新的排序层级 * feat(ci): 集成 SignPath 代码签名服务 - 添加 SignPath 自动签名工作流(Windows) - 配置 release-editor.yml 支持代码签名 - 将构建改为草稿模式,等待签名完成后发布 - 添加证书文件到 .gitignore 防止泄露 * fix(asset): 修复 Web 构建资产路径解析和全局单例移除 ## 资产路径修复 - 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接 - BrowserPathResolver 支持两种模式: - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser) - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建) - BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理 ## 架构改进 - 移除全局单例 - 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入 - 移除 globalPathResolver 导出,改用 PathResolutionService - 移除 globalPathResolutionService 导出 - ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖 - EngineService 使用 new AssetManager() 替代全局实例 ## 新增服务 - PathResolutionService: 统一路径解析接口 - RuntimeModeService: 运行时模式查询服务 - SerializationContext: EntityRef 序列化上下文 ## 其他改进 - 完善 ServiceToken 注释说明本地定义的意图 - 导出 BrowserPathResolveMode 类型 * fix(build): 添加 world-streaming composite 设置修复类型检查 * fix(build): 移除 world-streaming 引用避免 composite 冲突 * fix(build): 将 const enum 改为 enum 兼容 isolatedModules * fix(build): 添加缺失的 IAssetManager 导入
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework';
|
||
import { SortingLayers, TransformComponent } from '@esengine/engine-core';
|
||
import { Color } from '@esengine/ecs-framework-math';
|
||
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
|
||
import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent';
|
||
|
||
/**
|
||
* Tilemap render data for a single tilemap layer
|
||
* 单个瓦片地图图层的渲染数据
|
||
*/
|
||
export interface TilemapRenderData {
|
||
/** Entity ID | 实体ID */
|
||
entityId: number;
|
||
/** Layer index within the tilemap | 图层在瓦片地图中的索引 */
|
||
layerIndex: number;
|
||
/** Transform data [x, y, rotation, scaleX, scaleY, originX, originY] per tile | 每个瓦片的变换数据 */
|
||
transforms: Float32Array;
|
||
/** Texture IDs per tile | 每个瓦片的纹理ID */
|
||
textureIds: Uint32Array;
|
||
/** UV coordinates [u0, v0, u1, v1] per tile | 每个瓦片的UV坐标 */
|
||
uvs: Float32Array;
|
||
/** Packed colors (ARGB) per tile | 每个瓦片的打包颜色 */
|
||
colors: Uint32Array;
|
||
/** Number of tiles in this layer | 此图层的瓦片数量 */
|
||
tileCount: number;
|
||
/**
|
||
* 排序层名称
|
||
* Sorting layer name
|
||
*/
|
||
sortingLayer: string;
|
||
/**
|
||
* 层内排序顺序
|
||
* Order within the sorting layer
|
||
*/
|
||
orderInLayer: number;
|
||
/** Sorting order for rendering (deprecated, use sortingLayer + orderInLayer) | 渲染排序顺序(已弃用,使用 sortingLayer + orderInLayer) */
|
||
sortingOrder: number;
|
||
/** Texture path for loading | 纹理路径 */
|
||
texturePath?: string;
|
||
/** Material ID for this layer (0 = default) | 此图层的材质ID(0 = 默认) */
|
||
materialId: number;
|
||
}
|
||
|
||
/**
|
||
* 视口边界
|
||
*/
|
||
export interface ViewportBounds {
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}
|
||
|
||
/**
|
||
* Cache key for layer render data
|
||
* 图层渲染数据的缓存键
|
||
*/
|
||
type LayerCacheKey = `${number}_${number}`;
|
||
|
||
/**
|
||
* 瓦片地图渲染系统 - 准备瓦片地图渲染数据(按图层)
|
||
* Tilemap rendering system - Prepares tilemap render data (per layer)
|
||
*/
|
||
@ECSSystem('TilemapRendering', { updateOrder: 40 })
|
||
export class TilemapRenderingSystem extends EntitySystem implements IRenderDataProvider {
|
||
/** Cache for layer render data: key = "entityId_layerIndex" | 图层渲染数据缓存 */
|
||
private _layerRenderDataCache: Map<LayerCacheKey, TilemapRenderData> = new Map();
|
||
/** Current frame render data | 当前帧渲染数据 */
|
||
private _currentFrameData: TilemapRenderData[] = [];
|
||
/** Viewport bounds for culling | 视口边界用于剔除 */
|
||
private _viewportBounds: ViewportBounds | null = null;
|
||
|
||
constructor() {
|
||
super(Matcher.empty().all(TilemapComponent, TransformComponent));
|
||
}
|
||
|
||
/**
|
||
* Set viewport bounds for tile culling
|
||
* 设置视口边界用于瓦片剔除
|
||
*/
|
||
setViewportBounds(bounds: ViewportBounds): void {
|
||
this._viewportBounds = bounds;
|
||
}
|
||
|
||
/**
|
||
* Get render data for current frame
|
||
* 获取当前帧的渲染数据
|
||
*/
|
||
getRenderData(): readonly TilemapRenderData[] {
|
||
return this._currentFrameData;
|
||
}
|
||
|
||
protected override onBegin(): void {
|
||
this._currentFrameData = [];
|
||
}
|
||
|
||
protected override process(entities: readonly Entity[]): void {
|
||
for (const entity of entities) {
|
||
if (!entity.enabled) continue;
|
||
|
||
const tilemap = entity.getComponent(TilemapComponent) as TilemapComponent | null;
|
||
const transform = entity.getComponent(TransformComponent) as TransformComponent | null;
|
||
|
||
if (!tilemap || !transform || !tilemap.visible) continue;
|
||
|
||
// Process each layer separately
|
||
// 分别处理每个图层
|
||
const layers = tilemap.layers;
|
||
for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
|
||
const layer = layers[layerIndex];
|
||
if (!layer.visible) continue;
|
||
|
||
const cacheKey: LayerCacheKey = `${entity.id}_${layerIndex}`;
|
||
let renderData = this._layerRenderDataCache.get(cacheKey);
|
||
|
||
if (!renderData || tilemap.renderDirty) {
|
||
renderData = this.buildLayerRenderData(entity.id, layerIndex, tilemap, transform, layer);
|
||
this._layerRenderDataCache.set(cacheKey, renderData);
|
||
} else {
|
||
this.updateLayerTransforms(renderData, layerIndex, tilemap, transform, layer);
|
||
}
|
||
|
||
if (renderData.tileCount > 0) {
|
||
this._currentFrameData.push(renderData);
|
||
}
|
||
}
|
||
|
||
// Clear dirty flag after processing all layers
|
||
// 处理完所有图层后清除脏标志
|
||
tilemap.renderDirty = false;
|
||
}
|
||
|
||
// Sort by sorting order (lower values render first)
|
||
// 按排序顺序排序(较小值先渲染)
|
||
this._currentFrameData.sort((a, b) => a.sortingOrder - b.sortingOrder);
|
||
}
|
||
|
||
/**
|
||
* Build render data for a single layer
|
||
* 为单个图层构建渲染数据
|
||
*/
|
||
private buildLayerRenderData(
|
||
entityId: number,
|
||
layerIndex: number,
|
||
tilemap: TilemapComponent,
|
||
transform: TransformComponent,
|
||
layer: ITilemapLayerData
|
||
): TilemapRenderData {
|
||
const layerData = tilemap.getLayerData(layerIndex);
|
||
if (!layerData) {
|
||
return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingLayer, tilemap.orderInLayer, tilemap.sortingOrder, layer.materialId);
|
||
}
|
||
|
||
const width = tilemap.width;
|
||
const height = tilemap.height;
|
||
const tileWidth = tilemap.tileWidth;
|
||
const tileHeight = tilemap.tileHeight;
|
||
|
||
// Calculate visible tile range
|
||
// 计算可见瓦片范围
|
||
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
|
||
width, height, tileWidth, tileHeight, transform
|
||
);
|
||
|
||
// Count non-empty tiles in this layer
|
||
// 计算此图层的非空瓦片数量
|
||
let tileCount = 0;
|
||
for (let row = startRow; row < endRow; row++) {
|
||
for (let col = startCol; col < endCol; col++) {
|
||
if (layerData[row * width + col] > 0) tileCount++;
|
||
}
|
||
}
|
||
|
||
if (tileCount === 0) {
|
||
return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingLayer, tilemap.orderInLayer, tilemap.sortingOrder, layer.materialId);
|
||
}
|
||
|
||
const transforms = new Float32Array(tileCount * 7);
|
||
const textureIds = new Uint32Array(tileCount);
|
||
const uvs = new Float32Array(tileCount * 4);
|
||
const colors = new Uint32Array(tileCount);
|
||
|
||
// Calculate color with layer opacity
|
||
// 计算带有图层透明度的颜色
|
||
const effectiveAlpha = tilemap.alpha * layer.opacity;
|
||
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
|
||
|
||
// Calculate rotation parameters
|
||
// 计算旋转参数
|
||
const cos = Math.cos(transform.rotation.z);
|
||
const sin = Math.sin(transform.rotation.z);
|
||
|
||
// Tilemap rotation pivot
|
||
// Tilemap 旋转中心点
|
||
const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2;
|
||
const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2;
|
||
|
||
let idx = 0;
|
||
let texturePath: string | undefined;
|
||
|
||
for (let row = startRow; row < endRow; row++) {
|
||
for (let col = startCol; col < endCol; col++) {
|
||
const gid = layerData[row * width + col];
|
||
if (gid <= 0) continue;
|
||
|
||
// Find corresponding tileset
|
||
// 查找对应的 tileset
|
||
const tilesetInfo = tilemap.getTilesetForGid(gid);
|
||
if (!tilesetInfo) continue;
|
||
|
||
const { index: tilesetIndex, localId } = tilesetInfo;
|
||
|
||
// Get texture path
|
||
// 获取纹理路径
|
||
if (!texturePath && tilemap.tilesets[tilesetIndex]) {
|
||
texturePath = tilemap.tilesets[tilesetIndex].source;
|
||
}
|
||
|
||
// Calculate tile local position (relative to tilemap center)
|
||
// 计算瓦片的本地位置(相对于 tilemap 中心)
|
||
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
|
||
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
|
||
|
||
// Apply rotation transform
|
||
// 应用旋转变换
|
||
const rotatedX = localX * cos - localY * sin + pivotX;
|
||
const rotatedY = localX * sin + localY * cos + pivotY;
|
||
|
||
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
|
||
const tOffset = idx * 7;
|
||
transforms[tOffset] = rotatedX;
|
||
transforms[tOffset + 1] = rotatedY;
|
||
transforms[tOffset + 2] = transform.rotation.z;
|
||
transforms[tOffset + 3] = tileWidth * transform.scale.x;
|
||
transforms[tOffset + 4] = tileHeight * transform.scale.y;
|
||
transforms[tOffset + 5] = 0.5;
|
||
transforms[tOffset + 6] = 0.5;
|
||
|
||
// Texture ID
|
||
textureIds[idx] = tilemap.tilesets[tilesetIndex]?.textureId || 0;
|
||
|
||
// UV coordinates
|
||
const tileUV = tilemap.getTileUV(tilesetIndex, localId);
|
||
const uvOffset = idx * 4;
|
||
if (tileUV) {
|
||
uvs[uvOffset] = tileUV[0];
|
||
uvs[uvOffset + 1] = tileUV[1];
|
||
uvs[uvOffset + 2] = tileUV[2];
|
||
uvs[uvOffset + 3] = tileUV[3];
|
||
} else {
|
||
uvs[uvOffset] = 0;
|
||
uvs[uvOffset + 1] = 0;
|
||
uvs[uvOffset + 2] = 1;
|
||
uvs[uvOffset + 3] = 1;
|
||
}
|
||
|
||
colors[idx] = colorValue;
|
||
idx++;
|
||
}
|
||
}
|
||
|
||
return {
|
||
entityId,
|
||
layerIndex,
|
||
transforms,
|
||
textureIds,
|
||
uvs,
|
||
colors,
|
||
tileCount,
|
||
sortingLayer: tilemap.sortingLayer,
|
||
orderInLayer: tilemap.orderInLayer + layerIndex,
|
||
sortingOrder: tilemap.sortingOrder + layerIndex * 0.001,
|
||
texturePath,
|
||
materialId: layer.materialId ?? 0
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update transforms for a layer (when only position/rotation/scale changed)
|
||
* 更新图层的变换(当只有位置/旋转/缩放改变时)
|
||
*/
|
||
private updateLayerTransforms(
|
||
renderData: TilemapRenderData,
|
||
layerIndex: number,
|
||
tilemap: TilemapComponent,
|
||
transform: TransformComponent,
|
||
layer: ITilemapLayerData
|
||
): void {
|
||
const layerData = tilemap.getLayerData(layerIndex);
|
||
if (!layerData) return;
|
||
|
||
const width = tilemap.width;
|
||
const height = tilemap.height;
|
||
const tileWidth = tilemap.tileWidth;
|
||
const tileHeight = tilemap.tileHeight;
|
||
|
||
// Calculate visible tile range
|
||
// 计算可见瓦片范围
|
||
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
|
||
width, height, tileWidth, tileHeight, transform
|
||
);
|
||
|
||
// Calculate rotation parameters
|
||
// 计算旋转参数
|
||
const cos = Math.cos(transform.rotation.z);
|
||
const sin = Math.sin(transform.rotation.z);
|
||
|
||
// Tilemap rotation pivot
|
||
// Tilemap 旋转中心点
|
||
const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2;
|
||
const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2;
|
||
|
||
let idx = 0;
|
||
for (let row = startRow; row < endRow; row++) {
|
||
for (let col = startCol; col < endCol; col++) {
|
||
if (layerData[row * width + col] <= 0) continue;
|
||
|
||
// Calculate tile local position
|
||
// 计算瓦片本地位置
|
||
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
|
||
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
|
||
|
||
// Apply rotation transform
|
||
// 应用旋转变换
|
||
const rotatedX = localX * cos - localY * sin + pivotX;
|
||
const rotatedY = localX * sin + localY * cos + pivotY;
|
||
|
||
const tOffset = idx * 7;
|
||
renderData.transforms[tOffset] = rotatedX;
|
||
renderData.transforms[tOffset + 1] = rotatedY;
|
||
renderData.transforms[tOffset + 2] = transform.rotation.z;
|
||
renderData.transforms[tOffset + 3] = tileWidth * transform.scale.x;
|
||
renderData.transforms[tOffset + 4] = tileHeight * transform.scale.y;
|
||
|
||
idx++;
|
||
}
|
||
}
|
||
|
||
// Update color (alpha or layer opacity may have changed)
|
||
// 更新颜色(alpha 或图层透明度可能已更改)
|
||
const effectiveAlpha = tilemap.alpha * layer.opacity;
|
||
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
|
||
for (let i = 0; i < renderData.colors.length; i++) {
|
||
renderData.colors[i] = colorValue;
|
||
}
|
||
|
||
// Update sorting order and material ID
|
||
// 更新排序顺序和材质ID
|
||
renderData.sortingOrder = tilemap.sortingOrder + layerIndex * 0.001;
|
||
renderData.materialId = layer.materialId ?? 0;
|
||
}
|
||
|
||
/**
|
||
* Calculate visible tile range based on viewport bounds
|
||
* 根据视口边界计算可见瓦片范围
|
||
*/
|
||
private calculateVisibleRange(
|
||
width: number,
|
||
height: number,
|
||
tileWidth: number,
|
||
tileHeight: number,
|
||
transform: TransformComponent
|
||
): { startCol: number; endCol: number; startRow: number; endRow: number } {
|
||
let startCol = 0, endCol = width;
|
||
let startRow = 0, endRow = height;
|
||
|
||
if (this._viewportBounds) {
|
||
const bounds = this._viewportBounds;
|
||
const mapX = transform.position.x;
|
||
const mapY = transform.position.y;
|
||
|
||
startCol = Math.max(0, Math.floor((bounds.left - mapX) / tileWidth));
|
||
endCol = Math.min(width, Math.ceil((bounds.right - mapX) / tileWidth));
|
||
startRow = Math.max(0, Math.floor((bounds.bottom - mapY) / tileHeight));
|
||
endRow = Math.min(height, Math.ceil((bounds.top - mapY) / tileHeight));
|
||
}
|
||
|
||
return { startCol, endCol, startRow, endRow };
|
||
}
|
||
|
||
/**
|
||
* Create empty render data
|
||
* 创建空的渲染数据
|
||
*/
|
||
private createEmptyRenderData(
|
||
entityId: number,
|
||
layerIndex: number,
|
||
sortingLayer: string,
|
||
orderInLayer: number,
|
||
sortingOrder: number,
|
||
materialId?: number
|
||
): TilemapRenderData {
|
||
return {
|
||
entityId,
|
||
layerIndex,
|
||
transforms: new Float32Array(0),
|
||
textureIds: new Uint32Array(0),
|
||
uvs: new Float32Array(0),
|
||
colors: new Uint32Array(0),
|
||
tileCount: 0,
|
||
sortingLayer,
|
||
orderInLayer: orderInLayer + layerIndex,
|
||
sortingOrder: sortingOrder + layerIndex * 0.001,
|
||
materialId: materialId ?? 0
|
||
};
|
||
}
|
||
|
||
protected override onRemoved(entity: Entity): void {
|
||
// Remove all cached layer data for this entity
|
||
// 移除此实体的所有缓存图层数据
|
||
const keysToDelete: LayerCacheKey[] = [];
|
||
for (const key of this._layerRenderDataCache.keys()) {
|
||
if (key.startsWith(`${entity.id}_`)) {
|
||
keysToDelete.push(key);
|
||
}
|
||
}
|
||
for (const key of keysToDelete) {
|
||
this._layerRenderDataCache.delete(key);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear all cached render data
|
||
* 清除所有缓存的渲染数据
|
||
*/
|
||
clearCache(): void {
|
||
this._layerRenderDataCache.clear();
|
||
this._currentFrameData = [];
|
||
}
|
||
}
|