refactor(ui): UI 系统架构重构 (#309)
* feat(ui): 动态图集系统与渲染调试增强 ## 核心功能 ### 动态图集系统 (Dynamic Atlas) - 新增 DynamicAtlasManager:运行时纹理打包,支持 MaxRects 算法 - 新增 DynamicAtlasService:自动纹理加载与图集管理 - 新增 BinPacker:高效矩形打包算法 - 支持动态/固定两种扩展策略 - 自动 UV 重映射,实现 UI 元素合批渲染 ### Frame Debugger 增强 - 新增合批分析面板,显示批次中断原因 - 新增 UI 元素层级信息(depth, worldOrderInLayer) - 新增实体高亮功能,点击可在场景中定位 - 新增动态图集可视化面板 - 改进渲染原语详情展示 ### 闪光效果 (Shiny Effect) - 新增 UIShinyEffectComponent:UI 闪光参数配置 - 新增 UIShinyEffectSystem:材质覆盖驱动的闪光动画 - 新增 ShinyEffectComponent/System(Sprite 版本) ## 引擎层改进 ### Rust 纹理管理扩展 - create_blank_texture:创建空白 GPU 纹理 - update_texture_region:局部纹理更新 - 支持动态图集的 GPU 端操作 ### 材质系统 - 新增 effects/ 目录:ShinyEffect 等效果实现 - 新增 interfaces/ 目录:IMaterial 等接口定义 - 新增 mixins/ 目录:可组合的材质功能 ### EngineBridge 扩展 - 新增 createBlankTexture/updateTextureRegion 方法 - 改进纹理加载回调机制 ## UI 渲染改进 - UIRenderCollector:支持合批调试信息 - 稳定排序:addIndex 保证渲染顺序一致性 - 九宫格渲染优化 - 材质覆盖支持 ## 其他改进 - 国际化:新增 Frame Debugger 相关翻译 - 编辑器:新增渲染调试入口 - 文档:新增架构设计文档目录 * refactor(ui): 引入新基础组件架构与渲染工具函数 Phase 1 重构 - 组件职责分离与代码复用: 新增基础组件层: - UIGraphicComponent: 所有可视 UI 元素的基类(颜色、透明度、raycast) - UIImageComponent: 纹理显示组件(支持简单、切片、平铺、填充模式) - UISelectableComponent: 可交互元素的基类(状态管理、颜色过渡) 新增渲染工具: - UIRenderUtils: 提取共享的坐标计算、边框渲染、阴影渲染等工具函数 - getUIRenderTransform: 统一的变换数据提取 - renderBorder/renderShadow: 复用的边框和阴影渲染逻辑 新增渲染系统: - UIGraphicRenderSystem: 处理新基础组件的统一渲染器 重构现有系统: - UIRectRenderSystem: 使用新工具函数,移除重复代码 - UIButtonRenderSystem: 使用新工具函数,移除重复代码 这些改动为后续统一渲染系统奠定基础。 * refactor(ui): UIProgressBarRenderSystem 使用渲染工具函数 - 使用 getUIRenderTransform 替代手动变换计算 - 使用 renderBorder 工具函数替代重复的边框渲染 - 使用 lerpColor 工具函数替代重复的颜色插值 - 简化方法签名,使用 UIRenderTransform 类型 - 移除约 135 行重复代码 * refactor(ui): Slider 和 ScrollView 渲染系统使用工具函数 - UISliderRenderSystem: 使用 getUIRenderTransform,简化方法签名 - UIScrollViewRenderSystem: 使用 getUIRenderTransform,简化方法签名 - 统一使用 UIRenderTransform 类型减少参数传递 - 消除重复的变换计算代码 * refactor(ui): 使用 UIWidgetMarker 消除硬编码组件依赖 - 新增 UIWidgetMarker 标记组件 - UIRectRenderSystem 改为检查标记而非硬编码4种组件类型 - 各 Widget 渲染系统自动添加标记组件 - 减少模块间耦合,提高可扩展性 * feat(ui): 实现 Canvas 隔离机制 - 新增 UICanvasComponent 定义 Canvas 渲染组 - UITransformComponent 添加 Canvas 相关字段:canvasEntityId, worldSortingLayer, pixelPerfect - UILayoutSystem 传播 Canvas 设置给子元素 - UIRenderUtils 使用 Canvas 继承的排序层 - 支持嵌套 Canvas 和不同渲染模式 * refactor(ui): 统一纹理管理工具函数 Phase 4: 纹理管理统一 新增: - UITextureUtils.ts: 统一的纹理描述符接口和验证函数 - UITextureDescriptor: 支持 GUID/textureId/path 多种纹理源 - isValidTextureGuid: GUID 验证 - getTextureKey: 获取用于合批的纹理键 - normalizeTextureDescriptor: 规范化各种输入格式 - utils/index.ts: 工具函数导出 修改: - UIGraphicRenderSystem: 使用新的纹理工具函数 - index.ts: 导出纹理工具类型和函数 * refactor(ui): 实现统一的脏标记机制 Phase 5: Dirty 标记机制 新增: - UIDirtyFlags.ts: 位标记枚举和追踪工具 - UIDirtyFlags: Visual/Layout/Transform/Material/Text 标记 - IDirtyTrackable: 脏追踪接口 - DirtyTracker: 辅助工具类 - 帧级别脏状态追踪 (markFrameDirty, isFrameDirty) 修改: - UIGraphicComponent: 实现 IDirtyTrackable - 属性 setter 自动设置脏标记 - 保留 setDirty/clearDirty 向后兼容 - UIImageComponent: 所有属性支持脏追踪 - textureGuid/imageType/fillAmount 等变化自动标记 - UIGraphicRenderSystem: 使用 clearDirtyFlags() 导出: - UIDirtyFlags, IDirtyTrackable, DirtyTracker - markFrameDirty, isFrameDirty, clearFrameDirty * refactor(ui): 移除过时的 dirty flag API 移除 UIGraphicComponent 中的兼容性 API: - 移除 _isDirty getter/setter - 移除 setDirty() 方法 - 移除 clearDirty() 方法 现在统一使用新的 dirty flag 系统: - isDirty() / hasDirtyFlag(flags) - markDirty(flags) / clearDirtyFlags() * fix(ui): 修复两个 TODO 功能 1. 滑块手柄命中测试 (UIInputSystem) - UISliderComponent 添加 getHandleBounds() 计算手柄边界 - UISliderComponent 添加 isPointInHandle() 精确命中测试 - UIInputSystem.handleSlider() 使用精确测试更新悬停状态 2. 径向填充渲染 (UIGraphicRenderSystem) - 实现 renderRadialFill() 方法 - 支持 radial90/radial180/radial360 三种模式 - 支持 fillOrigin (top/right/bottom/left) 和 fillClockwise - 使用多段矩形近似饼形填充效果 * feat(ui): 完善 UI 系统架构和九宫格渲染 * fix(ui): 修复文本渲染层级问题并清理调试代码 - 修复纹理就绪后调用 invalidateUIRenderCaches() 导致的无限循环 - 移除 UITextRenderSystem、UIButtonRenderSystem、UIRectRenderSystem 中的首帧调试输出 - 移除 UILayoutSystem 中的布局调试日志 - 清理所有 __UI_RENDER_DEBUG__ 条件日志 * refactor(ui): 优化渲染批处理和输入框组件 渲染系统: - 修复 RenderBatcher 保持渲染顺序 - 优化 Rust SpriteBatch 避免合并非连续精灵 - 增强 EngineRenderSystem 纹理就绪检测 输入框组件: - 增强 UIInputFieldComponent 功能 - 改进 UIInputSystem 输入处理 - 新增 TextMeasureService 文本测量服务 * fix(ui): 修复九宫格首帧渲染和InputField输入问题 - 修复九宫格首帧 size=0x0 问题: - Viewport.tsx: 预览模式读取图片尺寸存储到 importSettings - AssetDatabase: ISpriteSettings 添加 width/height 字段 - AssetMetadataService: getTextureSpriteInfo 使用元数据尺寸作为后备 - UIRectRenderSystem: 当 atlasEntry 不存在时使用 spriteInfo 尺寸 - WebBuildPipeline: 构建时包含 importSettings - AssetManager: 从 catalog 初始化时复制 importSettings - AssetTypes: IAssetCatalogEntry 添加 importSettings 字段 - 修复 InputField 无法输入问题: - UIRuntimeModule: manifest 添加 pluginExport: 'UIPlugin' - 确保预览模式正确加载 UI 插件并绑定 UIInputSystem - 添加调试日志用于排查纹理加载问题 * fix(sprite): 修复类型导出错误 MaterialPropertyOverride 和 MaterialOverrides 应从 @esengine/material-system 导出 * fix(ui-editor): 补充 AnchorPreset 拉伸预设的映射 添加 StretchTop, StretchMiddle, StretchBottom, StretchLeft, StretchCenter, StretchRight 的位置和锚点值映射
This commit is contained in:
@@ -0,0 +1,663 @@
|
|||||||
|
# ESEngine 材质系统统一架构重构方案
|
||||||
|
|
||||||
|
## 问题概述
|
||||||
|
|
||||||
|
当前 UI 和 Scene (Sprite) 两套渲染系统存在大量代码重复:
|
||||||
|
|
||||||
|
| 重复项 | Sprite | UI | 重复度 |
|
||||||
|
|--------|--------|----|----|
|
||||||
|
| 材质属性覆盖接口 | `MaterialPropertyOverride` | `UIMaterialPropertyOverride` | 100% |
|
||||||
|
| 材质方法 (12个) | `SpriteComponent` | `UIRenderComponent` | 100% |
|
||||||
|
| ShinyEffect 组件 | `ShinyEffectComponent` | `UIShinyEffectComponent` | 99% |
|
||||||
|
| ShinyEffect 系统 | `ShinyEffectSystem` | `UIShinyEffectSystem` | 98% |
|
||||||
|
|
||||||
|
**根本原因**:缺乏统一的材质覆盖接口抽象层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、统一材质覆盖接口
|
||||||
|
|
||||||
|
### 1.1 定义通用接口
|
||||||
|
|
||||||
|
在 `@esengine/material-system` 包中定义统一接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/material-system/src/interfaces/IMaterialOverridable.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material property override definition.
|
||||||
|
* 材质属性覆盖定义。
|
||||||
|
*/
|
||||||
|
export interface MaterialPropertyOverride {
|
||||||
|
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||||
|
value: number | number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for components that support material property overrides.
|
||||||
|
* 支持材质属性覆盖的组件接口。
|
||||||
|
*/
|
||||||
|
export interface IMaterialOverridable {
|
||||||
|
/** Material GUID for asset reference | 材质资产引用的 GUID */
|
||||||
|
materialGuid: string;
|
||||||
|
|
||||||
|
/** Current material overrides | 当前材质覆盖 */
|
||||||
|
readonly materialOverrides: MaterialOverrides;
|
||||||
|
|
||||||
|
/** Get current material ID | 获取当前材质 ID */
|
||||||
|
getMaterialId(): number;
|
||||||
|
|
||||||
|
/** Set material ID | 设置材质 ID */
|
||||||
|
setMaterialId(id: number): void;
|
||||||
|
|
||||||
|
// Uniform setters
|
||||||
|
setOverrideFloat(name: string, value: number): this;
|
||||||
|
setOverrideVec2(name: string, x: number, y: number): this;
|
||||||
|
setOverrideVec3(name: string, x: number, y: number, z: number): this;
|
||||||
|
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this;
|
||||||
|
setOverrideColor(name: string, r: number, g: number, b: number, a?: number): this;
|
||||||
|
setOverrideInt(name: string, value: number): this;
|
||||||
|
|
||||||
|
// Uniform getters
|
||||||
|
getOverride(name: string): MaterialPropertyOverride | undefined;
|
||||||
|
removeOverride(name: string): this;
|
||||||
|
clearOverrides(): this;
|
||||||
|
hasOverrides(): boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 创建 Mixin 实现
|
||||||
|
|
||||||
|
使用 Mixin 模式避免代码重复:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/material-system/src/mixins/MaterialOverridableMixin.ts
|
||||||
|
|
||||||
|
import type { MaterialPropertyOverride, MaterialOverrides } from '../interfaces/IMaterialOverridable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixin that provides material override functionality.
|
||||||
|
* 提供材质覆盖功能的 Mixin。
|
||||||
|
*/
|
||||||
|
export function MaterialOverridableMixin<TBase extends new (...args: any[]) => {}>(Base: TBase) {
|
||||||
|
return class extends Base {
|
||||||
|
materialGuid: string = '';
|
||||||
|
private _materialId: number = 0;
|
||||||
|
private _materialOverrides: MaterialOverrides = {};
|
||||||
|
|
||||||
|
get materialOverrides(): MaterialOverrides {
|
||||||
|
return this._materialOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaterialId(): number {
|
||||||
|
return this._materialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaterialId(id: number): void {
|
||||||
|
this._materialId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideFloat(name: string, value: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'float', value };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideVec2(name: string, x: number, y: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'vec2', value: [x, y] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideVec3(name: string, x: number, y: number, z: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
|
||||||
|
this._materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideInt(name: string, value: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'int', value: Math.floor(value) };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOverride(name: string): MaterialPropertyOverride | undefined {
|
||||||
|
return this._materialOverrides[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOverride(name: string): this {
|
||||||
|
delete this._materialOverrides[name];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOverrides(): this {
|
||||||
|
this._materialOverrides = {};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOverrides(): boolean {
|
||||||
|
return Object.keys(this._materialOverrides).length > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、Shader Property 元数据系统
|
||||||
|
|
||||||
|
### 2.1 定义属性元数据接口
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/material-system/src/interfaces/IShaderProperty.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader property UI metadata.
|
||||||
|
* 着色器属性 UI 元数据。
|
||||||
|
*/
|
||||||
|
export interface ShaderPropertyMeta {
|
||||||
|
/** Property type | 属性类型 */
|
||||||
|
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int' | 'texture';
|
||||||
|
|
||||||
|
/** Display label (supports i18n key) | 显示标签(支持 i18n 键) */
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** Property group for organization | 属性分组 */
|
||||||
|
group?: string;
|
||||||
|
|
||||||
|
/** Default value | 默认值 */
|
||||||
|
default?: number | number[] | string;
|
||||||
|
|
||||||
|
// Numeric constraints
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
|
||||||
|
/** UI hints | UI 提示 */
|
||||||
|
hint?: 'range' | 'angle' | 'hdr' | 'normal';
|
||||||
|
|
||||||
|
/** Tooltip description | 工具提示描述 */
|
||||||
|
tooltip?: string;
|
||||||
|
|
||||||
|
/** Whether to hide in inspector | 是否在检查器中隐藏 */
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended shader definition with property metadata.
|
||||||
|
* 带属性元数据的扩展着色器定义。
|
||||||
|
*/
|
||||||
|
export interface ShaderAssetDefinition {
|
||||||
|
/** Shader name | 着色器名称 */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** Display name for UI | UI 显示名称 */
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
/** Shader description | 着色器描述 */
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Vertex shader source (inline or path) | 顶点着色器源(内联或路径)*/
|
||||||
|
vertexSource: string;
|
||||||
|
|
||||||
|
/** Fragment shader source (inline or path) | 片段着色器源(内联或路径)*/
|
||||||
|
fragmentSource: string;
|
||||||
|
|
||||||
|
/** Property metadata for inspector | 检查器属性元数据 */
|
||||||
|
properties?: Record<string, ShaderPropertyMeta>;
|
||||||
|
|
||||||
|
/** Render queue / order | 渲染队列/顺序 */
|
||||||
|
renderQueue?: number;
|
||||||
|
|
||||||
|
/** Preset blend mode | 预设混合模式 */
|
||||||
|
blendMode?: 'alpha' | 'additive' | 'multiply' | 'opaque';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 .shader 资产文件格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "esengine://schemas/shader.json",
|
||||||
|
"version": 1,
|
||||||
|
"name": "Shiny",
|
||||||
|
"displayName": "闪光效果 | Shiny Effect",
|
||||||
|
"description": "扫光高亮动画着色器 | Sweeping highlight animation shader",
|
||||||
|
|
||||||
|
"vertexSource": "./shaders/sprite.vert",
|
||||||
|
"fragmentSource": "./shaders/shiny.frag",
|
||||||
|
|
||||||
|
"blendMode": "alpha",
|
||||||
|
"renderQueue": 2000,
|
||||||
|
|
||||||
|
"properties": {
|
||||||
|
"u_shinyProgress": {
|
||||||
|
"type": "float",
|
||||||
|
"label": "进度 | Progress",
|
||||||
|
"group": "Animation",
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"step": 0.01,
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
"u_shinyWidth": {
|
||||||
|
"type": "float",
|
||||||
|
"label": "宽度 | Width",
|
||||||
|
"group": "Effect",
|
||||||
|
"default": 0.25,
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"step": 0.01,
|
||||||
|
"tooltip": "闪光带宽度 | Width of the shiny band"
|
||||||
|
},
|
||||||
|
"u_shinyRotation": {
|
||||||
|
"type": "float",
|
||||||
|
"label": "角度 | Rotation",
|
||||||
|
"group": "Effect",
|
||||||
|
"default": 2.25,
|
||||||
|
"min": 0,
|
||||||
|
"max": 6.28,
|
||||||
|
"step": 0.01,
|
||||||
|
"hint": "angle"
|
||||||
|
},
|
||||||
|
"u_shinySoftness": {
|
||||||
|
"type": "float",
|
||||||
|
"label": "柔和度 | Softness",
|
||||||
|
"group": "Effect",
|
||||||
|
"default": 1.0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"step": 0.01
|
||||||
|
},
|
||||||
|
"u_shinyBrightness": {
|
||||||
|
"type": "float",
|
||||||
|
"label": "亮度 | Brightness",
|
||||||
|
"group": "Effect",
|
||||||
|
"default": 1.0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"step": 0.01
|
||||||
|
},
|
||||||
|
"u_shinyGloss": {
|
||||||
|
"type": "float",
|
||||||
|
"label": "光泽度 | Gloss",
|
||||||
|
"group": "Effect",
|
||||||
|
"default": 1.0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"step": 0.01,
|
||||||
|
"tooltip": "0=白色高光, 1=带颜色 | 0=white shine, 1=color-tinted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、统一效果组件/系统架构
|
||||||
|
|
||||||
|
### 3.1 抽取通用 ShinyEffect 基类
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/material-system/src/effects/BaseShinyEffect.ts
|
||||||
|
|
||||||
|
import { Component, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base shiny effect configuration (shared between UI and Sprite).
|
||||||
|
* 基础闪光效果配置(UI 和 Sprite 共享)。
|
||||||
|
*/
|
||||||
|
export abstract class BaseShinyEffect extends Component {
|
||||||
|
// ============= Effect Parameters =============
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 })
|
||||||
|
public width: number = 0.25;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 })
|
||||||
|
public rotation: number = 129;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 })
|
||||||
|
public softness: number = 1.0;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 })
|
||||||
|
public brightness: number = 1.0;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 })
|
||||||
|
public gloss: number = 1.0;
|
||||||
|
|
||||||
|
// ============= Animation Settings =============
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Play' })
|
||||||
|
public play: boolean = true;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Loop' })
|
||||||
|
public loop: boolean = true;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 })
|
||||||
|
public duration: number = 2.0;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 })
|
||||||
|
public loopDelay: number = 2.0;
|
||||||
|
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 })
|
||||||
|
public initialDelay: number = 0;
|
||||||
|
|
||||||
|
// ============= Runtime State =============
|
||||||
|
public progress: number = 0;
|
||||||
|
public elapsedTime: number = 0;
|
||||||
|
public inDelay: boolean = false;
|
||||||
|
public delayRemaining: number = 0;
|
||||||
|
public initialDelayProcessed: boolean = false;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.progress = 0;
|
||||||
|
this.elapsedTime = 0;
|
||||||
|
this.inDelay = false;
|
||||||
|
this.delayRemaining = 0;
|
||||||
|
this.initialDelayProcessed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.reset();
|
||||||
|
this.play = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.play = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRotationRadians(): number {
|
||||||
|
return this.rotation * Math.PI / 180;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 通用动画更新逻辑
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/material-system/src/effects/ShinyEffectAnimator.ts
|
||||||
|
|
||||||
|
import type { BaseShinyEffect } from './BaseShinyEffect';
|
||||||
|
import type { IMaterialOverridable } from '../interfaces/IMaterialOverridable';
|
||||||
|
import { BuiltInShaders } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared animator logic for shiny effect.
|
||||||
|
* 闪光效果共享的动画逻辑。
|
||||||
|
*/
|
||||||
|
export class ShinyEffectAnimator {
|
||||||
|
/**
|
||||||
|
* Update animation state.
|
||||||
|
* 更新动画状态。
|
||||||
|
*/
|
||||||
|
static updateAnimation(shiny: BaseShinyEffect, deltaTime: number): void {
|
||||||
|
if (!shiny.initialDelayProcessed && shiny.initialDelay > 0) {
|
||||||
|
shiny.delayRemaining = shiny.initialDelay;
|
||||||
|
shiny.inDelay = true;
|
||||||
|
shiny.initialDelayProcessed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiny.inDelay) {
|
||||||
|
shiny.delayRemaining -= deltaTime;
|
||||||
|
if (shiny.delayRemaining <= 0) {
|
||||||
|
shiny.inDelay = false;
|
||||||
|
shiny.elapsedTime = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shiny.elapsedTime += deltaTime;
|
||||||
|
shiny.progress = Math.min(shiny.elapsedTime / shiny.duration, 1.0);
|
||||||
|
|
||||||
|
if (shiny.progress >= 1.0) {
|
||||||
|
if (shiny.loop) {
|
||||||
|
shiny.inDelay = true;
|
||||||
|
shiny.delayRemaining = shiny.loopDelay;
|
||||||
|
shiny.progress = 0;
|
||||||
|
shiny.elapsedTime = 0;
|
||||||
|
} else {
|
||||||
|
shiny.play = false;
|
||||||
|
shiny.progress = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply material overrides.
|
||||||
|
* 应用材质覆盖。
|
||||||
|
*/
|
||||||
|
static applyMaterialOverrides(shiny: BaseShinyEffect, target: IMaterialOverridable): void {
|
||||||
|
if (target.getMaterialId() === 0) {
|
||||||
|
target.setMaterialId(BuiltInShaders.Shiny);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.setOverrideFloat('u_shinyProgress', shiny.progress);
|
||||||
|
target.setOverrideFloat('u_shinyWidth', shiny.width);
|
||||||
|
target.setOverrideFloat('u_shinyRotation', shiny.getRotationRadians());
|
||||||
|
target.setOverrideFloat('u_shinySoftness', shiny.softness);
|
||||||
|
target.setOverrideFloat('u_shinyBrightness', shiny.brightness);
|
||||||
|
target.setOverrideFloat('u_shinyGloss', shiny.gloss);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Material Inspector 设计
|
||||||
|
|
||||||
|
### 4.1 组件架构
|
||||||
|
|
||||||
|
```
|
||||||
|
MaterialPropertiesEditor (容器组件)
|
||||||
|
├── ShaderSelector (着色器选择器)
|
||||||
|
├── PropertyGroup (属性分组)
|
||||||
|
│ ├── FloatProperty (浮点属性)
|
||||||
|
│ ├── VectorProperty (向量属性)
|
||||||
|
│ ├── ColorProperty (颜色属性)
|
||||||
|
│ └── TextureProperty (纹理属性)
|
||||||
|
└── OverrideIndicator (覆盖指示器)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 核心组件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx
|
||||||
|
|
||||||
|
interface MaterialPropertiesEditorProps {
|
||||||
|
/** Target component implementing IMaterialOverridable */
|
||||||
|
target: IMaterialOverridable;
|
||||||
|
/** Current shader definition with property metadata */
|
||||||
|
shaderDef?: ShaderAssetDefinition;
|
||||||
|
/** Callback when property changes */
|
||||||
|
onChange?: (name: string, value: MaterialPropertyOverride) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaterialPropertiesEditor: React.FC<MaterialPropertiesEditorProps> = ({
|
||||||
|
target,
|
||||||
|
shaderDef,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
// Group properties by their group field
|
||||||
|
const groupedProps = useMemo(() => {
|
||||||
|
if (!shaderDef?.properties) return {};
|
||||||
|
|
||||||
|
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
|
||||||
|
for (const [name, meta] of Object.entries(shaderDef.properties)) {
|
||||||
|
if (meta.hidden) continue;
|
||||||
|
const group = meta.group || 'Default';
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push([name, meta]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [shaderDef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="material-properties-editor">
|
||||||
|
<ShaderSelector
|
||||||
|
currentShaderId={target.getMaterialId()}
|
||||||
|
onSelect={(id) => target.setMaterialId(id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{Object.entries(groupedProps).map(([group, props]) => (
|
||||||
|
<PropertyGroup key={group} title={group}>
|
||||||
|
{props.map(([name, meta]) => (
|
||||||
|
<PropertyField
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
meta={meta}
|
||||||
|
value={target.getOverride(name)?.value ?? meta.default}
|
||||||
|
onChange={(value) => {
|
||||||
|
applyOverride(target, name, meta.type, value);
|
||||||
|
onChange?.(name, target.getOverride(name)!);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PropertyGroup>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、实施计划
|
||||||
|
|
||||||
|
### Phase 1: 接口层 (1-2 天)
|
||||||
|
|
||||||
|
1. **创建 IMaterialOverridable 接口** (`packages/material-system/src/interfaces/`)
|
||||||
|
2. **创建 MaterialOverridableMixin** (`packages/material-system/src/mixins/`)
|
||||||
|
3. **导出新接口** (`packages/material-system/src/index.ts`)
|
||||||
|
|
||||||
|
### Phase 2: 重构现有组件 (2-3 天)
|
||||||
|
|
||||||
|
1. **修改 SpriteComponent**:实现 `IMaterialOverridable`,使用 Mixin
|
||||||
|
2. **修改 UIRenderComponent**:实现 `IMaterialOverridable`,使用 Mixin
|
||||||
|
3. **删除重复代码**:移除各组件中的重复材质方法
|
||||||
|
|
||||||
|
### Phase 3: 统一效果系统 (2-3 天)
|
||||||
|
|
||||||
|
1. **创建 BaseShinyEffect** (`packages/material-system/src/effects/`)
|
||||||
|
2. **创建 ShinyEffectAnimator** (`packages/material-system/src/effects/`)
|
||||||
|
3. **重构 ShinyEffectComponent**:继承 BaseShinyEffect
|
||||||
|
4. **重构 UIShinyEffectComponent**:继承 BaseShinyEffect
|
||||||
|
5. **重构系统**:使用 ShinyEffectAnimator
|
||||||
|
|
||||||
|
### Phase 4: Shader Property 系统 (2-3 天)
|
||||||
|
|
||||||
|
1. **定义 ShaderPropertyMeta 接口**
|
||||||
|
2. **扩展 ShaderDefinition** 添加 properties 字段
|
||||||
|
3. **创建 ShaderLoader** 支持 .shader 文件
|
||||||
|
4. **注册内置着色器属性元数据**
|
||||||
|
|
||||||
|
### Phase 5: Material Inspector (3-4 天)
|
||||||
|
|
||||||
|
1. **创建 MaterialPropertiesEditor 组件**
|
||||||
|
2. **创建 PropertyField 组件** (Float, Vector, Color, Texture)
|
||||||
|
3. **集成到现有 Inspector 系统**
|
||||||
|
4. **支持实时预览**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、文件修改清单
|
||||||
|
|
||||||
|
| 优先级 | 包 | 文件 | 操作 |
|
||||||
|
|--------|-----|------|------|
|
||||||
|
| P0 | material-system | `src/interfaces/IMaterialOverridable.ts` | 新建 |
|
||||||
|
| P0 | material-system | `src/mixins/MaterialOverridableMixin.ts` | 新建 |
|
||||||
|
| P0 | material-system | `src/interfaces/IShaderProperty.ts` | 新建 |
|
||||||
|
| P1 | material-system | `src/effects/BaseShinyEffect.ts` | 新建 |
|
||||||
|
| P1 | material-system | `src/effects/ShinyEffectAnimator.ts` | 新建 |
|
||||||
|
| P1 | sprite | `src/SpriteComponent.ts` | 重构 |
|
||||||
|
| P1 | ui | `src/components/UIRenderComponent.ts` | 重构 |
|
||||||
|
| P2 | sprite | `src/ShinyEffectComponent.ts` | 重构 |
|
||||||
|
| P2 | ui | `src/components/UIShinyEffectComponent.ts` | 重构 |
|
||||||
|
| P2 | sprite | `src/systems/ShinyEffectSystem.ts` | 重构 |
|
||||||
|
| P2 | ui | `src/systems/render/UIShinyEffectSystem.ts` | 重构 |
|
||||||
|
| P3 | material-system | `src/loaders/ShaderLoader.ts` | 扩展 |
|
||||||
|
| P3 | editor-app | `src/components/inspectors/material/*` | 新建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、Transform 组件统一(可选)
|
||||||
|
|
||||||
|
### 7.1 现状分析
|
||||||
|
|
||||||
|
| 特性 | TransformComponent | UITransformComponent |
|
||||||
|
|------|-------------------|---------------------|
|
||||||
|
| **坐标系** | 绝对坐标 (position.x/y/z) | 相对锚点坐标 (x/y + anchor) |
|
||||||
|
| **尺寸** | ❌ 无 | ✅ width/height + 约束 |
|
||||||
|
| **锚点系统** | ❌ 无 | ✅ anchorMin/Max |
|
||||||
|
| **3D 支持** | ✅ IVector3 | ❌ 纯 2D |
|
||||||
|
| **可见性** | ❌ 无 | ✅ visible, alpha |
|
||||||
|
|
||||||
|
### 7.2 结论
|
||||||
|
|
||||||
|
**不建议完全合并**,但可提取公共基类:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/engine-core/src/interfaces/ITransformBase.ts
|
||||||
|
|
||||||
|
export interface ITransformBase {
|
||||||
|
/** 旋转角度(度) | Rotation in degrees */
|
||||||
|
rotation: number;
|
||||||
|
|
||||||
|
/** X 缩放 | Scale X */
|
||||||
|
scaleX: number;
|
||||||
|
|
||||||
|
/** Y 缩放 | Scale Y */
|
||||||
|
scaleY: number;
|
||||||
|
|
||||||
|
/** 本地到世界矩阵 | Local to world matrix */
|
||||||
|
readonly localToWorldMatrix: Matrix2D;
|
||||||
|
|
||||||
|
/** 是否需要更新 | Dirty flag */
|
||||||
|
isDirty: boolean;
|
||||||
|
|
||||||
|
/** 世界坐标 X | World position X */
|
||||||
|
readonly worldX: number;
|
||||||
|
|
||||||
|
/** 世界坐标 Y | World position Y */
|
||||||
|
readonly worldY: number;
|
||||||
|
|
||||||
|
/** 世界旋转 | World rotation */
|
||||||
|
readonly worldRotation: number;
|
||||||
|
|
||||||
|
/** 世界缩放 X | World scale X */
|
||||||
|
readonly worldScaleX: number;
|
||||||
|
|
||||||
|
/** 世界缩放 Y | World scale Y */
|
||||||
|
readonly worldScaleY: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 收益
|
||||||
|
|
||||||
|
- 渲染系统可以统一处理 `ITransformBase`
|
||||||
|
- 减少 SpriteRenderSystem 和 UIRenderSystem 的重复
|
||||||
|
- Gizmo 系统可以共享变换操作逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、向后兼容性
|
||||||
|
|
||||||
|
1. **接口兼容**:现有组件的 API 保持不变
|
||||||
|
2. **序列化兼容**:不改变现有序列化格式
|
||||||
|
3. **渐进迁移**:可分阶段进行,不影响现有功能
|
||||||
@@ -10,6 +10,47 @@ import {
|
|||||||
IAssetCatalogEntry
|
IAssetCatalogEntry
|
||||||
} from '../types/AssetTypes';
|
} from '../types/AssetTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纹理 Sprite 信息(从 meta 文件的 importSettings 读取)
|
||||||
|
* Texture sprite info (read from meta file's importSettings)
|
||||||
|
*/
|
||||||
|
export interface ITextureSpriteInfo {
|
||||||
|
/**
|
||||||
|
* 九宫格切片边距 [top, right, bottom, left]
|
||||||
|
* Nine-patch slice border
|
||||||
|
*/
|
||||||
|
sliceBorder?: [number, number, number, number];
|
||||||
|
/**
|
||||||
|
* Sprite 锚点 [x, y](0-1 归一化)
|
||||||
|
* Sprite pivot point (0-1 normalized)
|
||||||
|
*/
|
||||||
|
pivot?: [number, number];
|
||||||
|
/**
|
||||||
|
* 纹理宽度(可选,需要纹理已加载)
|
||||||
|
* Texture width (optional, requires texture to be loaded)
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
/**
|
||||||
|
* 纹理高度(可选,需要纹理已加载)
|
||||||
|
* Texture height (optional, requires texture to be loaded)
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprite settings in import settings
|
||||||
|
* 导入设置中的 Sprite 设置
|
||||||
|
*/
|
||||||
|
interface ISpriteSettings {
|
||||||
|
sliceBorder?: [number, number, number, number];
|
||||||
|
pivot?: [number, number];
|
||||||
|
pixelsPerUnit?: number;
|
||||||
|
/** Texture width (from import settings) | 纹理宽度(来自导入设置) */
|
||||||
|
width?: number;
|
||||||
|
/** Texture height (from import settings) | 纹理高度(来自导入设置) */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asset database implementation
|
* Asset database implementation
|
||||||
* 资产数据库实现
|
* 资产数据库实现
|
||||||
@@ -212,6 +253,41 @@ export class AssetDatabase {
|
|||||||
return guid ? this._metadata.get(guid) : undefined;
|
return guid ? this._metadata.get(guid) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get texture sprite info from metadata
|
||||||
|
* 从元数据获取纹理 Sprite 信息
|
||||||
|
*
|
||||||
|
* Extracts spriteSettings from importSettings if available.
|
||||||
|
* 如果可用,从 importSettings 提取 spriteSettings。
|
||||||
|
*
|
||||||
|
* @param guid - Texture asset GUID | 纹理资产 GUID
|
||||||
|
* @returns Sprite info or undefined if not found/not a texture | Sprite 信息或未找到/非纹理则为 undefined
|
||||||
|
*/
|
||||||
|
getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
|
||||||
|
const metadata = this._metadata.get(guid);
|
||||||
|
if (!metadata) return undefined;
|
||||||
|
|
||||||
|
// Check if it's a texture asset
|
||||||
|
// 检查是否是纹理资产
|
||||||
|
if (metadata.type !== AssetType.Texture) return undefined;
|
||||||
|
|
||||||
|
// Extract spriteSettings from importSettings
|
||||||
|
// 从 importSettings 提取 spriteSettings
|
||||||
|
const importSettings = metadata.importSettings as Record<string, unknown> | undefined;
|
||||||
|
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
|
||||||
|
|
||||||
|
if (!spriteSettings) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sliceBorder: spriteSettings.sliceBorder,
|
||||||
|
pivot: spriteSettings.pivot,
|
||||||
|
// Include dimensions from import settings if available
|
||||||
|
// 如果可用,包含来自导入设置的尺寸
|
||||||
|
width: spriteSettings.width,
|
||||||
|
height: spriteSettings.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find assets by type
|
* Find assets by type
|
||||||
* 按类型查找资产
|
* 按类型查找资产
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ export class AssetManager implements IAssetManager {
|
|||||||
labels: [],
|
labels: [],
|
||||||
tags: new Map(),
|
tags: new Map(),
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
version: 1
|
version: 1,
|
||||||
|
// Include importSettings for sprite slicing (nine-patch), etc.
|
||||||
|
// 包含 importSettings 以支持精灵切片(九宫格)等功能
|
||||||
|
importSettings: entry.importSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
this._database.addAsset(metadata);
|
this._database.addAsset(metadata);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export { RuntimeCatalog, runtimeCatalog } from './runtime/RuntimeCatalog';
|
|||||||
export * from './interfaces/IAssetLoader';
|
export * from './interfaces/IAssetLoader';
|
||||||
export * from './interfaces/IAssetManager';
|
export * from './interfaces/IAssetManager';
|
||||||
export * from './interfaces/IAssetReader';
|
export * from './interfaces/IAssetReader';
|
||||||
|
export * from './interfaces/IAssetFileLoader';
|
||||||
export * from './interfaces/IResourceComponent';
|
export * from './interfaces/IResourceComponent';
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
@@ -58,13 +59,24 @@ export { PrefabLoader } from './loaders/PrefabLoader';
|
|||||||
|
|
||||||
// Integration
|
// Integration
|
||||||
export { EngineIntegration } from './integration/EngineIntegration';
|
export { EngineIntegration } from './integration/EngineIntegration';
|
||||||
export type { ITextureEngineBridge } from './integration/EngineIntegration';
|
export type { ITextureEngineBridge, TextureLoadCallback } from './integration/EngineIntegration';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
export { SceneResourceManager } from './services/SceneResourceManager';
|
export { SceneResourceManager } from './services/SceneResourceManager';
|
||||||
export type { IResourceLoader } from './services/SceneResourceManager';
|
export type { IResourceLoader } from './services/SceneResourceManager';
|
||||||
export { PathResolutionService } from './services/PathResolutionService';
|
export { PathResolutionService } from './services/PathResolutionService';
|
||||||
|
|
||||||
|
// Asset Metadata Service (primary API for sprite info)
|
||||||
|
// 资产元数据服务(sprite 信息的主要 API)
|
||||||
|
export {
|
||||||
|
setGlobalAssetDatabase,
|
||||||
|
getGlobalAssetDatabase,
|
||||||
|
setGlobalEngineBridge,
|
||||||
|
getGlobalEngineBridge,
|
||||||
|
getTextureSpriteInfo
|
||||||
|
} from './services/AssetMetadataService';
|
||||||
|
export type { ITextureSpriteInfo } from './core/AssetDatabase';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export { UVHelper } from './utils/UVHelper';
|
export { UVHelper } from './utils/UVHelper';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ export interface ITextureEngineBridge {
|
|||||||
*/
|
*/
|
||||||
unloadTexture(id: number): void;
|
unloadTexture(id: number): void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get texture info
|
|
||||||
* 获取纹理信息
|
|
||||||
*/
|
|
||||||
getTextureInfo(id: number): { width: number; height: number } | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or load texture by path.
|
* Get or load texture by path.
|
||||||
* 按路径获取或加载纹理。
|
* 按路径获取或加载纹理。
|
||||||
@@ -109,6 +103,20 @@ export interface ITextureEngineBridge {
|
|||||||
* @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise
|
* @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise
|
||||||
*/
|
*/
|
||||||
loadTextureAsync?(id: number, url: string): Promise<void>;
|
loadTextureAsync?(id: number, url: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get texture info by path.
|
||||||
|
* 通过路径获取纹理信息。
|
||||||
|
*
|
||||||
|
* This is the primary API for getting texture dimensions.
|
||||||
|
* The Rust engine is the single source of truth for texture dimensions.
|
||||||
|
* 这是获取纹理尺寸的主要 API。
|
||||||
|
* Rust 引擎是纹理尺寸的唯一事实来源。
|
||||||
|
*
|
||||||
|
* @param path Image path/URL | 图片路径/URL
|
||||||
|
* @returns Texture info or null if not loaded | 纹理信息或未加载则为 null
|
||||||
|
*/
|
||||||
|
getTextureInfoByPath?(path: string): { width: number; height: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,10 +139,43 @@ interface DataAssetEntry {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texture load callback type
|
||||||
|
* 纹理加载回调类型
|
||||||
|
*/
|
||||||
|
export type TextureLoadCallback = (guid: string, path: string, textureId: number) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asset system engine integration
|
* Asset system engine integration
|
||||||
* 资产系统引擎集成
|
* 资产系统引擎集成
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Texture sprite info (nine-patch border, pivot, etc.)
|
||||||
|
* 纹理 Sprite 信息(九宫格边距、锚点等)
|
||||||
|
*/
|
||||||
|
export interface ITextureSpriteInfo {
|
||||||
|
/**
|
||||||
|
* 九宫格切片边距 [top, right, bottom, left]
|
||||||
|
* Nine-patch slice border
|
||||||
|
*/
|
||||||
|
sliceBorder?: [number, number, number, number];
|
||||||
|
/**
|
||||||
|
* Sprite 锚点 [x, y](0-1 归一化)
|
||||||
|
* Sprite pivot point (0-1 normalized)
|
||||||
|
*/
|
||||||
|
pivot?: [number, number];
|
||||||
|
/**
|
||||||
|
* 纹理宽度
|
||||||
|
* Texture width
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
/**
|
||||||
|
* 纹理高度
|
||||||
|
* Texture height
|
||||||
|
*/
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class EngineIntegration {
|
export class EngineIntegration {
|
||||||
private _assetManager: AssetManager;
|
private _assetManager: AssetManager;
|
||||||
private _engineBridge?: ITextureEngineBridge;
|
private _engineBridge?: ITextureEngineBridge;
|
||||||
@@ -146,6 +187,54 @@ export class EngineIntegration {
|
|||||||
// Path-stable ID cache (persists across Play/Stop cycles)
|
// Path-stable ID cache (persists across Play/Stop cycles)
|
||||||
private static _pathIdCache = new Map<string, number>();
|
private static _pathIdCache = new Map<string, number>();
|
||||||
|
|
||||||
|
// 纹理 Sprite 信息缓存(全局静态,可供渲染系统访问)
|
||||||
|
// Texture sprite info cache (global static, accessible by render systems)
|
||||||
|
private static _textureSpriteInfoCache = new Map<AssetGUID, ITextureSpriteInfo>();
|
||||||
|
|
||||||
|
// 纹理加载回调(用于动态图集集成等)
|
||||||
|
// Texture load callback (for dynamic atlas integration, etc.)
|
||||||
|
private static _textureLoadCallbacks: TextureLoadCallback[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback to be called when textures are loaded
|
||||||
|
* 注册纹理加载时调用的回调
|
||||||
|
*
|
||||||
|
* This can be used for dynamic atlas integration.
|
||||||
|
* 可用于动态图集集成。
|
||||||
|
*
|
||||||
|
* @param callback - Callback function | 回调函数
|
||||||
|
*/
|
||||||
|
static onTextureLoad(callback: TextureLoadCallback): void {
|
||||||
|
if (!EngineIntegration._textureLoadCallbacks.includes(callback)) {
|
||||||
|
EngineIntegration._textureLoadCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a texture load callback
|
||||||
|
* 移除纹理加载回调
|
||||||
|
*/
|
||||||
|
static removeTextureLoadCallback(callback: TextureLoadCallback): void {
|
||||||
|
const index = EngineIntegration._textureLoadCallbacks.indexOf(callback);
|
||||||
|
if (index >= 0) {
|
||||||
|
EngineIntegration._textureLoadCallbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all callbacks of a texture load
|
||||||
|
* 通知所有回调纹理已加载
|
||||||
|
*/
|
||||||
|
private static notifyTextureLoad(guid: string, path: string, textureId: number): void {
|
||||||
|
for (const callback of EngineIntegration._textureLoadCallbacks) {
|
||||||
|
try {
|
||||||
|
callback(guid, path, textureId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EngineIntegration] Error in texture load callback:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Audio resource mappings | 音频资源映射
|
// Audio resource mappings | 音频资源映射
|
||||||
private _audioIdMap = new Map<AssetGUID, number>();
|
private _audioIdMap = new Map<AssetGUID, number>();
|
||||||
private _pathToAudioId = new Map<string, number>();
|
private _pathToAudioId = new Map<string, number>();
|
||||||
@@ -279,6 +368,16 @@ export class EngineIntegration {
|
|||||||
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
|
||||||
const metadata = result.metadata;
|
const metadata = result.metadata;
|
||||||
const assetPath = metadata.path;
|
const assetPath = metadata.path;
|
||||||
|
const textureAsset = result.asset;
|
||||||
|
|
||||||
|
// 缓存 sprite 信息(九宫格边距等)到静态缓存
|
||||||
|
// Cache sprite info (slice border, etc.) to static cache
|
||||||
|
EngineIntegration._textureSpriteInfoCache.set(guid, {
|
||||||
|
sliceBorder: textureAsset.sliceBorder,
|
||||||
|
pivot: textureAsset.pivot,
|
||||||
|
width: textureAsset.width,
|
||||||
|
height: textureAsset.height
|
||||||
|
});
|
||||||
|
|
||||||
// 生成路径稳定 ID
|
// 生成路径稳定 ID
|
||||||
// Generate path-stable ID
|
// Generate path-stable ID
|
||||||
@@ -309,9 +408,37 @@ export class EngineIntegration {
|
|||||||
this._textureIdMap.set(guid, stableId);
|
this._textureIdMap.set(guid, stableId);
|
||||||
this._pathToTextureId.set(assetPath, stableId);
|
this._pathToTextureId.set(assetPath, stableId);
|
||||||
|
|
||||||
|
// 通知回调(用于动态图集等)
|
||||||
|
// Notify callbacks (for dynamic atlas, etc.)
|
||||||
|
EngineIntegration.notifyTextureLoad(guid, engineUrl, stableId);
|
||||||
|
|
||||||
return stableId;
|
return stableId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get texture sprite info by GUID (static method for render system access)
|
||||||
|
* 通过 GUID 获取纹理 Sprite 信息(静态方法,供渲染系统访问)
|
||||||
|
*
|
||||||
|
* Returns cached sprite info including nine-patch slice border.
|
||||||
|
* Must call loadTextureByGuid first to populate the cache.
|
||||||
|
* 返回缓存的 sprite 信息,包括九宫格边距。
|
||||||
|
* 必须先调用 loadTextureByGuid 来填充缓存。
|
||||||
|
*
|
||||||
|
* @param guid - Texture asset GUID | 纹理资产 GUID
|
||||||
|
* @returns Sprite info or undefined if not loaded | Sprite 信息或未加载则为 undefined
|
||||||
|
*/
|
||||||
|
static getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
|
||||||
|
return EngineIntegration._textureSpriteInfoCache.get(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear texture sprite info cache
|
||||||
|
* 清除纹理 Sprite 信息缓存
|
||||||
|
*/
|
||||||
|
static clearTextureSpriteInfoCache(): void {
|
||||||
|
EngineIntegration._textureSpriteInfoCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch load textures
|
* Batch load textures
|
||||||
* 批量加载纹理
|
* 批量加载纹理
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Asset File Loader Interface
|
||||||
|
* 资产文件加载器接口
|
||||||
|
*
|
||||||
|
* High-level file loading abstraction that combines path resolution
|
||||||
|
* with platform-specific file reading.
|
||||||
|
* 高级文件加载抽象,结合路径解析和平台特定的文件读取。
|
||||||
|
*
|
||||||
|
* This is the unified entry point for all file loading in the engine.
|
||||||
|
* Different from IAssetLoader (which parses content), this interface
|
||||||
|
* handles the actual file fetching from asset paths.
|
||||||
|
* 这是引擎中所有文件加载的统一入口。
|
||||||
|
* 与 IAssetLoader(解析内容)不同,此接口处理从资产路径获取文件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset file loader interface.
|
||||||
|
* 资产文件加载器接口。
|
||||||
|
*
|
||||||
|
* Provides a unified API for loading files from asset paths (relative to project).
|
||||||
|
* Different platforms provide their own implementations.
|
||||||
|
* 提供从资产路径(相对于项目)加载文件的统一 API。
|
||||||
|
* 不同平台提供各自的实现。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Get global loader
|
||||||
|
* const loader = getGlobalAssetFileLoader();
|
||||||
|
*
|
||||||
|
* // Load image from asset path (relative to project)
|
||||||
|
* const image = await loader.loadImage('assets/demo/button.png');
|
||||||
|
*
|
||||||
|
* // Load text content
|
||||||
|
* const json = await loader.loadText('assets/config.json');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface IAssetFileLoader {
|
||||||
|
/**
|
||||||
|
* Load image from asset path.
|
||||||
|
* 从资产路径加载图片。
|
||||||
|
*
|
||||||
|
* @param assetPath - Asset path relative to project (e.g., "assets/demo/button.png").
|
||||||
|
* 相对于项目的资产路径。
|
||||||
|
* @returns Promise resolving to HTMLImageElement. | 返回 HTMLImageElement 的 Promise。
|
||||||
|
*/
|
||||||
|
loadImage(assetPath: string): Promise<HTMLImageElement>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load text content from asset path.
|
||||||
|
* 从资产路径加载文本内容。
|
||||||
|
*
|
||||||
|
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
|
||||||
|
* @returns Promise resolving to text content. | 返回文本内容的 Promise。
|
||||||
|
*/
|
||||||
|
loadText(assetPath: string): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load binary data from asset path.
|
||||||
|
* 从资产路径加载二进制数据。
|
||||||
|
*
|
||||||
|
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
|
||||||
|
* @returns Promise resolving to ArrayBuffer. | 返回 ArrayBuffer 的 Promise。
|
||||||
|
*/
|
||||||
|
loadBinary(assetPath: string): Promise<ArrayBuffer>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if asset file exists.
|
||||||
|
* 检查资产文件是否存在。
|
||||||
|
*
|
||||||
|
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
|
||||||
|
* @returns Promise resolving to boolean. | 返回布尔值的 Promise。
|
||||||
|
*/
|
||||||
|
exists(assetPath: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global asset file loader instance.
|
||||||
|
* 全局资产文件加载器实例。
|
||||||
|
*/
|
||||||
|
let globalAssetFileLoader: IAssetFileLoader | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the global asset file loader.
|
||||||
|
* 设置全局资产文件加载器。
|
||||||
|
*
|
||||||
|
* Should be called during engine initialization with platform-specific implementation.
|
||||||
|
* 应在引擎初始化期间使用平台特定的实现调用。
|
||||||
|
*
|
||||||
|
* @param loader - Asset file loader instance or null. | 资产文件加载器实例或 null。
|
||||||
|
*/
|
||||||
|
export function setGlobalAssetFileLoader(loader: IAssetFileLoader | null): void {
|
||||||
|
globalAssetFileLoader = loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global asset file loader.
|
||||||
|
* 获取全局资产文件加载器。
|
||||||
|
*
|
||||||
|
* @returns Asset file loader instance or null. | 资产文件加载器实例或 null。
|
||||||
|
*/
|
||||||
|
export function getGlobalAssetFileLoader(): IAssetFileLoader | null {
|
||||||
|
return globalAssetFileLoader;
|
||||||
|
}
|
||||||
@@ -144,6 +144,24 @@ export interface ITextureAsset {
|
|||||||
hasMipmaps: boolean;
|
hasMipmaps: boolean;
|
||||||
/** 原始数据(如果可用) / Raw image data if available */
|
/** 原始数据(如果可用) / Raw image data if available */
|
||||||
data?: ImageData | HTMLImageElement;
|
data?: ImageData | HTMLImageElement;
|
||||||
|
|
||||||
|
// ===== Sprite Settings =====
|
||||||
|
// ===== Sprite 设置 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 九宫格切片边距 [top, right, bottom, left]
|
||||||
|
* Nine-patch slice border
|
||||||
|
*
|
||||||
|
* Defines the non-stretchable borders for nine-patch rendering.
|
||||||
|
* 定义九宫格渲染时不可拉伸的边框区域。
|
||||||
|
*/
|
||||||
|
sliceBorder?: [number, number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprite 锚点 [x, y](0-1 归一化)
|
||||||
|
* Sprite pivot point (0-1 normalized)
|
||||||
|
*/
|
||||||
|
pivot?: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,24 +201,109 @@ export interface IAudioAsset {
|
|||||||
channels: number;
|
channels: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader property type
|
||||||
|
* 着色器属性类型
|
||||||
|
*/
|
||||||
|
export type ShaderPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'int' | 'sampler2D' | 'mat3' | 'mat4';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader property definition
|
||||||
|
* 着色器属性定义
|
||||||
|
*/
|
||||||
|
export interface IShaderProperty {
|
||||||
|
/** 属性名称(uniform 名) / Property name (uniform name) */
|
||||||
|
name: string;
|
||||||
|
/** 属性类型 / Property type */
|
||||||
|
type: ShaderPropertyType;
|
||||||
|
/** 默认值 / Default value */
|
||||||
|
default: number | number[];
|
||||||
|
/** 显示名称(编辑器用) / Display name for editor */
|
||||||
|
displayName?: string;
|
||||||
|
/** 值范围(用于 float/int) / Value range for float/int */
|
||||||
|
range?: [number, number];
|
||||||
|
/** 是否隐藏(内部使用) / Hidden from inspector */
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader asset interface
|
||||||
|
* 着色器资产接口
|
||||||
|
*
|
||||||
|
* Shader assets contain GLSL source code and property definitions.
|
||||||
|
* 着色器资产包含 GLSL 源代码和属性定义。
|
||||||
|
*/
|
||||||
|
export interface IShaderAsset {
|
||||||
|
/** 着色器名称 / Shader name (e.g., "UI/Shiny") */
|
||||||
|
name: string;
|
||||||
|
/** 顶点着色器源代码 / Vertex shader GLSL source */
|
||||||
|
vertex: string;
|
||||||
|
/** 片段着色器源代码 / Fragment shader GLSL source */
|
||||||
|
fragment: string;
|
||||||
|
/** 属性定义列表 / Property definitions */
|
||||||
|
properties: IShaderProperty[];
|
||||||
|
/** 编译后的着色器 ID(运行时填充) / Compiled shader ID (runtime) */
|
||||||
|
shaderId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material property value
|
||||||
|
* 材质属性值
|
||||||
|
*/
|
||||||
|
export type MaterialPropertyValue = number | number[] | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material animator configuration
|
||||||
|
* 材质动画器配置
|
||||||
|
*/
|
||||||
|
export interface IMaterialAnimator {
|
||||||
|
/** 要动画的属性名 / Property to animate */
|
||||||
|
property: string;
|
||||||
|
/** 起始值 / Start value */
|
||||||
|
from: number;
|
||||||
|
/** 结束值 / End value */
|
||||||
|
to: number;
|
||||||
|
/** 持续时间(秒) / Duration in seconds */
|
||||||
|
duration: number;
|
||||||
|
/** 是否循环 / Loop animation */
|
||||||
|
loop?: boolean;
|
||||||
|
/** 循环间隔(秒) / Delay between loops */
|
||||||
|
loopDelay?: number;
|
||||||
|
/** 缓动函数 / Easing function */
|
||||||
|
easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
||||||
|
/** 是否自动播放 / Auto play on start */
|
||||||
|
autoPlay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material asset interface
|
* Material asset interface
|
||||||
* 材质资产接口
|
* 材质资产接口
|
||||||
|
*
|
||||||
|
* Material assets reference a shader and define property values.
|
||||||
|
* 材质资产引用着色器并定义属性值。
|
||||||
*/
|
*/
|
||||||
export interface IMaterialAsset {
|
export interface IMaterialAsset {
|
||||||
/** 着色器名称 / Shader name */
|
/** 材质名称 / Material name */
|
||||||
|
name: string;
|
||||||
|
/** 着色器 GUID 或内置路径 / Shader GUID or built-in path (e.g., "builtin://shaders/Shiny") */
|
||||||
shader: string;
|
shader: string;
|
||||||
/** 材质属性 / Material properties */
|
/** 材质属性值 / Material property values */
|
||||||
properties: Map<string, unknown>;
|
properties: Record<string, MaterialPropertyValue>;
|
||||||
/** 纹理映射 / Texture slot mappings */
|
/** 纹理映射 / Texture slot mappings (property name -> texture GUID) */
|
||||||
textures: Map<string, AssetGUID>;
|
textures?: Record<string, AssetGUID>;
|
||||||
/** 渲染状态 / Render states */
|
/** 渲染状态 / Render states */
|
||||||
renderStates: {
|
renderStates?: {
|
||||||
cullMode?: 'none' | 'front' | 'back';
|
cullMode?: 'none' | 'front' | 'back';
|
||||||
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply';
|
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply' | 'screen';
|
||||||
depthTest?: boolean;
|
depthTest?: boolean;
|
||||||
depthWrite?: boolean;
|
depthWrite?: boolean;
|
||||||
};
|
};
|
||||||
|
/** 动画器配置(可选) / Animator configuration (optional) */
|
||||||
|
animator?: IMaterialAnimator;
|
||||||
|
/** 运行时:编译后的着色器 ID / Runtime: compiled shader ID */
|
||||||
|
_shaderId?: number;
|
||||||
|
/** 运行时:引擎材质 ID / Runtime: engine material ID */
|
||||||
|
_materialId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file
|
// 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
|
|||||||
|
|
||||||
// 预制体加载器 / Prefab loader
|
// 预制体加载器 / Prefab loader
|
||||||
this._loaders.set(AssetType.Prefab, new PrefabLoader());
|
this._loaders.set(AssetType.Prefab, new PrefabLoader());
|
||||||
|
|
||||||
|
// 注:Shader 和 Material 加载器由 material-system 模块注册
|
||||||
|
// Note: Shader and Material loaders are registered by material-system module
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ interface IEngineBridgeGlobal {
|
|||||||
unloadTexture?(textureId: number): void;
|
unloadTexture?(textureId: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprite settings from texture meta
|
||||||
|
* 纹理 meta 中的 Sprite 设置
|
||||||
|
*/
|
||||||
|
interface ISpriteSettings {
|
||||||
|
sliceBorder?: [number, number, number, number];
|
||||||
|
pivot?: [number, number];
|
||||||
|
pixelsPerUnit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取全局引擎桥接
|
* 获取全局引擎桥接
|
||||||
* Get global engine bridge
|
* Get global engine bridge
|
||||||
@@ -61,13 +71,22 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
|
|||||||
|
|
||||||
const image = content.image;
|
const image = content.image;
|
||||||
|
|
||||||
|
// Read sprite settings from import settings
|
||||||
|
// 从导入设置读取 sprite 设置
|
||||||
|
const importSettings = context.metadata.importSettings as Record<string, unknown> | undefined;
|
||||||
|
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
|
||||||
|
|
||||||
const textureAsset: ITextureAsset = {
|
const textureAsset: ITextureAsset = {
|
||||||
textureId: TextureLoader._nextTextureId++,
|
textureId: TextureLoader._nextTextureId++,
|
||||||
width: image.width,
|
width: image.width,
|
||||||
height: image.height,
|
height: image.height,
|
||||||
format: 'rgba',
|
format: 'rgba',
|
||||||
hasMipmaps: false,
|
hasMipmaps: false,
|
||||||
data: image
|
data: image,
|
||||||
|
// Include sprite settings if available
|
||||||
|
// 如果有则包含 sprite 设置
|
||||||
|
sliceBorder: spriteSettings?.sliceBorder,
|
||||||
|
pivot: spriteSettings?.pivot
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upload to GPU if bridge exists.
|
// Upload to GPU if bridge exists.
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Asset Metadata Service
|
||||||
|
* 资产元数据服务
|
||||||
|
*
|
||||||
|
* Provides global access to asset metadata without requiring asset loading.
|
||||||
|
* This service is independent of the texture loading path, allowing
|
||||||
|
* render systems to query sprite info regardless of how textures are loaded.
|
||||||
|
*
|
||||||
|
* 提供对资产元数据的全局访问,无需加载资产。
|
||||||
|
* 此服务独立于纹理加载路径,允许渲染系统查询 sprite 信息,
|
||||||
|
* 无论纹理是如何加载的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AssetDatabase, ITextureSpriteInfo } from '../core/AssetDatabase';
|
||||||
|
import type { AssetGUID } from '../types/AssetTypes';
|
||||||
|
import type { ITextureEngineBridge } from '../integration/EngineIntegration';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global asset database instance
|
||||||
|
* 全局资产数据库实例
|
||||||
|
*/
|
||||||
|
let globalAssetDatabase: AssetDatabase | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global engine bridge instance
|
||||||
|
* 全局引擎桥实例
|
||||||
|
*
|
||||||
|
* Used to query texture dimensions from Rust engine (single source of truth).
|
||||||
|
* 用于从 Rust 引擎查询纹理尺寸(唯一事实来源)。
|
||||||
|
*/
|
||||||
|
let globalEngineBridge: ITextureEngineBridge | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the global asset database
|
||||||
|
* 设置全局资产数据库
|
||||||
|
*
|
||||||
|
* Should be called during engine initialization.
|
||||||
|
* 应在引擎初始化期间调用。
|
||||||
|
*
|
||||||
|
* @param database - AssetDatabase instance | AssetDatabase 实例
|
||||||
|
*/
|
||||||
|
export function setGlobalAssetDatabase(database: AssetDatabase | null): void {
|
||||||
|
globalAssetDatabase = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global asset database
|
||||||
|
* 获取全局资产数据库
|
||||||
|
*
|
||||||
|
* @returns AssetDatabase instance or null | AssetDatabase 实例或 null
|
||||||
|
*/
|
||||||
|
export function getGlobalAssetDatabase(): AssetDatabase | null {
|
||||||
|
return globalAssetDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the global engine bridge
|
||||||
|
* 设置全局引擎桥
|
||||||
|
*
|
||||||
|
* The engine bridge is used to query texture dimensions directly from Rust engine.
|
||||||
|
* This is the single source of truth for texture dimensions.
|
||||||
|
* 引擎桥用于直接从 Rust 引擎查询纹理尺寸。
|
||||||
|
* 这是纹理尺寸的唯一事实来源。
|
||||||
|
*
|
||||||
|
* @param bridge - ITextureEngineBridge instance | ITextureEngineBridge 实例
|
||||||
|
*/
|
||||||
|
export function setGlobalEngineBridge(bridge: ITextureEngineBridge | null): void {
|
||||||
|
globalEngineBridge = bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global engine bridge
|
||||||
|
* 获取全局引擎桥
|
||||||
|
*
|
||||||
|
* @returns ITextureEngineBridge instance or null | ITextureEngineBridge 实例或 null
|
||||||
|
*/
|
||||||
|
export function getGlobalEngineBridge(): ITextureEngineBridge | null {
|
||||||
|
return globalEngineBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get texture sprite info by GUID
|
||||||
|
* 通过 GUID 获取纹理 Sprite 信息
|
||||||
|
*
|
||||||
|
* This is the primary API for render systems to query nine-patch/sprite info.
|
||||||
|
* It combines data from:
|
||||||
|
* - Asset metadata (sliceBorder, pivot) from AssetDatabase
|
||||||
|
* - Texture dimensions (width, height) from Rust engine (single source of truth)
|
||||||
|
*
|
||||||
|
* 这是渲染系统查询九宫格/sprite 信息的主要 API。
|
||||||
|
* 它合并来自:
|
||||||
|
* - AssetDatabase 的资产元数据(sliceBorder, pivot)
|
||||||
|
* - Rust 引擎的纹理尺寸(width, height)(唯一事实来源)
|
||||||
|
*
|
||||||
|
* @param guid - Texture asset GUID | 纹理资产 GUID
|
||||||
|
* @returns Sprite info or undefined | Sprite 信息或 undefined
|
||||||
|
*/
|
||||||
|
export function getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
|
||||||
|
// Get sprite settings from metadata
|
||||||
|
// 从元数据获取 sprite 设置
|
||||||
|
const metadataInfo = globalAssetDatabase?.getTextureSpriteInfo(guid);
|
||||||
|
|
||||||
|
// Get texture dimensions from Rust engine (single source of truth)
|
||||||
|
// 从 Rust 引擎获取纹理尺寸(唯一事实来源)
|
||||||
|
let dimensions: { width: number; height: number } | undefined;
|
||||||
|
|
||||||
|
if (globalEngineBridge?.getTextureInfoByPath && globalAssetDatabase) {
|
||||||
|
// Get asset path from database
|
||||||
|
// 从数据库获取资产路径
|
||||||
|
const metadata = globalAssetDatabase.getMetadata(guid);
|
||||||
|
if (metadata?.path) {
|
||||||
|
const engineInfo = globalEngineBridge.getTextureInfoByPath(metadata.path);
|
||||||
|
if (engineInfo) {
|
||||||
|
dimensions = engineInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no metadata and no dimensions, return undefined
|
||||||
|
// 如果没有元数据也没有尺寸,返回 undefined
|
||||||
|
if (!metadataInfo && !dimensions) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the two sources
|
||||||
|
// 合并两个数据源
|
||||||
|
// Prefer engine dimensions (runtime loaded), fallback to metadata dimensions (catalog stored)
|
||||||
|
// 优先使用引擎尺寸(运行时加载),后备使用元数据尺寸(目录存储)
|
||||||
|
return {
|
||||||
|
sliceBorder: metadataInfo?.sliceBorder,
|
||||||
|
pivot: metadataInfo?.pivot,
|
||||||
|
width: dimensions?.width ?? metadataInfo?.width,
|
||||||
|
height: dimensions?.height ?? metadataInfo?.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export type for convenience
|
||||||
|
// 为方便起见重新导出类型
|
||||||
|
export type { ITextureSpriteInfo };
|
||||||
@@ -406,6 +406,12 @@ export interface IAssetCatalogEntry {
|
|||||||
|
|
||||||
/** 可用变体 / Available variants (platform/quality specific) */
|
/** 可用变体 / Available variants (platform/quality specific) */
|
||||||
variants?: IAssetVariant[];
|
variants?: IAssetVariant[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import settings (e.g., sprite slicing for nine-patch)
|
||||||
|
* 导入设置(如九宫格切片信息)
|
||||||
|
*/
|
||||||
|
importSettings?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
|
||||||
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask';
|
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask' | 'entityRef';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 属性资源类型
|
* 属性资源类型
|
||||||
@@ -52,6 +52,16 @@ interface PropertyOptionsBase {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** 是否只读 | Read-only flag */
|
/** 是否只读 | Read-only flag */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否在 Inspector 中隐藏
|
||||||
|
* Whether to hide this property in Inspector
|
||||||
|
*
|
||||||
|
* Hidden properties are still serialized but not shown in the default PropertyInspector.
|
||||||
|
* Useful when a custom Inspector handles the property.
|
||||||
|
* 隐藏的属性仍然会被序列化,但不会在默认的 PropertyInspector 中显示。
|
||||||
|
* 适用于自定义 Inspector 处理该属性的情况。
|
||||||
|
*/
|
||||||
|
hidden?: boolean;
|
||||||
/** Action buttons | 操作按钮 */
|
/** Action buttons | 操作按钮 */
|
||||||
actions?: PropertyAction[];
|
actions?: PropertyAction[];
|
||||||
/** 此属性控制的其他组件属性 | Properties this field controls */
|
/** 此属性控制的其他组件属性 | Properties this field controls */
|
||||||
@@ -193,6 +203,17 @@ interface CollisionMaskPropertyOptions extends PropertyOptionsBase {
|
|||||||
type: 'collisionMask';
|
type: 'collisionMask';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体引用属性选项
|
||||||
|
* Entity reference property options
|
||||||
|
*
|
||||||
|
* Used for properties that store entity IDs and support drag-and-drop from SceneHierarchy.
|
||||||
|
* 用于存储实体 ID 的属性,支持从场景层级面板拖放。
|
||||||
|
*/
|
||||||
|
interface EntityRefPropertyOptions extends PropertyOptionsBase {
|
||||||
|
type: 'entityRef';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 属性选项联合类型
|
* 属性选项联合类型
|
||||||
* Property options union type
|
* Property options union type
|
||||||
@@ -208,7 +229,8 @@ export type PropertyOptions =
|
|||||||
| ArrayPropertyOptions
|
| ArrayPropertyOptions
|
||||||
| AnimationClipsPropertyOptions
|
| AnimationClipsPropertyOptions
|
||||||
| CollisionLayerPropertyOptions
|
| CollisionLayerPropertyOptions
|
||||||
| CollisionMaskPropertyOptions;
|
| CollisionMaskPropertyOptions
|
||||||
|
| EntityRefPropertyOptions;
|
||||||
|
|
||||||
// 使用 Symbol.for 创建全局 Symbol,确保跨包共享元数据
|
// 使用 Symbol.for 创建全局 Symbol,确保跨包共享元数据
|
||||||
// Use Symbol.for to create a global Symbol to ensure metadata sharing across packages
|
// Use Symbol.for to create a global Symbol to ensure metadata sharing across packages
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ export interface SystemMetadata {
|
|||||||
* Whether enabled by default (default true)
|
* Whether enabled by default (default true)
|
||||||
*/
|
*/
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否在编辑模式下运行(默认 true)
|
||||||
|
* Whether to run in edit mode (default true)
|
||||||
|
*
|
||||||
|
* 默认情况下,所有系统在编辑模式下都会运行。
|
||||||
|
* 当设置为 false 时,此系统在编辑模式(非 Play 状态)下不会执行。
|
||||||
|
* 适用于物理系统、AI 系统等只应在游戏运行时执行的系统。
|
||||||
|
*
|
||||||
|
* By default, all systems run in edit mode.
|
||||||
|
* When set to false, this system will NOT execute during edit mode
|
||||||
|
* (when not playing). Useful for physics, AI, and other systems
|
||||||
|
* that should only run during gameplay.
|
||||||
|
*/
|
||||||
|
runInEditMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,6 +181,17 @@ export function getSystemMetadata(systemType: new (...args: any[]) => EntitySyst
|
|||||||
return (systemType as any).__systemMetadata__;
|
return (systemType as any).__systemMetadata__;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从系统实例获取元数据
|
||||||
|
* Get metadata from system instance
|
||||||
|
*
|
||||||
|
* @param system 系统实例 | System instance
|
||||||
|
* @returns 系统元数据 | System metadata
|
||||||
|
*/
|
||||||
|
export function getSystemInstanceMetadata(system: EntitySystem): SystemMetadata | undefined {
|
||||||
|
return getSystemMetadata(system.constructor as new (...args: any[]) => EntitySystem);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统类型的名称,优先使用装饰器指定的名称
|
* 获取系统类型的名称,优先使用装饰器指定的名称
|
||||||
* Get system type name, preferring decorator-specified name
|
* Get system type name, preferring decorator-specified name
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export {
|
|||||||
getSystemTypeName,
|
getSystemTypeName,
|
||||||
getSystemInstanceTypeName,
|
getSystemInstanceTypeName,
|
||||||
getSystemMetadata,
|
getSystemMetadata,
|
||||||
|
getSystemInstanceMetadata,
|
||||||
SYSTEM_TYPE_NAME
|
SYSTEM_TYPE_NAME
|
||||||
} from './TypeDecorators';
|
} from './TypeDecorators';
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { QuerySystem } from './Core/QuerySystem';
|
|||||||
import { TypeSafeEventSystem } from './Core/EventSystem';
|
import { TypeSafeEventSystem } from './Core/EventSystem';
|
||||||
import { ReferenceTracker } from './Core/ReferenceTracker';
|
import { ReferenceTracker } from './Core/ReferenceTracker';
|
||||||
import { IScene, ISceneConfig } from './IScene';
|
import { IScene, ISceneConfig } from './IScene';
|
||||||
import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata } from './Decorators';
|
import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata, getSystemInstanceMetadata } from './Decorators';
|
||||||
import { TypedQueryBuilder } from './Core/Query/TypedQuery';
|
import { TypedQueryBuilder } from './Core/Query/TypedQuery';
|
||||||
import {
|
import {
|
||||||
SceneSerializer,
|
SceneSerializer,
|
||||||
@@ -558,7 +558,7 @@ export class Scene implements IScene {
|
|||||||
const updateHandle = ProfilerSDK.beginSample('Systems.update', ProfileCategory.ECS);
|
const updateHandle = ProfilerSDK.beginSample('Systems.update', ProfileCategory.ECS);
|
||||||
try {
|
try {
|
||||||
for (const system of systems) {
|
for (const system of systems) {
|
||||||
if (system.enabled) {
|
if (this._shouldSystemRun(system)) {
|
||||||
const systemHandle = ProfilerSDK.beginSample(system.systemName, ProfileCategory.ECS);
|
const systemHandle = ProfilerSDK.beginSample(system.systemName, ProfileCategory.ECS);
|
||||||
try {
|
try {
|
||||||
system.update();
|
system.update();
|
||||||
@@ -577,7 +577,7 @@ export class Scene implements IScene {
|
|||||||
const lateUpdateHandle = ProfilerSDK.beginSample('Systems.lateUpdate', ProfileCategory.ECS);
|
const lateUpdateHandle = ProfilerSDK.beginSample('Systems.lateUpdate', ProfileCategory.ECS);
|
||||||
try {
|
try {
|
||||||
for (const system of systems) {
|
for (const system of systems) {
|
||||||
if (system.enabled) {
|
if (this._shouldSystemRun(system)) {
|
||||||
const systemHandle = ProfilerSDK.beginSample(`${system.systemName}.late`, ProfileCategory.ECS);
|
const systemHandle = ProfilerSDK.beginSample(`${system.systemName}.late`, ProfileCategory.ECS);
|
||||||
try {
|
try {
|
||||||
system.lateUpdate();
|
system.lateUpdate();
|
||||||
@@ -602,6 +602,34 @@ export class Scene implements IScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查系统是否应该运行
|
||||||
|
* Check if a system should run
|
||||||
|
*
|
||||||
|
* @param system 要检查的系统 | System to check
|
||||||
|
* @returns 是否应该运行 | Whether it should run
|
||||||
|
*/
|
||||||
|
private _shouldSystemRun(system: EntitySystem): boolean {
|
||||||
|
// 系统必须启用
|
||||||
|
// System must be enabled
|
||||||
|
if (!system.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非编辑模式下,所有启用的系统都运行
|
||||||
|
// In non-edit mode, all enabled systems run
|
||||||
|
if (!this.isEditorMode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑模式下,默认所有系统都运行
|
||||||
|
// 只有明确标记 runInEditMode: false 的系统不运行
|
||||||
|
// In edit mode, all systems run by default
|
||||||
|
// Only systems explicitly marked runInEditMode: false are skipped
|
||||||
|
const metadata = getSystemInstanceMetadata(system);
|
||||||
|
return metadata?.runInEditMode !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行所有系统的延迟命令
|
* 执行所有系统的延迟命令
|
||||||
* Flush all systems' deferred commands
|
* Flush all systems' deferred commands
|
||||||
|
|||||||
@@ -384,17 +384,33 @@ export class EngineBridge implements ITextureEngineBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get texture information.
|
* Get texture info by path.
|
||||||
* 获取纹理信息。
|
* 通过路径获取纹理信息。
|
||||||
*
|
*
|
||||||
* @param id - Texture ID | 纹理ID
|
* This is the primary API for getting texture dimensions.
|
||||||
|
* The Rust engine is the single source of truth for texture dimensions.
|
||||||
|
* 这是获取纹理尺寸的主要 API。
|
||||||
|
* Rust 引擎是纹理尺寸的唯一事实来源。
|
||||||
|
*
|
||||||
|
* @param path - Image path/URL | 图片路径/URL
|
||||||
|
* @returns Texture info or null if not loaded | 纹理信息或未加载则为 null
|
||||||
*/
|
*/
|
||||||
getTextureInfo(id: number): { width: number; height: number } | null {
|
getTextureInfoByPath(path: string): { width: number; height: number } | null {
|
||||||
if (!this.initialized) return null;
|
if (!this.initialized) return null;
|
||||||
// TODO: Implement in Rust engine
|
|
||||||
// TODO: 在Rust引擎中实现
|
// Resolve path if resolver is set
|
||||||
// Return default values for now / 暂时返回默认值
|
// 如果设置了解析器,则解析路径
|
||||||
return { width: 64, height: 64 };
|
const resolvedPath = this.pathResolver ? this.pathResolver(path) : path;
|
||||||
|
|
||||||
|
// Query Rust engine for texture size
|
||||||
|
// 向 Rust 引擎查询纹理尺寸
|
||||||
|
const result = this.getEngine().getTextureSizeByPath(resolvedPath);
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: result[0],
|
||||||
|
height: result[1]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1010,6 +1026,302 @@ export class EngineBridge implements ITextureEngineBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Shader API =====
|
||||||
|
// ===== 着色器 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile and register a custom shader program.
|
||||||
|
* 编译并注册自定义着色器程序。
|
||||||
|
*
|
||||||
|
* @param vertexSource - Vertex shader GLSL source | 顶点着色器 GLSL 源代码
|
||||||
|
* @param fragmentSource - Fragment shader GLSL source | 片段着色器 GLSL 源代码
|
||||||
|
* @returns Promise resolving to shader ID | 解析为着色器 ID 的 Promise
|
||||||
|
*/
|
||||||
|
async compileShader(vertexSource: string, fragmentSource: string): Promise<number> {
|
||||||
|
if (!this.initialized) throw new Error('Engine not initialized');
|
||||||
|
return this.getEngine().compileShader(vertexSource, fragmentSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile and register a shader with a specific ID.
|
||||||
|
* 使用特定 ID 编译并注册着色器。
|
||||||
|
*
|
||||||
|
* @param shaderId - Desired shader ID | 期望的着色器 ID
|
||||||
|
* @param vertexSource - Vertex shader GLSL source | 顶点着色器 GLSL 源代码
|
||||||
|
* @param fragmentSource - Fragment shader GLSL source | 片段着色器 GLSL 源代码
|
||||||
|
*/
|
||||||
|
async compileShaderWithId(shaderId: number, vertexSource: string, fragmentSource: string): Promise<void> {
|
||||||
|
if (!this.initialized) throw new Error('Engine not initialized');
|
||||||
|
this.getEngine().compileShaderWithId(shaderId, vertexSource, fragmentSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a shader exists.
|
||||||
|
* 检查着色器是否存在。
|
||||||
|
*
|
||||||
|
* @param shaderId - Shader ID to check | 要检查的着色器 ID
|
||||||
|
*/
|
||||||
|
hasShader(shaderId: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().hasShader(shaderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a shader.
|
||||||
|
* 移除着色器。
|
||||||
|
*
|
||||||
|
* @param shaderId - Shader ID to remove | 要移除的着色器 ID
|
||||||
|
*/
|
||||||
|
removeShader(shaderId: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().removeShader(shaderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Material Management API =====
|
||||||
|
// ===== 材质管理 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new material.
|
||||||
|
* 创建新材质。
|
||||||
|
*
|
||||||
|
* @param name - Material name | 材质名称
|
||||||
|
* @param shaderId - Shader ID to use | 使用的着色器 ID
|
||||||
|
* @param blendMode - Blend mode | 混合模式
|
||||||
|
* @returns Material ID | 材质 ID
|
||||||
|
*/
|
||||||
|
createMaterial(name: string, shaderId: number, blendMode: number): number {
|
||||||
|
if (!this.initialized) return -1;
|
||||||
|
return this.getEngine().createMaterial(name, shaderId, blendMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a material with a specific ID.
|
||||||
|
* 使用特定 ID 创建材质。
|
||||||
|
*
|
||||||
|
* @param materialId - Desired material ID | 期望的材质 ID
|
||||||
|
* @param name - Material name | 材质名称
|
||||||
|
* @param shaderId - Shader ID to use | 使用的着色器 ID
|
||||||
|
* @param blendMode - Blend mode | 混合模式
|
||||||
|
*/
|
||||||
|
createMaterialWithId(materialId: number, name: string, shaderId: number, blendMode: number): void {
|
||||||
|
if (!this.initialized) return;
|
||||||
|
this.getEngine().createMaterialWithId(materialId, name, shaderId, blendMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a material exists.
|
||||||
|
* 检查材质是否存在。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID to check | 要检查的材质 ID
|
||||||
|
*/
|
||||||
|
hasMaterial(materialId: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().hasMaterial(materialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a material.
|
||||||
|
* 移除材质。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID to remove | 要移除的材质 ID
|
||||||
|
*/
|
||||||
|
removeMaterial(materialId: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().removeMaterial(materialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Material Uniform API =====
|
||||||
|
// ===== 材质 Uniform API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a float uniform on a material.
|
||||||
|
* 设置材质的浮点 uniform。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param name - Uniform name | Uniform 名称
|
||||||
|
* @param value - Float value | 浮点值
|
||||||
|
* @returns Whether the operation succeeded | 操作是否成功
|
||||||
|
*/
|
||||||
|
setMaterialFloat(materialId: number, name: string, value: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().setMaterialFloat(materialId, name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec2 uniform on a material.
|
||||||
|
* 设置材质的 vec2 uniform。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param name - Uniform name | Uniform 名称
|
||||||
|
* @param x - X component | X 分量
|
||||||
|
* @param y - Y component | Y 分量
|
||||||
|
* @returns Whether the operation succeeded | 操作是否成功
|
||||||
|
*/
|
||||||
|
setMaterialVec2(materialId: number, name: string, x: number, y: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().setMaterialVec2(materialId, name, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec3 uniform on a material.
|
||||||
|
* 设置材质的 vec3 uniform。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param name - Uniform name | Uniform 名称
|
||||||
|
* @param x - X component | X 分量
|
||||||
|
* @param y - Y component | Y 分量
|
||||||
|
* @param z - Z component | Z 分量
|
||||||
|
* @returns Whether the operation succeeded | 操作是否成功
|
||||||
|
*/
|
||||||
|
setMaterialVec3(materialId: number, name: string, x: number, y: number, z: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().setMaterialVec3(materialId, name, x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec4 uniform on a material.
|
||||||
|
* 设置材质的 vec4 uniform。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param name - Uniform name | Uniform 名称
|
||||||
|
* @param x - X component | X 分量
|
||||||
|
* @param y - Y component | Y 分量
|
||||||
|
* @param z - Z component | Z 分量
|
||||||
|
* @param w - W component | W 分量
|
||||||
|
* @returns Whether the operation succeeded | 操作是否成功
|
||||||
|
*/
|
||||||
|
setMaterialVec4(materialId: number, name: string, x: number, y: number, z: number, w: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().setMaterialVec4(materialId, name, x, y, z, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a color uniform on a material.
|
||||||
|
* 设置材质的颜色 uniform。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param name - Uniform name | Uniform 名称
|
||||||
|
* @param r - Red component (0-1) | 红色分量 (0-1)
|
||||||
|
* @param g - Green component (0-1) | 绿色分量 (0-1)
|
||||||
|
* @param b - Blue component (0-1) | 蓝色分量 (0-1)
|
||||||
|
* @param a - Alpha component (0-1) | Alpha 分量 (0-1)
|
||||||
|
* @returns Whether the operation succeeded | 操作是否成功
|
||||||
|
*/
|
||||||
|
setMaterialColor(materialId: number, name: string, r: number, g: number, b: number, a: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().setMaterialColor(materialId, name, r, g, b, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a material's blend mode.
|
||||||
|
* 设置材质的混合模式。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param blendMode - Blend mode (0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha)
|
||||||
|
* 混合模式 (0=无, 1=Alpha, 2=叠加, 3=正片叠底, 4=滤色, 5=预乘Alpha)
|
||||||
|
* @returns Whether the operation succeeded | 操作是否成功
|
||||||
|
*/
|
||||||
|
setMaterialBlendMode(materialId: number, blendMode: number): boolean {
|
||||||
|
if (!this.initialized) return false;
|
||||||
|
return this.getEngine().setMaterialBlendMode(materialId, blendMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Dynamic Atlas API =====
|
||||||
|
// ===== 动态图集 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a blank texture for dynamic atlas.
|
||||||
|
* 为动态图集创建空白纹理。
|
||||||
|
*
|
||||||
|
* This creates a texture that can be filled later using `updateTextureRegion`.
|
||||||
|
* Used for runtime atlas generation to batch UI elements with different textures.
|
||||||
|
* 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。
|
||||||
|
* 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。
|
||||||
|
*
|
||||||
|
* @param width - Texture width in pixels (recommended: 2048) | 纹理宽度(推荐:2048)
|
||||||
|
* @param height - Texture height in pixels (recommended: 2048) | 纹理高度(推荐:2048)
|
||||||
|
* @returns Texture ID for the created blank texture | 创建的空白纹理ID
|
||||||
|
*/
|
||||||
|
createBlankTexture(width: number, height: number): number {
|
||||||
|
if (!this.initialized) return -1;
|
||||||
|
return this.getEngine().createBlankTexture(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a region of an existing texture with pixel data.
|
||||||
|
* 使用像素数据更新现有纹理的区域。
|
||||||
|
*
|
||||||
|
* This is used for dynamic atlas to copy individual textures into the atlas.
|
||||||
|
* 用于动态图集将单个纹理复制到图集纹理中。
|
||||||
|
*
|
||||||
|
* @param id - The texture ID to update | 要更新的纹理ID
|
||||||
|
* @param x - X offset in the texture | 纹理中的X偏移
|
||||||
|
* @param y - Y offset in the texture | 纹理中的Y偏移
|
||||||
|
* @param width - Width of the region to update | 要更新的区域宽度
|
||||||
|
* @param height - Height of the region to update | 要更新的区域高度
|
||||||
|
* @param pixels - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据(每像素4字节)
|
||||||
|
*/
|
||||||
|
updateTextureRegion(
|
||||||
|
id: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8Array
|
||||||
|
): void {
|
||||||
|
if (!this.initialized) return;
|
||||||
|
this.getEngine().updateTextureRegion(id, x, y, width, height, pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply material overrides to a material.
|
||||||
|
* 将材质覆盖应用到材质。
|
||||||
|
*
|
||||||
|
* @param materialId - Material ID | 材质 ID
|
||||||
|
* @param overrides - Material property overrides | 材质属性覆盖
|
||||||
|
*/
|
||||||
|
applyMaterialOverrides(materialId: number, overrides: Record<string, { type: string; value: number | number[] }>): void {
|
||||||
|
if (!this.initialized || !overrides) return;
|
||||||
|
|
||||||
|
for (const [name, override] of Object.entries(overrides)) {
|
||||||
|
const { type, value } = override;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'float':
|
||||||
|
this.setMaterialFloat(materialId, name, value as number);
|
||||||
|
break;
|
||||||
|
case 'vec2':
|
||||||
|
{
|
||||||
|
const v = value as number[];
|
||||||
|
this.setMaterialVec2(materialId, name, v[0], v[1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'vec3':
|
||||||
|
{
|
||||||
|
const v = value as number[];
|
||||||
|
this.setMaterialVec3(materialId, name, v[0], v[1], v[2]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'vec4':
|
||||||
|
{
|
||||||
|
const v = value as number[];
|
||||||
|
this.setMaterialVec4(materialId, name, v[0], v[1], v[2], v[3]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'color':
|
||||||
|
{
|
||||||
|
const v = value as number[];
|
||||||
|
this.setMaterialColor(materialId, name, v[0], v[1], v[2], v[3] ?? 1.0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'int':
|
||||||
|
// Int is passed as float | Int 作为 float 传递
|
||||||
|
this.setMaterialFloat(materialId, name, value as number);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose the bridge and release resources.
|
* Dispose the bridge and release resources.
|
||||||
* 销毁桥接并释放资源。
|
* 销毁桥接并释放资源。
|
||||||
|
|||||||
@@ -35,17 +35,16 @@ import type { SpriteRenderData } from '../types';
|
|||||||
*/
|
*/
|
||||||
export class RenderBatcher {
|
export class RenderBatcher {
|
||||||
private sprites: SpriteRenderData[] = [];
|
private sprites: SpriteRenderData[] = [];
|
||||||
private sortByZ = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new render batcher.
|
* Create a new render batcher.
|
||||||
* 创建新的渲染批处理器。
|
* 创建新的渲染批处理器。
|
||||||
*
|
*
|
||||||
* @param sortByZ - Whether to sort sprites by Z order | 是否按Z顺序排序精灵
|
* Sprites are stored in insertion order. The caller is responsible
|
||||||
|
* for adding sprites in the correct render order (back-to-front for 2D).
|
||||||
|
* 精灵按插入顺序存储。调用者负责以正确的渲染顺序添加精灵(2D 中从后到前)。
|
||||||
*/
|
*/
|
||||||
constructor(sortByZ = false) {
|
constructor() {}
|
||||||
this.sortByZ = sortByZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a sprite to the batch.
|
* Add a sprite to the batch.
|
||||||
@@ -71,18 +70,20 @@ export class RenderBatcher {
|
|||||||
* Get all sprites in the batch.
|
* Get all sprites in the batch.
|
||||||
* 获取批处理中的所有精灵。
|
* 获取批处理中的所有精灵。
|
||||||
*
|
*
|
||||||
* @returns Sorted array of sprites | 排序后的精灵数组
|
* Sprites are returned in insertion order to preserve z-ordering.
|
||||||
|
* The rendering system is responsible for sorting sprites before adding them.
|
||||||
|
* 精灵按插入顺序返回以保持 z 顺序。
|
||||||
|
* 渲染系统负责在添加精灵前对其进行排序。
|
||||||
|
*
|
||||||
|
* @returns Array of sprites in insertion order | 按插入顺序排列的精灵数组
|
||||||
*/
|
*/
|
||||||
getSprites(): SpriteRenderData[] {
|
getSprites(): SpriteRenderData[] {
|
||||||
// Sort by material ID first, then texture ID for better batching
|
// NOTE: Previously sorted by materialId/textureId for batching optimization,
|
||||||
// 先按材质ID排序,再按纹理ID排序以获得更好的批处理效果
|
// but this broke z-ordering for UI elements where render order is critical.
|
||||||
if (!this.sortByZ) {
|
// Sprites should be added in the correct render order by the caller.
|
||||||
this.sprites.sort((a, b) => {
|
// 注意:之前按 materialId/textureId 排序以优化批处理,
|
||||||
const materialDiff = (a.materialId || 0) - (b.materialId || 0);
|
// 但这破坏了 UI 元素的 z 排序,而 UI 的渲染顺序至关重要。
|
||||||
if (materialDiff !== 0) return materialDiff;
|
// 调用者应该以正确的渲染顺序添加精灵。
|
||||||
return a.textureId - b.textureId;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.sprites;
|
return this.sprites;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { SpriteComponent } from '@esengine/sprite';
|
|||||||
import type { EngineBridge } from '../core/EngineBridge';
|
import type { EngineBridge } from '../core/EngineBridge';
|
||||||
import { RenderBatcher } from '../core/RenderBatcher';
|
import { RenderBatcher } from '../core/RenderBatcher';
|
||||||
import type { ITransformComponent } from '../core/SpriteRenderHelper';
|
import type { ITransformComponent } from '../core/SpriteRenderHelper';
|
||||||
import type { SpriteRenderData } from '../types';
|
import type { SpriteRenderData, MaterialOverrides } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render data from a provider
|
* Render data from a provider
|
||||||
@@ -47,6 +47,10 @@ export interface ProviderRenderData {
|
|||||||
* Overrides sortingLayer's bScreenSpace setting, for particles that need dynamic render space.
|
* Overrides sortingLayer's bScreenSpace setting, for particles that need dynamic render space.
|
||||||
*/
|
*/
|
||||||
bScreenSpace?: boolean;
|
bScreenSpace?: boolean;
|
||||||
|
/** Material IDs for each primitive. | 每个原语的材质 ID。 */
|
||||||
|
materialIds?: Uint32Array;
|
||||||
|
/** Material overrides (per-group). | 材质覆盖(按组)。 */
|
||||||
|
materialOverrides?: MaterialOverrides;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,6 +136,24 @@ export type GizmoDataProviderFn = (
|
|||||||
*/
|
*/
|
||||||
export type HasGizmoProviderFn = (component: Component) => boolean;
|
export type HasGizmoProviderFn = (component: Component) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function type for getting highlight color for gizmo.
|
||||||
|
* Used to inject GizmoInteractionService functionality from editor layer.
|
||||||
|
* 获取 gizmo 高亮颜色的函数类型。
|
||||||
|
* 用于从编辑器层注入 GizmoInteractionService 功能。
|
||||||
|
*/
|
||||||
|
export type GizmoHighlightColorFn = (
|
||||||
|
entityId: number,
|
||||||
|
baseColor: GizmoColorInternal,
|
||||||
|
isSelected: boolean
|
||||||
|
) => GizmoColorInternal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function type for getting hovered entity ID.
|
||||||
|
* 获取悬停实体 ID 的函数类型。
|
||||||
|
*/
|
||||||
|
export type GetHoveredEntityIdFn = () => number | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type for transform component constructor.
|
* Type for transform component constructor.
|
||||||
* 变换组件构造函数类型。
|
* 变换组件构造函数类型。
|
||||||
@@ -198,6 +220,11 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
private gizmoDataProvider: GizmoDataProviderFn | null = null;
|
private gizmoDataProvider: GizmoDataProviderFn | null = null;
|
||||||
private hasGizmoProvider: HasGizmoProviderFn | null = null;
|
private hasGizmoProvider: HasGizmoProviderFn | null = null;
|
||||||
|
|
||||||
|
// Gizmo interaction functions (injected from editor layer)
|
||||||
|
// Gizmo 交互函数(从编辑器层注入)
|
||||||
|
private gizmoHighlightColorFn: GizmoHighlightColorFn | null = null;
|
||||||
|
private getHoveredEntityIdFn: GetHoveredEntityIdFn | null = null;
|
||||||
|
|
||||||
// UI Canvas boundary settings
|
// UI Canvas boundary settings
|
||||||
// UI 画布边界设置
|
// UI 画布边界设置
|
||||||
private uiCanvasWidth: number = 0;
|
private uiCanvasWidth: number = 0;
|
||||||
@@ -218,6 +245,18 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
// 为 false(编辑器模式)时,UI 在世界空间渲染,跟随编辑器相机
|
// 为 false(编辑器模式)时,UI 在世界空间渲染,跟随编辑器相机
|
||||||
private previewMode: boolean = false;
|
private previewMode: boolean = false;
|
||||||
|
|
||||||
|
// ===== Material Instance Management =====
|
||||||
|
// ===== 材质实例管理 =====
|
||||||
|
// Maps (baseMaterialId, overridesHash) → instanceMaterialId
|
||||||
|
// 映射 (基础材质ID, 覆盖哈希) → 实例材质ID
|
||||||
|
private materialInstanceMap: Map<string, number> = new Map();
|
||||||
|
// Next instance ID (starts at 10000 to avoid collision with built-in materials)
|
||||||
|
// 下一个实例 ID(从 10000 开始以避免与内置材质冲突)
|
||||||
|
private nextMaterialInstanceId: number = 10000;
|
||||||
|
// Track instances used this frame for cleanup
|
||||||
|
// 跟踪本帧使用的实例以便清理
|
||||||
|
private usedInstancesThisFrame: Set<number> = new Set();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new engine render system.
|
* Create a new engine render system.
|
||||||
* 创建新的引擎渲染系统。
|
* 创建新的引擎渲染系统。
|
||||||
@@ -281,8 +320,10 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
|
|
||||||
// Collect all render items separated by render space
|
// Collect all render items separated by render space
|
||||||
// 按渲染空间分离收集所有渲染项
|
// 按渲染空间分离收集所有渲染项
|
||||||
const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = [];
|
// addIndex is used for stable sorting when sortKeys are equal
|
||||||
const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = [];
|
// addIndex 用于当 sortKey 相等时实现稳定排序
|
||||||
|
const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> = [];
|
||||||
|
const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> = [];
|
||||||
|
|
||||||
// Collect sprites from entities (all in world space)
|
// Collect sprites from entities (all in world space)
|
||||||
// 收集实体的 sprites(都在世界空间)
|
// 收集实体的 sprites(都在世界空间)
|
||||||
@@ -296,6 +337,24 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
// 收集 UI 渲染数据
|
// 收集 UI 渲染数据
|
||||||
if (this.uiRenderDataProvider) {
|
if (this.uiRenderDataProvider) {
|
||||||
const uiRenderData = this.uiRenderDataProvider.getRenderData();
|
const uiRenderData = this.uiRenderDataProvider.getRenderData();
|
||||||
|
// Use addIndex to preserve original order for stable sorting
|
||||||
|
// 使用 addIndex 保持原始顺序以实现稳定排序
|
||||||
|
let uiAddIndex = 0;
|
||||||
|
|
||||||
|
// DEBUG: 输出 UI 渲染数据
|
||||||
|
// DEBUG: Output UI render data
|
||||||
|
if ((globalThis as any).__UI_RENDER_DEBUG__) {
|
||||||
|
console.log('[EngineRenderSystem] UI render batches:', uiRenderData.map((data, i) => ({
|
||||||
|
index: i,
|
||||||
|
orderInLayer: data.orderInLayer,
|
||||||
|
sortingLayer: data.sortingLayer,
|
||||||
|
tileCount: data.tileCount,
|
||||||
|
sortKey: sortingLayerManager.getSortKey(data.sortingLayer, data.orderInLayer),
|
||||||
|
textureIds: Array.from(data.textureIds).slice(0, 3), // 只显示前3个 | Show first 3 only
|
||||||
|
textureGuid: data.textureGuid
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
for (const data of uiRenderData) {
|
for (const data of uiRenderData) {
|
||||||
const uiSprites = this.convertProviderDataToSprites(data);
|
const uiSprites = this.convertProviderDataToSprites(data);
|
||||||
if (uiSprites.length > 0) {
|
if (uiSprites.length > 0) {
|
||||||
@@ -303,9 +362,9 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
// UI always goes to screen space in preview mode, world space in editor mode
|
// UI always goes to screen space in preview mode, world space in editor mode
|
||||||
// UI 在预览模式下始终在屏幕空间,编辑器模式下在世界空间
|
// UI 在预览模式下始终在屏幕空间,编辑器模式下在世界空间
|
||||||
if (this.previewMode) {
|
if (this.previewMode) {
|
||||||
screenSpaceItems.push({ sortKey, sprites: uiSprites });
|
screenSpaceItems.push({ sortKey, sprites: uiSprites, addIndex: uiAddIndex++ });
|
||||||
} else {
|
} else {
|
||||||
worldSpaceItems.push({ sortKey, sprites: uiSprites });
|
worldSpaceItems.push({ sortKey, sprites: uiSprites, addIndex: uiAddIndex++ });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +379,10 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
if (this.previewMode && screenSpaceItems.length > 0) {
|
if (this.previewMode && screenSpaceItems.length > 0) {
|
||||||
this.renderScreenSpacePass(screenSpaceItems);
|
this.renderScreenSpacePass(screenSpaceItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Cleanup unused material instances =====
|
||||||
|
// ===== 清理未使用的材质实例 =====
|
||||||
|
this.cleanupUnusedMaterialInstances();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -465,11 +528,29 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
* 渲染世界空间内容。
|
* 渲染世界空间内容。
|
||||||
*/
|
*/
|
||||||
private renderWorldSpacePass(
|
private renderWorldSpacePass(
|
||||||
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
|
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }>
|
||||||
): void {
|
): void {
|
||||||
// Sort by sortKey (lower values render first, appear behind)
|
// Sort by sortKey (lower values render first, appear behind)
|
||||||
|
// Use addIndex as secondary key for stable sorting when sortKeys are equal
|
||||||
// 按 sortKey 排序(值越小越先渲染,显示在后面)
|
// 按 sortKey 排序(值越小越先渲染,显示在后面)
|
||||||
worldSpaceItems.sort((a, b) => a.sortKey - b.sortKey);
|
// 当 sortKey 相等时使用 addIndex 作为次要排序键以实现稳定排序
|
||||||
|
worldSpaceItems.sort((a, b) => {
|
||||||
|
const diff = a.sortKey - b.sortKey;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return (a.addIndex ?? 0) - (b.addIndex ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DEBUG: 输出排序后的世界空间渲染项
|
||||||
|
// DEBUG: Output sorted world space items
|
||||||
|
if ((globalThis as any).__UI_RENDER_DEBUG__) {
|
||||||
|
console.log('[EngineRenderSystem] World items after sort:', worldSpaceItems.map((item, i) => ({
|
||||||
|
index: i,
|
||||||
|
sortKey: item.sortKey,
|
||||||
|
addIndex: item.addIndex,
|
||||||
|
spriteCount: item.sprites.length,
|
||||||
|
firstTextureId: item.sprites[0]?.textureId
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
// Submit all sprites in sorted order
|
// Submit all sprites in sorted order
|
||||||
// 按排序顺序提交所有 sprites
|
// 按排序顺序提交所有 sprites
|
||||||
@@ -481,6 +562,11 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
|
|
||||||
if (!this.batcher.isEmpty) {
|
if (!this.batcher.isEmpty) {
|
||||||
const sprites = this.batcher.getSprites();
|
const sprites = this.batcher.getSprites();
|
||||||
|
|
||||||
|
// Apply material overrides before rendering
|
||||||
|
// 在渲染前应用材质覆盖
|
||||||
|
this.applySpriteMaterialOverrides(sprites);
|
||||||
|
|
||||||
this.bridge.submitSprites(sprites);
|
this.bridge.submitSprites(sprites);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,11 +598,15 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
* 渲染屏幕空间内容(UI、屏幕覆盖层、模态层)。
|
* 渲染屏幕空间内容(UI、屏幕覆盖层、模态层)。
|
||||||
*/
|
*/
|
||||||
private renderScreenSpacePass(
|
private renderScreenSpacePass(
|
||||||
screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
|
screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }>
|
||||||
): void {
|
): void {
|
||||||
// Sort by sortKey
|
// Sort by sortKey, use addIndex for stable sorting when equal
|
||||||
// 按 sortKey 排序
|
// 按 sortKey 排序,当相等时使用 addIndex 实现稳定排序
|
||||||
screenSpaceItems.sort((a, b) => a.sortKey - b.sortKey);
|
screenSpaceItems.sort((a, b) => {
|
||||||
|
const diff = a.sortKey - b.sortKey;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return (a.addIndex ?? 0) - (b.addIndex ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
// Switch to screen space projection
|
// Switch to screen space projection
|
||||||
// 切换到屏幕空间投影
|
// 切换到屏幕空间投影
|
||||||
@@ -539,6 +629,11 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
|
|
||||||
if (!this.batcher.isEmpty) {
|
if (!this.batcher.isEmpty) {
|
||||||
const sprites = this.batcher.getSprites();
|
const sprites = this.batcher.getSprites();
|
||||||
|
|
||||||
|
// Apply material overrides before rendering
|
||||||
|
// 在渲染前应用材质覆盖
|
||||||
|
this.applySpriteMaterialOverrides(sprites);
|
||||||
|
|
||||||
this.bridge.submitSprites(sprites);
|
this.bridge.submitSprites(sprites);
|
||||||
// Render overlay (without clearing screen)
|
// Render overlay (without clearing screen)
|
||||||
// 渲染叠加层(不清屏)
|
// 渲染叠加层(不清屏)
|
||||||
@@ -550,6 +645,147 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
this.bridge.popScreenSpaceMode();
|
this.bridge.popScreenSpaceMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a hash key for material overrides.
|
||||||
|
* 为材质覆盖生成哈希键。
|
||||||
|
*
|
||||||
|
* @param overrides - Material overrides | 材质覆盖
|
||||||
|
* @returns Hash string | 哈希字符串
|
||||||
|
*/
|
||||||
|
private hashMaterialOverrides(overrides: MaterialOverrides): string {
|
||||||
|
// Sort keys for consistent hashing
|
||||||
|
// 排序键以保持一致的哈希
|
||||||
|
const sortedKeys = Object.keys(overrides).sort();
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const override = overrides[key];
|
||||||
|
if (override) {
|
||||||
|
const valueStr = Array.isArray(override.value)
|
||||||
|
? override.value.map(v => v.toFixed(4)).join(',')
|
||||||
|
: override.value.toFixed(4);
|
||||||
|
parts.push(`${key}:${valueStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a material instance for a specific base material + overrides combination.
|
||||||
|
* 为特定的基础材质+覆盖组合获取或创建材质实例。
|
||||||
|
*
|
||||||
|
* This ensures each unique (baseMaterial, overrides) combination gets its own
|
||||||
|
* material instance, preventing shared material state issues.
|
||||||
|
* 这确保每个唯一的(基础材质,覆盖)组合都有自己的材质实例,
|
||||||
|
* 防止共享材质状态问题。
|
||||||
|
*
|
||||||
|
* @param baseMaterialId - Base material ID (e.g., 1 for Grayscale) | 基础材质ID
|
||||||
|
* @param overrides - Material property overrides | 材质属性覆盖
|
||||||
|
* @returns Instance material ID | 实例材质ID
|
||||||
|
*/
|
||||||
|
private getOrCreateMaterialInstance(baseMaterialId: number, overrides: MaterialOverrides): number {
|
||||||
|
const overridesHash = this.hashMaterialOverrides(overrides);
|
||||||
|
const instanceKey = `${baseMaterialId}:${overridesHash}`;
|
||||||
|
|
||||||
|
// Check if instance already exists
|
||||||
|
// 检查实例是否已存在
|
||||||
|
let instanceId = this.materialInstanceMap.get(instanceKey);
|
||||||
|
if (instanceId !== undefined) {
|
||||||
|
this.usedInstancesThisFrame.add(instanceId);
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new instance
|
||||||
|
// 创建新实例
|
||||||
|
instanceId = this.nextMaterialInstanceId++;
|
||||||
|
this.materialInstanceMap.set(instanceKey, instanceId);
|
||||||
|
this.usedInstancesThisFrame.add(instanceId);
|
||||||
|
|
||||||
|
// Clone the base material with the new ID
|
||||||
|
// 使用新ID克隆基础材质
|
||||||
|
// For built-in materials, shaderId = materialId (1:1 mapping)
|
||||||
|
// 对于内置材质,shaderId = materialId(1:1 映射)
|
||||||
|
const shaderId = baseMaterialId;
|
||||||
|
const blendMode = 1; // Alpha blending
|
||||||
|
this.bridge.createMaterialWithId(instanceId, `Instance_${baseMaterialId}_${instanceId}`, shaderId, blendMode);
|
||||||
|
|
||||||
|
// Apply overrides to the new instance
|
||||||
|
// 将覆盖应用到新实例
|
||||||
|
this.bridge.applyMaterialOverrides(instanceId, overrides);
|
||||||
|
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up unused material instances.
|
||||||
|
* 清理未使用的材质实例。
|
||||||
|
*
|
||||||
|
* Called at the end of each frame to remove instances that were not used.
|
||||||
|
* 在每帧结束时调用,移除未使用的实例。
|
||||||
|
*/
|
||||||
|
private cleanupUnusedMaterialInstances(): void {
|
||||||
|
const toRemove: string[] = [];
|
||||||
|
|
||||||
|
for (const [key, instanceId] of this.materialInstanceMap.entries()) {
|
||||||
|
if (!this.usedInstancesThisFrame.has(instanceId)) {
|
||||||
|
this.bridge.removeMaterial(instanceId);
|
||||||
|
toRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of toRemove) {
|
||||||
|
this.materialInstanceMap.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the used set for next frame
|
||||||
|
// 清除已用集合以便下一帧
|
||||||
|
this.usedInstancesThisFrame.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply material overrides from sprites to the engine.
|
||||||
|
* 将 sprites 的材质覆盖应用到引擎。
|
||||||
|
*
|
||||||
|
* For sprites with overrides, this creates unique material instances
|
||||||
|
* to ensure each sprite's overrides don't affect other sprites.
|
||||||
|
* 对于有覆盖的精灵,这会创建唯一的材质实例,
|
||||||
|
* 确保每个精灵的覆盖不会影响其他精灵。
|
||||||
|
*/
|
||||||
|
private applySpriteMaterialOverrides(sprites: SpriteRenderData[]): void {
|
||||||
|
// Track which instance materials we've already applied overrides to this frame
|
||||||
|
// 跟踪本帧已应用覆盖的实例材质
|
||||||
|
const appliedInstances = new Set<number>();
|
||||||
|
|
||||||
|
for (const sprite of sprites) {
|
||||||
|
const baseMaterialId = sprite.materialId;
|
||||||
|
|
||||||
|
// Skip if no material or no overrides
|
||||||
|
// 如果没有材质或没有覆盖,跳过
|
||||||
|
if (!baseMaterialId || baseMaterialId <= 0 || !sprite.materialOverrides) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideKeys = Object.keys(sprite.materialOverrides);
|
||||||
|
if (overrideKeys.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create a unique material instance for this sprite's overrides
|
||||||
|
// 为此精灵的覆盖获取或创建唯一的材质实例
|
||||||
|
const instanceId = this.getOrCreateMaterialInstance(baseMaterialId, sprite.materialOverrides);
|
||||||
|
|
||||||
|
// Update the sprite to use the instance material
|
||||||
|
// 更新精灵以使用实例材质
|
||||||
|
sprite.materialId = instanceId;
|
||||||
|
|
||||||
|
// Apply overrides if not already done for this instance
|
||||||
|
// 如果尚未为此实例应用覆盖,则应用
|
||||||
|
if (!appliedInstances.has(instanceId)) {
|
||||||
|
this.bridge.applyMaterialOverrides(instanceId, sprite.materialOverrides);
|
||||||
|
appliedInstances.add(instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert provider render data to sprite render data array.
|
* Convert provider render data to sprite render data array.
|
||||||
* 将提供者渲染数据转换为 Sprite 渲染数据数组。
|
* 将提供者渲染数据转换为 Sprite 渲染数据数组。
|
||||||
@@ -562,6 +798,11 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
textureId = this.bridge.getOrLoadTextureByPath(this.resolveAssetPath(data.textureGuid));
|
textureId = this.bridge.getOrLoadTextureByPath(this.resolveAssetPath(data.textureGuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for material data
|
||||||
|
// 检查材质数据
|
||||||
|
const hasMaterialIds = data.materialIds && data.materialIds.length > 0;
|
||||||
|
const hasMaterialOverrides = data.materialOverrides && Object.keys(data.materialOverrides).length > 0;
|
||||||
|
|
||||||
const sprites: SpriteRenderData[] = [];
|
const sprites: SpriteRenderData[] = [];
|
||||||
for (let i = 0; i < data.tileCount; i++) {
|
for (let i = 0; i < data.tileCount; i++) {
|
||||||
const tOffset = i * 7;
|
const tOffset = i * 7;
|
||||||
@@ -587,6 +828,15 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
color: data.colors[i]
|
color: data.colors[i]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add material data if present
|
||||||
|
// 如果存在材质数据,添加它
|
||||||
|
if (hasMaterialIds) {
|
||||||
|
renderData.materialId = data.materialIds![i];
|
||||||
|
}
|
||||||
|
if (hasMaterialOverrides) {
|
||||||
|
renderData.materialOverrides = data.materialOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
sprites.push(renderData);
|
sprites.push(renderData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,10 +851,15 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
const scene = Core.scene;
|
const scene = Core.scene;
|
||||||
if (!scene || !this.gizmoDataProvider || !this.hasGizmoProvider) return;
|
if (!scene || !this.gizmoDataProvider || !this.hasGizmoProvider) return;
|
||||||
|
|
||||||
|
// Get hovered entity ID for highlight
|
||||||
|
// 获取悬停的实体 ID 用于高亮
|
||||||
|
const hoveredEntityId = this.getHoveredEntityIdFn?.() ?? null;
|
||||||
|
|
||||||
// Iterate all entities in the scene
|
// Iterate all entities in the scene
|
||||||
// 遍历场景中的所有实体
|
// 遍历场景中的所有实体
|
||||||
for (const entity of scene.entities.buffer) {
|
for (const entity of scene.entities.buffer) {
|
||||||
const isSelected = this.selectedEntityIds.has(entity.id);
|
const isSelected = this.selectedEntityIds.has(entity.id);
|
||||||
|
const isHovered = entity.id === hoveredEntityId;
|
||||||
|
|
||||||
// Check each component for gizmo provider
|
// Check each component for gizmo provider
|
||||||
// 检查每个组件是否有 gizmo 提供者
|
// 检查每个组件是否有 gizmo 提供者
|
||||||
@@ -613,6 +868,15 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
try {
|
try {
|
||||||
const gizmoDataArray = this.gizmoDataProvider(component, entity, isSelected);
|
const gizmoDataArray = this.gizmoDataProvider(component, entity, isSelected);
|
||||||
for (const gizmoData of gizmoDataArray) {
|
for (const gizmoData of gizmoDataArray) {
|
||||||
|
// Apply hover highlight color if applicable
|
||||||
|
// 如果适用,应用悬停高亮颜色
|
||||||
|
if (isHovered && this.gizmoHighlightColorFn) {
|
||||||
|
gizmoData.color = this.gizmoHighlightColorFn(
|
||||||
|
entity.id,
|
||||||
|
gizmoData.color,
|
||||||
|
isSelected
|
||||||
|
);
|
||||||
|
}
|
||||||
this.renderGizmoData(gizmoData);
|
this.renderGizmoData(gizmoData);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1037,6 +1301,26 @@ export class EngineRenderSystem extends EntitySystem {
|
|||||||
this.hasGizmoProvider = hasProvider;
|
this.hasGizmoProvider = hasProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set gizmo interaction functions.
|
||||||
|
* 设置 gizmo 交互函数。
|
||||||
|
*
|
||||||
|
* This allows the editor layer to inject GizmoInteractionService functionality
|
||||||
|
* for hover highlighting and click selection.
|
||||||
|
* 这允许编辑器层注入 GizmoInteractionService 功能,
|
||||||
|
* 用于悬停高亮和点击选择。
|
||||||
|
*
|
||||||
|
* @param highlightColorFn - Function to get highlight color for gizmo
|
||||||
|
* @param getHoveredEntityIdFn - Function to get currently hovered entity ID
|
||||||
|
*/
|
||||||
|
setGizmoInteraction(
|
||||||
|
highlightColorFn: GizmoHighlightColorFn,
|
||||||
|
getHoveredEntityIdFn: GetHoveredEntityIdFn
|
||||||
|
): void {
|
||||||
|
this.gizmoHighlightColorFn = highlightColorFn;
|
||||||
|
this.getHoveredEntityIdFn = getHoveredEntityIdFn;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set gizmo visibility.
|
* Set gizmo visibility.
|
||||||
* 设置Gizmo可见性。
|
* 设置Gizmo可见性。
|
||||||
|
|||||||
@@ -338,6 +338,23 @@ export class GameEngine {
|
|||||||
* 注销视口。
|
* 注销视口。
|
||||||
*/
|
*/
|
||||||
unregisterViewport(id: string): void;
|
unregisterViewport(id: string): void;
|
||||||
|
/**
|
||||||
|
* Create a blank texture for dynamic atlas.
|
||||||
|
* 为动态图集创建空白纹理。
|
||||||
|
*
|
||||||
|
* This creates a texture that can be filled later using `updateTextureRegion`.
|
||||||
|
* Used for runtime atlas generation to batch UI elements with different textures.
|
||||||
|
* 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。
|
||||||
|
* 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `width` - Texture width in pixels (recommended: 2048) | 纹理宽度(推荐:2048)
|
||||||
|
* * `height` - Texture height in pixels (recommended: 2048) | 纹理高度(推荐:2048)
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* The texture ID for the created blank texture | 创建的空白纹理ID
|
||||||
|
*/
|
||||||
|
createBlankTexture(width: number, height: number): number;
|
||||||
/**
|
/**
|
||||||
* Load texture by path, returning texture ID.
|
* Load texture by path, returning texture ID.
|
||||||
* 按路径加载纹理,返回纹理ID。
|
* 按路径加载纹理,返回纹理ID。
|
||||||
@@ -346,6 +363,22 @@ export class GameEngine {
|
|||||||
* * `path` - Image path/URL to load | 要加载的图片路径/URL
|
* * `path` - Image path/URL to load | 要加载的图片路径/URL
|
||||||
*/
|
*/
|
||||||
loadTextureByPath(path: string): number;
|
loadTextureByPath(path: string): number;
|
||||||
|
/**
|
||||||
|
* Update a region of an existing texture with pixel data.
|
||||||
|
* 使用像素数据更新现有纹理的区域。
|
||||||
|
*
|
||||||
|
* This is used for dynamic atlas to copy individual textures into the atlas.
|
||||||
|
* 用于动态图集将单个纹理复制到图集纹理中。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `id` - The texture ID to update | 要更新的纹理ID
|
||||||
|
* * `x` - X offset in the texture | 纹理中的X偏移
|
||||||
|
* * `y` - Y offset in the texture | 纹理中的Y偏移
|
||||||
|
* * `width` - Width of the region to update | 要更新的区域宽度
|
||||||
|
* * `height` - Height of the region to update | 要更新的区域高度
|
||||||
|
* * `pixels` - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据(每像素4字节)
|
||||||
|
*/
|
||||||
|
updateTextureRegion(id: number, x: number, y: number, width: number, height: number, pixels: Uint8Array): void;
|
||||||
/**
|
/**
|
||||||
* Compile a shader with a specific ID.
|
* Compile a shader with a specific ID.
|
||||||
* 使用特定ID编译着色器。
|
* 使用特定ID编译着色器。
|
||||||
@@ -381,6 +414,17 @@ export class GameEngine {
|
|||||||
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
|
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
|
||||||
*/
|
*/
|
||||||
clearTexturePathCache(): void;
|
clearTexturePathCache(): void;
|
||||||
|
/**
|
||||||
|
* Get texture size by path.
|
||||||
|
* 按路径获取纹理尺寸。
|
||||||
|
*
|
||||||
|
* Returns an array [width, height] or null if not found.
|
||||||
|
* 返回数组 [width, height],如果未找到则返回 null。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Image path to lookup | 要查找的图片路径
|
||||||
|
*/
|
||||||
|
getTextureSizeByPath(path: string): Float32Array | undefined;
|
||||||
/**
|
/**
|
||||||
* 获取正在加载中的纹理数量
|
* 获取正在加载中的纹理数量
|
||||||
* Get the number of textures currently loading
|
* Get the number of textures currently loading
|
||||||
@@ -448,6 +492,7 @@ export interface InitOutput {
|
|||||||
readonly gameengine_clearTexturePathCache: (a: number) => void;
|
readonly gameengine_clearTexturePathCache: (a: number) => void;
|
||||||
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
|
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
|
||||||
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||||
|
readonly gameengine_createBlankTexture: (a: number, b: number, c: number) => [number, number, number];
|
||||||
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
|
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
|
||||||
readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||||
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
|
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
|
||||||
@@ -455,6 +500,7 @@ export interface InitOutput {
|
|||||||
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [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_getTextureIdByPath: (a: number, b: number, c: number) => number;
|
||||||
readonly gameengine_getTextureLoadingCount: (a: number) => number;
|
readonly gameengine_getTextureLoadingCount: (a: number) => number;
|
||||||
|
readonly gameengine_getTextureSizeByPath: (a: number, b: number, c: number) => any;
|
||||||
readonly gameengine_getTextureState: (a: number, b: number) => [number, number];
|
readonly gameengine_getTextureState: (a: number, b: number) => [number, number];
|
||||||
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
|
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
|
||||||
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
readonly gameengine_getViewportIds: (a: number) => [number, number];
|
||||||
@@ -494,6 +540,7 @@ export interface InitOutput {
|
|||||||
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number];
|
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number];
|
||||||
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
|
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
|
||||||
readonly gameengine_updateInput: (a: number) => void;
|
readonly gameengine_updateInput: (a: number) => void;
|
||||||
|
readonly gameengine_updateTextureRegion: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number];
|
||||||
readonly gameengine_width: (a: number) => number;
|
readonly gameengine_width: (a: number) => number;
|
||||||
readonly gameengine_worldToScreen: (a: number, b: number, c: number) => [number, number];
|
readonly gameengine_worldToScreen: (a: number, b: number, c: number) => [number, number];
|
||||||
readonly init: () => void;
|
readonly init: () => void;
|
||||||
|
|||||||
@@ -162,6 +162,22 @@ function App() {
|
|||||||
const [commandManager] = useState(() => new CommandManager());
|
const [commandManager] = useState(() => new CommandManager());
|
||||||
const { t, locale, changeLocale } = useLocale();
|
const { t, locale, changeLocale } = useLocale();
|
||||||
|
|
||||||
|
// Play 模式状态(用于层级面板实时同步)
|
||||||
|
// Play mode state (for hierarchy panel real-time sync)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
|
// 监听 Play 状态变化
|
||||||
|
// Listen for play state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!messageHubRef.current || !initialized) return;
|
||||||
|
|
||||||
|
const unsubscribe = messageHubRef.current.subscribe('viewport:playState:changed', (data: { isPlaying: boolean }) => {
|
||||||
|
setIsPlaying(data.isPlaying);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [initialized]);
|
||||||
|
|
||||||
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
|
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
|
||||||
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
|
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
|
||||||
useStoreSubscriptions({
|
useStoreSubscriptions({
|
||||||
@@ -169,6 +185,7 @@ function App() {
|
|||||||
entityStore: entityStoreRef.current,
|
entityStore: entityStoreRef.current,
|
||||||
sceneManager: sceneManagerRef.current,
|
sceneManager: sceneManagerRef.current,
|
||||||
enabled: initialized,
|
enabled: initialized,
|
||||||
|
isPlaying,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 同步 locale 到 TauriDialogService
|
// 同步 locale 到 TauriDialogService
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ import {
|
|||||||
Vector3FieldEditor,
|
Vector3FieldEditor,
|
||||||
Vector4FieldEditor,
|
Vector4FieldEditor,
|
||||||
ColorFieldEditor,
|
ColorFieldEditor,
|
||||||
AnimationClipsFieldEditor
|
AnimationClipsFieldEditor,
|
||||||
|
EntityRefFieldEditor
|
||||||
} from '../../infrastructure/field-editors';
|
} from '../../infrastructure/field-editors';
|
||||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||||
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||||
@@ -249,6 +250,7 @@ export class ServiceRegistry {
|
|||||||
fieldEditorRegistry.register(new Vector4FieldEditor());
|
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||||
|
fieldEditorRegistry.register(new EntityRefFieldEditor());
|
||||||
|
|
||||||
// 注册组件检查器
|
// 注册组件检查器
|
||||||
// Register component inspectors
|
// Register component inspectors
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as LucideIcons from 'lucide-react';
|
|||||||
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
|
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
|
||||||
import { AssetField } from './inspectors/fields/AssetField';
|
import { AssetField } from './inspectors/fields/AssetField';
|
||||||
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
|
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
|
||||||
|
import { EntityRefField } from './inspectors/fields/EntityRefField';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
import '../styles/PropertyInspector.css';
|
import '../styles/PropertyInspector.css';
|
||||||
|
|
||||||
@@ -339,6 +340,17 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'entityRef':
|
||||||
|
return (
|
||||||
|
<EntityRefField
|
||||||
|
key={propertyName}
|
||||||
|
label={label}
|
||||||
|
value={value ?? 0}
|
||||||
|
readonly={metadata.readOnly}
|
||||||
|
onChange={(newValue) => handleChange(propertyName, newValue)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'array': {
|
case 'array': {
|
||||||
const arrayMeta = metadata as {
|
const arrayMeta = metadata as {
|
||||||
itemType?: { type: string; extensions?: string[]; assetType?: string };
|
itemType?: { type: string; extensions?: string[]; assetType?: string };
|
||||||
|
|||||||
@@ -162,28 +162,25 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
|||||||
const initialValues = new Map<string, any>();
|
const initialValues = new Map<string, any>();
|
||||||
|
|
||||||
for (const [key, descriptor] of allSettings.entries()) {
|
for (const [key, descriptor] of allSettings.entries()) {
|
||||||
if (key.startsWith('project.') && projectService) {
|
// 特定的 project 设置需要从 ProjectService 加载
|
||||||
if (key === 'project.uiDesignResolution.width') {
|
// Specific project settings need to load from ProjectService
|
||||||
const resolution = projectService.getUIDesignResolution();
|
if (key === 'project.uiDesignResolution.width' && projectService) {
|
||||||
initialValues.set(key, resolution.width);
|
const resolution = projectService.getUIDesignResolution();
|
||||||
} else if (key === 'project.uiDesignResolution.height') {
|
initialValues.set(key, resolution.width);
|
||||||
const resolution = projectService.getUIDesignResolution();
|
} else if (key === 'project.uiDesignResolution.height' && projectService) {
|
||||||
initialValues.set(key, resolution.height);
|
const resolution = projectService.getUIDesignResolution();
|
||||||
} else if (key === 'project.uiDesignResolution.preset') {
|
initialValues.set(key, resolution.height);
|
||||||
const resolution = projectService.getUIDesignResolution();
|
} else if (key === 'project.uiDesignResolution.preset' && projectService) {
|
||||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
const resolution = projectService.getUIDesignResolution();
|
||||||
} else if (key === 'project.disabledModules') {
|
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||||
// Load disabled modules from ProjectService
|
} else if (key === 'project.disabledModules' && projectService) {
|
||||||
initialValues.set(key, projectService.getDisabledModules());
|
// Load disabled modules from ProjectService
|
||||||
} else {
|
initialValues.set(key, projectService.getDisabledModules());
|
||||||
initialValues.set(key, descriptor.defaultValue);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// 其他设置(包括 project.dynamicAtlas.*)从 SettingsService 加载
|
||||||
|
// Other settings (including project.dynamicAtlas.*) load from SettingsService
|
||||||
const value = settings.get(key, descriptor.defaultValue);
|
const value = settings.get(key, descriptor.defaultValue);
|
||||||
initialValues.set(key, value);
|
initialValues.set(key, value);
|
||||||
if (key.startsWith('profiler.')) {
|
|
||||||
console.log(`[SettingsWindow] Loading ${key}: stored=${settings.get(key, undefined)}, default=${descriptor.defaultValue}, using=${value}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +205,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
|||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
|
|
||||||
// 实时保存设置
|
// 实时保存设置
|
||||||
|
// Real-time save settings
|
||||||
const settings = SettingsService.getInstance();
|
const settings = SettingsService.getInstance();
|
||||||
if (!key.startsWith('project.')) {
|
|
||||||
|
// 除了特定的 project 设置需要延迟保存外,其他都实时保存
|
||||||
|
// Save in real-time except for specific project settings that need deferred save
|
||||||
|
const deferredProjectSettings = [
|
||||||
|
'project.uiDesignResolution.',
|
||||||
|
'project.disabledModules'
|
||||||
|
];
|
||||||
|
const shouldDeferSave = deferredProjectSettings.some(prefix => key.startsWith(prefix));
|
||||||
|
|
||||||
|
if (!shouldDeferSave) {
|
||||||
settings.set(key, value);
|
settings.set(key, value);
|
||||||
console.log(`[SettingsWindow] Saved ${key}:`, value);
|
console.log(`[SettingsWindow] Saved ${key}:`, value);
|
||||||
|
|
||||||
// 触发设置变更事件
|
// 触发设置变更事件
|
||||||
|
// Trigger settings changed event
|
||||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||||
detail: { [key]: value }
|
detail: { [key]: value }
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -321,6 +321,15 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
scaleSnapRef.current = scaleSnapValue;
|
scaleSnapRef.current = scaleSnapValue;
|
||||||
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
|
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
|
||||||
|
|
||||||
|
// 发布 Play 状态变化事件,用于层级面板实时同步
|
||||||
|
// Publish play state change event for hierarchy panel real-time sync
|
||||||
|
useEffect(() => {
|
||||||
|
messageHub?.publish('viewport:playState:changed', {
|
||||||
|
playState,
|
||||||
|
isPlaying: playState === 'playing'
|
||||||
|
});
|
||||||
|
}, [playState, messageHub]);
|
||||||
|
|
||||||
// Snap helper functions
|
// Snap helper functions
|
||||||
const snapToGrid = useCallback((value: number): number => {
|
const snapToGrid = useCallback((value: number): number => {
|
||||||
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
|
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
|
||||||
@@ -376,6 +385,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
|
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
commandManagerRef.current = commandManager ?? null;
|
commandManagerRef.current = commandManager ?? null;
|
||||||
@@ -438,7 +448,33 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
// Left button (0) for transform or camera pan (if no transform mode active)
|
// Left button (0) for transform or camera pan (if no transform mode active)
|
||||||
else if (e.button === 0) {
|
else if (e.button === 0) {
|
||||||
if (transformModeRef.current === 'select') {
|
if (transformModeRef.current === 'select') {
|
||||||
// In select mode, left click pans camera
|
// In select mode, first check if clicking on a gizmo
|
||||||
|
// 在选择模式下,首先检查是否点击了 gizmo
|
||||||
|
const gizmoService = EngineService.getInstance().getGizmoInteractionService();
|
||||||
|
if (gizmoService) {
|
||||||
|
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||||
|
const zoom = camera2DZoomRef.current;
|
||||||
|
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
|
||||||
|
|
||||||
|
if (hitEntityId !== null) {
|
||||||
|
// Find and select the hit entity
|
||||||
|
// 找到并选中命中的实体
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (scene) {
|
||||||
|
const hitEntity = scene.entities.findEntityById(hitEntityId);
|
||||||
|
if (hitEntity && messageHubRef.current) {
|
||||||
|
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||||
|
entityStore?.selectEntity(hitEntity);
|
||||||
|
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
|
||||||
|
e.preventDefault();
|
||||||
|
return; // Don't start camera pan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No gizmo hit, left click pans camera
|
||||||
|
// 没有点击到 gizmo,左键拖动相机
|
||||||
isDraggingCameraRef.current = true;
|
isDraggingCameraRef.current = true;
|
||||||
canvas.style.cursor = 'grabbing';
|
canvas.style.cursor = 'grabbing';
|
||||||
} else {
|
} else {
|
||||||
@@ -478,6 +514,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
x: prev.x - (deltaX * dpr) / zoom,
|
x: prev.x - (deltaX * dpr) / zoom,
|
||||||
y: prev.y + (deltaY * dpr) / zoom
|
y: prev.y + (deltaY * dpr) / zoom
|
||||||
}));
|
}));
|
||||||
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
} else if (isDraggingTransformRef.current) {
|
} else if (isDraggingTransformRef.current) {
|
||||||
// Transform selected entity based on mode
|
// Transform selected entity based on mode
|
||||||
const entity = selectedEntityRef.current;
|
const entity = selectedEntityRef.current;
|
||||||
@@ -592,11 +629,30 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
} else {
|
} else {
|
||||||
|
// Not dragging - update gizmo hover state
|
||||||
|
// 没有拖拽时 - 更新 gizmo 悬停状态
|
||||||
|
if (playStateRef.current !== 'playing') {
|
||||||
|
const gizmoService = EngineService.getInstance().getGizmoInteractionService();
|
||||||
|
if (gizmoService) {
|
||||||
|
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||||
|
const zoom = camera2DZoomRef.current;
|
||||||
|
gizmoService.updateMousePosition(worldPos.x, worldPos.y, zoom);
|
||||||
|
|
||||||
|
// Update cursor based on hover state
|
||||||
|
// 根据悬停状态更新光标
|
||||||
|
const hoveredId = gizmoService.getHoveredEntityId();
|
||||||
|
if (hoveredId !== null) {
|
||||||
|
canvas.style.cursor = 'pointer';
|
||||||
|
} else {
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
@@ -904,8 +960,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
|
|
||||||
await EngineService.getInstance().loadSceneResources();
|
await EngineService.getInstance().loadSceneResources();
|
||||||
|
|
||||||
|
// 同步 EntityStore 并通知层级面板更新
|
||||||
|
// Sync EntityStore and notify hierarchy panel to update
|
||||||
const entityStore = Core.services.tryResolve(EntityStoreService);
|
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||||
entityStore?.syncFromScene();
|
entityStore?.syncFromScene();
|
||||||
|
|
||||||
|
// 发布运行时场景切换事件,通知层级面板更新
|
||||||
|
// Publish runtime scene change event to notify hierarchy panel
|
||||||
|
const sceneName = fullPath.split(/[/\\]/).pop()?.replace('.ecs', '') || 'Unknown';
|
||||||
|
messageHub?.publish('runtime:scene:changed', {
|
||||||
|
path: fullPath,
|
||||||
|
sceneName,
|
||||||
|
isPlayMode: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
|
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
|
||||||
@@ -1167,7 +1234,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
|
|
||||||
// Build asset catalog and copy files
|
// Build asset catalog and copy files
|
||||||
// 构建资产目录并复制文件
|
// 构建资产目录并复制文件
|
||||||
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
|
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string; importSettings?: Record<string, unknown> }> = {};
|
||||||
|
|
||||||
for (const assetPath of assetPaths) {
|
for (const assetPath of assetPaths) {
|
||||||
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
|
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
|
||||||
@@ -1180,11 +1247,11 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get filename and determine relative path
|
// Get filename and determine relative path
|
||||||
// 路径格式:相对于 assets 目录,不包含 'assets/' 前缀
|
// 路径格式:包含 'assets/' 前缀,与运行时资产加载器格式一致
|
||||||
// Path format: relative to assets directory, without 'assets/' prefix
|
// Path format: includes 'assets/' prefix, consistent with runtime asset loader
|
||||||
const filename = assetPath.split(/[/\\]/).pop() || '';
|
const filename = assetPath.split(/[/\\]/).pop() || '';
|
||||||
const destPath = `${assetsDir}\\${filename}`;
|
const destPath = `${assetsDir}\\${filename}`;
|
||||||
const relativePath = filename;
|
const relativePath = `assets/${filename}`;
|
||||||
|
|
||||||
// Copy file
|
// Copy file
|
||||||
await TauriAPI.copyFile(assetPath, destPath);
|
await TauriAPI.copyFile(assetPath, destPath);
|
||||||
@@ -1206,6 +1273,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
// 检查此资产是否通过 GUID 引用(如粒子资产)
|
// 检查此资产是否通过 GUID 引用(如粒子资产)
|
||||||
// 如果是,使用原始 GUID;否则根据路径生成
|
// 如果是,使用原始 GUID;否则根据路径生成
|
||||||
let guid: string | undefined;
|
let guid: string | undefined;
|
||||||
|
let importSettings: Record<string, unknown> | undefined;
|
||||||
for (const [originalGuid, mappedPath] of guidToPath.entries()) {
|
for (const [originalGuid, mappedPath] of guidToPath.entries()) {
|
||||||
if (mappedPath === assetPath) {
|
if (mappedPath === assetPath) {
|
||||||
guid = originalGuid;
|
guid = originalGuid;
|
||||||
@@ -1216,12 +1284,61 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
|||||||
guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
|
guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get importSettings from meta file for nine-patch and other settings
|
||||||
|
// 从 meta 文件获取 importSettings,用于九宫格和其他设置
|
||||||
|
if (assetRegistry) {
|
||||||
|
try {
|
||||||
|
const meta = await assetRegistry.metaManager.getOrCreateMeta(assetPath);
|
||||||
|
if (meta.importSettings) {
|
||||||
|
importSettings = meta.importSettings as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Meta file may not exist, that's ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For texture assets, read image dimensions and store in importSettings
|
||||||
|
// 对于纹理资产,读取图片尺寸并存储到 importSettings
|
||||||
|
if (assetType === 'texture') {
|
||||||
|
try {
|
||||||
|
// Read image as base64 and get dimensions
|
||||||
|
// 读取图片为 base64 并获取尺寸
|
||||||
|
const base64Data = await TauriAPI.readFileAsBase64(assetPath);
|
||||||
|
const dimensions = await new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image'));
|
||||||
|
img.src = `data:image/${ext.slice(1)};base64,${base64Data}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure importSettings and spriteSettings exist
|
||||||
|
// 确保 importSettings 和 spriteSettings 存在
|
||||||
|
if (!importSettings) {
|
||||||
|
importSettings = {};
|
||||||
|
}
|
||||||
|
if (!importSettings.spriteSettings) {
|
||||||
|
importSettings.spriteSettings = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dimensions to spriteSettings
|
||||||
|
// 将尺寸添加到 spriteSettings
|
||||||
|
const spriteSettings = importSettings.spriteSettings as Record<string, unknown>;
|
||||||
|
spriteSettings.width = dimensions.width;
|
||||||
|
spriteSettings.height = dimensions.height;
|
||||||
|
|
||||||
|
console.log(`[Viewport] Texture ${filename}: ${dimensions.width}x${dimensions.height}`);
|
||||||
|
} catch (dimError) {
|
||||||
|
console.warn(`[Viewport] Failed to get dimensions for ${filename}:`, dimError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
catalogEntries[guid] = {
|
catalogEntries[guid] = {
|
||||||
guid,
|
guid,
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
type: assetType,
|
type: assetType,
|
||||||
size: 0,
|
size: 0,
|
||||||
hash: ''
|
hash: '',
|
||||||
|
importSettings
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);
|
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);
|
||||||
|
|||||||
@@ -399,6 +399,24 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Batch breaker item highlight */
|
||||||
|
.event-item.batch-breaker {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item.batch-breaker:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-name.batch-breaker {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .event-icon.breaker {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== Right Panel ==================== */
|
/* ==================== Right Panel ==================== */
|
||||||
.render-debug-right {
|
.render-debug-right {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -536,6 +554,28 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Batch fix tip */
|
||||||
|
.batch-fix-tip {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffc107;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batch breaker warning */
|
||||||
|
.batch-breaker-warning {
|
||||||
|
color: #f59e0b !important;
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-top: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== Stats Bar ==================== */
|
/* ==================== Stats Bar ==================== */
|
||||||
.render-debug-stats {
|
.render-debug-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -631,3 +671,147 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== Clickable Stats ==================== */
|
||||||
|
.render-debug-stats .stat-item.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-stats .stat-item.clickable:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-stats .stat-item.atlas-enabled {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-debug-stats .stat-item.atlas-disabled {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Atlas Preview Modal ==================== */
|
||||||
|
.atlas-preview-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-content {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-page-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #252525;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-page-tab {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #333;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-page-tab:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-page-tab.active {
|
||||||
|
background: #4a9eff;
|
||||||
|
border-color: #4a9eff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 350px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-canvas-container canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #252525;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-info .hint {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-entry-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-entry-info .label {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-entry-info .value {
|
||||||
|
color: #4a9eff;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atlas-preview-stats .error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,18 +26,21 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Radio,
|
Radio,
|
||||||
Square,
|
Square,
|
||||||
Type
|
Type,
|
||||||
|
Grid3x3
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { WebviewWindow, getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
import { WebviewWindow, getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||||
import { emit, emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { emit, emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo } from '../../services/RenderDebugService';
|
import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo, type UniformDebugValue, type AtlasStats, type AtlasPageDebugInfo, type AtlasEntryDebugInfo } from '../../services/RenderDebugService';
|
||||||
|
import type { BatchDebugInfo } from '@esengine/ui';
|
||||||
|
import { EngineService } from '../../services/EngineService';
|
||||||
import './RenderDebugPanel.css';
|
import './RenderDebugPanel.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染事件类型
|
* 渲染事件类型
|
||||||
* Render event type
|
* Render event type
|
||||||
*/
|
*/
|
||||||
type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw';
|
type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw' | 'ui-batch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染事件
|
* 渲染事件
|
||||||
@@ -52,6 +55,8 @@ interface RenderEvent {
|
|||||||
data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any;
|
data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any;
|
||||||
drawCalls?: number;
|
drawCalls?: number;
|
||||||
vertices?: number;
|
vertices?: number;
|
||||||
|
/** 合批调试信息 | Batch debug info */
|
||||||
|
batchInfo?: BatchDebugInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RenderDebugPanelProps {
|
interface RenderDebugPanelProps {
|
||||||
@@ -74,6 +79,10 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
const [frameHistory, setFrameHistory] = useState<RenderDebugSnapshot[]>([]);
|
const [frameHistory, setFrameHistory] = useState<RenderDebugSnapshot[]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1); // -1 表示实时模式 | -1 means live mode
|
const [historyIndex, setHistoryIndex] = useState(-1); // -1 表示实时模式 | -1 means live mode
|
||||||
|
|
||||||
|
// 图集预览状态 | Atlas preview state
|
||||||
|
const [showAtlasPreview, setShowAtlasPreview] = useState(false);
|
||||||
|
const [selectedAtlasPage, setSelectedAtlasPage] = useState(0);
|
||||||
|
|
||||||
// 窗口拖动状态 | Window drag state
|
// 窗口拖动状态 | Window drag state
|
||||||
const [position, setPosition] = useState({ x: 100, y: 60 });
|
const [position, setPosition] = useState({ x: 100, y: 60 });
|
||||||
const [size, setSize] = useState({ width: 900, height: 600 });
|
const [size, setSize] = useState({ width: 900, height: 600 });
|
||||||
@@ -84,6 +93,39 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const windowRef = useRef<HTMLDivElement>(null);
|
const windowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 高亮相关 | Highlight related
|
||||||
|
const previousSelectedIdsRef = useRef<number[] | null>(null);
|
||||||
|
const engineService = useRef(EngineService.getInstance());
|
||||||
|
|
||||||
|
// 处理事件选中并高亮实体 | Handle event selection and highlight entity
|
||||||
|
const handleEventSelect = useCallback((event: RenderEvent | null) => {
|
||||||
|
setSelectedEvent(event);
|
||||||
|
|
||||||
|
// 获取实体 ID | Get entity ID
|
||||||
|
const entityId = event?.data?.entityId;
|
||||||
|
|
||||||
|
if (entityId !== undefined) {
|
||||||
|
// 保存原始选中状态(只保存一次)| Save original selection (only once)
|
||||||
|
if (previousSelectedIdsRef.current === null) {
|
||||||
|
previousSelectedIdsRef.current = engineService.current.getSelectedEntityIds?.() || [];
|
||||||
|
}
|
||||||
|
// 高亮选中的实体 | Highlight selected entity
|
||||||
|
engineService.current.setSelectedEntityIds([entityId]);
|
||||||
|
} else if (previousSelectedIdsRef.current !== null) {
|
||||||
|
// 恢复原始选中状态 | Restore original selection
|
||||||
|
engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current);
|
||||||
|
previousSelectedIdsRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 面板关闭时恢复原始选中状态 | Restore original selection when panel closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible && previousSelectedIdsRef.current !== null) {
|
||||||
|
engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current);
|
||||||
|
previousSelectedIdsRef.current = null;
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
// 弹出为独立窗口 | Pop out to separate window
|
// 弹出为独立窗口 | Pop out to separate window
|
||||||
const handlePopOut = useCallback(async () => {
|
const handlePopOut = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -181,8 +223,85 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// UI 元素 | UI elements
|
// UI 批次和元素 | UI batches and elements
|
||||||
if (snap.uiElements && snap.uiElements.length > 0) {
|
// 使用 entityIds 进行精确的批次-元素匹配 | Use entityIds for precise batch-element matching
|
||||||
|
if (snap.uiBatches && snap.uiBatches.length > 0) {
|
||||||
|
const uiChildren: RenderEvent[] = [];
|
||||||
|
|
||||||
|
// 构建 entityId -> UI 元素的映射 | Build entityId -> UI element map
|
||||||
|
const uiElementMap = new Map<number, UIDebugInfo>();
|
||||||
|
snap.uiElements?.forEach(ui => {
|
||||||
|
if (ui.entityId !== undefined) {
|
||||||
|
uiElementMap.set(ui.entityId, ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个批次创建事件,包含其子元素 | Create events for each batch with its child elements
|
||||||
|
snap.uiBatches.forEach((batch) => {
|
||||||
|
const reasonLabels: Record<string, string> = {
|
||||||
|
'first': '',
|
||||||
|
'sortingLayer': '⚠️ Layer',
|
||||||
|
'texture': '⚠️ Texture',
|
||||||
|
'material': '⚠️ Material'
|
||||||
|
};
|
||||||
|
const reasonLabel = reasonLabels[batch.reason] || '';
|
||||||
|
const batchName = batch.reason === 'first'
|
||||||
|
? `DC ${batch.batchIndex}: ${batch.primitiveCount} prims`
|
||||||
|
: `DC ${batch.batchIndex} ${reasonLabel}: ${batch.primitiveCount} prims`;
|
||||||
|
|
||||||
|
// 从 entityIds 获取此批次的 UI 元素 | Get UI elements for this batch from entityIds
|
||||||
|
const batchElements: RenderEvent[] = [];
|
||||||
|
const entityIds = batch.entityIds ?? [];
|
||||||
|
const firstEntityId = batch.firstEntityId;
|
||||||
|
|
||||||
|
entityIds.forEach((entityId) => {
|
||||||
|
const ui = uiElementMap.get(entityId);
|
||||||
|
if (ui) {
|
||||||
|
// 使用 firstEntityId 精确标记打断批次的元素 | Use firstEntityId to precisely mark batch breaker
|
||||||
|
const isBreaker = entityId === firstEntityId && batch.reason !== 'first';
|
||||||
|
batchElements.push({
|
||||||
|
id: eventId++,
|
||||||
|
type: 'ui' as RenderEventType,
|
||||||
|
name: isBreaker
|
||||||
|
? `⚡ ${ui.type}: ${ui.entityName}`
|
||||||
|
: `${ui.type}: ${ui.entityName}`,
|
||||||
|
data: {
|
||||||
|
...ui,
|
||||||
|
isBatchBreaker: isBreaker,
|
||||||
|
breakReason: isBreaker ? batch.reason : undefined,
|
||||||
|
batchIndex: batch.batchIndex
|
||||||
|
},
|
||||||
|
drawCalls: 0,
|
||||||
|
vertices: 4
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uiChildren.push({
|
||||||
|
id: eventId++,
|
||||||
|
type: 'ui-batch' as RenderEventType,
|
||||||
|
name: batchName,
|
||||||
|
batchInfo: batch,
|
||||||
|
children: batchElements.length > 0 ? batchElements : undefined,
|
||||||
|
expanded: batchElements.length > 0 && batchElements.length <= 10,
|
||||||
|
drawCalls: 1,
|
||||||
|
vertices: batch.primitiveCount * 4
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPrimitives = snap.uiBatches.reduce((sum, b) => sum + b.primitiveCount, 0);
|
||||||
|
const dcCount = snap.uiBatches.length;
|
||||||
|
newEvents.push({
|
||||||
|
id: eventId++,
|
||||||
|
type: 'batch',
|
||||||
|
name: `UI Render (${dcCount} DC, ${snap.uiElements?.length ?? 0} elements)`,
|
||||||
|
children: uiChildren,
|
||||||
|
expanded: true,
|
||||||
|
drawCalls: dcCount,
|
||||||
|
vertices: totalPrimitives * 4
|
||||||
|
});
|
||||||
|
} else if (snap.uiElements && snap.uiElements.length > 0) {
|
||||||
|
// 回退:没有批次信息时按元素显示 | Fallback: show by element when no batch info
|
||||||
const uiChildren: RenderEvent[] = snap.uiElements.map((ui) => ({
|
const uiChildren: RenderEvent[] = snap.uiElements.map((ui) => ({
|
||||||
id: eventId++,
|
id: eventId++,
|
||||||
type: 'ui' as RenderEventType,
|
type: 'ui' as RenderEventType,
|
||||||
@@ -234,9 +353,9 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
if (snap) {
|
if (snap) {
|
||||||
setSnapshot(snap);
|
setSnapshot(snap);
|
||||||
setEvents(buildEventsFromSnapshot(snap));
|
setEvents(buildEventsFromSnapshot(snap));
|
||||||
setSelectedEvent(null);
|
handleEventSelect(null);
|
||||||
}
|
}
|
||||||
}, [frameHistory, buildEventsFromSnapshot]);
|
}, [frameHistory, buildEventsFromSnapshot, handleEventSelect]);
|
||||||
|
|
||||||
// 返回实时模式 | Return to live mode
|
// 返回实时模式 | Return to live mode
|
||||||
const goLive = useCallback(() => {
|
const goLive = useCallback(() => {
|
||||||
@@ -467,62 +586,122 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(`${particles.length} particles sampled`, margin, rect.height - 6);
|
ctx.fillText(`${particles.length} particles sampled`, margin, rect.height - 6);
|
||||||
|
|
||||||
} else if (data?.uv) {
|
} else if (data?.uv || data?.textureUrl) {
|
||||||
// Sprite 或单个粒子:显示 UV 区域 | Sprite or single particle: show UV region
|
// Sprite 或 UI 元素:显示纹理和 UV 区域 | Sprite or UI element: show texture and UV region
|
||||||
const uv = data.uv;
|
const uv = data.uv ?? [0, 0, 1, 1];
|
||||||
const previewSize = Math.min(viewWidth, viewHeight);
|
const previewSize = Math.min(viewWidth, viewHeight) - 30; // 留出底部文字空间
|
||||||
const offsetX = (rect.width - previewSize) / 2;
|
const offsetX = (rect.width - previewSize) / 2;
|
||||||
const offsetY = (rect.height - previewSize) / 2;
|
const offsetY = margin;
|
||||||
|
|
||||||
// 绘制纹理边框 | Draw texture border
|
// 绘制棋盘格背景(透明度指示)| Draw checkerboard background (transparency indicator)
|
||||||
ctx.strokeStyle = '#333';
|
const checkerSize = 8;
|
||||||
ctx.lineWidth = 1;
|
for (let cx = 0; cx < previewSize; cx += checkerSize) {
|
||||||
ctx.strokeRect(offsetX, offsetY, previewSize, previewSize);
|
for (let cy = 0; cy < previewSize; cy += checkerSize) {
|
||||||
|
const isLight = ((cx / checkerSize) + (cy / checkerSize)) % 2 === 0;
|
||||||
// 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid
|
ctx.fillStyle = isLight ? '#2a2a2a' : '#1f1f1f';
|
||||||
const tilesX = data._animTilesX ?? (data.systemName ? 1 : 1);
|
ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize);
|
||||||
const tilesY = data._animTilesY ?? 1;
|
|
||||||
|
|
||||||
if (tilesX > 1 || tilesY > 1) {
|
|
||||||
const cellWidth = previewSize / tilesX;
|
|
||||||
const cellHeight = previewSize / tilesY;
|
|
||||||
|
|
||||||
// 绘制网格 | Draw grid
|
|
||||||
ctx.strokeStyle = '#2a2a2a';
|
|
||||||
for (let i = 0; i <= tilesX; i++) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(offsetX + i * cellWidth, offsetY);
|
|
||||||
ctx.lineTo(offsetX + i * cellWidth, offsetY + previewSize);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let j = 0; j <= tilesY; j++) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(offsetX, offsetY + j * cellHeight);
|
|
||||||
ctx.lineTo(offsetX + previewSize, offsetY + j * cellHeight);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 高亮 UV 区域 | Highlight UV region
|
// 如果有纹理 URL,加载并绘制纹理 | If texture URL exists, load and draw texture
|
||||||
const x = offsetX + uv[0] * previewSize;
|
if (data.textureUrl) {
|
||||||
const y = offsetY + uv[1] * previewSize;
|
const img = document.createElement('img');
|
||||||
const w = (uv[2] - uv[0]) * previewSize;
|
img.onload = () => {
|
||||||
const h = (uv[3] - uv[1]) * previewSize;
|
// 重新获取 context(异步回调中需要)| Re-get context (needed in async callback)
|
||||||
|
const ctx2 = canvas.getContext('2d');
|
||||||
|
if (!ctx2) return;
|
||||||
|
ctx2.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(74, 158, 255, 0.3)';
|
// 绘制纹理 | Draw texture
|
||||||
ctx.fillRect(x, y, w, h);
|
ctx2.drawImage(img, offsetX, offsetY, previewSize, previewSize);
|
||||||
ctx.strokeStyle = '#4a9eff';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.strokeRect(x, y, w, h);
|
|
||||||
|
|
||||||
// 显示 UV 坐标 | Show UV coordinates
|
// 高亮 UV 区域 | Highlight UV region
|
||||||
ctx.fillStyle = '#4a9eff';
|
const x = offsetX + uv[0] * previewSize;
|
||||||
ctx.font = '10px Consolas, monospace';
|
const y = offsetY + uv[1] * previewSize;
|
||||||
ctx.textAlign = 'left';
|
const w = (uv[2] - uv[0]) * previewSize;
|
||||||
ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, offsetY + previewSize + 14);
|
const h = (uv[3] - uv[1]) * previewSize;
|
||||||
|
|
||||||
if (data.frame !== undefined) {
|
ctx2.fillStyle = 'rgba(74, 158, 255, 0.2)';
|
||||||
ctx.fillText(`Frame: ${data.frame}`, offsetX, offsetY + previewSize + 26);
|
ctx2.fillRect(x, y, w, h);
|
||||||
|
ctx2.strokeStyle = '#4a9eff';
|
||||||
|
ctx2.lineWidth = 2;
|
||||||
|
ctx2.strokeRect(x, y, w, h);
|
||||||
|
|
||||||
|
// 绘制边框 | Draw border
|
||||||
|
ctx2.strokeStyle = '#444';
|
||||||
|
ctx2.lineWidth = 1;
|
||||||
|
ctx2.strokeRect(offsetX, offsetY, previewSize, previewSize);
|
||||||
|
|
||||||
|
// 显示信息 | Show info
|
||||||
|
ctx2.fillStyle = '#4a9eff';
|
||||||
|
ctx2.font = '10px Consolas, monospace';
|
||||||
|
ctx2.textAlign = 'left';
|
||||||
|
const infoY = offsetY + previewSize + 14;
|
||||||
|
ctx2.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY);
|
||||||
|
if (data.aspectRatio !== undefined) {
|
||||||
|
ctx2.fillStyle = '#10b981';
|
||||||
|
ctx2.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY);
|
||||||
|
}
|
||||||
|
if (data.color) {
|
||||||
|
ctx2.fillStyle = '#f59e0b';
|
||||||
|
ctx2.fillText(`color: ${data.color}`, offsetX, infoY + 12);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = data.textureUrl;
|
||||||
|
} else {
|
||||||
|
// 没有纹理时绘制占位符 | Draw placeholder when no texture
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(offsetX, offsetY, previewSize, previewSize);
|
||||||
|
|
||||||
|
// 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid
|
||||||
|
const tilesX = data._animTilesX ?? 1;
|
||||||
|
const tilesY = data._animTilesY ?? 1;
|
||||||
|
|
||||||
|
if (tilesX > 1 || tilesY > 1) {
|
||||||
|
const cellWidth = previewSize / tilesX;
|
||||||
|
const cellHeight = previewSize / tilesY;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#2a2a2a';
|
||||||
|
for (let i = 0; i <= tilesX; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(offsetX + i * cellWidth, offsetY);
|
||||||
|
ctx.lineTo(offsetX + i * cellWidth, offsetY + previewSize);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let j = 0; j <= tilesY; j++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(offsetX, offsetY + j * cellHeight);
|
||||||
|
ctx.lineTo(offsetX + previewSize, offsetY + j * cellHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮 UV 区域 | Highlight UV region
|
||||||
|
const x = offsetX + uv[0] * previewSize;
|
||||||
|
const y = offsetY + uv[1] * previewSize;
|
||||||
|
const w = (uv[2] - uv[0]) * previewSize;
|
||||||
|
const h = (uv[3] - uv[1]) * previewSize;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(74, 158, 255, 0.3)';
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.strokeStyle = '#4a9eff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x, y, w, h);
|
||||||
|
|
||||||
|
// 显示信息 | Show info
|
||||||
|
ctx.fillStyle = '#4a9eff';
|
||||||
|
ctx.font = '10px Consolas, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
const infoY = offsetY + previewSize + 14;
|
||||||
|
ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY);
|
||||||
|
if (data.aspectRatio !== undefined) {
|
||||||
|
ctx.fillStyle = '#10b981';
|
||||||
|
ctx.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY);
|
||||||
|
}
|
||||||
|
if (data.frame !== undefined) {
|
||||||
|
ctx.fillText(`Frame: ${data.frame}`, offsetX, infoY + 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 其他事件类型 | Other event types
|
// 其他事件类型 | Other event types
|
||||||
@@ -707,7 +886,7 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
event={event}
|
event={event}
|
||||||
depth={0}
|
depth={0}
|
||||||
selected={selectedEvent?.id === event.id}
|
selected={selectedEvent?.id === event.id}
|
||||||
onSelect={setSelectedEvent}
|
onSelect={handleEventSelect}
|
||||||
onToggle={toggleExpand}
|
onToggle={toggleExpand}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -767,10 +946,39 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
|
|||||||
<Image size={12} />
|
<Image size={12} />
|
||||||
<span>Systems: {snapshot?.particles?.length ?? 0}</span>
|
<span>Systems: {snapshot?.particles?.length ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 动态图集统计 | Dynamic atlas stats */}
|
||||||
|
{snapshot?.atlasStats && (
|
||||||
|
<div
|
||||||
|
className={`stat-item clickable ${snapshot.atlasStats.enabled ? 'atlas-enabled' : 'atlas-disabled'}`}
|
||||||
|
title={
|
||||||
|
snapshot.atlasStats.enabled
|
||||||
|
? `Click to view atlas. ${snapshot.atlasStats.pageCount} pages, ${snapshot.atlasStats.textureCount} textures, ${(snapshot.atlasStats.averageOccupancy * 100).toFixed(0)}% occupancy`
|
||||||
|
: 'Dynamic Atlas: Disabled'
|
||||||
|
}
|
||||||
|
onClick={() => snapshot.atlasStats?.enabled && setShowAtlasPreview(true)}
|
||||||
|
>
|
||||||
|
<Grid3x3 size={12} />
|
||||||
|
<span>
|
||||||
|
Atlas: {snapshot.atlasStats.enabled
|
||||||
|
? `${snapshot.atlasStats.textureCount}/${snapshot.atlasStats.pageCount}p`
|
||||||
|
: 'Off'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */}
|
{/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */}
|
||||||
{!standalone && <div className="resize-handle" onMouseDown={handleResizeMouseDown} />}
|
{!standalone && <div className="resize-handle" onMouseDown={handleResizeMouseDown} />}
|
||||||
|
|
||||||
|
{/* 图集预览弹窗 | Atlas preview modal */}
|
||||||
|
{showAtlasPreview && snapshot?.atlasStats?.pages && (
|
||||||
|
<AtlasPreviewModal
|
||||||
|
atlasStats={snapshot.atlasStats}
|
||||||
|
selectedPage={selectedAtlasPage}
|
||||||
|
onSelectPage={setSelectedAtlasPage}
|
||||||
|
onClose={() => setShowAtlasPreview(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -788,12 +996,14 @@ interface EventItemProps {
|
|||||||
const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect, onToggle }) => {
|
const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect, onToggle }) => {
|
||||||
const hasChildren = event.children && event.children.length > 0;
|
const hasChildren = event.children && event.children.length > 0;
|
||||||
const iconSize = 12;
|
const iconSize = 12;
|
||||||
|
const isBatchBreaker = event.data?.isBatchBreaker === true;
|
||||||
|
|
||||||
const getTypeIcon = () => {
|
const getTypeIcon = () => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'sprite': return <Image size={iconSize} className="event-icon sprite" />;
|
case 'sprite': return <Image size={iconSize} className="event-icon sprite" />;
|
||||||
case 'particle': return <Sparkles size={iconSize} className="event-icon particle" />;
|
case 'particle': return <Sparkles size={iconSize} className="event-icon particle" />;
|
||||||
case 'ui': return <Square size={iconSize} className="event-icon ui" />;
|
case 'ui': return <Square size={iconSize} className={`event-icon ui ${isBatchBreaker ? 'breaker' : ''}`} />;
|
||||||
|
case 'ui-batch': return <Layers size={iconSize} className="event-icon ui" />;
|
||||||
case 'batch': return <Layers size={iconSize} className="event-icon batch" />;
|
case 'batch': return <Layers size={iconSize} className="event-icon batch" />;
|
||||||
default: return <Monitor size={iconSize} className="event-icon" />;
|
default: return <Monitor size={iconSize} className="event-icon" />;
|
||||||
}
|
}
|
||||||
@@ -802,7 +1012,7 @@ const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect,
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`event-item ${selected ? 'selected' : ''}`}
|
className={`event-item ${selected ? 'selected' : ''} ${isBatchBreaker ? 'batch-breaker' : ''}`}
|
||||||
style={{ paddingLeft: 8 + depth * 16 }}
|
style={{ paddingLeft: 8 + depth * 16 }}
|
||||||
onClick={() => onSelect(event)}
|
onClick={() => onSelect(event)}
|
||||||
>
|
>
|
||||||
@@ -814,8 +1024,8 @@ const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect,
|
|||||||
<span className="expand-icon placeholder" />
|
<span className="expand-icon placeholder" />
|
||||||
)}
|
)}
|
||||||
{getTypeIcon()}
|
{getTypeIcon()}
|
||||||
<span className="event-name">{event.name}</span>
|
<span className={`event-name ${isBatchBreaker ? 'batch-breaker' : ''}`}>{event.name}</span>
|
||||||
{event.drawCalls !== undefined && (
|
{event.drawCalls !== undefined && event.drawCalls > 0 && (
|
||||||
<span className="event-draws">{event.drawCalls}</span>
|
<span className="event-draws">{event.drawCalls}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -948,6 +1158,8 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
|||||||
});
|
});
|
||||||
}, [event, data]);
|
}, [event, data]);
|
||||||
|
|
||||||
|
const batchInfo = event.batchInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="details-grid">
|
<div className="details-grid">
|
||||||
<DetailRow label="Event" value={event.name} />
|
<DetailRow label="Event" value={event.name} />
|
||||||
@@ -955,6 +1167,48 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
|||||||
<DetailRow label="Draw Calls" value={event.drawCalls?.toString() ?? '-'} />
|
<DetailRow label="Draw Calls" value={event.drawCalls?.toString() ?? '-'} />
|
||||||
<DetailRow label="Vertices" value={event.vertices?.toString() ?? '-'} />
|
<DetailRow label="Vertices" value={event.vertices?.toString() ?? '-'} />
|
||||||
|
|
||||||
|
{/* UI 批次信息 | UI batch info */}
|
||||||
|
{event.type === 'ui-batch' && batchInfo && (
|
||||||
|
<>
|
||||||
|
<div className="details-section">Batch Break Reason</div>
|
||||||
|
<DetailRow
|
||||||
|
label="Reason"
|
||||||
|
value={batchInfo.reason === 'first' ? 'First batch' : batchInfo.reason}
|
||||||
|
highlight={batchInfo.reason !== 'first'}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Detail" value={batchInfo.detail} />
|
||||||
|
<div className="details-section">Batch Properties</div>
|
||||||
|
<DetailRow label="Batch Index" value={batchInfo.batchIndex.toString()} />
|
||||||
|
<DetailRow label="Primitives" value={batchInfo.primitiveCount.toString()} />
|
||||||
|
<DetailRow label="Sorting Layer" value={batchInfo.sortingLayer} />
|
||||||
|
<DetailRow label="Order" value={batchInfo.orderInLayer.toString()} />
|
||||||
|
<DetailRow
|
||||||
|
label="Texture"
|
||||||
|
value={batchInfo.textureKey.startsWith('atlas:')
|
||||||
|
? `🗂️ ${batchInfo.textureKey}`
|
||||||
|
: batchInfo.textureKey}
|
||||||
|
highlight={batchInfo.textureKey.startsWith('atlas:')}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Material ID" value={batchInfo.materialId.toString()} />
|
||||||
|
{batchInfo.reason !== 'first' && (
|
||||||
|
<>
|
||||||
|
<div className="details-section">How to Fix</div>
|
||||||
|
<div className="batch-fix-tip">
|
||||||
|
{batchInfo.reason === 'sortingLayer' && (
|
||||||
|
<span>将这些元素放在同一个排序层中</span>
|
||||||
|
)}
|
||||||
|
{batchInfo.reason === 'texture' && (
|
||||||
|
<span>使用相同的纹理,或将纹理合并到图集中</span>
|
||||||
|
)}
|
||||||
|
{batchInfo.reason === 'material' && (
|
||||||
|
<span>使用相同的材质/着色器</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className="details-section">Properties</div>
|
<div className="details-section">Properties</div>
|
||||||
@@ -971,6 +1225,17 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
|||||||
<DetailRow label="Sort Layer" value={data.sortingLayer || 'Default'} />
|
<DetailRow label="Sort Layer" value={data.sortingLayer || 'Default'} />
|
||||||
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
|
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
|
||||||
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
||||||
|
<div className="details-section">Material</div>
|
||||||
|
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight />
|
||||||
|
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} />
|
||||||
|
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="details-section">Uniforms</div>
|
||||||
|
<UniformList uniforms={data.uniforms} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="details-section">Vertex Attributes</div>
|
||||||
|
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1018,6 +1283,19 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
|||||||
{/* UI 元素数据 | UI element data */}
|
{/* UI 元素数据 | UI element data */}
|
||||||
{event.type === 'ui' && data.entityName && (
|
{event.type === 'ui' && data.entityName && (
|
||||||
<>
|
<>
|
||||||
|
{/* 如果是打断合批的元素,显示警告 | Show warning if this element breaks batching */}
|
||||||
|
{data.isBatchBreaker && (
|
||||||
|
<>
|
||||||
|
<div className="details-section batch-breaker-warning">⚡ Batch Breaker</div>
|
||||||
|
<div className="batch-fix-tip">
|
||||||
|
此元素导致了新的 Draw Call。
|
||||||
|
{data.breakReason === 'sortingLayer' && ' 原因:排序层与前一个元素不同。'}
|
||||||
|
{data.breakReason === 'orderInLayer' && ' 原因:层内顺序与前一个元素不同。'}
|
||||||
|
{data.breakReason === 'texture' && ' 原因:纹理与前一个元素不同。'}
|
||||||
|
{data.breakReason === 'material' && ' 原因:材质/着色器与前一个元素不同。'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DetailRow label="Entity" value={data.entityName} />
|
<DetailRow label="Entity" value={data.entityName} />
|
||||||
<DetailRow label="Type" value={data.type} highlight />
|
<DetailRow label="Type" value={data.type} highlight />
|
||||||
<DetailRow label="Position" value={`(${data.x?.toFixed(0)}, ${data.y?.toFixed(0)})`} />
|
<DetailRow label="Position" value={`(${data.x?.toFixed(0)}, ${data.y?.toFixed(0)})`} />
|
||||||
@@ -1026,14 +1304,20 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
|||||||
<DetailRow label="Rotation" value={`${((data.rotation ?? 0) * 180 / Math.PI).toFixed(1)}°`} />
|
<DetailRow label="Rotation" value={`${((data.rotation ?? 0) * 180 / Math.PI).toFixed(1)}°`} />
|
||||||
<DetailRow label="Visible" value={data.visible ? 'Yes' : 'No'} />
|
<DetailRow label="Visible" value={data.visible ? 'Yes' : 'No'} />
|
||||||
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
|
||||||
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} />
|
<div className="details-section">Sorting</div>
|
||||||
|
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} highlight={data.isBatchBreaker && data.breakReason === 'sortingLayer'} />
|
||||||
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
|
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
|
||||||
|
<DetailRow label="Depth" value={data.depth?.toString() ?? '0'} />
|
||||||
|
<DetailRow label="World Order" value={data.worldOrderInLayer?.toString() ?? '0'} highlight />
|
||||||
{data.backgroundColor && (
|
{data.backgroundColor && (
|
||||||
<DetailRow label="Background" value={data.backgroundColor} />
|
<DetailRow label="Background" value={data.backgroundColor} />
|
||||||
)}
|
)}
|
||||||
{data.textureGuid && (
|
{data.textureGuid && (
|
||||||
<TexturePreview textureUrl={data.textureUrl} texturePath={data.textureGuid} />
|
<TexturePreview textureUrl={data.textureUrl} texturePath={data.textureGuid} />
|
||||||
)}
|
)}
|
||||||
|
{!data.textureGuid && data.isBatchBreaker && data.breakReason === 'texture' && (
|
||||||
|
<DetailRow label="Texture" value="(none / solid)" highlight />
|
||||||
|
)}
|
||||||
{data.text && (
|
{data.text && (
|
||||||
<>
|
<>
|
||||||
<div className="details-section">Text</div>
|
<div className="details-section">Text</div>
|
||||||
@@ -1041,6 +1325,17 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
|
|||||||
{data.fontSize && <DetailRow label="Font Size" value={data.fontSize.toString()} />}
|
{data.fontSize && <DetailRow label="Font Size" value={data.fontSize.toString()} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="details-section">Material</div>
|
||||||
|
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
|
||||||
|
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
|
||||||
|
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="details-section">Uniforms</div>
|
||||||
|
<UniformList uniforms={data.uniforms} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="details-section">Vertex Attributes</div>
|
||||||
|
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -1056,4 +1351,350 @@ const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }>
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 uniform 值
|
||||||
|
* Format uniform value
|
||||||
|
*/
|
||||||
|
function formatUniformValue(uniform: UniformDebugValue): string {
|
||||||
|
const { type, value } = uniform;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return type === 'int' ? value.toString() : value.toFixed(4);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(v => v.toFixed(3)).join(', ');
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform 列表组件
|
||||||
|
* Uniform list component
|
||||||
|
*/
|
||||||
|
const UniformList: React.FC<{ uniforms: Record<string, UniformDebugValue> }> = ({ uniforms }) => {
|
||||||
|
const entries = Object.entries(uniforms);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <DetailRow label="Uniforms" value="(none)" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{entries.map(([name, uniform]) => (
|
||||||
|
<DetailRow
|
||||||
|
key={name}
|
||||||
|
label={name.replace(/^u_/, '')}
|
||||||
|
value={`${formatUniformValue(uniform)} (${uniform.type})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图集预览弹窗组件
|
||||||
|
* Atlas Preview Modal Component
|
||||||
|
*/
|
||||||
|
interface AtlasPreviewModalProps {
|
||||||
|
atlasStats: AtlasStats;
|
||||||
|
selectedPage: number;
|
||||||
|
onSelectPage: (page: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AtlasPreviewModal: React.FC<AtlasPreviewModalProps> = ({
|
||||||
|
atlasStats,
|
||||||
|
selectedPage,
|
||||||
|
onSelectPage,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [hoveredEntry, setHoveredEntry] = useState<AtlasEntryDebugInfo | null>(null);
|
||||||
|
const [loadedImages, setLoadedImages] = useState<Map<string, HTMLImageElement>>(new Map());
|
||||||
|
|
||||||
|
// 缩放和平移状态 | Zoom and pan state
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const currentPage = atlasStats.pages[selectedPage];
|
||||||
|
|
||||||
|
// 重置视图当页面切换时 | Reset view when page changes
|
||||||
|
useEffect(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPanOffset({ x: 0, y: 0 });
|
||||||
|
}, [selectedPage]);
|
||||||
|
|
||||||
|
// 预加载所有纹理图像 | Preload all texture images
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPage) return;
|
||||||
|
|
||||||
|
const newImages = new Map<string, HTMLImageElement>();
|
||||||
|
let loadCount = 0;
|
||||||
|
const totalCount = currentPage.entries.filter(e => e.dataUrl).length;
|
||||||
|
|
||||||
|
currentPage.entries.forEach(entry => {
|
||||||
|
if (entry.dataUrl) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.onload = () => {
|
||||||
|
newImages.set(entry.guid, img);
|
||||||
|
loadCount++;
|
||||||
|
if (loadCount === totalCount) {
|
||||||
|
setLoadedImages(new Map(newImages));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
loadCount++;
|
||||||
|
if (loadCount === totalCount) {
|
||||||
|
setLoadedImages(new Map(newImages));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = entry.dataUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果没有图像需要加载,立即设置空 Map
|
||||||
|
if (totalCount === 0) {
|
||||||
|
setLoadedImages(new Map());
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
// 绘制图集预览 | Draw atlas preview
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !currentPage) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio;
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const pageSize = currentPage.width;
|
||||||
|
// 基础缩放:让图集适应画布 | Base scale: fit atlas to canvas
|
||||||
|
const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9;
|
||||||
|
// 应用用户缩放 | Apply user zoom
|
||||||
|
const scale = baseScale * zoom;
|
||||||
|
// 计算中心偏移 + 用户平移 | Calculate center offset + user pan
|
||||||
|
const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x;
|
||||||
|
const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y;
|
||||||
|
|
||||||
|
// 背景 | Background
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||||
|
|
||||||
|
// 棋盘格背景(在图集区域内)| Checkerboard background (inside atlas area)
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(offsetX, offsetY, pageSize * scale, pageSize * scale);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
const checkerSize = Math.max(8, 16 * zoom);
|
||||||
|
for (let cx = 0; cx < pageSize * scale; cx += checkerSize) {
|
||||||
|
for (let cy = 0; cy < pageSize * scale; cy += checkerSize) {
|
||||||
|
const isLight = (Math.floor(cx / checkerSize) + Math.floor(cy / checkerSize)) % 2 === 0;
|
||||||
|
ctx.fillStyle = isLight ? '#2a2a2a' : '#222';
|
||||||
|
ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 绘制图集边框 | Draw atlas border
|
||||||
|
ctx.strokeStyle = '#444';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(offsetX, offsetY, pageSize * scale, pageSize * scale);
|
||||||
|
|
||||||
|
// 绘制每个纹理区域 | Draw each texture region
|
||||||
|
const colors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
||||||
|
currentPage.entries.forEach((entry, idx) => {
|
||||||
|
const x = offsetX + entry.x * scale;
|
||||||
|
const y = offsetY + entry.y * scale;
|
||||||
|
const w = entry.width * scale;
|
||||||
|
const h = entry.height * scale;
|
||||||
|
|
||||||
|
const color = colors[idx % colors.length] ?? '#4a9eff';
|
||||||
|
const isHovered = hoveredEntry?.guid === entry.guid;
|
||||||
|
|
||||||
|
// 尝试绘制图像 | Try to draw image
|
||||||
|
const img = loadedImages.get(entry.guid);
|
||||||
|
if (img) {
|
||||||
|
ctx.drawImage(img, x, y, w, h);
|
||||||
|
} else {
|
||||||
|
// 没有图像时显示占位背景 | Show placeholder when no image
|
||||||
|
ctx.fillStyle = `${color}40`;
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边框 | Border
|
||||||
|
ctx.strokeStyle = isHovered ? '#fff' : (img ? '#333' : color);
|
||||||
|
ctx.lineWidth = isHovered ? 2 : 1;
|
||||||
|
ctx.strokeRect(x, y, w, h);
|
||||||
|
|
||||||
|
// 高亮时显示尺寸标签 | Show size label when hovered
|
||||||
|
if (isHovered || (!img && w > 30 && h > 20)) {
|
||||||
|
// 半透明背景 | Semi-transparent background
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
const labelText = `${entry.width}x${entry.height}`;
|
||||||
|
ctx.font = `${Math.max(10, 10 * zoom)}px Consolas`;
|
||||||
|
const textWidth = ctx.measureText(labelText).width;
|
||||||
|
ctx.fillRect(x + w / 2 - textWidth / 2 - 4, y + h / 2 - 8, textWidth + 8, 16);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(labelText, x + w / 2, y + h / 2 + 4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绘制信息 | Draw info
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.font = '11px system-ui';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`${currentPage.width}x${currentPage.height} | ${(currentPage.occupancy * 100).toFixed(1)}% | Zoom: ${(zoom * 100).toFixed(0)}%`, 8, rect.height - 8);
|
||||||
|
|
||||||
|
}, [currentPage, hoveredEntry, loadedImages, zoom, panOffset]);
|
||||||
|
|
||||||
|
// 鼠标悬停检测和拖动 | Mouse hover detection and dragging
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !currentPage) return;
|
||||||
|
|
||||||
|
// 处理拖动平移 | Handle pan dragging
|
||||||
|
if (isPanning) {
|
||||||
|
const dx = e.clientX - lastMousePos.x;
|
||||||
|
const dy = e.clientY - lastMousePos.y;
|
||||||
|
setPanOffset(prev => ({ x: prev.x + dx, y: prev.y + dy }));
|
||||||
|
setLastMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const pageSize = currentPage.width;
|
||||||
|
const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9;
|
||||||
|
const scale = baseScale * zoom;
|
||||||
|
const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x;
|
||||||
|
const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y;
|
||||||
|
|
||||||
|
// 检查是否悬停在某个条目上 | Check if hovering over an entry
|
||||||
|
let found: AtlasEntryDebugInfo | null = null;
|
||||||
|
for (const entry of currentPage.entries) {
|
||||||
|
const x = offsetX + entry.x * scale;
|
||||||
|
const y = offsetY + entry.y * scale;
|
||||||
|
const w = entry.width * scale;
|
||||||
|
const h = entry.height * scale;
|
||||||
|
|
||||||
|
if (mouseX >= x && mouseX <= x + w && mouseY >= y && mouseY <= y + h) {
|
||||||
|
found = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHoveredEntry(found);
|
||||||
|
}, [currentPage, isPanning, lastMousePos, zoom, panOffset]);
|
||||||
|
|
||||||
|
// 滚轮缩放 | Wheel zoom
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
setZoom(prev => Math.max(0.5, Math.min(10, prev * delta)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 开始拖动 | Start dragging
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (e.button === 0 || e.button === 1) { // 左键或中键 | Left or middle button
|
||||||
|
setIsPanning(true);
|
||||||
|
setLastMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 结束拖动 | End dragging
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsPanning(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 双击重置视图 | Double click to reset view
|
||||||
|
const handleDoubleClick = useCallback(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPanOffset({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="atlas-preview-modal" onClick={onClose}>
|
||||||
|
<div className="atlas-preview-content" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="atlas-preview-header">
|
||||||
|
<span>Dynamic Atlas Preview</span>
|
||||||
|
<button className="window-btn" onClick={onClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 页面选择器 | Page selector */}
|
||||||
|
{atlasStats.pages.length > 1 && (
|
||||||
|
<div className="atlas-page-tabs">
|
||||||
|
{atlasStats.pages.map((page, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className={`atlas-page-tab ${selectedPage === idx ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectPage(idx)}
|
||||||
|
>
|
||||||
|
Page {idx} ({(page.occupancy * 100).toFixed(0)}%)
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图集可视化 | Atlas visualization */}
|
||||||
|
<div className="atlas-preview-canvas-container">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={() => { setHoveredEntry(null); setIsPanning(false); }}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 悬停信息 | Hover info */}
|
||||||
|
<div className="atlas-preview-info">
|
||||||
|
{hoveredEntry ? (
|
||||||
|
<>
|
||||||
|
<div className="atlas-entry-info">
|
||||||
|
<span className="label">GUID:</span>
|
||||||
|
<span className="value">{hoveredEntry.guid.slice(0, 8)}...</span>
|
||||||
|
</div>
|
||||||
|
<div className="atlas-entry-info">
|
||||||
|
<span className="label">Position:</span>
|
||||||
|
<span className="value">({hoveredEntry.x}, {hoveredEntry.y})</span>
|
||||||
|
</div>
|
||||||
|
<div className="atlas-entry-info">
|
||||||
|
<span className="label">Size:</span>
|
||||||
|
<span className="value">{hoveredEntry.width} x {hoveredEntry.height}</span>
|
||||||
|
</div>
|
||||||
|
<div className="atlas-entry-info">
|
||||||
|
<span className="label">UV:</span>
|
||||||
|
<span className="value">[{hoveredEntry.uv.map(v => v.toFixed(3)).join(', ')}]</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="hint">Scroll to zoom, drag to pan, double-click to reset</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 | Statistics */}
|
||||||
|
<div className="atlas-preview-stats">
|
||||||
|
<span>Total: {atlasStats.textureCount} textures in {atlasStats.pageCount} page(s)</span>
|
||||||
|
<span>Avg Occupancy: {(atlasStats.averageOccupancy * 100).toFixed(1)}%</span>
|
||||||
|
{atlasStats.loadingCount > 0 && <span>Loading: {atlasStats.loadingCount}</span>}
|
||||||
|
{atlasStats.failedCount > 0 && <span className="error">Failed: {atlasStats.failedCount}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default RenderDebugPanel;
|
export default RenderDebugPanel;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Entity Reference Field Styles
|
||||||
|
* 实体引用字段样式
|
||||||
|
*
|
||||||
|
* Uses property-field and property-label from PropertyInspector.css for consistency.
|
||||||
|
* 使用 PropertyInspector.css 中的 property-field 和 property-label 以保持一致性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Input container - matches property-input styling */
|
||||||
|
.entity-ref-field__input {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 2px;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-ref-field__input:hover:not(.readonly) {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-ref-field__input.drag-over {
|
||||||
|
border-color: var(--accent-color, #4a9eff);
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-ref-field__input.readonly {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entity name - clickable to navigate */
|
||||||
|
.entity-ref-field__name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
color: #ddd;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-ref-field__name:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--accent-color, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clear button */
|
||||||
|
.entity-ref-field__clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-ref-field__clear:hover {
|
||||||
|
background: rgba(255, 100, 100, 0.2);
|
||||||
|
color: #ff6464;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder text */
|
||||||
|
.entity-ref-field__placeholder {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Entity Reference Field
|
||||||
|
* 实体引用字段
|
||||||
|
*
|
||||||
|
* Allows drag-and-drop of entities from SceneHierarchy.
|
||||||
|
* 支持从场景层级面板拖拽实体。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { useHierarchyStore } from '../../../stores';
|
||||||
|
import './EntityRefField.css';
|
||||||
|
|
||||||
|
export interface EntityRefFieldProps {
|
||||||
|
/** Field label | 字段标签 */
|
||||||
|
label: string;
|
||||||
|
/** Current entity ID (0 = none) | 当前实体 ID (0 = 无) */
|
||||||
|
value: number;
|
||||||
|
/** Value change callback | 值变更回调 */
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
/** Placeholder text | 占位文本 */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Read-only mode | 只读模式 */
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EntityRefField: React.FC<EntityRefFieldProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = '拖拽实体到此处 / Drop entity here',
|
||||||
|
readonly = false
|
||||||
|
}) => {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
// Get entity name for display
|
||||||
|
// 获取实体名称用于显示
|
||||||
|
const getEntityName = useCallback((): string | null => {
|
||||||
|
if (!value || value === 0) return null;
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) return null;
|
||||||
|
const entity = scene.entities.findEntityById(value);
|
||||||
|
return entity?.name || `Entity #${value}`;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const entityName = getEntityName();
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
// Check if dragging an entity
|
||||||
|
// 检查是否拖拽实体
|
||||||
|
if (e.dataTransfer.types.includes('entity-id')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'link';
|
||||||
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
|
}, [readonly]);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const entityIdStr = e.dataTransfer.getData('entity-id');
|
||||||
|
if (entityIdStr) {
|
||||||
|
const entityId = parseInt(entityIdStr, 10);
|
||||||
|
if (!isNaN(entityId) && entityId > 0) {
|
||||||
|
onChange(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [readonly, onChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
if (readonly) return;
|
||||||
|
onChange(0);
|
||||||
|
}, [readonly, onChange]);
|
||||||
|
|
||||||
|
const handleNavigateToEntity = useCallback(() => {
|
||||||
|
if (!value || value === 0) return;
|
||||||
|
|
||||||
|
// Select the referenced entity in SceneHierarchy
|
||||||
|
// 在场景层级面板中选择引用的实体
|
||||||
|
const { setSelectedIds } = useHierarchyStore.getState();
|
||||||
|
setSelectedIds(new Set([value]));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="property-field entity-ref-field">
|
||||||
|
<label className="property-label">{label}</label>
|
||||||
|
<div
|
||||||
|
className={`entity-ref-field__input ${isDragOver ? 'drag-over' : ''} ${readonly ? 'readonly' : ''}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{entityName ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="entity-ref-field__name"
|
||||||
|
onClick={handleNavigateToEntity}
|
||||||
|
title="点击选择此实体 / Click to select this entity"
|
||||||
|
>
|
||||||
|
{entityName}
|
||||||
|
</span>
|
||||||
|
{!readonly && (
|
||||||
|
<button
|
||||||
|
className="entity-ref-field__clear"
|
||||||
|
onClick={handleClear}
|
||||||
|
title="清除引用 / Clear reference"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="entity-ref-field__placeholder">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* Material properties editor component.
|
||||||
|
* 材质属性编辑器组件。
|
||||||
|
*
|
||||||
|
* This component provides a UI for editing shader uniform values
|
||||||
|
* based on shader property metadata.
|
||||||
|
* 此组件提供基于着色器属性元数据编辑着色器 uniform 值的 UI。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Palette } from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
IMaterialOverridable,
|
||||||
|
ShaderPropertyMeta,
|
||||||
|
MaterialPropertyOverride
|
||||||
|
} from '@esengine/material-system';
|
||||||
|
import {
|
||||||
|
BuiltInShaders,
|
||||||
|
getShaderPropertiesById
|
||||||
|
} from '@esengine/material-system';
|
||||||
|
|
||||||
|
// Shader name mapping
|
||||||
|
const SHADER_NAMES: Record<number, string> = {
|
||||||
|
0: 'DefaultSprite',
|
||||||
|
1: 'Grayscale',
|
||||||
|
2: 'Tint',
|
||||||
|
3: 'Flash',
|
||||||
|
4: 'Outline',
|
||||||
|
5: 'Shiny'
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MaterialPropertiesEditorProps {
|
||||||
|
/** Target component implementing IMaterialOverridable */
|
||||||
|
target: IMaterialOverridable;
|
||||||
|
/** Callback when property changes */
|
||||||
|
onChange?: (name: string, value: MaterialPropertyOverride) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material properties editor.
|
||||||
|
* 材质属性编辑器。
|
||||||
|
*/
|
||||||
|
export const MaterialPropertiesEditor: React.FC<MaterialPropertiesEditorProps> = ({
|
||||||
|
target,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Effect', 'Default']));
|
||||||
|
|
||||||
|
const materialId = target.getMaterialId();
|
||||||
|
const shaderName = SHADER_NAMES[materialId] || `Custom(${materialId})`;
|
||||||
|
const properties = getShaderPropertiesById(materialId);
|
||||||
|
|
||||||
|
// Group properties
|
||||||
|
const groupedProps = useMemo(() => {
|
||||||
|
if (!properties) return {};
|
||||||
|
|
||||||
|
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
|
||||||
|
for (const [name, meta] of Object.entries(properties)) {
|
||||||
|
if (meta.hidden) continue;
|
||||||
|
const group = meta.group || 'Default';
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push([name, meta]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [properties]);
|
||||||
|
|
||||||
|
const toggleGroup = (group: string) => {
|
||||||
|
setExpandedGroups(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(group)) {
|
||||||
|
next.delete(group);
|
||||||
|
} else {
|
||||||
|
next.add(group);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (name: string, meta: ShaderPropertyMeta, newValue: number | number[]) => {
|
||||||
|
const override: MaterialPropertyOverride = {
|
||||||
|
type: meta.type === 'texture' ? 'int' : meta.type as MaterialPropertyOverride['type'],
|
||||||
|
value: newValue
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply to target
|
||||||
|
switch (meta.type) {
|
||||||
|
case 'float':
|
||||||
|
target.setOverrideFloat(name, newValue as number);
|
||||||
|
break;
|
||||||
|
case 'int':
|
||||||
|
target.setOverrideInt(name, newValue as number);
|
||||||
|
break;
|
||||||
|
case 'vec2':
|
||||||
|
const v2 = newValue as number[];
|
||||||
|
target.setOverrideVec2(name, v2[0] ?? 0, v2[1] ?? 0);
|
||||||
|
break;
|
||||||
|
case 'vec3':
|
||||||
|
const v3 = newValue as number[];
|
||||||
|
target.setOverrideVec3(name, v3[0] ?? 0, v3[1] ?? 0, v3[2] ?? 0);
|
||||||
|
break;
|
||||||
|
case 'vec4':
|
||||||
|
const v4 = newValue as number[];
|
||||||
|
target.setOverrideVec4(name, v4[0] ?? 0, v4[1] ?? 0, v4[2] ?? 0, v4[3] ?? 0);
|
||||||
|
break;
|
||||||
|
case 'color':
|
||||||
|
const c = newValue as number[];
|
||||||
|
target.setOverrideColor(name, c[0] ?? 1, c[1] ?? 1, c[2] ?? 1, c[3] ?? 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(name, override);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentValue = (name: string, meta: ShaderPropertyMeta): number | number[] => {
|
||||||
|
const override = target.getOverride(name);
|
||||||
|
if (override) {
|
||||||
|
return override.value as number | number[];
|
||||||
|
}
|
||||||
|
return meta.default as number | number[] ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse i18n label
|
||||||
|
const parseLabel = (label: string): string => {
|
||||||
|
// Format: "中文 | English" - for now just return as-is
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="material-properties-editor" style={{ fontSize: '12px' }}>
|
||||||
|
{/* Shader selector */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: '#3a3a3a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
<Palette size={14} style={{ marginRight: '8px', color: '#888' }} />
|
||||||
|
<span style={{ color: '#aaa', marginRight: '8px' }}>Shader:</span>
|
||||||
|
<select
|
||||||
|
value={materialId}
|
||||||
|
onChange={(e) => target.setMaterialId(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
border: '1px solid #4a4a4a',
|
||||||
|
borderRadius: '3px',
|
||||||
|
padding: '3px 6px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={0}>DefaultSprite</option>
|
||||||
|
<option value={1}>Grayscale</option>
|
||||||
|
<option value={2}>Tint</option>
|
||||||
|
<option value={3}>Flash</option>
|
||||||
|
<option value={4}>Outline</option>
|
||||||
|
<option value={5}>Shiny</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property groups */}
|
||||||
|
{Object.entries(groupedProps).map(([group, props]) => (
|
||||||
|
<div key={group} style={{ marginBottom: '4px' }}>
|
||||||
|
{/* Group header */}
|
||||||
|
<div
|
||||||
|
onClick={() => toggleGroup(group)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: '#333',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedGroups.has(group) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
<span style={{ marginLeft: '4px', color: '#aaa', fontWeight: 500 }}>{group}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
{expandedGroups.has(group) && (
|
||||||
|
<div style={{ padding: '4px 8px' }}>
|
||||||
|
{props.map(([name, meta]) => (
|
||||||
|
<PropertyEditor
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
meta={meta}
|
||||||
|
value={getCurrentValue(name, meta)}
|
||||||
|
onChange={(v) => handleChange(name, meta, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!properties && (
|
||||||
|
<div style={{ color: '#666', padding: '8px', fontStyle: 'italic' }}>
|
||||||
|
No editable properties for {shaderName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PropertyEditorProps {
|
||||||
|
name: string;
|
||||||
|
meta: ShaderPropertyMeta;
|
||||||
|
value: number | number[];
|
||||||
|
onChange: (value: number | number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual property editor.
|
||||||
|
* 单个属性编辑器。
|
||||||
|
*/
|
||||||
|
const PropertyEditor: React.FC<PropertyEditorProps> = ({ name, meta, value, onChange }) => {
|
||||||
|
const displayName = name.replace(/^u_/, '');
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
border: '1px solid #4a4a4a',
|
||||||
|
borderRadius: '3px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
width: '60px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInput = () => {
|
||||||
|
switch (meta.type) {
|
||||||
|
case 'float':
|
||||||
|
case 'int':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={typeof value === 'number' ? value : 0}
|
||||||
|
min={meta.min}
|
||||||
|
max={meta.max}
|
||||||
|
step={meta.step ?? (meta.type === 'int' ? 1 : 0.01)}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vec2':
|
||||||
|
const v2 = Array.isArray(value) ? value : [0, 0];
|
||||||
|
const v2x = v2[0] ?? 0;
|
||||||
|
const v2y = v2[1] ?? 0;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={v2x}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(e) => onChange([parseFloat(e.target.value) || 0, v2y])}
|
||||||
|
style={{ ...inputStyle, width: '50px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={v2y}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(e) => onChange([v2x, parseFloat(e.target.value) || 0])}
|
||||||
|
style={{ ...inputStyle, width: '50px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vec3':
|
||||||
|
const v3 = Array.isArray(value) ? value : [0, 0, 0];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
type="number"
|
||||||
|
value={v3[i]}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newVal = [...v3];
|
||||||
|
newVal[i] = parseFloat(e.target.value) || 0;
|
||||||
|
onChange(newVal);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, width: '40px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vec4':
|
||||||
|
const v4 = Array.isArray(value) ? value : [0, 0, 0, 0];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
type="number"
|
||||||
|
value={v4[i]}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newVal = [...v4];
|
||||||
|
newVal[i] = parseFloat(e.target.value) || 0;
|
||||||
|
onChange(newVal);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, width: '35px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'color':
|
||||||
|
const c = Array.isArray(value) ? value : [1, 1, 1, 1];
|
||||||
|
const cr = c[0] ?? 1;
|
||||||
|
const cg = c[1] ?? 1;
|
||||||
|
const cb = c[2] ?? 1;
|
||||||
|
const ca = c[3] ?? 1;
|
||||||
|
const hexColor = `#${Math.round(cr * 255).toString(16).padStart(2, '0')}${Math.round(cg * 255).toString(16).padStart(2, '0')}${Math.round(cb * 255).toString(16).padStart(2, '0')}`;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hexColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const hex = e.target.value;
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
onChange([r, g, b, ca]);
|
||||||
|
}}
|
||||||
|
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ca}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(e) => onChange([cr, cg, cb, parseFloat(e.target.value) || 1])}
|
||||||
|
style={{ ...inputStyle, width: '40px' }}
|
||||||
|
title="Alpha"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <span style={{ color: '#666' }}>Unsupported type: {meta.type}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '3px 0',
|
||||||
|
borderBottom: '1px solid #333'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#aaa' }} title={meta.tooltip}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialPropertiesEditor;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Material Inspector components.
|
||||||
|
* 材质 Inspector 组件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { MaterialPropertiesEditor } from './MaterialPropertiesEditor';
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
|
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { Core } from '@esengine/ecs-framework';
|
import { Core } from '@esengine/ecs-framework';
|
||||||
import { AssetRegistryService } from '@esengine/editor-core';
|
import { AssetRegistryService, MessageHub } from '@esengine/editor-core';
|
||||||
import type { ISpriteSettings } from '@esengine/asset-system-editor';
|
import type { ISpriteSettings } from '@esengine/asset-system-editor';
|
||||||
import { EngineService } from '../../../services/EngineService';
|
import { EngineService } from '../../../services/EngineService';
|
||||||
import { AssetFileInfo } from '../types';
|
import { AssetFileInfo } from '../types';
|
||||||
@@ -315,6 +315,18 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
|||||||
|
|
||||||
setSpriteSettings(newSettings);
|
setSpriteSettings(newSettings);
|
||||||
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
|
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
|
||||||
|
|
||||||
|
// 通知 EngineService 同步资产数据库(以便渲染系统获取最新的九宫格设置)
|
||||||
|
// Notify EngineService to sync asset database (so render systems get latest sprite settings)
|
||||||
|
const messageHub = Core.services.tryResolve(MessageHub);
|
||||||
|
if (messageHub) {
|
||||||
|
messageHub.publish('assets:changed', {
|
||||||
|
type: 'modify',
|
||||||
|
path: fileInfo.path,
|
||||||
|
relativePath: assetRegistry.absoluteToRelative(fileInfo.path) || fileInfo.path,
|
||||||
|
guid: meta.guid
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update sprite settings:', error);
|
console.error('Failed to update sprite settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface UseStoreSubscriptionsOptions {
|
|||||||
entityStore: EntityStoreService | null;
|
entityStore: EntityStoreService | null;
|
||||||
sceneManager: SceneManagerService | null;
|
sceneManager: SceneManagerService | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** 是否处于 Play 模式 | Whether in play mode */
|
||||||
|
isPlaying?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,8 +37,10 @@ export function useStoreSubscriptions({
|
|||||||
entityStore,
|
entityStore,
|
||||||
sceneManager,
|
sceneManager,
|
||||||
enabled,
|
enabled,
|
||||||
|
isPlaying = false,
|
||||||
}: UseStoreSubscriptionsOptions): void {
|
}: UseStoreSubscriptionsOptions): void {
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
|
const lastEntityCountRef = useRef(0);
|
||||||
|
|
||||||
// ===== HierarchyStore 订阅 | HierarchyStore subscriptions =====
|
// ===== HierarchyStore 订阅 | HierarchyStore subscriptions =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,9 +72,38 @@ export function useStoreSubscriptions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 处理实体选择 | Handle entity selection
|
// 处理实体选择 | Handle entity selection
|
||||||
|
// Also expand parent nodes so selected entity is visible
|
||||||
|
// 同时展开父节点以便选中的实体可见
|
||||||
const handleEntitySelection = (data: { entity: { id: number } | null }) => {
|
const handleEntitySelection = (data: { entity: { id: number } | null }) => {
|
||||||
if (data.entity) {
|
if (data.entity) {
|
||||||
setSelectedIds(new Set([data.entity.id]));
|
setSelectedIds(new Set([data.entity.id]));
|
||||||
|
|
||||||
|
// Expand all ancestor nodes | 展开所有祖先节点
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (scene) {
|
||||||
|
const entity = scene.entities.findEntityById(data.entity.id);
|
||||||
|
if (entity) {
|
||||||
|
const ancestorIds: number[] = [];
|
||||||
|
// Use HierarchyComponent to get parent chain
|
||||||
|
// 使用 HierarchyComponent 获取父节点链
|
||||||
|
let currentEntity = entity;
|
||||||
|
let hierarchy = currentEntity.getComponent(HierarchyComponent);
|
||||||
|
while (hierarchy?.parentId != null) {
|
||||||
|
ancestorIds.push(hierarchy.parentId);
|
||||||
|
const parentEntity = scene.entities.findEntityById(hierarchy.parentId);
|
||||||
|
if (!parentEntity) break;
|
||||||
|
currentEntity = parentEntity;
|
||||||
|
hierarchy = currentEntity.getComponent(HierarchyComponent);
|
||||||
|
}
|
||||||
|
if (ancestorIds.length > 0) {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
ancestorIds.forEach(id => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}
|
}
|
||||||
@@ -129,7 +162,25 @@ export function useStoreSubscriptions({
|
|||||||
});
|
});
|
||||||
const unsubSaved = messageHub.subscribe('scene:saved', updateSceneInfo);
|
const unsubSaved = messageHub.subscribe('scene:saved', updateSceneInfo);
|
||||||
const unsubModified = messageHub.subscribe('scene:modified', updateSceneInfo);
|
const unsubModified = messageHub.subscribe('scene:modified', updateSceneInfo);
|
||||||
const unsubRestored = messageHub.subscribe('scene:restored', updateEntities);
|
// scene:restored 在 Stop 时触发,需要同时更新场景信息和实体列表
|
||||||
|
// scene:restored is triggered on Stop, needs to update both scene info and entities
|
||||||
|
const unsubRestored = messageHub.subscribe('scene:restored', () => {
|
||||||
|
updateSceneInfo();
|
||||||
|
updateEntities();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅运行时场景切换事件(Play 模式下的场景切换)
|
||||||
|
// Subscribe to runtime scene change event (scene switching in Play mode)
|
||||||
|
const unsubRuntimeSceneChanged = messageHub.subscribe('runtime:scene:changed', (data: any) => {
|
||||||
|
if (data.sceneName) {
|
||||||
|
setSceneInfo({
|
||||||
|
sceneName: `[Play] ${data.sceneName}`,
|
||||||
|
sceneFilePath: data.path || null,
|
||||||
|
isModified: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateEntities();
|
||||||
|
});
|
||||||
|
|
||||||
// 订阅实体事件 | Subscribe to entity events
|
// 订阅实体事件 | Subscribe to entity events
|
||||||
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
|
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
|
||||||
@@ -150,6 +201,7 @@ export function useStoreSubscriptions({
|
|||||||
unsubSaved();
|
unsubSaved();
|
||||||
unsubModified();
|
unsubModified();
|
||||||
unsubRestored();
|
unsubRestored();
|
||||||
|
unsubRuntimeSceneChanged();
|
||||||
unsubAdd();
|
unsubAdd();
|
||||||
unsubRemove();
|
unsubRemove();
|
||||||
unsubClear();
|
unsubClear();
|
||||||
@@ -348,4 +400,43 @@ export function useStoreSubscriptions({
|
|||||||
unsubPropertyChanged();
|
unsubPropertyChanged();
|
||||||
};
|
};
|
||||||
}, [enabled, messageHub]);
|
}, [enabled, messageHub]);
|
||||||
|
|
||||||
|
// ===== Play 模式实时同步 | Play mode real-time sync =====
|
||||||
|
// 在 Play 模式下定期检查场景实体变化,同步到层级面板
|
||||||
|
// Periodically check scene entity changes in play mode and sync to hierarchy panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !entityStore || !isPlaying) return;
|
||||||
|
|
||||||
|
const { setEntities } = useHierarchyStore.getState();
|
||||||
|
|
||||||
|
// 同步实体列表(检查是否有变化)
|
||||||
|
// Sync entity list (check for changes)
|
||||||
|
const syncEntities = () => {
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
const currentCount = scene.entities.count;
|
||||||
|
|
||||||
|
// 只有实体数量变化时才同步(性能优化)
|
||||||
|
// Only sync when entity count changes (performance optimization)
|
||||||
|
if (currentCount !== lastEntityCountRef.current) {
|
||||||
|
lastEntityCountRef.current = currentCount;
|
||||||
|
entityStore.syncFromScene();
|
||||||
|
setEntities([...entityStore.getRootEntities()]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每 500ms 检查一次(Play 模式下足够实时)
|
||||||
|
// Check every 500ms (real-time enough for play mode)
|
||||||
|
const intervalId = setInterval(syncEntities, 500);
|
||||||
|
|
||||||
|
// 立即同步一次
|
||||||
|
// Sync immediately
|
||||||
|
syncEntities();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
lastEntityCountRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [enabled, entityStore, isPlaying]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import i18n from 'i18next';
|
|
||||||
import { initReactI18next } from 'react-i18next';
|
|
||||||
import zh from './locales/zh.json';
|
|
||||||
import en from './locales/en.json';
|
|
||||||
|
|
||||||
i18n
|
|
||||||
.use(initReactI18next)
|
|
||||||
.init({
|
|
||||||
resources: {
|
|
||||||
zh: { translation: zh },
|
|
||||||
en: { translation: en }
|
|
||||||
},
|
|
||||||
lng: 'zh',
|
|
||||||
fallbackLng: 'en',
|
|
||||||
interpolation: {
|
|
||||||
escapeValue: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default i18n;
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
{
|
|
||||||
"hierarchy": {
|
|
||||||
"visibility": "Toggle Visibility",
|
|
||||||
"hideEntity": "Hide Entity",
|
|
||||||
"showEntity": "Show Entity",
|
|
||||||
"emptyHint": "No entities in scene"
|
|
||||||
},
|
|
||||||
"behaviorTree": {
|
|
||||||
"title": "Behavior Tree Editor",
|
|
||||||
"close": "Close",
|
|
||||||
"nodePalette": "Node Palette",
|
|
||||||
"properties": "Properties",
|
|
||||||
"blackboard": "Blackboard",
|
|
||||||
"noNodeSelected": "No node selected",
|
|
||||||
"noConfigurableProperties": "This node has no configurable properties",
|
|
||||||
"apply": "Apply",
|
|
||||||
"reset": "Reset",
|
|
||||||
"addVariable": "Add Variable",
|
|
||||||
"variableName": "Variable Name",
|
|
||||||
"type": "Type",
|
|
||||||
"value": "Value",
|
|
||||||
"defaultGroup": "Default Group",
|
|
||||||
"rootNode": "Root Node",
|
|
||||||
"rootNodeOnlyOneChild": "Root node can only connect to one child",
|
|
||||||
"dragToCreate": "Drag nodes from the left to below the root node to start creating behavior tree",
|
|
||||||
"connectFirst": "Connect the root node with the first node first",
|
|
||||||
"nodeCount": "Nodes",
|
|
||||||
"noSelection": "No selection",
|
|
||||||
"selectedCount": "{{count}} nodes selected",
|
|
||||||
"idle": "Idle",
|
|
||||||
"running": "Running",
|
|
||||||
"paused": "Paused",
|
|
||||||
"step": "Step",
|
|
||||||
"run": "Run",
|
|
||||||
"pause": "Pause",
|
|
||||||
"resume": "Resume",
|
|
||||||
"stop": "Stop",
|
|
||||||
"stepExecution": "Step Execution",
|
|
||||||
"resetExecution": "Reset",
|
|
||||||
"clear": "Clear",
|
|
||||||
"resetView": "Reset View",
|
|
||||||
"tick": "Tick",
|
|
||||||
"executing": "Executing",
|
|
||||||
"success": "Success",
|
|
||||||
"failure": "Failure",
|
|
||||||
"startingExecution": "Starting execution from root...",
|
|
||||||
"tickNumber": "Tick {{tick}}",
|
|
||||||
"executionStopped": "Execution stopped after {{tick}} ticks",
|
|
||||||
"executionPaused": "Execution paused",
|
|
||||||
"executionResumed": "Execution resumed",
|
|
||||||
"resetToInitial": "Reset to initial state",
|
|
||||||
"currentValue": "Current Value"
|
|
||||||
},
|
|
||||||
"components": {
|
|
||||||
"category": {
|
|
||||||
"core": "Core",
|
|
||||||
"rendering": "Rendering",
|
|
||||||
"physics": "Physics",
|
|
||||||
"audio": "Audio",
|
|
||||||
"tilemap": "Tilemap"
|
|
||||||
},
|
|
||||||
"material": {
|
|
||||||
"name": "Material",
|
|
||||||
"description": "Custom material and shader component"
|
|
||||||
},
|
|
||||||
"transform": {
|
|
||||||
"description": "Transform - Position, Rotation, Scale"
|
|
||||||
},
|
|
||||||
"sprite": {
|
|
||||||
"description": "Sprite - 2D Image Rendering"
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"description": "Text - Text Rendering"
|
|
||||||
},
|
|
||||||
"camera": {
|
|
||||||
"description": "Camera - View Control"
|
|
||||||
},
|
|
||||||
"rigidBody": {
|
|
||||||
"description": "RigidBody - Physics Simulation"
|
|
||||||
},
|
|
||||||
"boxCollider": {
|
|
||||||
"description": "Box Collider"
|
|
||||||
},
|
|
||||||
"circleCollider": {
|
|
||||||
"description": "Circle Collider"
|
|
||||||
},
|
|
||||||
"audioSource": {
|
|
||||||
"description": "Audio Source"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"file": {
|
|
||||||
"create": {
|
|
||||||
"material": "Material",
|
|
||||||
"shader": "Shader"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entity": {
|
|
||||||
"create": {
|
|
||||||
"materialEntity": "Material Entity"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
{
|
|
||||||
"hierarchy": {
|
|
||||||
"visibility": "切换可见性",
|
|
||||||
"hideEntity": "隐藏实体",
|
|
||||||
"showEntity": "显示实体",
|
|
||||||
"emptyHint": "场景中没有实体"
|
|
||||||
},
|
|
||||||
"behaviorTree": {
|
|
||||||
"title": "行为树编辑器",
|
|
||||||
"close": "关闭",
|
|
||||||
"nodePalette": "节点面板",
|
|
||||||
"properties": "属性",
|
|
||||||
"blackboard": "黑板",
|
|
||||||
"noNodeSelected": "未选择节点",
|
|
||||||
"noConfigurableProperties": "此节点没有可配置的属性",
|
|
||||||
"apply": "应用",
|
|
||||||
"reset": "重置",
|
|
||||||
"addVariable": "添加变量",
|
|
||||||
"variableName": "变量名",
|
|
||||||
"type": "类型",
|
|
||||||
"value": "值",
|
|
||||||
"defaultGroup": "默认分组",
|
|
||||||
"rootNode": "根节点",
|
|
||||||
"rootNodeOnlyOneChild": "根节点只能连接一个子节点",
|
|
||||||
"dragToCreate": "从左侧拖拽节点到根节点下方开始创建行为树",
|
|
||||||
"connectFirst": "先连接根节点与第一个节点",
|
|
||||||
"nodeCount": "节点数",
|
|
||||||
"noSelection": "未选择节点",
|
|
||||||
"selectedCount": "已选择 {{count}} 个节点",
|
|
||||||
"idle": "空闲",
|
|
||||||
"running": "运行中",
|
|
||||||
"paused": "已暂停",
|
|
||||||
"step": "单步",
|
|
||||||
"run": "运行",
|
|
||||||
"pause": "暂停",
|
|
||||||
"resume": "继续",
|
|
||||||
"stop": "停止",
|
|
||||||
"stepExecution": "单步执行",
|
|
||||||
"resetExecution": "重置",
|
|
||||||
"clear": "清空",
|
|
||||||
"resetView": "重置视图",
|
|
||||||
"tick": "帧",
|
|
||||||
"executing": "执行中",
|
|
||||||
"success": "成功",
|
|
||||||
"failure": "失败",
|
|
||||||
"startingExecution": "从根节点开始执行...",
|
|
||||||
"tickNumber": "第 {{tick}} 帧",
|
|
||||||
"executionStopped": "执行停止,共 {{tick}} 帧",
|
|
||||||
"executionPaused": "执行已暂停",
|
|
||||||
"executionResumed": "执行已恢复",
|
|
||||||
"resetToInitial": "重置到初始状态",
|
|
||||||
"currentValue": "当前值"
|
|
||||||
},
|
|
||||||
"components": {
|
|
||||||
"category": {
|
|
||||||
"core": "基础",
|
|
||||||
"rendering": "渲染",
|
|
||||||
"physics": "物理",
|
|
||||||
"audio": "音频",
|
|
||||||
"tilemap": "瓦片地图"
|
|
||||||
},
|
|
||||||
"material": {
|
|
||||||
"name": "材质",
|
|
||||||
"description": "自定义材质和着色器组件"
|
|
||||||
},
|
|
||||||
"transform": {
|
|
||||||
"description": "变换组件 - 位置、旋转、缩放"
|
|
||||||
},
|
|
||||||
"sprite": {
|
|
||||||
"description": "精灵组件 - 2D图像渲染"
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"description": "文本组件 - 文本渲染"
|
|
||||||
},
|
|
||||||
"camera": {
|
|
||||||
"description": "相机组件 - 视图控制"
|
|
||||||
},
|
|
||||||
"rigidBody": {
|
|
||||||
"description": "刚体组件 - 物理模拟"
|
|
||||||
},
|
|
||||||
"boxCollider": {
|
|
||||||
"description": "盒型碰撞器"
|
|
||||||
},
|
|
||||||
"circleCollider": {
|
|
||||||
"description": "圆形碰撞器"
|
|
||||||
},
|
|
||||||
"audioSource": {
|
|
||||||
"description": "音频源组件"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"file": {
|
|
||||||
"create": {
|
|
||||||
"material": "材质",
|
|
||||||
"shader": "着色器"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entity": {
|
|
||||||
"create": {
|
|
||||||
"materialEntity": "材质实体"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Entity Reference Field Editor
|
||||||
|
* 实体引用字段编辑器
|
||||||
|
*
|
||||||
|
* Handles editing of entity reference fields with drag-and-drop support.
|
||||||
|
* 处理实体引用字段的编辑,支持拖放操作。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||||
|
import { EntityRefField } from '../../components/inspectors/fields/EntityRefField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field editor for entity references (entity IDs)
|
||||||
|
* 实体引用(实体 ID)的字段编辑器
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - Drag-and-drop entities from SceneHierarchy
|
||||||
|
* - Click to navigate to referenced entity
|
||||||
|
* - Clear button to remove reference
|
||||||
|
*
|
||||||
|
* 支持:
|
||||||
|
* - 从场景层级面板拖放实体
|
||||||
|
* - 点击导航到引用的实体
|
||||||
|
* - 清除按钮移除引用
|
||||||
|
*/
|
||||||
|
export class EntityRefFieldEditor implements IFieldEditor<number> {
|
||||||
|
readonly type = 'entityRef';
|
||||||
|
readonly name = 'Entity Reference Field Editor';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this editor can handle the given field type
|
||||||
|
* 检查此编辑器是否可以处理给定的字段类型
|
||||||
|
*/
|
||||||
|
canHandle(fieldType: string): boolean {
|
||||||
|
return fieldType === 'entityRef' ||
|
||||||
|
fieldType === 'entityReference' ||
|
||||||
|
fieldType === 'EntityRef' ||
|
||||||
|
fieldType.endsWith('EntityId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the entity reference field
|
||||||
|
* 渲染实体引用字段
|
||||||
|
*/
|
||||||
|
render({ label, value, onChange, context }: FieldEditorProps<number>): React.ReactElement {
|
||||||
|
const placeholder = context.metadata?.placeholder || '拖拽实体到此处 / Drop entity here';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityRefField
|
||||||
|
label={label}
|
||||||
|
value={value ?? 0}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readonly={context.readonly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from './AssetFieldEditor';
|
|||||||
export * from './VectorFieldEditors';
|
export * from './VectorFieldEditors';
|
||||||
export * from './ColorFieldEditor';
|
export * from './ColorFieldEditor';
|
||||||
export * from './AnimationClipsFieldEditor';
|
export * from './AnimationClipsFieldEditor';
|
||||||
|
export * from './EntityRefFieldEditor';
|
||||||
|
|||||||
@@ -1223,6 +1223,32 @@ export const en: Translations = {
|
|||||||
label: 'Module List',
|
label: 'Module List',
|
||||||
description: 'Uncheck modules you do not need. Core modules cannot be disabled. New modules are enabled by default.'
|
description: 'Uncheck modules you do not need. Core modules cannot be disabled. New modules are enabled by default.'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dynamicAtlas: {
|
||||||
|
title: 'Dynamic Atlas',
|
||||||
|
description: 'Runtime atlas configuration for UI batching optimization',
|
||||||
|
enabled: {
|
||||||
|
label: 'Enable Dynamic Atlas',
|
||||||
|
description: 'Enable runtime dynamic atlas to reduce Draw Calls'
|
||||||
|
},
|
||||||
|
expansionStrategy: {
|
||||||
|
label: 'Expansion Strategy',
|
||||||
|
description: 'Choose how the atlas expands',
|
||||||
|
fixed: 'Fixed Size (No rebuild cost)',
|
||||||
|
dynamic: 'Dynamic Expansion (Better memory efficiency)'
|
||||||
|
},
|
||||||
|
fixedPageSize: {
|
||||||
|
label: 'Page Size',
|
||||||
|
description: 'Size of each atlas page in fixed mode'
|
||||||
|
},
|
||||||
|
maxPages: {
|
||||||
|
label: 'Max Pages',
|
||||||
|
description: 'Maximum number of atlas pages allowed'
|
||||||
|
},
|
||||||
|
maxTextureSize: {
|
||||||
|
label: 'Max Texture Size',
|
||||||
|
description: 'Maximum size of individual textures that can be added to the atlas'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1139,6 +1139,32 @@ export const es: Translations = {
|
|||||||
label: 'Lista de Módulos',
|
label: 'Lista de Módulos',
|
||||||
description: 'Desmarcar módulos que no necesitas. Los módulos principales no se pueden deshabilitar. Los nuevos módulos se habilitan por defecto.'
|
description: 'Desmarcar módulos que no necesitas. Los módulos principales no se pueden deshabilitar. Los nuevos módulos se habilitan por defecto.'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dynamicAtlas: {
|
||||||
|
title: 'Atlas Dinámico',
|
||||||
|
description: 'Configuración de atlas en tiempo de ejecución para optimización de batching de UI',
|
||||||
|
enabled: {
|
||||||
|
label: 'Habilitar Atlas Dinámico',
|
||||||
|
description: 'Habilitar atlas dinámico en tiempo de ejecución para reducir Draw Calls'
|
||||||
|
},
|
||||||
|
expansionStrategy: {
|
||||||
|
label: 'Estrategia de Expansión',
|
||||||
|
description: 'Elegir cómo se expande el atlas',
|
||||||
|
fixed: 'Tamaño Fijo (Sin costo de reconstrucción)',
|
||||||
|
dynamic: 'Expansión Dinámica (Mejor eficiencia de memoria)'
|
||||||
|
},
|
||||||
|
fixedPageSize: {
|
||||||
|
label: 'Tamaño de Página',
|
||||||
|
description: 'Tamaño de cada página del atlas en modo fijo'
|
||||||
|
},
|
||||||
|
maxPages: {
|
||||||
|
label: 'Páginas Máximas',
|
||||||
|
description: 'Número máximo de páginas de atlas permitidas'
|
||||||
|
},
|
||||||
|
maxTextureSize: {
|
||||||
|
label: 'Tamaño Máximo de Textura',
|
||||||
|
description: 'Tamaño máximo de texturas individuales que pueden añadirse al atlas'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1223,6 +1223,32 @@ export const zh: Translations = {
|
|||||||
label: '模块列表',
|
label: '模块列表',
|
||||||
description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。'
|
description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dynamicAtlas: {
|
||||||
|
title: '动态图集',
|
||||||
|
description: '运行时图集配置,用于 UI 合批优化',
|
||||||
|
enabled: {
|
||||||
|
label: '启用动态图集',
|
||||||
|
description: '启用运行时动态图集以减少 Draw Call'
|
||||||
|
},
|
||||||
|
expansionStrategy: {
|
||||||
|
label: '扩展策略',
|
||||||
|
description: '选择图集的扩展方式',
|
||||||
|
fixed: '固定大小(无重建开销)',
|
||||||
|
dynamic: '动态扩展(内存效率更高)'
|
||||||
|
},
|
||||||
|
fixedPageSize: {
|
||||||
|
label: '页面大小',
|
||||||
|
description: '固定模式下每个图集页面的大小'
|
||||||
|
},
|
||||||
|
maxPages: {
|
||||||
|
label: '最大页数',
|
||||||
|
description: '允许的最大图集页面数量'
|
||||||
|
},
|
||||||
|
maxTextureSize: {
|
||||||
|
label: '最大纹理尺寸',
|
||||||
|
description: '可加入图集的最大单个纹理尺寸,超过此尺寸的纹理将不会被合批'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import './i18n/config';
|
|
||||||
|
|
||||||
// Set log level to Warn in production to reduce console noise
|
// Set log level to Warn in production to reduce console noise
|
||||||
setGlobalLogLevel(LogLevel.Warn);
|
setGlobalLogLevel(LogLevel.Warn);
|
||||||
|
|||||||
@@ -136,6 +136,74 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
|||||||
}
|
}
|
||||||
} as any // Cast to any to allow custom props
|
} as any // Cast to any to allow custom props
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dynamic-atlas',
|
||||||
|
title: '$pluginSettings.project.dynamicAtlas.title',
|
||||||
|
description: '$pluginSettings.project.dynamicAtlas.description',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'project.dynamicAtlas.enabled',
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.enabled.label',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: true,
|
||||||
|
description: '$pluginSettings.project.dynamicAtlas.enabled.description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project.dynamicAtlas.expansionStrategy',
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.label',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'fixed',
|
||||||
|
description: '$pluginSettings.project.dynamicAtlas.expansionStrategy.description',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.fixed',
|
||||||
|
value: 'fixed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.dynamic',
|
||||||
|
value: 'dynamic'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project.dynamicAtlas.fixedPageSize',
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.fixedPageSize.label',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 1024,
|
||||||
|
description: '$pluginSettings.project.dynamicAtlas.fixedPageSize.description',
|
||||||
|
options: [
|
||||||
|
{ label: '512 x 512', value: 512 },
|
||||||
|
{ label: '1024 x 1024', value: 1024 },
|
||||||
|
{ label: '2048 x 2048', value: 2048 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project.dynamicAtlas.maxPages',
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.maxPages.label',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 4,
|
||||||
|
description: '$pluginSettings.project.dynamicAtlas.maxPages.description',
|
||||||
|
options: [
|
||||||
|
{ label: '1', value: 1 },
|
||||||
|
{ label: '2', value: 2 },
|
||||||
|
{ label: '4', value: 4 },
|
||||||
|
{ label: '8', value: 8 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project.dynamicAtlas.maxTextureSize',
|
||||||
|
label: '$pluginSettings.project.dynamicAtlas.maxTextureSize.label',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 512,
|
||||||
|
description: '$pluginSettings.project.dynamicAtlas.maxTextureSize.description',
|
||||||
|
options: [
|
||||||
|
{ label: '256 x 256', value: 256 },
|
||||||
|
{ label: '512 x 512', value: 512 },
|
||||||
|
{ label: '1024 x 1024', value: 1024 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -172,11 +240,35 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
|||||||
logger.info('UI design resolution changed, applying...');
|
logger.info('UI design resolution changed, applying...');
|
||||||
this.applyUIDesignResolution();
|
this.applyUIDesignResolution();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if dynamic atlas settings changed
|
||||||
|
// 检查动态图集设置是否更改
|
||||||
|
if ('project.dynamicAtlas.enabled' in changedSettings ||
|
||||||
|
'project.dynamicAtlas.expansionStrategy' in changedSettings ||
|
||||||
|
'project.dynamicAtlas.fixedPageSize' in changedSettings ||
|
||||||
|
'project.dynamicAtlas.maxPages' in changedSettings ||
|
||||||
|
'project.dynamicAtlas.maxTextureSize' in changedSettings) {
|
||||||
|
|
||||||
|
logger.info('Dynamic atlas settings changed, reinitializing...');
|
||||||
|
this.applyDynamicAtlasSettings();
|
||||||
|
}
|
||||||
}) as EventListener;
|
}) as EventListener;
|
||||||
|
|
||||||
window.addEventListener('settings:changed', this.settingsListener);
|
window.addEventListener('settings:changed', this.settingsListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply dynamic atlas settings
|
||||||
|
* 应用动态图集设置
|
||||||
|
*/
|
||||||
|
private applyDynamicAtlasSettings(): void {
|
||||||
|
const engineService = EngineService.getInstance();
|
||||||
|
if (engineService.isInitialized()) {
|
||||||
|
engineService.reinitializeDynamicAtlas();
|
||||||
|
logger.info('Dynamic atlas settings applied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply UI design resolution from ProjectService
|
* Apply UI design resolution from ProjectService
|
||||||
* 从 ProjectService 应用 UI 设计分辨率
|
* 从 ProjectService 应用 UI 设计分辨率
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Editor Asset File Loader
|
||||||
|
* 编辑器资产文件加载器
|
||||||
|
*
|
||||||
|
* Platform-specific implementation of IAssetFileLoader for Tauri editor.
|
||||||
|
* Combines path resolution with TauriAssetReader for unified asset loading.
|
||||||
|
* Tauri 编辑器的 IAssetFileLoader 平台特定实现。
|
||||||
|
* 结合路径解析和 TauriAssetReader 实现统一的资产加载。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAssetFileLoader, IAssetReader } from '@esengine/asset-system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for EditorAssetFileLoader.
|
||||||
|
* EditorAssetFileLoader 配置。
|
||||||
|
*/
|
||||||
|
export interface EditorAssetFileLoaderConfig {
|
||||||
|
/**
|
||||||
|
* Function to get current project path.
|
||||||
|
* 获取当前项目路径的函数。
|
||||||
|
*/
|
||||||
|
getProjectPath: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor asset file loader implementation.
|
||||||
|
* 编辑器资产文件加载器实现。
|
||||||
|
*
|
||||||
|
* This loader combines:
|
||||||
|
* - Path resolution: converts relative asset paths to absolute paths
|
||||||
|
* - Platform reading: uses IAssetReader (TauriAssetReader) for actual file loading
|
||||||
|
*
|
||||||
|
* 此加载器结合:
|
||||||
|
* - 路径解析:将相对资产路径转换为绝对路径
|
||||||
|
* - 平台读取:使用 IAssetReader (TauriAssetReader) 进行实际文件加载
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const loader = new EditorAssetFileLoader(assetReader, {
|
||||||
|
* getProjectPath: () => projectService.getCurrentProject()?.path
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Load from relative asset path
|
||||||
|
* const image = await loader.loadImage('assets/demo/button.png');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class EditorAssetFileLoader implements IAssetFileLoader {
|
||||||
|
/**
|
||||||
|
* Create a new editor asset file loader.
|
||||||
|
* 创建新的编辑器资产文件加载器。
|
||||||
|
*
|
||||||
|
* @param assetReader - Platform-specific asset reader (e.g., TauriAssetReader).
|
||||||
|
* 平台特定的资产读取器。
|
||||||
|
* @param config - Loader configuration. | 加载器配置。
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly assetReader: IAssetReader,
|
||||||
|
private readonly config: EditorAssetFileLoaderConfig
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image from asset path.
|
||||||
|
* 从资产路径加载图片。
|
||||||
|
*/
|
||||||
|
async loadImage(assetPath: string): Promise<HTMLImageElement> {
|
||||||
|
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||||
|
return this.assetReader.loadImage(absolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load text content from asset path.
|
||||||
|
* 从资产路径加载文本内容。
|
||||||
|
*/
|
||||||
|
async loadText(assetPath: string): Promise<string> {
|
||||||
|
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||||
|
return this.assetReader.readText(absolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load binary data from asset path.
|
||||||
|
* 从资产路径加载二进制数据。
|
||||||
|
*/
|
||||||
|
async loadBinary(assetPath: string): Promise<ArrayBuffer> {
|
||||||
|
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||||
|
return this.assetReader.readBinary(absolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if asset file exists.
|
||||||
|
* 检查资产文件是否存在。
|
||||||
|
*/
|
||||||
|
async exists(assetPath: string): Promise<boolean> {
|
||||||
|
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||||
|
return this.assetReader.exists(absolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve relative asset path to absolute file system path.
|
||||||
|
* 将相对资产路径解析为绝对文件系统路径。
|
||||||
|
*
|
||||||
|
* @param assetPath - Relative asset path (e.g., "assets/demo/button.png").
|
||||||
|
* 相对资产路径。
|
||||||
|
* @returns Absolute file system path. | 绝对文件系统路径。
|
||||||
|
*/
|
||||||
|
private resolveToAbsolutePath(assetPath: string): string {
|
||||||
|
// Already an absolute path or URL - return as-is
|
||||||
|
// 已经是绝对路径或 URL - 直接返回
|
||||||
|
if (this.isAbsoluteOrUrl(assetPath)) {
|
||||||
|
return assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project path and combine with asset path
|
||||||
|
// 获取项目路径并与资产路径组合
|
||||||
|
const projectPath = this.config.getProjectPath();
|
||||||
|
if (!projectPath) {
|
||||||
|
// No project open, return original path
|
||||||
|
// 没有打开项目,返回原始路径
|
||||||
|
console.warn('[EditorAssetFileLoader] No project open, cannot resolve path:', assetPath);
|
||||||
|
return assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine separator based on project path format
|
||||||
|
// 根据项目路径格式确定分隔符
|
||||||
|
const separator = projectPath.includes('\\') ? '\\' : '/';
|
||||||
|
|
||||||
|
// Normalize asset path separators to match project path
|
||||||
|
// 规范化资产路径分隔符以匹配项目路径
|
||||||
|
const normalizedAssetPath = assetPath.replace(/\//g, separator);
|
||||||
|
|
||||||
|
// Combine paths
|
||||||
|
// 组合路径
|
||||||
|
return `${projectPath}${separator}${normalizedAssetPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if path is already absolute or a URL.
|
||||||
|
* 检查路径是否已经是绝对路径或 URL。
|
||||||
|
*/
|
||||||
|
private isAbsoluteOrUrl(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path.startsWith('http://') ||
|
||||||
|
path.startsWith('https://') ||
|
||||||
|
path.startsWith('data:') ||
|
||||||
|
path.startsWith('asset://') ||
|
||||||
|
path.startsWith('/') ||
|
||||||
|
/^[a-zA-Z]:/.test(path) // Windows absolute path (e.g., "C:\...")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,24 @@
|
|||||||
* Uses the unified GameRuntime architecture
|
* Uses the unified GameRuntime architecture
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, type SystemContext } from '@esengine/editor-core';
|
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, GizmoInteractionService, GizmoInteractionServiceToken, type SystemContext } from '@esengine/editor-core';
|
||||||
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, PluginServiceRegistry } from '@esengine/ecs-framework';
|
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, PluginServiceRegistry } from '@esengine/ecs-framework';
|
||||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||||
import { ParticleSystemComponent } from '@esengine/particle';
|
import { ParticleSystemComponent } from '@esengine/particle';
|
||||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
import {
|
||||||
|
invalidateUIRenderCaches,
|
||||||
|
UIRenderProviderToken,
|
||||||
|
UIInputSystemToken,
|
||||||
|
initializeDynamicAtlasService,
|
||||||
|
reinitializeDynamicAtlasService,
|
||||||
|
registerTexturePathMapping,
|
||||||
|
AtlasExpansionStrategy,
|
||||||
|
type IAtlasEngineBridge,
|
||||||
|
type DynamicAtlasConfig
|
||||||
|
} from '@esengine/ui';
|
||||||
|
import { SettingsService } from './SettingsService';
|
||||||
import * as esEngine from '@esengine/engine';
|
import * as esEngine from '@esengine/engine';
|
||||||
import {
|
import {
|
||||||
AssetManager,
|
AssetManager,
|
||||||
@@ -22,8 +33,12 @@ import {
|
|||||||
SceneResourceManager,
|
SceneResourceManager,
|
||||||
AssetType,
|
AssetType,
|
||||||
AssetManagerToken,
|
AssetManagerToken,
|
||||||
isValidGUID
|
isValidGUID,
|
||||||
|
setGlobalAssetDatabase,
|
||||||
|
setGlobalEngineBridge,
|
||||||
|
setGlobalAssetFileLoader
|
||||||
} from '@esengine/asset-system';
|
} from '@esengine/asset-system';
|
||||||
|
import { EditorAssetFileLoader } from './EditorAssetFileLoader';
|
||||||
import {
|
import {
|
||||||
GameRuntime,
|
GameRuntime,
|
||||||
createGameRuntime,
|
createGameRuntime,
|
||||||
@@ -56,6 +71,7 @@ export class EngineService {
|
|||||||
private _modulesInitialized = false;
|
private _modulesInitialized = false;
|
||||||
private _running = false;
|
private _running = false;
|
||||||
private _canvasId: string | null = null;
|
private _canvasId: string | null = null;
|
||||||
|
private _gizmoInteractionService: GizmoInteractionService | null = null;
|
||||||
|
|
||||||
// 资产系统相关
|
// 资产系统相关
|
||||||
private _assetManager: AssetManager | null = null;
|
private _assetManager: AssetManager | null = null;
|
||||||
@@ -68,6 +84,9 @@ export class EngineService {
|
|||||||
// 编辑器相机状态(用于恢复)
|
// 编辑器相机状态(用于恢复)
|
||||||
private _editorCameraState = { x: 0, y: 0, zoom: 1 };
|
private _editorCameraState = { x: 0, y: 0, zoom: 1 };
|
||||||
|
|
||||||
|
// 当前选中的实体 IDs(用于高亮)| Currently selected entity IDs (for highlighting)
|
||||||
|
private _selectedEntityIds: number[] = [];
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +165,13 @@ export class EngineService {
|
|||||||
|
|
||||||
await this._runtime.initialize();
|
await this._runtime.initialize();
|
||||||
|
|
||||||
|
// 设置 MaterialManager 的引擎桥接(上传内置 shader 到 GPU)
|
||||||
|
// Set engine bridge for MaterialManager (upload built-in shaders to GPU)
|
||||||
|
const materialManager = getMaterialManager();
|
||||||
|
if (materialManager && this._runtime.bridge) {
|
||||||
|
materialManager.setEngineBridge(this._runtime.bridge);
|
||||||
|
}
|
||||||
|
|
||||||
// 启用性能分析器(编辑器模式默认启用)
|
// 启用性能分析器(编辑器模式默认启用)
|
||||||
ProfilerSDK.setEnabled(true);
|
ProfilerSDK.setEnabled(true);
|
||||||
|
|
||||||
@@ -157,6 +183,21 @@ export class EngineService {
|
|||||||
GizmoRegistry.hasProvider(component.constructor as any)
|
GizmoRegistry.hasProvider(component.constructor as any)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 初始化 Gizmo 交互服务
|
||||||
|
// Initialize Gizmo Interaction Service
|
||||||
|
this._gizmoInteractionService = new GizmoInteractionService();
|
||||||
|
Core.pluginServices.register(GizmoInteractionServiceToken, this._gizmoInteractionService);
|
||||||
|
|
||||||
|
// 设置 Gizmo 交互函数到渲染系统
|
||||||
|
// Set gizmo interaction functions to render system
|
||||||
|
if (this._runtime.renderSystem) {
|
||||||
|
this._runtime.renderSystem.setGizmoInteraction(
|
||||||
|
(entityId: number, baseColor: { r: number; g: number; b: number; a: number }, isSelected: boolean) =>
|
||||||
|
this._gizmoInteractionService!.getHighlightColor(entityId, baseColor, isSelected),
|
||||||
|
() => this._gizmoInteractionService!.getHoveredEntityId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化资产系统
|
// 初始化资产系统
|
||||||
await this._initializeAssetSystem();
|
await this._initializeAssetSystem();
|
||||||
|
|
||||||
@@ -437,6 +478,22 @@ export class EngineService {
|
|||||||
// 将 AssetRegistryService 的数据同步到 assetManager 的数据库
|
// 将 AssetRegistryService 的数据同步到 assetManager 的数据库
|
||||||
await this._syncAssetRegistryToManager();
|
await this._syncAssetRegistryToManager();
|
||||||
|
|
||||||
|
// 设置全局资产数据库(供渲染系统查询 sprite 元数据)
|
||||||
|
// Set global asset database (for render systems to query sprite metadata)
|
||||||
|
setGlobalAssetDatabase(this._assetManager.getDatabase());
|
||||||
|
|
||||||
|
// 设置全局资产文件加载器(供动态图集服务等使用)
|
||||||
|
// Set global asset file loader (for DynamicAtlasService etc.)
|
||||||
|
const editorAssetFileLoader = new EditorAssetFileLoader(assetReader, {
|
||||||
|
getProjectPath: () => {
|
||||||
|
if (projectService && projectService.isProjectOpen()) {
|
||||||
|
return projectService.getCurrentProject()?.path ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setGlobalAssetFileLoader(editorAssetFileLoader);
|
||||||
|
|
||||||
const pathTransformerFn = (path: string) => {
|
const pathTransformerFn = (path: string) => {
|
||||||
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
||||||
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
||||||
@@ -461,6 +518,33 @@ export class EngineService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (this._runtime?.bridge) {
|
if (this._runtime?.bridge) {
|
||||||
|
// 为 EngineBridge 设置路径解析器(用于 getTextureInfoByPath 等方法)
|
||||||
|
// Set path resolver for EngineBridge (for getTextureInfoByPath etc.)
|
||||||
|
this._runtime.bridge.setPathResolver((assetPath: string) => {
|
||||||
|
// 空路径直接返回
|
||||||
|
if (!assetPath) return assetPath;
|
||||||
|
|
||||||
|
// 已经是 URL 则直接返回
|
||||||
|
if (assetPath.startsWith('http://') ||
|
||||||
|
assetPath.startsWith('https://') ||
|
||||||
|
assetPath.startsWith('data:') ||
|
||||||
|
assetPath.startsWith('asset://')) {
|
||||||
|
return assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 pathTransformerFn 转换路径为 Tauri URL
|
||||||
|
let fullPath = assetPath;
|
||||||
|
// 如果路径不以 'assets/' 开头,添加前缀
|
||||||
|
if (!assetPath.startsWith('assets/') && !assetPath.startsWith('assets\\')) {
|
||||||
|
fullPath = `assets/${assetPath}`;
|
||||||
|
}
|
||||||
|
return pathTransformerFn(fullPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置全局引擎桥(供渲染系统查询纹理尺寸 - 唯一事实来源)
|
||||||
|
// Set global engine bridge (for render systems to query texture dimensions - single source of truth)
|
||||||
|
setGlobalEngineBridge(this._runtime.bridge);
|
||||||
|
|
||||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||||
|
|
||||||
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
||||||
@@ -503,6 +587,58 @@ export class EngineService {
|
|||||||
this._sceneResourceManager = new SceneResourceManager();
|
this._sceneResourceManager = new SceneResourceManager();
|
||||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||||
|
|
||||||
|
// 初始化动态图集服务(用于 UI 合批)
|
||||||
|
// Initialize dynamic atlas service (for UI batching)
|
||||||
|
const bridge = this._runtime.bridge;
|
||||||
|
if (bridge.createBlankTexture && bridge.updateTextureRegion) {
|
||||||
|
const atlasBridge: IAtlasEngineBridge = {
|
||||||
|
createBlankTexture: (width: number, height: number) => {
|
||||||
|
return bridge.createBlankTexture(width, height);
|
||||||
|
},
|
||||||
|
updateTextureRegion: (
|
||||||
|
id: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8Array
|
||||||
|
) => {
|
||||||
|
bridge.updateTextureRegion(id, x, y, width, height, pixels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从设置中获取动态图集配置
|
||||||
|
// Get dynamic atlas config from settings
|
||||||
|
const settingsService = SettingsService.getInstance();
|
||||||
|
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||||
|
|
||||||
|
if (atlasEnabled) {
|
||||||
|
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||||
|
const expansionStrategy = strategyValue === 'dynamic'
|
||||||
|
? AtlasExpansionStrategy.Dynamic
|
||||||
|
: AtlasExpansionStrategy.Fixed;
|
||||||
|
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||||
|
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||||
|
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||||
|
|
||||||
|
initializeDynamicAtlasService(atlasBridge, {
|
||||||
|
expansionStrategy,
|
||||||
|
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
|
||||||
|
fixedPageSize, // 固定模式页面大小 | Fixed mode page size
|
||||||
|
maxPageSize: 2048, // 最大页面大小 | Max page size
|
||||||
|
maxPages,
|
||||||
|
maxTextureSize,
|
||||||
|
padding: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册纹理加载回调,当纹理加载时自动注册路径映射
|
||||||
|
// Register texture load callback to register path mapping when textures load
|
||||||
|
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
|
||||||
|
registerTexturePathMapping(guid, path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
|
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
|
||||||
if (sceneManagerService) {
|
if (sceneManagerService) {
|
||||||
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
|
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
|
||||||
@@ -570,6 +706,13 @@ export class EngineService {
|
|||||||
// 1. Check for explicit loaderType in .meta file (user override)
|
// 1. Check for explicit loaderType in .meta file (user override)
|
||||||
// 1. 检查 .meta 文件中的显式 loaderType(用户覆盖)
|
// 1. 检查 .meta 文件中的显式 loaderType(用户覆盖)
|
||||||
const meta = metaManager.getMetaByGUID(asset.guid);
|
const meta = metaManager.getMetaByGUID(asset.guid);
|
||||||
|
|
||||||
|
// Debug: log meta for textures with importSettings
|
||||||
|
// 调试:记录有 importSettings 的纹理 meta
|
||||||
|
if (meta?.importSettings?.spriteSettings) {
|
||||||
|
console.log(`[EngineService] Syncing asset with spriteSettings: ${asset.path}`, meta.importSettings.spriteSettings);
|
||||||
|
}
|
||||||
|
|
||||||
if (meta?.loaderType) {
|
if (meta?.loaderType) {
|
||||||
assetType = meta.loaderType;
|
assetType = meta.loaderType;
|
||||||
}
|
}
|
||||||
@@ -607,10 +750,13 @@ export class EngineService {
|
|||||||
size: asset.size,
|
size: asset.size,
|
||||||
hash: asset.hash || '',
|
hash: asset.hash || '',
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
labels: [],
|
labels: meta?.labels || [],
|
||||||
tags: new Map(),
|
tags: new Map(),
|
||||||
lastModified: asset.lastModified,
|
lastModified: asset.lastModified,
|
||||||
version: 1
|
version: 1,
|
||||||
|
// 包含 importSettings(包含 spriteSettings 等)用于渲染系统查询
|
||||||
|
// Include importSettings (contains spriteSettings etc.) for render systems to query
|
||||||
|
importSettings: meta?.importSettings as Record<string, unknown> | undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,10 +830,13 @@ export class EngineService {
|
|||||||
size: asset.size,
|
size: asset.size,
|
||||||
hash: asset.hash || '',
|
hash: asset.hash || '',
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
labels: [],
|
labels: meta?.labels || [],
|
||||||
tags: new Map(),
|
tags: new Map(),
|
||||||
lastModified: asset.lastModified,
|
lastModified: asset.lastModified,
|
||||||
version: 1
|
version: 1,
|
||||||
|
// 包含 importSettings(包含 spriteSettings 等)用于渲染系统查询
|
||||||
|
// Include importSettings (contains spriteSettings etc.) for render systems to query
|
||||||
|
importSettings: meta?.importSettings as Record<string, unknown> | undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(`Asset synced to runtime: ${asset.path} (${data.guid})`);
|
logger.debug(`Asset synced to runtime: ${asset.path} (${data.guid})`);
|
||||||
@@ -1137,11 +1286,29 @@ export class EngineService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set selected entity IDs for gizmo display.
|
* Set selected entity IDs for gizmo display.
|
||||||
|
* 设置选中的实体 ID 用于 Gizmo 显示。
|
||||||
*/
|
*/
|
||||||
setSelectedEntityIds(ids: number[]): void {
|
setSelectedEntityIds(ids: number[]): void {
|
||||||
|
this._selectedEntityIds = [...ids];
|
||||||
this._runtime?.setSelectedEntityIds(ids);
|
this._runtime?.setSelectedEntityIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently selected entity IDs.
|
||||||
|
* 获取当前选中的实体 IDs。
|
||||||
|
*/
|
||||||
|
getSelectedEntityIds(): number[] {
|
||||||
|
return [...this._selectedEntityIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gizmo interaction service.
|
||||||
|
* 获取 Gizmo 交互服务。
|
||||||
|
*/
|
||||||
|
getGizmoInteractionService(): GizmoInteractionService | null {
|
||||||
|
return this._gizmoInteractionService;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set transform tool mode.
|
* Set transform tool mode.
|
||||||
*/
|
*/
|
||||||
@@ -1229,6 +1396,76 @@ export class EngineService {
|
|||||||
return this._runtime;
|
return this._runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinitialize dynamic atlas with current settings.
|
||||||
|
* 使用当前设置重新初始化动态图集。
|
||||||
|
*
|
||||||
|
* Call this when dynamic atlas settings change to apply them.
|
||||||
|
* 当动态图集设置更改时调用此方法以应用更改。
|
||||||
|
*/
|
||||||
|
reinitializeDynamicAtlas(): void {
|
||||||
|
const bridge = this._runtime?.bridge;
|
||||||
|
if (!bridge?.createBlankTexture || !bridge?.updateTextureRegion) {
|
||||||
|
logger.warn('Dynamic atlas requires createBlankTexture and updateTextureRegion');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atlasBridge: IAtlasEngineBridge = {
|
||||||
|
createBlankTexture: (width: number, height: number) => {
|
||||||
|
return bridge.createBlankTexture!(width, height);
|
||||||
|
},
|
||||||
|
updateTextureRegion: (
|
||||||
|
id: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8Array
|
||||||
|
) => {
|
||||||
|
bridge.updateTextureRegion!(id, x, y, width, height, pixels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从设置中获取动态图集配置
|
||||||
|
// Get dynamic atlas config from settings
|
||||||
|
const settingsService = SettingsService.getInstance();
|
||||||
|
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||||
|
|
||||||
|
if (!atlasEnabled) {
|
||||||
|
logger.info('Dynamic atlas is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||||
|
const expansionStrategy = strategyValue === 'dynamic'
|
||||||
|
? AtlasExpansionStrategy.Dynamic
|
||||||
|
: AtlasExpansionStrategy.Fixed;
|
||||||
|
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||||
|
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||||
|
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||||
|
|
||||||
|
logger.info('Dynamic atlas settings read from SettingsService:', {
|
||||||
|
strategyValue,
|
||||||
|
expansionStrategy: expansionStrategy === AtlasExpansionStrategy.Dynamic ? 'dynamic' : 'fixed',
|
||||||
|
fixedPageSize,
|
||||||
|
maxPages,
|
||||||
|
maxTextureSize
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: DynamicAtlasConfig = {
|
||||||
|
expansionStrategy,
|
||||||
|
initialPageSize: 256,
|
||||||
|
fixedPageSize,
|
||||||
|
maxPageSize: 2048,
|
||||||
|
maxPages,
|
||||||
|
maxTextureSize,
|
||||||
|
padding: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
reinitializeDynamicAtlasService(atlasBridge, config);
|
||||||
|
logger.info('Dynamic atlas reinitialized with config:', config);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose engine resources.
|
* Dispose engine resources.
|
||||||
*/
|
*/
|
||||||
@@ -1242,8 +1479,13 @@ export class EngineService {
|
|||||||
// 切换项目时清空数据库以释放内存
|
// 切换项目时清空数据库以释放内存
|
||||||
this._assetManager.getDatabase().clear();
|
this._assetManager.getDatabase().clear();
|
||||||
this._assetManager = null;
|
this._assetManager = null;
|
||||||
|
// 清除全局资产数据库引用 | Clear global asset database reference
|
||||||
|
setGlobalAssetDatabase(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除全局引擎桥引用 | Clear global engine bridge reference
|
||||||
|
setGlobalEngineBridge(null);
|
||||||
|
|
||||||
this._engineIntegration = null;
|
this._engineIntegration = null;
|
||||||
|
|
||||||
if (this._runtime) {
|
if (this._runtime) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Core, Entity } from '@esengine/ecs-framework';
|
|||||||
import { TransformComponent } from '@esengine/engine-core';
|
import { TransformComponent } from '@esengine/engine-core';
|
||||||
import { SpriteComponent } from '@esengine/sprite';
|
import { SpriteComponent } from '@esengine/sprite';
|
||||||
import { ParticleSystemComponent } from '@esengine/particle';
|
import { ParticleSystemComponent } from '@esengine/particle';
|
||||||
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
|
import { UITransformComponent, UIRenderComponent, UITextComponent, getUIRenderCollector, type BatchDebugInfo, registerTexturePathMapping, getDynamicAtlasService } from '@esengine/ui';
|
||||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
@@ -26,6 +26,15 @@ export interface TextureDebugInfo {
|
|||||||
state: 'loading' | 'ready' | 'failed';
|
state: 'loading' | 'ready' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader uniform 值
|
||||||
|
* Shader uniform value
|
||||||
|
*/
|
||||||
|
export interface UniformDebugValue {
|
||||||
|
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||||
|
value: number | number[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sprite 调试信息
|
* Sprite 调试信息
|
||||||
* Sprite debug info
|
* Sprite debug info
|
||||||
@@ -47,6 +56,14 @@ export interface SpriteDebugInfo {
|
|||||||
alpha: number;
|
alpha: number;
|
||||||
sortingLayer: string;
|
sortingLayer: string;
|
||||||
orderInLayer: number;
|
orderInLayer: number;
|
||||||
|
/** 材质/着色器 ID | Material/Shader ID */
|
||||||
|
materialId: number;
|
||||||
|
/** 着色器名称 | Shader name */
|
||||||
|
shaderName: string;
|
||||||
|
/** Shader uniform 覆盖值 | Shader uniform override values */
|
||||||
|
uniforms: Record<string, UniformDebugValue>;
|
||||||
|
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
|
||||||
|
aspectRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,17 +120,86 @@ export interface UIDebugInfo {
|
|||||||
alpha: number;
|
alpha: number;
|
||||||
sortingLayer: string;
|
sortingLayer: string;
|
||||||
orderInLayer: number;
|
orderInLayer: number;
|
||||||
|
/** 层级深度(从根节点计算)| Hierarchy depth (from root) */
|
||||||
|
depth: number;
|
||||||
|
/** 世界层内顺序 = depth * 1000 + orderInLayer | World order in layer */
|
||||||
|
worldOrderInLayer: number;
|
||||||
textureGuid?: string;
|
textureGuid?: string;
|
||||||
textureUrl?: string;
|
textureUrl?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
/** 材质/着色器 ID | Material/Shader ID */
|
||||||
|
materialId: number;
|
||||||
|
/** 着色器名称 | Shader name */
|
||||||
|
shaderName: string;
|
||||||
|
/** Shader uniform 覆盖值 | Shader uniform override values */
|
||||||
|
uniforms: Record<string, UniformDebugValue>;
|
||||||
|
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
|
||||||
|
aspectRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染调试快照
|
* 渲染调试快照
|
||||||
* Render debug snapshot
|
* Render debug snapshot
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 图集条目调试信息
|
||||||
|
* Atlas entry debug info
|
||||||
|
*/
|
||||||
|
export interface AtlasEntryDebugInfo {
|
||||||
|
/** 纹理 GUID | Texture GUID */
|
||||||
|
guid: string;
|
||||||
|
/** 在图集中的位置 | Position in atlas */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** UV 坐标 | UV coordinates */
|
||||||
|
uv: [number, number, number, number];
|
||||||
|
/** 纹理图像 data URL(用于预览)| Texture image data URL (for preview) */
|
||||||
|
dataUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图集页面调试信息
|
||||||
|
* Atlas page debug info
|
||||||
|
*/
|
||||||
|
export interface AtlasPageDebugInfo {
|
||||||
|
/** 页面索引 | Page index */
|
||||||
|
pageIndex: number;
|
||||||
|
/** 纹理 ID | Texture ID */
|
||||||
|
textureId: number;
|
||||||
|
/** 页面尺寸 | Page size */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** 占用率 | Occupancy */
|
||||||
|
occupancy: number;
|
||||||
|
/** 此页面中的条目 | Entries in this page */
|
||||||
|
entries: AtlasEntryDebugInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态图集统计信息
|
||||||
|
* Dynamic atlas statistics
|
||||||
|
*/
|
||||||
|
export interface AtlasStats {
|
||||||
|
/** 是否启用 | Whether enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 图集页数 | Number of atlas pages */
|
||||||
|
pageCount: number;
|
||||||
|
/** 已加入图集的纹理数 | Number of textures in atlas */
|
||||||
|
textureCount: number;
|
||||||
|
/** 平均占用率 | Average occupancy */
|
||||||
|
averageOccupancy: number;
|
||||||
|
/** 正在加载的纹理数 | Number of loading textures */
|
||||||
|
loadingCount: number;
|
||||||
|
/** 加载失败的纹理数 | Number of failed textures */
|
||||||
|
failedCount: number;
|
||||||
|
/** 每个页面的详细信息 | Detailed info for each page */
|
||||||
|
pages: AtlasPageDebugInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderDebugSnapshot {
|
export interface RenderDebugSnapshot {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
frameNumber: number;
|
frameNumber: number;
|
||||||
@@ -121,15 +207,42 @@ export interface RenderDebugSnapshot {
|
|||||||
sprites: SpriteDebugInfo[];
|
sprites: SpriteDebugInfo[];
|
||||||
particles: ParticleDebugInfo[];
|
particles: ParticleDebugInfo[];
|
||||||
uiElements: UIDebugInfo[];
|
uiElements: UIDebugInfo[];
|
||||||
|
/** UI 合批调试信息 | UI batch debug info */
|
||||||
|
uiBatches: BatchDebugInfo[];
|
||||||
|
/** 动态图集统计 | Dynamic atlas stats */
|
||||||
|
atlasStats?: AtlasStats;
|
||||||
stats: {
|
stats: {
|
||||||
totalSprites: number;
|
totalSprites: number;
|
||||||
totalParticles: number;
|
totalParticles: number;
|
||||||
totalUIElements: number;
|
totalUIElements: number;
|
||||||
totalTextures: number;
|
totalTextures: number;
|
||||||
drawCalls: number;
|
drawCalls: number;
|
||||||
|
/** UI 批次数 | UI batch count */
|
||||||
|
uiBatchCount: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置着色器 ID 到名称的映射
|
||||||
|
* Built-in shader ID to name mapping
|
||||||
|
*/
|
||||||
|
const SHADER_NAMES: Record<number, string> = {
|
||||||
|
0: 'DefaultSprite',
|
||||||
|
1: 'Grayscale',
|
||||||
|
2: 'Tint',
|
||||||
|
3: 'Flash',
|
||||||
|
4: 'Outline',
|
||||||
|
5: 'Shiny'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据材质/着色器 ID 获取着色器名称
|
||||||
|
* Get shader name from material/shader ID
|
||||||
|
*/
|
||||||
|
function getShaderName(id: number): string {
|
||||||
|
return SHADER_NAMES[id] ?? `Custom(${id})`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染调试服务
|
* 渲染调试服务
|
||||||
* Render Debug Service
|
* Render Debug Service
|
||||||
@@ -187,18 +300,15 @@ export class RenderDebugService {
|
|||||||
|
|
||||||
// 从缓存获取 | Get from cache
|
// 从缓存获取 | Get from cache
|
||||||
if (this._textureCache.has(textureGuid)) {
|
if (this._textureCache.has(textureGuid)) {
|
||||||
console.log('[RenderDebugService] Texture from cache:', textureGuid);
|
|
||||||
return this._textureCache.get(textureGuid);
|
return this._textureCache.get(textureGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在加载中,返回 undefined | If loading, return undefined
|
// 如果正在加载中,返回 undefined | If loading, return undefined
|
||||||
if (this._texturePending.has(textureGuid)) {
|
if (this._texturePending.has(textureGuid)) {
|
||||||
console.log('[RenderDebugService] Texture loading:', textureGuid);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步加载纹理 | Load texture asynchronously
|
// 异步加载纹理 | Load texture asynchronously
|
||||||
console.log('[RenderDebugService] Starting texture load:', textureGuid);
|
|
||||||
this._loadTextureToCache(textureGuid);
|
this._loadTextureToCache(textureGuid);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -260,12 +370,16 @@ export class RenderDebugService {
|
|||||||
: resolvedPath;
|
: resolvedPath;
|
||||||
|
|
||||||
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
||||||
console.log('[RenderDebugService] Loading texture:', fullPath);
|
|
||||||
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
||||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
|
|
||||||
this._textureCache.set(textureGuid, dataUrl);
|
this._textureCache.set(textureGuid, dataUrl);
|
||||||
|
|
||||||
|
// 注册 GUID 到 data URL 映射(用于动态图集)
|
||||||
|
// Register GUID to data URL mapping (for dynamic atlas)
|
||||||
|
if (isGuid) {
|
||||||
|
registerTexturePathMapping(textureGuid, dataUrl);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -285,6 +399,57 @@ export class RenderDebugService {
|
|||||||
|
|
||||||
this._frameNumber++;
|
this._frameNumber++;
|
||||||
|
|
||||||
|
// 收集 UI 合批信息 | Collect UI batch info
|
||||||
|
const uiCollector = getUIRenderCollector();
|
||||||
|
const uiBatches = [...uiCollector.getBatchDebugInfo()];
|
||||||
|
|
||||||
|
// 收集动态图集统计 | Collect dynamic atlas stats
|
||||||
|
const atlasService = getDynamicAtlasService();
|
||||||
|
let atlasStats: AtlasStats | undefined;
|
||||||
|
if (atlasService) {
|
||||||
|
const stats = atlasService.getStats();
|
||||||
|
const pageDetails = atlasService.getPageDetails();
|
||||||
|
|
||||||
|
// 转换页面详细信息 | Convert page details
|
||||||
|
const pages: AtlasPageDebugInfo[] = pageDetails.map(page => ({
|
||||||
|
pageIndex: page.pageIndex,
|
||||||
|
textureId: page.textureId,
|
||||||
|
width: page.width,
|
||||||
|
height: page.height,
|
||||||
|
occupancy: page.occupancy,
|
||||||
|
entries: page.entries.map(e => ({
|
||||||
|
guid: e.guid,
|
||||||
|
x: e.entry.region.x,
|
||||||
|
y: e.entry.region.y,
|
||||||
|
width: e.entry.region.width,
|
||||||
|
height: e.entry.region.height,
|
||||||
|
uv: e.entry.uv,
|
||||||
|
// 从纹理缓存获取 data URL | Get data URL from texture cache
|
||||||
|
dataUrl: this._textureCache.get(e.guid)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
atlasStats = {
|
||||||
|
enabled: true,
|
||||||
|
pageCount: stats.pageCount,
|
||||||
|
textureCount: stats.textureCount,
|
||||||
|
averageOccupancy: stats.averageOccupancy,
|
||||||
|
loadingCount: stats.loadingCount,
|
||||||
|
failedCount: stats.failedCount,
|
||||||
|
pages
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
atlasStats = {
|
||||||
|
enabled: false,
|
||||||
|
pageCount: 0,
|
||||||
|
textureCount: 0,
|
||||||
|
averageOccupancy: 0,
|
||||||
|
loadingCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
pages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot: RenderDebugSnapshot = {
|
const snapshot: RenderDebugSnapshot = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
frameNumber: this._frameNumber,
|
frameNumber: this._frameNumber,
|
||||||
@@ -292,12 +457,15 @@ export class RenderDebugService {
|
|||||||
sprites: this._collectSprites(scene.entities.buffer),
|
sprites: this._collectSprites(scene.entities.buffer),
|
||||||
particles: this._collectParticles(scene.entities.buffer),
|
particles: this._collectParticles(scene.entities.buffer),
|
||||||
uiElements: this._collectUI(scene.entities.buffer),
|
uiElements: this._collectUI(scene.entities.buffer),
|
||||||
|
uiBatches,
|
||||||
|
atlasStats,
|
||||||
stats: {
|
stats: {
|
||||||
totalSprites: 0,
|
totalSprites: 0,
|
||||||
totalParticles: 0,
|
totalParticles: 0,
|
||||||
totalUIElements: 0,
|
totalUIElements: 0,
|
||||||
totalTextures: 0,
|
totalTextures: 0,
|
||||||
drawCalls: 0,
|
drawCalls: 0,
|
||||||
|
uiBatchCount: uiBatches.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,6 +474,7 @@ export class RenderDebugService {
|
|||||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||||
|
snapshot.stats.drawCalls = uiBatches.length; // UI batches as draw calls
|
||||||
|
|
||||||
// 保存快照 | Save snapshot
|
// 保存快照 | Save snapshot
|
||||||
this._snapshots.push(snapshot);
|
this._snapshots.push(snapshot);
|
||||||
@@ -378,6 +547,24 @@ export class RenderDebugService {
|
|||||||
: transform.rotation.z;
|
: transform.rotation.z;
|
||||||
|
|
||||||
const textureGuid = sprite.textureGuid ?? '';
|
const textureGuid = sprite.textureGuid ?? '';
|
||||||
|
const materialId = sprite.getMaterialId?.() ?? 0;
|
||||||
|
|
||||||
|
// 收集 uniform 覆盖值 | Collect uniform override values
|
||||||
|
const uniforms: Record<string, UniformDebugValue> = {};
|
||||||
|
const overrides = sprite.materialOverrides ?? {};
|
||||||
|
for (const [name, override] of Object.entries(overrides)) {
|
||||||
|
uniforms[name] = {
|
||||||
|
type: override.type,
|
||||||
|
value: override.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 aspectRatio (与 Rust 端一致: width / height)
|
||||||
|
// Calculate aspectRatio (same as Rust side: width / height)
|
||||||
|
const width = sprite.width * (transform.scale?.x ?? 1);
|
||||||
|
const height = sprite.height * (transform.scale?.y ?? 1);
|
||||||
|
const aspectRatio = Math.abs(height) > 0.001 ? width / height : 1.0;
|
||||||
|
|
||||||
sprites.push({
|
sprites.push({
|
||||||
entityId: entity.id,
|
entityId: entity.id,
|
||||||
entityName: entity.name,
|
entityName: entity.name,
|
||||||
@@ -394,6 +581,10 @@ export class RenderDebugService {
|
|||||||
alpha: sprite.alpha,
|
alpha: sprite.alpha,
|
||||||
sortingLayer: sprite.sortingLayer,
|
sortingLayer: sprite.sortingLayer,
|
||||||
orderInLayer: sprite.orderInLayer,
|
orderInLayer: sprite.orderInLayer,
|
||||||
|
materialId,
|
||||||
|
shaderName: getShaderName(materialId),
|
||||||
|
uniforms,
|
||||||
|
aspectRatio,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,6 +710,30 @@ export class RenderDebugService {
|
|||||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// 获取材质/着色器 ID | Get material/shader ID
|
||||||
|
const materialId = uiRender?.getMaterialId?.() ?? 0;
|
||||||
|
|
||||||
|
// 收集 uniform 覆盖值 | Collect uniform override values
|
||||||
|
const uniforms: Record<string, UniformDebugValue> = {};
|
||||||
|
const overrides = uiRender?.materialOverrides ?? {};
|
||||||
|
for (const [name, override] of Object.entries(overrides)) {
|
||||||
|
uniforms[name] = {
|
||||||
|
type: override.type,
|
||||||
|
value: override.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 aspectRatio (与 Rust 端一致: width / height)
|
||||||
|
// Calculate aspectRatio (same as Rust side: width / height)
|
||||||
|
const uiWidth = uiTransform.width * (uiTransform.scaleX ?? 1);
|
||||||
|
const uiHeight = uiTransform.height * (uiTransform.scaleY ?? 1);
|
||||||
|
const aspectRatio = Math.abs(uiHeight) > 0.001 ? uiWidth / uiHeight : 1.0;
|
||||||
|
|
||||||
|
// 获取世界层内顺序并计算层级深度 | Get world order in layer and compute depth
|
||||||
|
// worldOrderInLayer = depth * 1000 + orderInLayer
|
||||||
|
const worldOrderInLayer = uiTransform.worldOrderInLayer ?? uiTransform.orderInLayer;
|
||||||
|
const depth = Math.floor(worldOrderInLayer / 1000);
|
||||||
|
|
||||||
uiElements.push({
|
uiElements.push({
|
||||||
entityId: entity.id,
|
entityId: entity.id,
|
||||||
entityName: entity.name,
|
entityName: entity.name,
|
||||||
@@ -534,11 +749,17 @@ export class RenderDebugService {
|
|||||||
alpha: uiTransform.worldAlpha,
|
alpha: uiTransform.worldAlpha,
|
||||||
sortingLayer: uiTransform.sortingLayer,
|
sortingLayer: uiTransform.sortingLayer,
|
||||||
orderInLayer: uiTransform.orderInLayer,
|
orderInLayer: uiTransform.orderInLayer,
|
||||||
|
depth,
|
||||||
|
worldOrderInLayer,
|
||||||
textureGuid: textureGuid || undefined,
|
textureGuid: textureGuid || undefined,
|
||||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
text: uiText?.text,
|
text: uiText?.text,
|
||||||
fontSize: uiText?.fontSize,
|
fontSize: uiText?.fontSize,
|
||||||
|
materialId,
|
||||||
|
shaderName: getShaderName(materialId),
|
||||||
|
uniforms,
|
||||||
|
aspectRatio,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export class TauriAssetReader implements IAssetReader {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
// 允许跨域访问,防止 canvas 被污染
|
||||||
|
// Allow cross-origin access to prevent canvas tainting
|
||||||
|
image.crossOrigin = 'anonymous';
|
||||||
image.onload = () => resolve(image);
|
image.onload = () => resolve(image);
|
||||||
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
|
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
|
||||||
image.src = assetUrl;
|
image.src = assetUrl;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ body {
|
|||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
/* 禁用全局文本选择,原生应用风格 | Disable global text selection for native app feel */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -35,6 +38,9 @@ textarea,
|
|||||||
select {
|
select {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
/* 输入框允许文本选择 | Allow text selection in inputs */
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@@ -47,6 +53,18 @@ select {
|
|||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 允许特定元素文本选择 | Allow text selection for specific elements */
|
||||||
|
.selectable,
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
.code-preview-content,
|
||||||
|
.file-preview-content,
|
||||||
|
.output-log-content,
|
||||||
|
.json-viewer {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全局滚动条样式 */
|
/* 全局滚动条样式 */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* Gizmo Hit Tester
|
||||||
|
* Gizmo 命中测试器
|
||||||
|
*
|
||||||
|
* Implements hit testing algorithms for various gizmo types in TypeScript.
|
||||||
|
* 在 TypeScript 端实现各种 Gizmo 类型的命中测试算法。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IGizmoRenderData,
|
||||||
|
IRectGizmoData,
|
||||||
|
ICircleGizmoData,
|
||||||
|
ILineGizmoData,
|
||||||
|
ICapsuleGizmoData
|
||||||
|
} from './IGizmoProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gizmo Hit Tester
|
||||||
|
* Gizmo 命中测试器
|
||||||
|
*
|
||||||
|
* Provides static methods for testing if a point intersects with various gizmo shapes.
|
||||||
|
* 提供静态方法来测试点是否与各种 gizmo 形状相交。
|
||||||
|
*/
|
||||||
|
export class GizmoHitTester {
|
||||||
|
/** Line hit tolerance in world units (adjusted by zoom) | 线条命中容差(世界单位,根据缩放调整) */
|
||||||
|
private static readonly BASE_LINE_TOLERANCE = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if point is inside a rect gizmo (considers rotation and origin)
|
||||||
|
* 测试点是否在矩形 gizmo 内(考虑旋转和原点)
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param rect Rect gizmo data | 矩形 gizmo 数据
|
||||||
|
* @returns True if point is inside | 如果点在内部返回 true
|
||||||
|
*/
|
||||||
|
static hitTestRect(worldX: number, worldY: number, rect: IRectGizmoData): boolean {
|
||||||
|
const cx = rect.x;
|
||||||
|
const cy = rect.y;
|
||||||
|
const halfW = rect.width / 2;
|
||||||
|
const halfH = rect.height / 2;
|
||||||
|
const rotation = rect.rotation || 0;
|
||||||
|
|
||||||
|
// Transform point to rect's local coordinate system (inverse rotation)
|
||||||
|
// 将点转换到矩形的本地坐标系(逆旋转)
|
||||||
|
const cos = Math.cos(-rotation);
|
||||||
|
const sin = Math.sin(-rotation);
|
||||||
|
const dx = worldX - cx;
|
||||||
|
const dy = worldY - cy;
|
||||||
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
|
||||||
|
// Adjust for origin offset
|
||||||
|
// 根据原点偏移调整
|
||||||
|
const originOffsetX = (rect.originX - 0.5) * rect.width;
|
||||||
|
const originOffsetY = (rect.originY - 0.5) * rect.height;
|
||||||
|
const adjustedX = localX + originOffsetX;
|
||||||
|
const adjustedY = localY + originOffsetY;
|
||||||
|
|
||||||
|
return adjustedX >= -halfW && adjustedX <= halfW &&
|
||||||
|
adjustedY >= -halfH && adjustedY <= halfH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if point is inside a circle gizmo
|
||||||
|
* 测试点是否在圆形 gizmo 内
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param circle Circle gizmo data | 圆形 gizmo 数据
|
||||||
|
* @returns True if point is inside | 如果点在内部返回 true
|
||||||
|
*/
|
||||||
|
static hitTestCircle(worldX: number, worldY: number, circle: ICircleGizmoData): boolean {
|
||||||
|
const dx = worldX - circle.x;
|
||||||
|
const dy = worldY - circle.y;
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
return distSq <= circle.radius * circle.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if point is near a line gizmo
|
||||||
|
* 测试点是否在线条 gizmo 附近
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param line Line gizmo data | 线条 gizmo 数据
|
||||||
|
* @param tolerance Hit tolerance in world units | 命中容差(世界单位)
|
||||||
|
* @returns True if point is within tolerance of line | 如果点在线条容差范围内返回 true
|
||||||
|
*/
|
||||||
|
static hitTestLine(worldX: number, worldY: number, line: ILineGizmoData, tolerance: number): boolean {
|
||||||
|
const points = line.points;
|
||||||
|
if (points.length < 2) return false;
|
||||||
|
|
||||||
|
const count = line.closed ? points.length : points.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[(i + 1) % points.length];
|
||||||
|
|
||||||
|
if (this.pointToSegmentDistance(worldX, worldY, p1.x, p1.y, p2.x, p2.y) <= tolerance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if point is inside a capsule gizmo
|
||||||
|
* 测试点是否在胶囊 gizmo 内
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param capsule Capsule gizmo data | 胶囊 gizmo 数据
|
||||||
|
* @returns True if point is inside | 如果点在内部返回 true
|
||||||
|
*/
|
||||||
|
static hitTestCapsule(worldX: number, worldY: number, capsule: ICapsuleGizmoData): boolean {
|
||||||
|
const cx = capsule.x;
|
||||||
|
const cy = capsule.y;
|
||||||
|
const rotation = capsule.rotation || 0;
|
||||||
|
|
||||||
|
// Transform point to capsule's local coordinate system
|
||||||
|
// 将点转换到胶囊的本地坐标系
|
||||||
|
const cos = Math.cos(-rotation);
|
||||||
|
const sin = Math.sin(-rotation);
|
||||||
|
const dx = worldX - cx;
|
||||||
|
const dy = worldY - cy;
|
||||||
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
|
||||||
|
// Capsule = two half-circles + middle rectangle
|
||||||
|
// 胶囊 = 两个半圆 + 中间矩形
|
||||||
|
const topCircleY = capsule.halfHeight;
|
||||||
|
const bottomCircleY = -capsule.halfHeight;
|
||||||
|
|
||||||
|
// Check if inside middle rectangle
|
||||||
|
// 检查是否在中间矩形内
|
||||||
|
if (Math.abs(localY) <= capsule.halfHeight && Math.abs(localX) <= capsule.radius) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if inside top half-circle
|
||||||
|
// 检查是否在上半圆内
|
||||||
|
const distToTopSq = localX * localX + (localY - topCircleY) * (localY - topCircleY);
|
||||||
|
if (distToTopSq <= capsule.radius * capsule.radius) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if inside bottom half-circle
|
||||||
|
// 检查是否在下半圆内
|
||||||
|
const distToBottomSq = localX * localX + (localY - bottomCircleY) * (localY - bottomCircleY);
|
||||||
|
if (distToBottomSq <= capsule.radius * capsule.radius) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic hit test for any gizmo type
|
||||||
|
* 通用命中测试,适用于任何 gizmo 类型
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param gizmo Gizmo data | Gizmo 数据
|
||||||
|
* @param zoom Current viewport zoom level | 当前视口缩放级别
|
||||||
|
* @returns True if point hits the gizmo | 如果点命中 gizmo 返回 true
|
||||||
|
*/
|
||||||
|
static hitTest(worldX: number, worldY: number, gizmo: IGizmoRenderData, zoom: number = 1): boolean {
|
||||||
|
// Convert screen pixel tolerance to world units
|
||||||
|
// 将屏幕像素容差转换为世界单位
|
||||||
|
const lineTolerance = this.BASE_LINE_TOLERANCE / zoom;
|
||||||
|
|
||||||
|
switch (gizmo.type) {
|
||||||
|
case 'rect':
|
||||||
|
return this.hitTestRect(worldX, worldY, gizmo);
|
||||||
|
case 'circle':
|
||||||
|
return this.hitTestCircle(worldX, worldY, gizmo);
|
||||||
|
case 'line':
|
||||||
|
return this.hitTestLine(worldX, worldY, gizmo, lineTolerance);
|
||||||
|
case 'capsule':
|
||||||
|
return this.hitTestCapsule(worldX, worldY, gizmo);
|
||||||
|
case 'grid':
|
||||||
|
// Grid typically doesn't need hit testing
|
||||||
|
// 网格通常不需要命中测试
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance from point to line segment
|
||||||
|
* 计算点到线段的距离
|
||||||
|
*
|
||||||
|
* @param px Point X | 点 X
|
||||||
|
* @param py Point Y | 点 Y
|
||||||
|
* @param x1 Segment start X | 线段起点 X
|
||||||
|
* @param y1 Segment start Y | 线段起点 Y
|
||||||
|
* @param x2 Segment end X | 线段终点 X
|
||||||
|
* @param y2 Segment end Y | 线段终点 Y
|
||||||
|
* @returns Distance from point to segment | 点到线段的距离
|
||||||
|
*/
|
||||||
|
private static pointToSegmentDistance(
|
||||||
|
px: number, py: number,
|
||||||
|
x1: number, y1: number,
|
||||||
|
x2: number, y2: number
|
||||||
|
): number {
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const lengthSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (lengthSq === 0) {
|
||||||
|
// Segment degenerates to a point
|
||||||
|
// 线段退化为点
|
||||||
|
return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate projection parameter t
|
||||||
|
// 计算投影参数 t
|
||||||
|
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
// Nearest point on segment
|
||||||
|
// 线段上最近的点
|
||||||
|
const nearestX = x1 + t * dx;
|
||||||
|
const nearestY = y1 + t * dy;
|
||||||
|
|
||||||
|
return Math.sqrt((px - nearestX) * (px - nearestX) + (py - nearestY) * (py - nearestY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the center point of any gizmo
|
||||||
|
* 获取任意 gizmo 的中心点
|
||||||
|
*
|
||||||
|
* @param gizmo Gizmo data | Gizmo 数据
|
||||||
|
* @returns Center point { x, y } | 中心点 { x, y }
|
||||||
|
*/
|
||||||
|
static getGizmoCenter(gizmo: IGizmoRenderData): { x: number; y: number } {
|
||||||
|
switch (gizmo.type) {
|
||||||
|
case 'rect':
|
||||||
|
case 'circle':
|
||||||
|
case 'capsule':
|
||||||
|
return { x: gizmo.x, y: gizmo.y };
|
||||||
|
case 'line':
|
||||||
|
if (gizmo.points.length === 0) return { x: 0, y: 0 };
|
||||||
|
const sumX = gizmo.points.reduce((sum, p) => sum + p.x, 0);
|
||||||
|
const sumY = gizmo.points.reduce((sum, p) => sum + p.y, 0);
|
||||||
|
return {
|
||||||
|
x: sumX / gizmo.points.length,
|
||||||
|
y: sumY / gizmo.points.length
|
||||||
|
};
|
||||||
|
case 'grid':
|
||||||
|
return {
|
||||||
|
x: gizmo.x + gizmo.width / 2,
|
||||||
|
y: gizmo.y + gizmo.height / 2
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@
|
|||||||
|
|
||||||
export * from './IGizmoProvider';
|
export * from './IGizmoProvider';
|
||||||
export * from './GizmoRegistry';
|
export * from './GizmoRegistry';
|
||||||
|
export * from './GizmoHitTester';
|
||||||
|
|||||||
@@ -394,12 +394,28 @@ export class AssetRegistryService implements IService {
|
|||||||
// 处理文件创建 - 注册新资产并生成 .meta
|
// 处理文件创建 - 注册新资产并生成 .meta
|
||||||
if (changeType === 'create' || changeType === 'modify') {
|
if (changeType === 'create' || changeType === 'modify') {
|
||||||
for (const absolutePath of paths) {
|
for (const absolutePath of paths) {
|
||||||
// Handle .meta file changes - invalidate cache
|
// Handle .meta file changes - invalidate cache and notify listeners
|
||||||
// 处理 .meta 文件变化 - 使缓存失效
|
// 处理 .meta 文件变化 - 使缓存失效并通知监听者
|
||||||
if (absolutePath.endsWith('.meta')) {
|
if (absolutePath.endsWith('.meta')) {
|
||||||
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
|
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
|
||||||
this._metaManager.invalidateCache(assetPath);
|
this._metaManager.invalidateCache(assetPath);
|
||||||
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
|
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
|
||||||
|
|
||||||
|
// Notify listeners that the asset's metadata has changed
|
||||||
|
// 通知监听者资产的元数据已变化
|
||||||
|
const relativePath = this.absoluteToRelative(assetPath);
|
||||||
|
if (relativePath) {
|
||||||
|
const metadata = this._database.getMetadataByPath(relativePath);
|
||||||
|
if (metadata) {
|
||||||
|
this._messageHub?.publish('assets:changed', {
|
||||||
|
type: 'modify',
|
||||||
|
path: assetPath,
|
||||||
|
relativePath,
|
||||||
|
guid: metadata.guid
|
||||||
|
});
|
||||||
|
logger.debug(`Published assets:changed for meta file: ${relativePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -868,6 +868,7 @@ ${userScriptImports}
|
|||||||
type: string;
|
type: string;
|
||||||
size: number;
|
size: number;
|
||||||
hash: string;
|
hash: string;
|
||||||
|
importSettings?: Record<string, unknown>;
|
||||||
}>
|
}>
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -952,7 +953,10 @@ ${userScriptImports}
|
|||||||
path: relativePath,
|
path: relativePath,
|
||||||
type: assetType,
|
type: assetType,
|
||||||
size,
|
size,
|
||||||
hash: hashFileInfo(relativePath, size)
|
hash: hashFileInfo(relativePath, size),
|
||||||
|
// Include importSettings for sprite slicing info (nine-patch, etc.)
|
||||||
|
// 包含 importSettings 以支持精灵切片信息(九宫格等)
|
||||||
|
importSettings: meta.importSettings
|
||||||
};
|
};
|
||||||
addedEntries++;
|
addedEntries++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* Gizmo Interaction Service
|
||||||
|
* Gizmo 交互服务
|
||||||
|
*
|
||||||
|
* Manages gizmo hover detection, highlighting, and click selection.
|
||||||
|
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import type { Entity, ComponentType } from '@esengine/ecs-framework';
|
||||||
|
import { GizmoHitTester } from '../Gizmos/GizmoHitTester';
|
||||||
|
import { GizmoRegistry } from '../Gizmos/GizmoRegistry';
|
||||||
|
import type { IGizmoRenderData, GizmoColor } from '../Gizmos/IGizmoProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gizmo hit result
|
||||||
|
* Gizmo 命中结果
|
||||||
|
*/
|
||||||
|
export interface GizmoHitResult {
|
||||||
|
/** Hit gizmo data | 命中的 Gizmo 数据 */
|
||||||
|
gizmo: IGizmoRenderData;
|
||||||
|
/** Associated entity ID | 关联的实体 ID */
|
||||||
|
entityId: number;
|
||||||
|
/** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gizmo interaction service interface
|
||||||
|
* Gizmo 交互服务接口
|
||||||
|
*/
|
||||||
|
export interface IGizmoInteractionService {
|
||||||
|
/**
|
||||||
|
* Get currently hovered entity ID
|
||||||
|
* 获取当前悬停的实体 ID
|
||||||
|
*/
|
||||||
|
getHoveredEntityId(): number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update mouse position and perform hit test
|
||||||
|
* 更新鼠标位置并执行命中测试
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param zoom Current viewport zoom level | 当前视口缩放级别
|
||||||
|
*/
|
||||||
|
updateMousePosition(worldX: number, worldY: number, zoom: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get highlight color for entity (applies hover effect if applicable)
|
||||||
|
* 获取实体的高亮颜色(如果适用则应用悬停效果)
|
||||||
|
*
|
||||||
|
* @param entityId Entity ID | 实体 ID
|
||||||
|
* @param baseColor Base gizmo color | 基础 Gizmo 颜色
|
||||||
|
* @param isSelected Whether entity is selected | 实体是否被选中
|
||||||
|
* @returns Adjusted color | 调整后的颜色
|
||||||
|
*/
|
||||||
|
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle click at position, return hit entity ID
|
||||||
|
* 处理位置点击,返回命中的实体 ID
|
||||||
|
*
|
||||||
|
* @param worldX World X coordinate | 世界 X 坐标
|
||||||
|
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||||
|
* @param zoom Current viewport zoom level | 当前视口缩放级别
|
||||||
|
* @returns Hit entity ID or null | 命中的实体 ID 或 null
|
||||||
|
*/
|
||||||
|
handleClick(worldX: number, worldY: number, zoom: number): number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear hover state
|
||||||
|
* 清除悬停状态
|
||||||
|
*/
|
||||||
|
clearHover(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gizmo Interaction Service
|
||||||
|
* Gizmo 交互服务
|
||||||
|
*
|
||||||
|
* Manages gizmo hover detection, highlighting, and click selection.
|
||||||
|
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
|
||||||
|
*/
|
||||||
|
export class GizmoInteractionService implements IGizmoInteractionService {
|
||||||
|
private hoveredEntityId: number | null = null;
|
||||||
|
private hoveredGizmo: IGizmoRenderData | null = null;
|
||||||
|
|
||||||
|
/** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */
|
||||||
|
private static readonly HOVER_COLOR_MULTIPLIER = 1.3;
|
||||||
|
/** Hover alpha boost | 悬停时 Alpha 增量 */
|
||||||
|
private static readonly HOVER_ALPHA_BOOST = 0.3;
|
||||||
|
|
||||||
|
// ===== Click cycling state | 点击循环状态 =====
|
||||||
|
/** Last click position | 上次点击位置 */
|
||||||
|
private lastClickPos: { x: number; y: number } | null = null;
|
||||||
|
/** Last click time | 上次点击时间 */
|
||||||
|
private lastClickTime: number = 0;
|
||||||
|
/** All hit entities at current click position | 当前点击位置的所有命中实体 */
|
||||||
|
private hitEntitiesAtClick: number[] = [];
|
||||||
|
/** Current cycle index | 当前循环索引 */
|
||||||
|
private cycleIndex: number = 0;
|
||||||
|
/** Position tolerance for same-position detection | 判断相同位置的容差 */
|
||||||
|
private static readonly CLICK_POSITION_TOLERANCE = 5;
|
||||||
|
/** Time tolerance for cycling (ms) | 循环的时间容差(毫秒) */
|
||||||
|
private static readonly CLICK_TIME_TOLERANCE = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently hovered entity ID
|
||||||
|
* 获取当前悬停的实体 ID
|
||||||
|
*/
|
||||||
|
getHoveredEntityId(): number | null {
|
||||||
|
return this.hoveredEntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently hovered gizmo data
|
||||||
|
* 获取当前悬停的 Gizmo 数据
|
||||||
|
*/
|
||||||
|
getHoveredGizmo(): IGizmoRenderData | null {
|
||||||
|
return this.hoveredGizmo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update mouse position and perform hit test
|
||||||
|
* 更新鼠标位置并执行命中测试
|
||||||
|
*/
|
||||||
|
updateMousePosition(worldX: number, worldY: number, zoom: number): void {
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) {
|
||||||
|
this.hoveredEntityId = null;
|
||||||
|
this.hoveredGizmo = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let closestHit: GizmoHitResult | null = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
|
||||||
|
// Iterate all entities and collect gizmo data for hit testing
|
||||||
|
// 遍历所有实体,收集 gizmo 数据进行命中测试
|
||||||
|
for (const entity of scene.entities.buffer) {
|
||||||
|
// Skip entities without gizmo providers
|
||||||
|
// 跳过没有 gizmo 提供者的实体
|
||||||
|
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const component of entity.components) {
|
||||||
|
const componentType = component.constructor as ComponentType;
|
||||||
|
if (!GizmoRegistry.hasProvider(componentType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
|
||||||
|
for (const gizmo of gizmos) {
|
||||||
|
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
|
||||||
|
// Calculate distance to gizmo center for sorting
|
||||||
|
// 计算到 gizmo 中心的距离用于排序
|
||||||
|
const center = GizmoHitTester.getGizmoCenter(gizmo);
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestHit = {
|
||||||
|
gizmo,
|
||||||
|
entityId: entity.id,
|
||||||
|
distance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hoveredEntityId = closestHit?.entityId ?? null;
|
||||||
|
this.hoveredGizmo = closestHit?.gizmo ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get highlight color for entity
|
||||||
|
* 获取实体的高亮颜色
|
||||||
|
*/
|
||||||
|
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor {
|
||||||
|
const isHovered = entityId === this.hoveredEntityId;
|
||||||
|
|
||||||
|
if (!isHovered) {
|
||||||
|
return baseColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply hover highlight: brighten color and increase alpha
|
||||||
|
// 应用悬停高亮:提亮颜色并增加透明度
|
||||||
|
return {
|
||||||
|
r: Math.min(1, baseColor.r * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
|
||||||
|
g: Math.min(1, baseColor.g * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
|
||||||
|
b: Math.min(1, baseColor.b * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
|
||||||
|
a: Math.min(1, baseColor.a + GizmoInteractionService.HOVER_ALPHA_BOOST)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle click at position, return hit entity ID
|
||||||
|
* Supports cycling through overlapping entities on repeated clicks
|
||||||
|
* 处理位置点击,返回命中的实体 ID
|
||||||
|
* 支持重复点击时循环选择重叠的实体
|
||||||
|
*/
|
||||||
|
handleClick(worldX: number, worldY: number, zoom: number): number | null {
|
||||||
|
const now = Date.now();
|
||||||
|
const isSamePosition = this.lastClickPos !== null &&
|
||||||
|
Math.abs(worldX - this.lastClickPos.x) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom &&
|
||||||
|
Math.abs(worldY - this.lastClickPos.y) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom;
|
||||||
|
const isWithinTimeWindow = (now - this.lastClickTime) < GizmoInteractionService.CLICK_TIME_TOLERANCE;
|
||||||
|
|
||||||
|
// If clicking at same position within time window, cycle to next entity
|
||||||
|
// 如果在时间窗口内点击相同位置,循环到下一个实体
|
||||||
|
if (isSamePosition && isWithinTimeWindow && this.hitEntitiesAtClick.length > 1) {
|
||||||
|
this.cycleIndex = (this.cycleIndex + 1) % this.hitEntitiesAtClick.length;
|
||||||
|
this.lastClickTime = now;
|
||||||
|
const selectedId = this.hitEntitiesAtClick[this.cycleIndex];
|
||||||
|
this.hoveredEntityId = selectedId;
|
||||||
|
return selectedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New position or timeout - collect all hit entities
|
||||||
|
// 新位置或超时 - 收集所有命中的实体
|
||||||
|
this.hitEntitiesAtClick = this.collectAllHitEntities(worldX, worldY, zoom);
|
||||||
|
this.cycleIndex = 0;
|
||||||
|
this.lastClickPos = { x: worldX, y: worldY };
|
||||||
|
this.lastClickTime = now;
|
||||||
|
|
||||||
|
if (this.hitEntitiesAtClick.length > 0) {
|
||||||
|
const selectedId = this.hitEntitiesAtClick[0];
|
||||||
|
this.hoveredEntityId = selectedId;
|
||||||
|
return selectedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all entities hit at the given position, sorted by distance
|
||||||
|
* 收集给定位置命中的所有实体,按距离排序
|
||||||
|
*/
|
||||||
|
private collectAllHitEntities(worldX: number, worldY: number, zoom: number): number[] {
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) return [];
|
||||||
|
|
||||||
|
const hits: GizmoHitResult[] = [];
|
||||||
|
|
||||||
|
for (const entity of scene.entities.buffer) {
|
||||||
|
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entityHit = false;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (const component of entity.components) {
|
||||||
|
const componentType = component.constructor as ComponentType;
|
||||||
|
if (!GizmoRegistry.hasProvider(componentType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
|
||||||
|
for (const gizmo of gizmos) {
|
||||||
|
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
|
||||||
|
entityHit = true;
|
||||||
|
const center = GizmoHitTester.getGizmoCenter(gizmo);
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
|
||||||
|
);
|
||||||
|
minDistance = Math.min(minDistance, distance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityHit) {
|
||||||
|
hits.push({
|
||||||
|
gizmo: {} as IGizmoRenderData, // Not needed for sorting
|
||||||
|
entityId: entity.id,
|
||||||
|
distance: minDistance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by distance (closest first)
|
||||||
|
// 按距离排序(最近的在前)
|
||||||
|
hits.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
return hits.map(hit => hit.entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear hover state
|
||||||
|
* 清除悬停状态
|
||||||
|
*/
|
||||||
|
clearHover(): void {
|
||||||
|
this.hoveredEntityId = null;
|
||||||
|
this.hoveredGizmo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import type { LocaleService, Locale, TranslationParams, PluginTranslations } fro
|
|||||||
import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub';
|
import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub';
|
||||||
import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService';
|
import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService';
|
||||||
import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
|
import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
|
||||||
|
import type { IGizmoInteractionService } from './Services/GizmoInteractionService';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LocaleService Token
|
// LocaleService Token
|
||||||
@@ -203,9 +204,28 @@ export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabServ
|
|||||||
// 重新导出类型方便使用
|
// 重新导出类型方便使用
|
||||||
export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
|
export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GizmoInteractionService Token
|
||||||
|
// Gizmo 交互服务令牌
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gizmo 交互服务令牌
|
||||||
|
* Gizmo interaction service token
|
||||||
|
*
|
||||||
|
* 用于注册和获取 Gizmo 交互服务。
|
||||||
|
* For registering and getting gizmo interaction service.
|
||||||
|
*/
|
||||||
|
export const GizmoInteractionServiceToken = createServiceToken<IGizmoInteractionService>('gizmoInteractionService');
|
||||||
|
|
||||||
|
// Re-export interface for convenience
|
||||||
|
// 重新导出接口方便使用
|
||||||
|
export type { IGizmoInteractionService } from './Services/GizmoInteractionService';
|
||||||
|
|
||||||
// Re-export classes for direct use (backwards compatibility)
|
// Re-export classes for direct use (backwards compatibility)
|
||||||
// 重新导出类以供直接使用(向后兼容)
|
// 重新导出类以供直接使用(向后兼容)
|
||||||
export { LocaleService } from './Services/LocaleService';
|
export { LocaleService } from './Services/LocaleService';
|
||||||
export { MessageHub } from './Services/MessageHub';
|
export { MessageHub } from './Services/MessageHub';
|
||||||
export { EntityStoreService } from './Services/EntityStoreService';
|
export { EntityStoreService } from './Services/EntityStoreService';
|
||||||
export { PrefabService } from './Services/PrefabService';
|
export { PrefabService } from './Services/PrefabService';
|
||||||
|
export { GizmoInteractionService } from './Services/GizmoInteractionService';
|
||||||
|
|||||||
@@ -117,6 +117,39 @@ export interface IEngineBridge {
|
|||||||
* @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded
|
* @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded
|
||||||
*/
|
*/
|
||||||
waitForAllTextures?(timeout?: number): Promise<void>;
|
waitForAllTextures?(timeout?: number): Promise<void>;
|
||||||
|
|
||||||
|
// ===== Dynamic Atlas API (Optional) =====
|
||||||
|
// ===== 动态图集 API(可选)=====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建空白纹理(用于动态图集)
|
||||||
|
* Create blank texture (for dynamic atlas)
|
||||||
|
*
|
||||||
|
* @param width 宽度 | Width
|
||||||
|
* @param height 高度 | Height
|
||||||
|
* @returns 纹理 ID | Texture ID
|
||||||
|
*/
|
||||||
|
createBlankTexture?(width: number, height: number): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新纹理区域
|
||||||
|
* Update texture region
|
||||||
|
*
|
||||||
|
* @param id 纹理 ID | Texture ID
|
||||||
|
* @param x X 坐标 | X coordinate
|
||||||
|
* @param y Y 坐标 | Y coordinate
|
||||||
|
* @param width 宽度 | Width
|
||||||
|
* @param height 高度 | Height
|
||||||
|
* @param pixels RGBA 像素数据 | RGBA pixel data
|
||||||
|
*/
|
||||||
|
updateTextureRegion?(
|
||||||
|
id: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8Array
|
||||||
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -81,14 +81,15 @@ export class TransformSystem extends EntitySystem {
|
|||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
// 构建仿射变换矩阵: Scale -> Rotate -> Translate
|
// 构建仿射变换矩阵: Scale -> Rotate -> Translate
|
||||||
// [a c tx] [sx 0 0] [cos -sin 0] [1 0 tx]
|
// 顺时针旋转 | Clockwise rotation
|
||||||
// [b d ty] = [0 sy 0] * [sin cos 0] * [0 1 ty]
|
// [a c tx] [sx 0 0] [cos sin 0] [1 0 tx]
|
||||||
|
// [b d ty] = [0 sy 0] * [-sin cos 0] * [0 1 ty]
|
||||||
// [0 0 1] [0 0 1] [0 0 1] [0 0 1]
|
// [0 0 1] [0 0 1] [0 0 1] [0 0 1]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
a: scale.x * cos,
|
a: scale.x * cos,
|
||||||
b: scale.x * sin,
|
b: -scale.x * sin,
|
||||||
c: scale.y * -sin,
|
c: scale.y * sin,
|
||||||
d: scale.y * cos,
|
d: scale.y * cos,
|
||||||
tx: position.x,
|
tx: position.x,
|
||||||
ty: position.y
|
ty: position.y
|
||||||
|
|||||||
@@ -349,6 +349,16 @@ impl Engine {
|
|||||||
self.texture_manager.get_texture_id_by_path(path)
|
self.texture_manager.get_texture_id_by_path(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get texture size by path.
|
||||||
|
/// 按路径获取纹理尺寸。
|
||||||
|
///
|
||||||
|
/// Returns None if texture is not loaded or path not found.
|
||||||
|
/// 如果纹理未加载或路径未找到,返回 None。
|
||||||
|
pub fn get_texture_size_by_path(&self, path: &str) -> Option<(f32, f32)> {
|
||||||
|
let id = self.texture_manager.get_texture_id_by_path(path)?;
|
||||||
|
self.texture_manager.get_texture_size(id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get or load texture by path.
|
/// Get or load texture by path.
|
||||||
/// 按路径获取或加载纹理。
|
/// 按路径获取或加载纹理。
|
||||||
pub fn get_or_load_by_path(&mut self, path: &str) -> Result<u32> {
|
pub fn get_or_load_by_path(&mut self, path: &str) -> Result<u32> {
|
||||||
@@ -374,6 +384,32 @@ impl Engine {
|
|||||||
self.texture_manager.clear_all();
|
self.texture_manager.clear_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a blank texture for dynamic atlas.
|
||||||
|
/// 为动态图集创建空白纹理。
|
||||||
|
///
|
||||||
|
/// This creates a texture that can be filled later using `update_texture_region`.
|
||||||
|
/// 创建一个可以稍后使用 `update_texture_region` 填充的纹理。
|
||||||
|
pub fn create_blank_texture(&mut self, width: u32, height: u32) -> Result<u32> {
|
||||||
|
self.texture_manager.create_blank_texture(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a region of an existing texture.
|
||||||
|
/// 更新现有纹理的区域。
|
||||||
|
///
|
||||||
|
/// Used for dynamic atlas to copy textures into the atlas.
|
||||||
|
/// 用于动态图集将纹理复制到图集中。
|
||||||
|
pub fn update_texture_region(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pixels: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.texture_manager.update_texture_region(id, x, y, width, height, pixels)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取纹理加载状态
|
/// 获取纹理加载状态
|
||||||
/// Get texture loading state
|
/// Get texture loading state
|
||||||
pub fn get_texture_state(&self, id: u32) -> crate::renderer::texture::TextureState {
|
pub fn get_texture_state(&self, id: u32) -> crate::renderer::texture::TextureState {
|
||||||
|
|||||||
@@ -212,6 +212,24 @@ impl GameEngine {
|
|||||||
self.engine.get_texture_id_by_path(path)
|
self.engine.get_texture_id_by_path(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get texture size by path.
|
||||||
|
/// 按路径获取纹理尺寸。
|
||||||
|
///
|
||||||
|
/// Returns an array [width, height] or null if not found.
|
||||||
|
/// 返回数组 [width, height],如果未找到则返回 null。
|
||||||
|
///
|
||||||
|
/// # Arguments | 参数
|
||||||
|
/// * `path` - Image path to lookup | 要查找的图片路径
|
||||||
|
#[wasm_bindgen(js_name = getTextureSizeByPath)]
|
||||||
|
pub fn get_texture_size_by_path(&self, path: &str) -> Option<js_sys::Float32Array> {
|
||||||
|
self.engine.get_texture_size_by_path(path).map(|(w, h)| {
|
||||||
|
let arr = js_sys::Float32Array::new_with_length(2);
|
||||||
|
arr.set_index(0, w);
|
||||||
|
arr.set_index(1, h);
|
||||||
|
arr
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Get or load texture by path.
|
/// Get or load texture by path.
|
||||||
/// 按路径获取或加载纹理。
|
/// 按路径获取或加载纹理。
|
||||||
///
|
///
|
||||||
@@ -722,4 +740,60 @@ impl GameEngine {
|
|||||||
pub fn clear_all_textures(&mut self) {
|
pub fn clear_all_textures(&mut self) {
|
||||||
self.engine.clear_all_textures();
|
self.engine.clear_all_textures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Dynamic Atlas API =====
|
||||||
|
// ===== 动态图集 API =====
|
||||||
|
|
||||||
|
/// Create a blank texture for dynamic atlas.
|
||||||
|
/// 为动态图集创建空白纹理。
|
||||||
|
///
|
||||||
|
/// This creates a texture that can be filled later using `updateTextureRegion`.
|
||||||
|
/// Used for runtime atlas generation to batch UI elements with different textures.
|
||||||
|
/// 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。
|
||||||
|
/// 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。
|
||||||
|
///
|
||||||
|
/// # Arguments | 参数
|
||||||
|
/// * `width` - Texture width in pixels (recommended: 2048) | 纹理宽度(推荐:2048)
|
||||||
|
/// * `height` - Texture height in pixels (recommended: 2048) | 纹理高度(推荐:2048)
|
||||||
|
///
|
||||||
|
/// # Returns | 返回
|
||||||
|
/// The texture ID for the created blank texture | 创建的空白纹理ID
|
||||||
|
#[wasm_bindgen(js_name = createBlankTexture)]
|
||||||
|
pub fn create_blank_texture(
|
||||||
|
&mut self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> std::result::Result<u32, JsValue> {
|
||||||
|
self.engine
|
||||||
|
.create_blank_texture(width, height)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a region of an existing texture with pixel data.
|
||||||
|
/// 使用像素数据更新现有纹理的区域。
|
||||||
|
///
|
||||||
|
/// This is used for dynamic atlas to copy individual textures into the atlas.
|
||||||
|
/// 用于动态图集将单个纹理复制到图集纹理中。
|
||||||
|
///
|
||||||
|
/// # Arguments | 参数
|
||||||
|
/// * `id` - The texture ID to update | 要更新的纹理ID
|
||||||
|
/// * `x` - X offset in the texture | 纹理中的X偏移
|
||||||
|
/// * `y` - Y offset in the texture | 纹理中的Y偏移
|
||||||
|
/// * `width` - Width of the region to update | 要更新的区域宽度
|
||||||
|
/// * `height` - Height of the region to update | 要更新的区域高度
|
||||||
|
/// * `pixels` - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据(每像素4字节)
|
||||||
|
#[wasm_bindgen(js_name = updateTextureRegion)]
|
||||||
|
pub fn update_texture_region(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pixels: &[u8],
|
||||||
|
) -> std::result::Result<(), JsValue> {
|
||||||
|
self.engine
|
||||||
|
.update_texture_region(id, x, y, width, height, pixels)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,15 +82,22 @@ impl Transform2D {
|
|||||||
///
|
///
|
||||||
/// The matrix is constructed as: T * R * S (translate, rotate, scale).
|
/// The matrix is constructed as: T * R * S (translate, rotate, scale).
|
||||||
/// 矩阵构造顺序为:T * R * S(平移、旋转、缩放)。
|
/// 矩阵构造顺序为:T * R * S(平移、旋转、缩放)。
|
||||||
|
///
|
||||||
|
/// Uses left-hand coordinate system convention:
|
||||||
|
/// 使用左手坐标系约定:
|
||||||
|
/// - Positive rotation = clockwise (when viewed from +Z)
|
||||||
|
/// - 正旋转 = 顺时针(从 +Z 方向观察时)
|
||||||
pub fn to_matrix(&self) -> Mat3 {
|
pub fn to_matrix(&self) -> Mat3 {
|
||||||
let cos = self.rotation.cos();
|
let cos = self.rotation.cos();
|
||||||
let sin = self.rotation.sin();
|
let sin = self.rotation.sin();
|
||||||
|
|
||||||
// Construct TRS matrix directly for performance
|
// Construct TRS matrix directly for performance
|
||||||
// 直接构造TRS矩阵以提高性能
|
// 直接构造TRS矩阵以提高性能
|
||||||
|
// Clockwise rotation: [cos, -sin; sin, cos] (column-major)
|
||||||
|
// 顺时针旋转矩阵
|
||||||
Mat3::from_cols(
|
Mat3::from_cols(
|
||||||
glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0),
|
glam::Vec3::new(cos * self.scale.x, -sin * self.scale.x, 0.0),
|
||||||
glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0),
|
glam::Vec3::new(sin * self.scale.y, cos * self.scale.y, 0.0),
|
||||||
glam::Vec3::new(self.position.x, self.position.y, 1.0),
|
glam::Vec3::new(self.position.x, self.position.y, 1.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -101,6 +108,9 @@ impl Transform2D {
|
|||||||
/// # Arguments | 参数
|
/// # Arguments | 参数
|
||||||
/// * `width` - Sprite width | 精灵宽度
|
/// * `width` - Sprite width | 精灵宽度
|
||||||
/// * `height` - Sprite height | 精灵高度
|
/// * `height` - Sprite height | 精灵高度
|
||||||
|
///
|
||||||
|
/// Uses left-hand coordinate system (clockwise positive rotation).
|
||||||
|
/// 使用左手坐标系(顺时针正旋转)。
|
||||||
pub fn to_matrix_with_origin(&self, width: f32, height: f32) -> Mat3 {
|
pub fn to_matrix_with_origin(&self, width: f32, height: f32) -> Mat3 {
|
||||||
let ox = -self.origin.x * width * self.scale.x;
|
let ox = -self.origin.x * width * self.scale.x;
|
||||||
let oy = -self.origin.y * height * self.scale.y;
|
let oy = -self.origin.y * height * self.scale.y;
|
||||||
@@ -108,14 +118,16 @@ impl Transform2D {
|
|||||||
let cos = self.rotation.cos();
|
let cos = self.rotation.cos();
|
||||||
let sin = self.rotation.sin();
|
let sin = self.rotation.sin();
|
||||||
|
|
||||||
// Apply origin offset after rotation
|
// Apply origin offset after rotation (clockwise rotation)
|
||||||
// 在旋转后应用原点偏移
|
// 在旋转后应用原点偏移(顺时针旋转)
|
||||||
let tx = self.position.x + ox * cos - oy * sin;
|
let tx = self.position.x + ox * cos + oy * sin;
|
||||||
let ty = self.position.y + ox * sin + oy * cos;
|
let ty = self.position.y - ox * sin + oy * cos;
|
||||||
|
|
||||||
|
// Clockwise rotation matrix
|
||||||
|
// 顺时针旋转矩阵
|
||||||
Mat3::from_cols(
|
Mat3::from_cols(
|
||||||
glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0),
|
glam::Vec3::new(cos * self.scale.x, -sin * self.scale.x, 0.0),
|
||||||
glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0),
|
glam::Vec3::new(sin * self.scale.y, cos * self.scale.y, 0.0),
|
||||||
glam::Vec3::new(tx, ty, 1.0),
|
glam::Vec3::new(tx, ty, 1.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,13 +113,20 @@ impl Vec2 {
|
|||||||
|
|
||||||
/// Rotate the vector by an angle (in radians).
|
/// Rotate the vector by an angle (in radians).
|
||||||
/// 按角度旋转向量(弧度)。
|
/// 按角度旋转向量(弧度)。
|
||||||
|
///
|
||||||
|
/// Uses left-hand coordinate system convention:
|
||||||
|
/// 使用左手坐标系约定:
|
||||||
|
/// - Positive angle = clockwise rotation (when viewed from +Z)
|
||||||
|
/// - 正角度 = 顺时针旋转(从 +Z 方向观察时)
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn rotate(&self, angle: f32) -> Self {
|
pub fn rotate(&self, angle: f32) -> Self {
|
||||||
let cos = angle.cos();
|
let cos = angle.cos();
|
||||||
let sin = angle.sin();
|
let sin = angle.sin();
|
||||||
|
// Clockwise rotation matrix: [cos, sin; -sin, cos]
|
||||||
|
// 顺时针旋转矩阵
|
||||||
Self {
|
Self {
|
||||||
x: self.x * cos - self.y * sin,
|
x: self.x * cos + self.y * sin,
|
||||||
y: self.x * sin + self.y * cos,
|
y: -self.x * sin + self.y * cos,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Sprite batch renderer for efficient 2D rendering.
|
//! Sprite batch renderer for efficient 2D rendering.
|
||||||
//! 用于高效2D渲染的精灵批处理渲染器。
|
//! 用于高效2D渲染的精灵批处理渲染器。
|
||||||
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use web_sys::{
|
use web_sys::{
|
||||||
WebGl2RenderingContext, WebGlBuffer, WebGlVertexArrayObject,
|
WebGl2RenderingContext, WebGlBuffer, WebGlVertexArrayObject,
|
||||||
};
|
};
|
||||||
@@ -66,17 +65,23 @@ pub struct SpriteBatch {
|
|||||||
/// 最大精灵数。
|
/// 最大精灵数。
|
||||||
max_sprites: usize,
|
max_sprites: usize,
|
||||||
|
|
||||||
/// Per-material-texture vertex data buffers (insertion-ordered).
|
/// Batches stored as (key, vertices) pairs in submission order.
|
||||||
/// 按材质和纹理分组的顶点数据缓冲区(保持插入顺序)。
|
/// 按提交顺序存储的批次(键,顶点)对。
|
||||||
///
|
///
|
||||||
/// Uses IndexMap to preserve render order - sprites submitted first
|
/// Only consecutive sprites with the same BatchKey are batched together.
|
||||||
/// are rendered first (appear behind later sprites).
|
/// Sprites with the same key but separated by different keys are kept in separate batches
|
||||||
/// 使用 IndexMap 保持渲染顺序 - 先提交的精灵先渲染(显示在后面)。
|
/// to preserve correct render order.
|
||||||
batches: IndexMap<BatchKey, Vec<f32>>,
|
/// 只有连续的相同 BatchKey 的 sprites 才会合批。
|
||||||
|
/// 相同 key 但被其他 key 分隔的 sprites 保持在独立批次中以保证正确的渲染顺序。
|
||||||
|
batches: Vec<(BatchKey, Vec<f32>)>,
|
||||||
|
|
||||||
/// Total sprite count across all batches.
|
/// Total sprite count across all batches.
|
||||||
/// 所有批次的总精灵数。
|
/// 所有批次的总精灵数。
|
||||||
sprite_count: usize,
|
sprite_count: usize,
|
||||||
|
|
||||||
|
/// Last batch key used, for determining if we can merge into the last batch.
|
||||||
|
/// 上一个使用的批次键,用于判断是否可以合并到最后一个批次。
|
||||||
|
last_batch_key: Option<BatchKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpriteBatch {
|
impl SpriteBatch {
|
||||||
@@ -140,8 +145,9 @@ impl SpriteBatch {
|
|||||||
vbo,
|
vbo,
|
||||||
ibo,
|
ibo,
|
||||||
max_sprites,
|
max_sprites,
|
||||||
batches: IndexMap::new(),
|
batches: Vec::new(),
|
||||||
sprite_count: 0,
|
sprite_count: 0,
|
||||||
|
last_batch_key: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +174,15 @@ impl SpriteBatch {
|
|||||||
|
|
||||||
/// Set up vertex attribute pointers.
|
/// Set up vertex attribute pointers.
|
||||||
/// 设置顶点属性指针。
|
/// 设置顶点属性指针。
|
||||||
|
///
|
||||||
|
/// Vertex layout (9 floats per vertex):
|
||||||
|
/// 顶点布局(每顶点 9 个浮点数):
|
||||||
|
/// - location 0: position (2 floats) - offset 0
|
||||||
|
/// - location 1: tex_coord (2 floats) - offset 8
|
||||||
|
/// - location 2: color (4 floats) - offset 16
|
||||||
|
/// - location 3: aspect_ratio (1 float) - offset 32
|
||||||
fn setup_vertex_attributes(gl: &WebGl2RenderingContext) {
|
fn setup_vertex_attributes(gl: &WebGl2RenderingContext) {
|
||||||
let stride = (FLOATS_PER_VERTEX * 4) as i32;
|
let stride = (FLOATS_PER_VERTEX * 4) as i32; // 9 * 4 = 36 bytes
|
||||||
|
|
||||||
// Position attribute (location = 0) | 位置属性
|
// Position attribute (location = 0) | 位置属性
|
||||||
gl.enable_vertex_attrib_array(0);
|
gl.enable_vertex_attrib_array(0);
|
||||||
@@ -203,15 +216,27 @@ impl SpriteBatch {
|
|||||||
stride,
|
stride,
|
||||||
16, // 4 floats * 4 bytes
|
16, // 4 floats * 4 bytes
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Aspect ratio attribute (location = 3) | 宽高比属性
|
||||||
|
// Used by shaders for aspect-ratio-aware transformations
|
||||||
|
// 用于着色器中的宽高比感知变换
|
||||||
|
gl.enable_vertex_attrib_array(3);
|
||||||
|
gl.vertex_attrib_pointer_with_i32(
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
WebGl2RenderingContext::FLOAT,
|
||||||
|
false,
|
||||||
|
stride,
|
||||||
|
32, // (2 + 2 + 4) floats * 4 bytes
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the batch for a new frame.
|
/// Clear the batch for a new frame.
|
||||||
/// 为新帧清空批处理。
|
/// 为新帧清空批处理。
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
for batch in self.batches.values_mut() {
|
self.batches.clear();
|
||||||
batch.clear();
|
|
||||||
}
|
|
||||||
self.sprite_count = 0;
|
self.sprite_count = 0;
|
||||||
|
self.last_batch_key = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add sprites from batch data.
|
/// Add sprites from batch data.
|
||||||
@@ -302,21 +327,40 @@ impl SpriteBatch {
|
|||||||
let width = scale_x;
|
let width = scale_x;
|
||||||
let height = scale_y;
|
let height = scale_y;
|
||||||
|
|
||||||
|
// Calculate aspect ratio (width / height), default 1.0 for degenerate cases
|
||||||
|
// 计算宽高比(宽度/高度),退化情况下默认为 1.0
|
||||||
|
let aspect_ratio = if height.abs() > 0.001 {
|
||||||
|
width / height
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
let batch_key = BatchKey {
|
let batch_key = BatchKey {
|
||||||
material_id: material_ids[i],
|
material_id: material_ids[i],
|
||||||
texture_id: texture_ids[i],
|
texture_id: texture_ids[i],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get or create batch for this material+texture combination | 获取或创建此材质+纹理组合的批次
|
// Only batch consecutive sprites with the same key to preserve render order
|
||||||
let batch = self.batches
|
// 只对连续相同 key 的 sprites 合批以保持渲染顺序
|
||||||
.entry(batch_key)
|
let should_create_new_batch = match self.last_batch_key {
|
||||||
.or_insert_with(Vec::new);
|
Some(last_key) => batch_key != last_key,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_create_new_batch {
|
||||||
|
// Create a new batch | 创建新批次
|
||||||
|
self.batches.push((batch_key, Vec::new()));
|
||||||
|
self.last_batch_key = Some(batch_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to the last batch | 添加到最后一个批次
|
||||||
|
let batch = &mut self.batches.last_mut().unwrap().1;
|
||||||
|
|
||||||
// Calculate transformed vertices and add to batch | 计算变换后的顶点并添加到批次
|
// Calculate transformed vertices and add to batch | 计算变换后的顶点并添加到批次
|
||||||
Self::add_sprite_vertices_to_batch(
|
Self::add_sprite_vertices_to_batch(
|
||||||
batch,
|
batch,
|
||||||
x, y, width, height, rotation, origin_x, origin_y,
|
x, y, width, height, rotation, origin_x, origin_y,
|
||||||
u0, v0, u1, v1, color_arr,
|
u0, v0, u1, v1, color_arr, aspect_ratio,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +370,9 @@ impl SpriteBatch {
|
|||||||
|
|
||||||
/// Add vertices for a single sprite to a batch.
|
/// Add vertices for a single sprite to a batch.
|
||||||
/// 为单个精灵添加顶点到批次。
|
/// 为单个精灵添加顶点到批次。
|
||||||
|
///
|
||||||
|
/// Each vertex contains: position(2) + tex_coord(2) + color(4) + aspect_ratio(1) = 9 floats
|
||||||
|
/// 每个顶点包含: 位置(2) + 纹理坐标(2) + 颜色(4) + 宽高比(1) = 9 个浮点数
|
||||||
#[inline]
|
#[inline]
|
||||||
fn add_sprite_vertices_to_batch(
|
fn add_sprite_vertices_to_batch(
|
||||||
batch: &mut Vec<f32>,
|
batch: &mut Vec<f32>,
|
||||||
@@ -341,6 +388,7 @@ impl SpriteBatch {
|
|||||||
u1: f32,
|
u1: f32,
|
||||||
v1: f32,
|
v1: f32,
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
|
aspect_ratio: f32,
|
||||||
) {
|
) {
|
||||||
let cos = rotation.cos();
|
let cos = rotation.cos();
|
||||||
let sin = rotation.sin();
|
let sin = rotation.sin();
|
||||||
@@ -393,6 +441,10 @@ impl SpriteBatch {
|
|||||||
|
|
||||||
// Color | 颜色
|
// Color | 颜色
|
||||||
batch.extend_from_slice(&color);
|
batch.extend_from_slice(&color);
|
||||||
|
|
||||||
|
// Aspect ratio (same for all 4 vertices of a quad)
|
||||||
|
// 宽高比(四边形的 4 个顶点相同)
|
||||||
|
batch.push(aspect_ratio);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,16 +484,16 @@ impl SpriteBatch {
|
|||||||
gl.bind_vertex_array(None);
|
gl.bind_vertex_array(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all batches for rendering (in insertion order).
|
/// Get all batches for rendering (in submission order).
|
||||||
/// 获取所有批次用于渲染(按插入顺序)。
|
/// 获取所有批次用于渲染(按提交顺序)。
|
||||||
pub fn batches(&self) -> &IndexMap<BatchKey, Vec<f32>> {
|
pub fn batches(&self) -> &[(BatchKey, Vec<f32>)] {
|
||||||
&self.batches
|
&self.batches
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush a specific batch by key.
|
/// Flush a specific batch by index.
|
||||||
/// 按键刷新特定批次。
|
/// 按索引刷新特定批次。
|
||||||
pub fn flush_for_batch(&self, gl: &WebGl2RenderingContext, key: &BatchKey) {
|
pub fn flush_batch_at(&self, gl: &WebGl2RenderingContext, index: usize) {
|
||||||
if let Some(vertices) = self.batches.get(key) {
|
if let Some((_, vertices)) = self.batches.get(index) {
|
||||||
self.flush_batch(gl, vertices);
|
self.flush_batch(gl, vertices);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ pub const VERTEX_SIZE: usize = std::mem::size_of::<SpriteVertex>();
|
|||||||
|
|
||||||
/// Number of floats per vertex.
|
/// Number of floats per vertex.
|
||||||
/// 每个顶点的浮点数数量。
|
/// 每个顶点的浮点数数量。
|
||||||
pub const FLOATS_PER_VERTEX: usize = 8;
|
///
|
||||||
|
/// Layout: position(2) + tex_coord(2) + color(4) + aspect_ratio(1) = 9
|
||||||
|
/// 布局: 位置(2) + 纹理坐标(2) + 颜色(4) + 宽高比(1) = 9
|
||||||
|
pub const FLOATS_PER_VERTEX: usize = 9;
|
||||||
|
|
||||||
/// Sprite vertex data.
|
/// Sprite vertex data.
|
||||||
/// 精灵顶点数据。
|
/// 精灵顶点数据。
|
||||||
///
|
///
|
||||||
/// Each sprite requires 4 vertices (quad), each with position, UV, and color.
|
/// Each sprite requires 4 vertices (quad), each with position, UV, color, and aspect ratio.
|
||||||
/// 每个精灵需要4个顶点(四边形),每个顶点包含位置、UV和颜色。
|
/// 每个精灵需要4个顶点(四边形),每个顶点包含位置、UV、颜色和宽高比。
|
||||||
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
|
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
pub struct SpriteVertex {
|
pub struct SpriteVertex {
|
||||||
@@ -30,6 +33,15 @@ pub struct SpriteVertex {
|
|||||||
/// Color (r, g, b, a).
|
/// Color (r, g, b, a).
|
||||||
/// 颜色。
|
/// 颜色。
|
||||||
pub color: [f32; 4],
|
pub color: [f32; 4],
|
||||||
|
|
||||||
|
/// Aspect ratio (width / height) for shader effects.
|
||||||
|
/// 宽高比(宽度/高度),用于着色器效果。
|
||||||
|
///
|
||||||
|
/// This allows shaders to apply aspect-ratio-aware transformations
|
||||||
|
/// (e.g., rotation in shiny effects) without per-instance uniforms.
|
||||||
|
/// 这允许着色器应用宽高比感知的变换(如闪光效果中的旋转),
|
||||||
|
/// 无需每实例 uniform。
|
||||||
|
pub aspect_ratio: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpriteVertex {
|
impl SpriteVertex {
|
||||||
@@ -40,11 +52,13 @@ impl SpriteVertex {
|
|||||||
position: [f32; 2],
|
position: [f32; 2],
|
||||||
tex_coord: [f32; 2],
|
tex_coord: [f32; 2],
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
|
aspect_ratio: f32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
position,
|
position,
|
||||||
tex_coord,
|
tex_coord,
|
||||||
color,
|
color,
|
||||||
|
aspect_ratio,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +69,7 @@ impl Default for SpriteVertex {
|
|||||||
position: [0.0, 0.0],
|
position: [0.0, 0.0],
|
||||||
tex_coord: [0.0, 0.0],
|
tex_coord: [0.0, 0.0],
|
||||||
color: [1.0, 1.0, 1.0, 1.0],
|
color: [1.0, 1.0, 1.0, 1.0],
|
||||||
|
aspect_ratio: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
//! 2D camera implementation.
|
//! 2D camera implementation.
|
||||||
//! 2D相机实现。
|
//! 2D相机实现。
|
||||||
|
//!
|
||||||
|
//! Uses left-hand coordinate system convention:
|
||||||
|
//! 使用左手坐标系约定:
|
||||||
|
//! - X axis: positive to the right / X 轴:正方向向右
|
||||||
|
//! - Y axis: positive upward (in world space) / Y 轴:正方向向上(世界空间)
|
||||||
|
//! - Z axis: positive into the screen / Z 轴:正方向指向屏幕内
|
||||||
|
//! - Positive rotation: clockwise (when viewed from +Z) / 正旋转:顺时针(从 +Z 观察)
|
||||||
|
|
||||||
use crate::math::Vec2;
|
use crate::math::Vec2;
|
||||||
use glam::Mat3;
|
use glam::Mat3;
|
||||||
@@ -67,6 +74,7 @@ impl Camera2D {
|
|||||||
/// - World: Y-up, origin at camera position | 世界坐标:Y向上,原点在相机位置
|
/// - World: Y-up, origin at camera position | 世界坐标:Y向上,原点在相机位置
|
||||||
/// - Screen: Y-down, origin at top-left | 屏幕坐标:Y向下,原点在左上角
|
/// - Screen: Y-down, origin at top-left | 屏幕坐标:Y向下,原点在左上角
|
||||||
/// - NDC: Y-up, origin at center [-1, 1] | NDC:Y向上,原点在中心
|
/// - NDC: Y-up, origin at center [-1, 1] | NDC:Y向上,原点在中心
|
||||||
|
/// - Rotation: positive = clockwise | 旋转:正 = 顺时针
|
||||||
///
|
///
|
||||||
/// When zoom=1, 1 world unit = 1 screen pixel.
|
/// When zoom=1, 1 world unit = 1 screen pixel.
|
||||||
/// 当zoom=1时,1个世界单位 = 1个屏幕像素。
|
/// 当zoom=1时,1个世界单位 = 1个屏幕像素。
|
||||||
@@ -81,8 +89,8 @@ impl Camera2D {
|
|||||||
let sx = 2.0 / self.width * self.zoom;
|
let sx = 2.0 / self.width * self.zoom;
|
||||||
let sy = 2.0 / self.height * self.zoom;
|
let sy = 2.0 / self.height * self.zoom;
|
||||||
|
|
||||||
// Handle rotation
|
// Handle rotation (clockwise positive)
|
||||||
// 处理旋转
|
// 处理旋转(顺时针为正)
|
||||||
let cos = self.rotation.cos();
|
let cos = self.rotation.cos();
|
||||||
let sin = self.rotation.sin();
|
let sin = self.rotation.sin();
|
||||||
|
|
||||||
@@ -97,15 +105,17 @@ impl Camera2D {
|
|||||||
// 组合缩放、旋转和平移
|
// 组合缩放、旋转和平移
|
||||||
// Matrix = Scale * Rotation * Translation (applied right to left)
|
// Matrix = Scale * Rotation * Translation (applied right to left)
|
||||||
// 矩阵 = 缩放 * 旋转 * 平移(从右到左应用)
|
// 矩阵 = 缩放 * 旋转 * 平移(从右到左应用)
|
||||||
|
// Clockwise rotation: [cos, -sin; sin, cos]
|
||||||
|
// 顺时针旋转矩阵
|
||||||
if self.rotation != 0.0 {
|
if self.rotation != 0.0 {
|
||||||
// With rotation: need to rotate the translation as well
|
// With rotation: need to rotate the translation as well (clockwise)
|
||||||
// 有旋转时:平移也需要旋转
|
// 有旋转时:平移也需要旋转(顺时针)
|
||||||
let rtx = tx * cos - ty * sin;
|
let rtx = tx * cos + ty * sin;
|
||||||
let rty = tx * sin + ty * cos;
|
let rty = -tx * sin + ty * cos;
|
||||||
|
|
||||||
Mat3::from_cols(
|
Mat3::from_cols(
|
||||||
glam::Vec3::new(sx * cos, sx * sin, 0.0),
|
glam::Vec3::new(sx * cos, -sx * sin, 0.0),
|
||||||
glam::Vec3::new(-sy * sin, sy * cos, 0.0),
|
glam::Vec3::new(sy * sin, sy * cos, 0.0),
|
||||||
glam::Vec3::new(rtx, rty, 1.0),
|
glam::Vec3::new(rtx, rty, 1.0),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -124,6 +134,7 @@ impl Camera2D {
|
|||||||
///
|
///
|
||||||
/// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角,Y向下
|
/// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角,Y向下
|
||||||
/// World: Y-up, camera at center | 世界:Y向上,相机在中心
|
/// World: Y-up, camera at center | 世界:Y向上,相机在中心
|
||||||
|
/// Rotation: positive = clockwise | 旋转:正 = 顺时针
|
||||||
pub fn screen_to_world(&self, screen: Vec2) -> Vec2 {
|
pub fn screen_to_world(&self, screen: Vec2) -> Vec2 {
|
||||||
// Convert screen to NDC-like coordinates (centered, Y-up)
|
// Convert screen to NDC-like coordinates (centered, Y-up)
|
||||||
// 将屏幕坐标转换为类NDC坐标(居中,Y向上)
|
// 将屏幕坐标转换为类NDC坐标(居中,Y向上)
|
||||||
@@ -138,11 +149,15 @@ impl Camera2D {
|
|||||||
if self.rotation != 0.0 {
|
if self.rotation != 0.0 {
|
||||||
// Apply inverse rotation around camera position
|
// Apply inverse rotation around camera position
|
||||||
// 围绕相机位置应用反向旋转
|
// 围绕相机位置应用反向旋转
|
||||||
|
// Inverse of clockwise θ is clockwise -θ
|
||||||
|
// 顺时针 θ 的逆变换是顺时针 -θ
|
||||||
let dx = world_x - self.position.x;
|
let dx = world_x - self.position.x;
|
||||||
let dy = world_y - self.position.y;
|
let dy = world_y - self.position.y;
|
||||||
let cos = (-self.rotation).cos();
|
let cos = self.rotation.cos(); // cos(-θ) = cos(θ)
|
||||||
let sin = (-self.rotation).sin();
|
let sin = self.rotation.sin(); // for clockwise -θ: use -sin(θ)
|
||||||
|
|
||||||
|
// Clockwise rotation with -θ: x' = x*cos + y*(-sin), y' = -x*(-sin) + y*cos
|
||||||
|
// 用 -θ 做顺时针旋转
|
||||||
Vec2::new(
|
Vec2::new(
|
||||||
dx * cos - dy * sin + self.position.x,
|
dx * cos - dy * sin + self.position.x,
|
||||||
dx * sin + dy * cos + self.position.y,
|
dx * sin + dy * cos + self.position.y,
|
||||||
@@ -157,14 +172,19 @@ impl Camera2D {
|
|||||||
///
|
///
|
||||||
/// World: Y-up | 世界:Y向上
|
/// World: Y-up | 世界:Y向上
|
||||||
/// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角,Y向下
|
/// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角,Y向下
|
||||||
|
/// Rotation: positive = clockwise | 旋转:正 = 顺时针
|
||||||
pub fn world_to_screen(&self, world: Vec2) -> Vec2 {
|
pub fn world_to_screen(&self, world: Vec2) -> Vec2 {
|
||||||
let dx = world.x - self.position.x;
|
let dx = world.x - self.position.x;
|
||||||
let dy = world.y - self.position.y;
|
let dy = world.y - self.position.y;
|
||||||
|
|
||||||
|
// Apply clockwise rotation
|
||||||
|
// 应用顺时针旋转
|
||||||
let (rx, ry) = if self.rotation != 0.0 {
|
let (rx, ry) = if self.rotation != 0.0 {
|
||||||
let cos = self.rotation.cos();
|
let cos = self.rotation.cos();
|
||||||
let sin = self.rotation.sin();
|
let sin = self.rotation.sin();
|
||||||
(dx * cos - dy * sin, dx * sin + dy * cos)
|
// Clockwise: x' = x*cos + y*sin, y' = -x*sin + y*cos
|
||||||
|
// 顺时针旋转公式
|
||||||
|
(dx * cos + dy * sin, -dx * sin + dy * cos)
|
||||||
} else {
|
} else {
|
||||||
(dx, dy)
|
(dx, dy)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,19 +116,10 @@ impl Renderer2D {
|
|||||||
/// Render the current frame.
|
/// Render the current frame.
|
||||||
/// 渲染当前帧。
|
/// 渲染当前帧。
|
||||||
pub fn render(&mut self, gl: &WebGl2RenderingContext, texture_manager: &TextureManager) -> Result<()> {
|
pub fn render(&mut self, gl: &WebGl2RenderingContext, texture_manager: &TextureManager) -> Result<()> {
|
||||||
use super::batch::BatchKey;
|
|
||||||
|
|
||||||
if self.sprite_batch.sprite_count() == 0 {
|
if self.sprite_batch.sprite_count() == 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect non-empty batch keys | 收集非空批次键
|
|
||||||
let batch_keys: Vec<BatchKey> = self.sprite_batch.batches()
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, vertices)| !vertices.is_empty())
|
|
||||||
.map(|(key, _)| *key)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Track current state to minimize state changes | 跟踪当前状态以最小化状态切换
|
// Track current state to minimize state changes | 跟踪当前状态以最小化状态切换
|
||||||
let mut current_material_id: u32 = u32::MAX;
|
let mut current_material_id: u32 = u32::MAX;
|
||||||
let mut current_texture_id: u32 = u32::MAX;
|
let mut current_texture_id: u32 = u32::MAX;
|
||||||
@@ -136,7 +127,16 @@ impl Renderer2D {
|
|||||||
// Get projection matrix once | 一次性获取投影矩阵
|
// Get projection matrix once | 一次性获取投影矩阵
|
||||||
let projection = self.camera.projection_matrix();
|
let projection = self.camera.projection_matrix();
|
||||||
|
|
||||||
for batch_key in batch_keys {
|
// Iterate through batches in submission order (preserves render order)
|
||||||
|
// 按提交顺序遍历批次(保持渲染顺序)
|
||||||
|
for batch_idx in 0..self.sprite_batch.batches().len() {
|
||||||
|
let (batch_key, vertices) = &self.sprite_batch.batches()[batch_idx];
|
||||||
|
|
||||||
|
// Skip empty batches | 跳过空批次
|
||||||
|
if vertices.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Switch material if needed | 如需切换材质
|
// Switch material if needed | 如需切换材质
|
||||||
if batch_key.material_id != current_material_id {
|
if batch_key.material_id != current_material_id {
|
||||||
current_material_id = batch_key.material_id;
|
current_material_id = batch_key.material_id;
|
||||||
@@ -169,8 +169,8 @@ impl Renderer2D {
|
|||||||
texture_manager.bind_texture(batch_key.texture_id, 0);
|
texture_manager.bind_texture(batch_key.texture_id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush this batch | 刷新此批次
|
// Flush this batch by index | 按索引刷新此批次
|
||||||
self.sprite_batch.flush_for_batch(gl, &batch_key);
|
self.sprite_batch.flush_batch_at(gl, batch_idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear batch for next frame | 清空批处理以供下一帧使用
|
// Clear batch for next frame | 清空批处理以供下一帧使用
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ pub struct TextureManager {
|
|||||||
/// 纹理加载状态(使用 Rc<RefCell<>> 以便闭包可以修改)
|
/// 纹理加载状态(使用 Rc<RefCell<>> 以便闭包可以修改)
|
||||||
/// Texture loading states (using Rc<RefCell<>> so closures can modify)
|
/// Texture loading states (using Rc<RefCell<>> so closures can modify)
|
||||||
texture_states: Rc<RefCell<HashMap<u32, TextureState>>>,
|
texture_states: Rc<RefCell<HashMap<u32, TextureState>>>,
|
||||||
|
|
||||||
|
/// 纹理尺寸缓存(使用 Rc<RefCell<>> 以便闭包可以修改)
|
||||||
|
/// Texture dimensions cache (using Rc<RefCell<>> so closures can modify)
|
||||||
|
/// Key: texture ID, Value: (width, height)
|
||||||
|
texture_dimensions: Rc<RefCell<HashMap<u32, (u32, u32)>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextureManager {
|
impl TextureManager {
|
||||||
@@ -65,6 +70,7 @@ impl TextureManager {
|
|||||||
next_id: 1, // Start from 1, 0 is reserved for default
|
next_id: 1, // Start from 1, 0 is reserved for default
|
||||||
default_texture: None,
|
default_texture: None,
|
||||||
texture_states: Rc::new(RefCell::new(HashMap::new())),
|
texture_states: Rc::new(RefCell::new(HashMap::new())),
|
||||||
|
texture_dimensions: Rc::new(RefCell::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create default white texture | 创建默认白色纹理
|
// Create default white texture | 创建默认白色纹理
|
||||||
@@ -150,6 +156,9 @@ impl TextureManager {
|
|||||||
let states_for_onload = Rc::clone(&self.texture_states);
|
let states_for_onload = Rc::clone(&self.texture_states);
|
||||||
let states_for_onerror = Rc::clone(&self.texture_states);
|
let states_for_onerror = Rc::clone(&self.texture_states);
|
||||||
|
|
||||||
|
// Clone dimensions map for closure | 克隆尺寸映射用于闭包
|
||||||
|
let dimensions_for_onload = Rc::clone(&self.texture_dimensions);
|
||||||
|
|
||||||
// Load actual image asynchronously | 异步加载实际图片
|
// Load actual image asynchronously | 异步加载实际图片
|
||||||
let gl = self.gl.clone();
|
let gl = self.gl.clone();
|
||||||
|
|
||||||
@@ -205,6 +214,12 @@ impl TextureManager {
|
|||||||
WebGl2RenderingContext::LINEAR as i32,
|
WebGl2RenderingContext::LINEAR as i32,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 存储纹理尺寸(从加载的图片获取)
|
||||||
|
// Store texture dimensions (from loaded image)
|
||||||
|
let width = image_clone.width();
|
||||||
|
let height = image_clone.height();
|
||||||
|
dimensions_for_onload.borrow_mut().insert(texture_id, (width, height));
|
||||||
|
|
||||||
// 标记为就绪 | Mark as ready
|
// 标记为就绪 | Mark as ready
|
||||||
states_for_onload.borrow_mut().insert(texture_id, TextureState::Ready);
|
states_for_onload.borrow_mut().insert(texture_id, TextureState::Ready);
|
||||||
|
|
||||||
@@ -236,8 +251,21 @@ impl TextureManager {
|
|||||||
|
|
||||||
/// Get texture size by ID.
|
/// Get texture size by ID.
|
||||||
/// 按ID获取纹理尺寸。
|
/// 按ID获取纹理尺寸。
|
||||||
|
///
|
||||||
|
/// First checks the dimensions cache (updated when texture loads),
|
||||||
|
/// then falls back to the Texture struct.
|
||||||
|
/// 首先检查尺寸缓存(在纹理加载时更新),
|
||||||
|
/// 然后回退到 Texture 结构体。
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn get_texture_size(&self, id: u32) -> Option<(f32, f32)> {
|
pub fn get_texture_size(&self, id: u32) -> Option<(f32, f32)> {
|
||||||
|
// Check dimensions cache first (has actual loaded dimensions)
|
||||||
|
// 首先检查尺寸缓存(有实际加载的尺寸)
|
||||||
|
if let Some(&(w, h)) = self.texture_dimensions.borrow().get(&id) {
|
||||||
|
return Some((w as f32, h as f32));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to texture struct (may have placeholder dimensions)
|
||||||
|
// 回退到纹理结构体(可能是占位符尺寸)
|
||||||
self.textures
|
self.textures
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.map(|t| (t.width as f32, t.height as f32))
|
.map(|t| (t.width as f32, t.height as f32))
|
||||||
@@ -329,6 +357,8 @@ impl TextureManager {
|
|||||||
self.path_to_id.retain(|_, &mut v| v != id);
|
self.path_to_id.retain(|_, &mut v| v != id);
|
||||||
// Remove state | 移除状态
|
// Remove state | 移除状态
|
||||||
self.texture_states.borrow_mut().remove(&id);
|
self.texture_states.borrow_mut().remove(&id);
|
||||||
|
// Remove dimensions | 移除尺寸
|
||||||
|
self.texture_dimensions.borrow_mut().remove(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load texture by path, returning texture ID.
|
/// Load texture by path, returning texture ID.
|
||||||
@@ -409,8 +439,144 @@ impl TextureManager {
|
|||||||
// Clear texture states | 清除纹理状态
|
// Clear texture states | 清除纹理状态
|
||||||
self.texture_states.borrow_mut().clear();
|
self.texture_states.borrow_mut().clear();
|
||||||
|
|
||||||
|
// Clear texture dimensions | 清除纹理尺寸
|
||||||
|
self.texture_dimensions.borrow_mut().clear();
|
||||||
|
|
||||||
// Reset ID counter (1 is reserved for first texture, 0 for default)
|
// Reset ID counter (1 is reserved for first texture, 0 for default)
|
||||||
// 重置ID计数器(1保留给第一个纹理,0给默认纹理)
|
// 重置ID计数器(1保留给第一个纹理,0给默认纹理)
|
||||||
self.next_id = 1;
|
self.next_id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a blank texture with specified dimensions.
|
||||||
|
/// 创建具有指定尺寸的空白纹理。
|
||||||
|
///
|
||||||
|
/// This is used for dynamic atlas creation where textures
|
||||||
|
/// are later filled with content using `update_texture_region`.
|
||||||
|
/// 用于动态图集创建,之后使用 `update_texture_region` 填充内容。
|
||||||
|
///
|
||||||
|
/// # Arguments | 参数
|
||||||
|
/// * `width` - Texture width in pixels | 纹理宽度(像素)
|
||||||
|
/// * `height` - Texture height in pixels | 纹理高度(像素)
|
||||||
|
///
|
||||||
|
/// # Returns | 返回
|
||||||
|
/// The texture ID for the created texture | 创建的纹理ID
|
||||||
|
pub fn create_blank_texture(&mut self, width: u32, height: u32) -> Result<u32> {
|
||||||
|
let texture = self.gl
|
||||||
|
.create_texture()
|
||||||
|
.ok_or_else(|| EngineError::TextureLoadFailed("Failed to create blank texture".into()))?;
|
||||||
|
|
||||||
|
self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture));
|
||||||
|
|
||||||
|
// Initialize with transparent pixels
|
||||||
|
// 使用透明像素初始化
|
||||||
|
let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
|
||||||
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
WebGl2RenderingContext::RGBA as i32,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
0,
|
||||||
|
WebGl2RenderingContext::RGBA,
|
||||||
|
WebGl2RenderingContext::UNSIGNED_BYTE,
|
||||||
|
None, // NULL data - allocate but don't fill
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set texture parameters for atlas use
|
||||||
|
// 设置图集使用的纹理参数
|
||||||
|
self.gl.tex_parameteri(
|
||||||
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
|
WebGl2RenderingContext::TEXTURE_WRAP_S,
|
||||||
|
WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
|
||||||
|
);
|
||||||
|
self.gl.tex_parameteri(
|
||||||
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
|
WebGl2RenderingContext::TEXTURE_WRAP_T,
|
||||||
|
WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
|
||||||
|
);
|
||||||
|
self.gl.tex_parameteri(
|
||||||
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
|
WebGl2RenderingContext::TEXTURE_MIN_FILTER,
|
||||||
|
WebGl2RenderingContext::LINEAR as i32,
|
||||||
|
);
|
||||||
|
self.gl.tex_parameteri(
|
||||||
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
|
WebGl2RenderingContext::TEXTURE_MAG_FILTER,
|
||||||
|
WebGl2RenderingContext::LINEAR as i32,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign ID and store
|
||||||
|
// 分配ID并存储
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
|
||||||
|
self.textures.insert(id, Texture::new(texture, width, height));
|
||||||
|
self.texture_states.borrow_mut().insert(id, TextureState::Ready);
|
||||||
|
self.texture_dimensions.borrow_mut().insert(id, (width, height));
|
||||||
|
|
||||||
|
log::debug!("Created blank texture {} ({}x{}) | 创建空白纹理 {} ({}x{})", id, width, height, id, width, height);
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a region of an existing texture with pixel data.
|
||||||
|
/// 使用像素数据更新现有纹理的区域。
|
||||||
|
///
|
||||||
|
/// This is used for dynamic atlas to copy individual textures
|
||||||
|
/// into the atlas texture.
|
||||||
|
/// 用于动态图集将单个纹理复制到图集纹理中。
|
||||||
|
///
|
||||||
|
/// # Arguments | 参数
|
||||||
|
/// * `id` - The texture ID to update | 要更新的纹理ID
|
||||||
|
/// * `x` - X offset in the texture | 纹理中的X偏移
|
||||||
|
/// * `y` - Y offset in the texture | 纹理中的Y偏移
|
||||||
|
/// * `width` - Width of the region to update | 要更新的区域宽度
|
||||||
|
/// * `height` - Height of the region to update | 要更新的区域高度
|
||||||
|
/// * `pixels` - RGBA pixel data (4 bytes per pixel) | RGBA像素数据(每像素4字节)
|
||||||
|
///
|
||||||
|
/// # Returns | 返回
|
||||||
|
/// Ok(()) on success, Err if texture not found or update failed
|
||||||
|
/// 成功时返回 Ok(()),纹理未找到或更新失败时返回 Err
|
||||||
|
pub fn update_texture_region(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pixels: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let texture = self.textures.get(&id)
|
||||||
|
.ok_or_else(|| EngineError::TextureLoadFailed(format!("Texture {} not found", id)))?;
|
||||||
|
|
||||||
|
// Validate pixel data size
|
||||||
|
// 验证像素数据大小
|
||||||
|
let expected_size = (width * height * 4) as usize;
|
||||||
|
if pixels.len() != expected_size {
|
||||||
|
return Err(EngineError::TextureLoadFailed(format!(
|
||||||
|
"Pixel data size mismatch: expected {}, got {} | 像素数据大小不匹配:预期 {},实际 {}",
|
||||||
|
expected_size, pixels.len(), expected_size, pixels.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture.handle));
|
||||||
|
|
||||||
|
// Use texSubImage2D to update a region
|
||||||
|
// 使用 texSubImage2D 更新区域
|
||||||
|
self.gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
|
||||||
|
WebGl2RenderingContext::TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
x as i32,
|
||||||
|
y as i32,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
WebGl2RenderingContext::RGBA,
|
||||||
|
WebGl2RenderingContext::UNSIGNED_BYTE,
|
||||||
|
Some(pixels),
|
||||||
|
).map_err(|e| EngineError::TextureLoadFailed(format!("texSubImage2D failed: {:?}", e)))?;
|
||||||
|
|
||||||
|
log::trace!("Updated texture {} region ({},{}) {}x{} | 更新纹理 {} 区域 ({},{}) {}x{}",
|
||||||
|
id, x, y, width, height, id, x, y, width, height);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
GRAYSCALE_FRAGMENT_SHADER,
|
GRAYSCALE_FRAGMENT_SHADER,
|
||||||
TINT_FRAGMENT_SHADER,
|
TINT_FRAGMENT_SHADER,
|
||||||
FLASH_FRAGMENT_SHADER,
|
FLASH_FRAGMENT_SHADER,
|
||||||
OUTLINE_FRAGMENT_SHADER
|
OUTLINE_FRAGMENT_SHADER,
|
||||||
|
SHINY_FRAGMENT_SHADER
|
||||||
} from './Shader';
|
} from './Shader';
|
||||||
import { BuiltInMaterials, BuiltInShaders, UniformType } from './types';
|
import { BuiltInMaterials, BuiltInShaders, UniformType } from './types';
|
||||||
import type { IAssetManager } from '@esengine/asset-system';
|
import type { IAssetManager } from '@esengine/asset-system';
|
||||||
@@ -103,10 +104,67 @@ export class MaterialManager {
|
|||||||
* Set the engine bridge for GPU operations.
|
* Set the engine bridge for GPU operations.
|
||||||
* 设置用于GPU操作的引擎桥接。
|
* 设置用于GPU操作的引擎桥接。
|
||||||
*
|
*
|
||||||
|
* When set, uploads all built-in shaders to the GPU.
|
||||||
|
* 设置后,将所有内置着色器上传到GPU。
|
||||||
|
*
|
||||||
* @param bridge - Engine bridge instance. | 引擎桥接实例。
|
* @param bridge - Engine bridge instance. | 引擎桥接实例。
|
||||||
*/
|
*/
|
||||||
setEngineBridge(bridge: IEngineBridge): void {
|
setEngineBridge(bridge: IEngineBridge): void {
|
||||||
this.engineBridge = bridge;
|
this.engineBridge = bridge;
|
||||||
|
|
||||||
|
// Upload all existing shaders to the engine
|
||||||
|
// 将所有现有着色器上传到引擎
|
||||||
|
this.uploadShadersToEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload all registered shaders to the engine.
|
||||||
|
* 将所有已注册的着色器上传到引擎。
|
||||||
|
*
|
||||||
|
* Called automatically when engine bridge is set.
|
||||||
|
* 设置引擎桥接时自动调用。
|
||||||
|
*/
|
||||||
|
private uploadShadersToEngine(): void {
|
||||||
|
if (!this.engineBridge) return;
|
||||||
|
|
||||||
|
let shadersUploaded = 0;
|
||||||
|
let materialsCreated = 0;
|
||||||
|
|
||||||
|
for (const [shaderId, shader] of this.shaders) {
|
||||||
|
// Skip if already compiled
|
||||||
|
// 跳过已编译的着色器
|
||||||
|
if (shader.compiled) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Compile shader
|
||||||
|
// 编译着色器
|
||||||
|
this.engineBridge.compileShaderWithId(
|
||||||
|
shaderId,
|
||||||
|
shader.vertexSource,
|
||||||
|
shader.fragmentSource
|
||||||
|
);
|
||||||
|
shader.markCompiled();
|
||||||
|
shadersUploaded++;
|
||||||
|
logger.debug(`Uploaded shader ${shader.name} (ID: ${shaderId}) to engine`);
|
||||||
|
|
||||||
|
// Create a material for this shader if it doesn't exist in the engine
|
||||||
|
// 为此着色器创建材质(如果引擎中不存在)
|
||||||
|
// This allows sprites to reference the shader via materialId
|
||||||
|
// 这允许精灵通过 materialId 引用着色器
|
||||||
|
if (!this.engineBridge.hasMaterial(shaderId)) {
|
||||||
|
// Use shaderId as materialId for built-in shaders (1:1 mapping)
|
||||||
|
// 对于内置着色器,使用 shaderId 作为 materialId(1:1 映射)
|
||||||
|
// BlendMode 1 = Alpha blending
|
||||||
|
this.engineBridge.createMaterialWithId(shaderId, shader.name, shaderId, 1);
|
||||||
|
materialsCreated++;
|
||||||
|
logger.debug(`Created material ${shader.name} (ID: ${shaderId}) for shader`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to upload shader ${shader.name} (ID: ${shaderId}):`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Uploaded ${shadersUploaded} shaders and created ${materialsCreated} materials | 已上传 ${shadersUploaded} 个着色器,创建 ${materialsCreated} 个材质`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,6 +196,7 @@ export class MaterialManager {
|
|||||||
{ id: BuiltInShaders.Tint, name: 'Tint', vertex: DEFAULT_VERTEX_SHADER, fragment: TINT_FRAGMENT_SHADER },
|
{ id: BuiltInShaders.Tint, name: 'Tint', vertex: DEFAULT_VERTEX_SHADER, fragment: TINT_FRAGMENT_SHADER },
|
||||||
{ id: BuiltInShaders.Flash, name: 'Flash', vertex: DEFAULT_VERTEX_SHADER, fragment: FLASH_FRAGMENT_SHADER },
|
{ id: BuiltInShaders.Flash, name: 'Flash', vertex: DEFAULT_VERTEX_SHADER, fragment: FLASH_FRAGMENT_SHADER },
|
||||||
{ id: BuiltInShaders.Outline, name: 'Outline', vertex: DEFAULT_VERTEX_SHADER, fragment: OUTLINE_FRAGMENT_SHADER },
|
{ id: BuiltInShaders.Outline, name: 'Outline', vertex: DEFAULT_VERTEX_SHADER, fragment: OUTLINE_FRAGMENT_SHADER },
|
||||||
|
{ id: BuiltInShaders.Shiny, name: 'Shiny', vertex: DEFAULT_VERTEX_SHADER, fragment: SHINY_FRAGMENT_SHADER },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { id, name, vertex, fragment } of builtInShaders) {
|
for (const { id, name, vertex, fragment } of builtInShaders) {
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
* MaterialSystemPlugin for ES Engine.
|
* MaterialSystemPlugin for ES Engine.
|
||||||
* ES引擎的材质系统插件。
|
* ES引擎的材质系统插件。
|
||||||
*
|
*
|
||||||
* 注意:材质系统不注册独立组件,材质作为渲染组件(如 SpriteComponent)的属性使用
|
* Provides:
|
||||||
|
* - Material and Shader management
|
||||||
|
* - Built-in shaders (Default, Grayscale, Tint, Flash, Outline, Shiny)
|
||||||
|
*
|
||||||
|
* 提供:
|
||||||
|
* - 材质和着色器管理
|
||||||
|
* - 内置着色器
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MaterialManager, getMaterialManager } from './MaterialManager';
|
import { MaterialManager, getMaterialManager } from './MaterialManager';
|
||||||
@@ -82,7 +88,9 @@ const manifest: ModuleManifest = {
|
|||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
isEngineModule: true,
|
isEngineModule: true,
|
||||||
dependencies: ['core', 'asset-system'],
|
dependencies: ['core', 'asset-system'],
|
||||||
exports: { other: ['Material', 'Shader', 'MaterialManager'] },
|
exports: {
|
||||||
|
other: ['Material', 'Shader', 'MaterialManager']
|
||||||
|
},
|
||||||
requiresWasm: false
|
requiresWasm: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ export class Shader {
|
|||||||
/**
|
/**
|
||||||
* Default sprite vertex shader source.
|
* Default sprite vertex shader source.
|
||||||
* 默认精灵顶点着色器源代码。
|
* 默认精灵顶点着色器源代码。
|
||||||
|
*
|
||||||
|
* Vertex layout (9 floats per vertex):
|
||||||
|
* 顶点布局(每顶点 9 个浮点数):
|
||||||
|
* - location 0: position (2 floats)
|
||||||
|
* - location 1: tex_coord (2 floats)
|
||||||
|
* - location 2: color (4 floats)
|
||||||
|
* - location 3: aspect_ratio (1 float)
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_VERTEX_SHADER = `#version 300 es
|
export const DEFAULT_VERTEX_SHADER = `#version 300 es
|
||||||
precision highp float;
|
precision highp float;
|
||||||
@@ -128,6 +135,7 @@ precision highp float;
|
|||||||
layout(location = 0) in vec2 a_position;
|
layout(location = 0) in vec2 a_position;
|
||||||
layout(location = 1) in vec2 a_texCoord;
|
layout(location = 1) in vec2 a_texCoord;
|
||||||
layout(location = 2) in vec4 a_color;
|
layout(location = 2) in vec4 a_color;
|
||||||
|
layout(location = 3) in float a_aspectRatio;
|
||||||
|
|
||||||
// Uniforms | 统一变量
|
// Uniforms | 统一变量
|
||||||
uniform mat3 u_projection;
|
uniform mat3 u_projection;
|
||||||
@@ -135,6 +143,7 @@ uniform mat3 u_projection;
|
|||||||
// Outputs to fragment shader | 输出到片段着色器
|
// Outputs to fragment shader | 输出到片段着色器
|
||||||
out vec2 v_texCoord;
|
out vec2 v_texCoord;
|
||||||
out vec4 v_color;
|
out vec4 v_color;
|
||||||
|
out float v_aspectRatio;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Apply projection matrix | 应用投影矩阵
|
// Apply projection matrix | 应用投影矩阵
|
||||||
@@ -144,6 +153,7 @@ void main() {
|
|||||||
// Pass through to fragment shader | 传递到片段着色器
|
// Pass through to fragment shader | 传递到片段着色器
|
||||||
v_texCoord = a_texCoord;
|
v_texCoord = a_texCoord;
|
||||||
v_color = a_color;
|
v_color = a_color;
|
||||||
|
v_aspectRatio = a_aspectRatio;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -157,6 +167,7 @@ precision highp float;
|
|||||||
// Inputs from vertex shader | 来自顶点着色器的输入
|
// Inputs from vertex shader | 来自顶点着色器的输入
|
||||||
in vec2 v_texCoord;
|
in vec2 v_texCoord;
|
||||||
in vec4 v_color;
|
in vec4 v_color;
|
||||||
|
in float v_aspectRatio;
|
||||||
|
|
||||||
// Texture sampler | 纹理采样器
|
// Texture sampler | 纹理采样器
|
||||||
uniform sampler2D u_texture;
|
uniform sampler2D u_texture;
|
||||||
@@ -185,6 +196,7 @@ precision highp float;
|
|||||||
|
|
||||||
in vec2 v_texCoord;
|
in vec2 v_texCoord;
|
||||||
in vec4 v_color;
|
in vec4 v_color;
|
||||||
|
in float v_aspectRatio;
|
||||||
|
|
||||||
uniform sampler2D u_texture;
|
uniform sampler2D u_texture;
|
||||||
uniform float u_grayscale; // 0.0 = full color, 1.0 = full grayscale
|
uniform float u_grayscale; // 0.0 = full color, 1.0 = full grayscale
|
||||||
@@ -215,6 +227,7 @@ precision highp float;
|
|||||||
|
|
||||||
in vec2 v_texCoord;
|
in vec2 v_texCoord;
|
||||||
in vec4 v_color;
|
in vec4 v_color;
|
||||||
|
in float v_aspectRatio;
|
||||||
|
|
||||||
uniform sampler2D u_texture;
|
uniform sampler2D u_texture;
|
||||||
uniform vec4 u_tintColor; // Tint color to apply
|
uniform vec4 u_tintColor; // Tint color to apply
|
||||||
@@ -243,6 +256,7 @@ precision highp float;
|
|||||||
|
|
||||||
in vec2 v_texCoord;
|
in vec2 v_texCoord;
|
||||||
in vec4 v_color;
|
in vec4 v_color;
|
||||||
|
in float v_aspectRatio;
|
||||||
|
|
||||||
uniform sampler2D u_texture;
|
uniform sampler2D u_texture;
|
||||||
uniform vec4 u_flashColor; // Flash color
|
uniform vec4 u_flashColor; // Flash color
|
||||||
@@ -273,6 +287,7 @@ precision highp float;
|
|||||||
|
|
||||||
in vec2 v_texCoord;
|
in vec2 v_texCoord;
|
||||||
in vec4 v_color;
|
in vec4 v_color;
|
||||||
|
in float v_aspectRatio;
|
||||||
|
|
||||||
uniform sampler2D u_texture;
|
uniform sampler2D u_texture;
|
||||||
uniform vec4 u_outlineColor;
|
uniform vec4 u_outlineColor;
|
||||||
@@ -309,3 +324,98 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shiny/Shimmer effect fragment shader.
|
||||||
|
* 闪光效果片段着色器。
|
||||||
|
*
|
||||||
|
* Uses v_aspectRatio from vertex attribute for aspect-ratio-aware rotation.
|
||||||
|
* 使用顶点属性中的 v_aspectRatio 进行宽高比感知的旋转。
|
||||||
|
*/
|
||||||
|
export const SHINY_FRAGMENT_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
in float v_aspectRatio;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
|
||||||
|
// Shiny effect uniforms | 闪光效果 uniform 变量
|
||||||
|
uniform float u_shinyProgress; // Animation progress (0-1) | 动画进度
|
||||||
|
uniform float u_shinyWidth; // Width of shine band (0-1) | 闪光带宽度
|
||||||
|
uniform float u_shinyRotation; // Rotation in radians | 旋转角度(弧度)
|
||||||
|
uniform float u_shinySoftness; // Edge softness (0-1) | 边缘柔和度
|
||||||
|
uniform float u_shinyBrightness; // Brightness multiplier | 亮度倍数
|
||||||
|
uniform float u_shinyGloss; // Gloss intensity (0=white, 1=color-tinted) | 光泽度
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
float originAlpha = texColor.a;
|
||||||
|
vec4 color = texColor * v_color;
|
||||||
|
|
||||||
|
// Early discard for transparent pixels
|
||||||
|
if (color.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate rotated position for the sweep (0 to 1 range)
|
||||||
|
// 计算旋转后的扫描位置(0 到 1 范围)
|
||||||
|
//
|
||||||
|
// 1. 计算基础方向向量 dir = (cos(θ), sin(θ))
|
||||||
|
// 2. 宽高比校正:dir.x *= height/width = 1/aspectRatio
|
||||||
|
// 3. 归一化方向向量
|
||||||
|
// 4. 计算扫描位置(考虑纹理坐标 Y 轴方向)
|
||||||
|
//
|
||||||
|
// 1. Calculate base direction vector dir = (cos(θ), sin(θ))
|
||||||
|
// 2. Aspect ratio correction: dir.x *= height/width = 1/aspectRatio
|
||||||
|
// 3. Normalize direction vector
|
||||||
|
// 4. Calculate sweep position (accounting for texture Y-axis direction)
|
||||||
|
//
|
||||||
|
vec2 center = v_texCoord - vec2(0.5);
|
||||||
|
float cosR = cos(u_shinyRotation);
|
||||||
|
float sinR = sin(u_shinyRotation);
|
||||||
|
|
||||||
|
// Aspect ratio correction: scale X by 1/aspectRatio (height/width)
|
||||||
|
// v_aspectRatio is passed from vertex attribute, calculated at render time
|
||||||
|
// 宽高比校正:X 分量乘以 1/aspectRatio(即 height/width)
|
||||||
|
// v_aspectRatio 从顶点属性传入,在渲染时计算
|
||||||
|
float adjCosR = cosR / max(v_aspectRatio, 0.001);
|
||||||
|
|
||||||
|
// Normalize the direction vector
|
||||||
|
// 归一化方向向量
|
||||||
|
float len = sqrt(adjCosR * adjCosR + sinR * sinR);
|
||||||
|
float dirX = adjCosR / len;
|
||||||
|
float dirY = sinR / len;
|
||||||
|
|
||||||
|
// Sweep position: project onto perpendicular direction
|
||||||
|
// Y-axis flip: texture coords have Y pointing up, but we want top-to-bottom sweep
|
||||||
|
// 扫描位置:投影到垂直方向
|
||||||
|
// Y 轴翻转:纹理坐标 Y 向上,但我们需要从上到下扫描
|
||||||
|
float rotatedPos = (center.x * dirY - center.y * dirX) + 0.5;
|
||||||
|
|
||||||
|
// Map progress to location (-0.5 to 1.5 range for smooth entry/exit)
|
||||||
|
float location = u_shinyProgress * 2.0 - 0.5;
|
||||||
|
|
||||||
|
// Calculate normalized distance (1 at center, 0 at edges)
|
||||||
|
// 计算归一化距离(中心为1,边缘为0)
|
||||||
|
float normalized = 1.0 - clamp(abs((rotatedPos - location) / max(u_shinyWidth, 0.001)), 0.0, 1.0);
|
||||||
|
|
||||||
|
// Apply softness with smoothstep
|
||||||
|
// 使用 smoothstep 应用柔和度
|
||||||
|
float shinePower = smoothstep(0.0, u_shinySoftness * 2.0, normalized);
|
||||||
|
|
||||||
|
// Calculate reflect color: lerp between white and bright original color
|
||||||
|
// 计算反射颜色:在白色和明亮的原色之间插值
|
||||||
|
vec3 reflectColor = mix(vec3(1.0), color.rgb * 10.0, u_shinyGloss);
|
||||||
|
|
||||||
|
// Apply shine: additive blend with halved intensity
|
||||||
|
// 应用高光:半强度加性混合
|
||||||
|
vec3 shineAdd = originAlpha * (shinePower * 0.5) * u_shinyBrightness * reflectColor;
|
||||||
|
vec3 finalColor = color.rgb + shineAdd;
|
||||||
|
|
||||||
|
fragColor = vec4(finalColor, color.a);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Base shiny effect component for ES Engine.
|
||||||
|
* ES引擎基础闪光效果组件。
|
||||||
|
*
|
||||||
|
* This abstract base class provides shared shiny effect properties and methods
|
||||||
|
* that can be extended by both SpriteShinyEffectComponent and UIShinyEffectComponent.
|
||||||
|
* 此抽象基类提供可由 SpriteShinyEffectComponent 和 UIShinyEffectComponent 扩展的
|
||||||
|
* 共享闪光效果属性和方法。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for shiny effect configuration.
|
||||||
|
* 闪光效果配置的基础接口。
|
||||||
|
*
|
||||||
|
* This interface defines all properties needed for the shiny effect animation.
|
||||||
|
* 此接口定义了闪光效果动画所需的所有属性。
|
||||||
|
*/
|
||||||
|
export interface IShinyEffect {
|
||||||
|
// ============= Effect Parameters =============
|
||||||
|
// ============= 效果参数 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the shiny band (0.0 - 1.0).
|
||||||
|
* 闪光带宽度 (0.0 - 1.0)。
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation angle in degrees.
|
||||||
|
* 旋转角度(度)。
|
||||||
|
*/
|
||||||
|
rotation: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge softness (0.0 - 1.0).
|
||||||
|
* 边缘柔和度 (0.0 - 1.0)。
|
||||||
|
*/
|
||||||
|
softness: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brightness multiplier.
|
||||||
|
* 亮度倍增器。
|
||||||
|
*/
|
||||||
|
brightness: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gloss intensity.
|
||||||
|
* 光泽度。
|
||||||
|
*/
|
||||||
|
gloss: number;
|
||||||
|
|
||||||
|
// ============= Animation Settings =============
|
||||||
|
// ============= 动画设置 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the animation is playing.
|
||||||
|
* 动画是否正在播放。
|
||||||
|
*/
|
||||||
|
play: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to loop the animation.
|
||||||
|
* 是否循环动画。
|
||||||
|
*/
|
||||||
|
loop: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation duration in seconds.
|
||||||
|
* 动画持续时间(秒)。
|
||||||
|
*/
|
||||||
|
duration: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay between loops in seconds.
|
||||||
|
* 循环之间的延迟(秒)。
|
||||||
|
*/
|
||||||
|
loopDelay: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial delay before first play in seconds.
|
||||||
|
* 首次播放前的初始延迟(秒)。
|
||||||
|
*/
|
||||||
|
initialDelay: number;
|
||||||
|
|
||||||
|
// ============= Runtime State =============
|
||||||
|
// ============= 运行时状态 =============
|
||||||
|
|
||||||
|
/** Current animation progress (0.0 - 1.0). | 当前动画进度。 */
|
||||||
|
progress: number;
|
||||||
|
|
||||||
|
/** Current elapsed time in the animation cycle. | 当前周期已用时间。 */
|
||||||
|
elapsedTime: number;
|
||||||
|
|
||||||
|
/** Whether currently in delay phase. | 是否处于延迟阶段。 */
|
||||||
|
inDelay: boolean;
|
||||||
|
|
||||||
|
/** Remaining delay time. | 剩余延迟时间。 */
|
||||||
|
delayRemaining: number;
|
||||||
|
|
||||||
|
/** Whether the initial delay has been processed. | 初始延迟是否已处理。 */
|
||||||
|
initialDelayProcessed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default values for shiny effect properties.
|
||||||
|
* 闪光效果属性的默认值。
|
||||||
|
*/
|
||||||
|
export const SHINY_EFFECT_DEFAULTS = {
|
||||||
|
width: 0.25,
|
||||||
|
rotation: 129,
|
||||||
|
softness: 1.0,
|
||||||
|
brightness: 1.0,
|
||||||
|
gloss: 1.0,
|
||||||
|
play: true,
|
||||||
|
loop: true,
|
||||||
|
duration: 2.0,
|
||||||
|
loopDelay: 2.0,
|
||||||
|
initialDelay: 0,
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
inDelay: false,
|
||||||
|
delayRemaining: 0,
|
||||||
|
initialDelayProcessed: false
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property metadata for shiny effect Inspector.
|
||||||
|
* 闪光效果 Inspector 的属性元数据。
|
||||||
|
*/
|
||||||
|
export const SHINY_EFFECT_PROPERTIES = {
|
||||||
|
width: { type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 },
|
||||||
|
rotation: { type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 },
|
||||||
|
softness: { type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 },
|
||||||
|
brightness: { type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 },
|
||||||
|
gloss: { type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 },
|
||||||
|
play: { type: 'boolean', label: 'Play' },
|
||||||
|
loop: { type: 'boolean', label: 'Loop' },
|
||||||
|
duration: { type: 'number', label: 'Duration', min: 0.1, step: 0.1 },
|
||||||
|
loopDelay: { type: 'number', label: 'Loop Delay', min: 0, step: 0.1 },
|
||||||
|
initialDelay: { type: 'number', label: 'Initial Delay', min: 0, step: 0.1 }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset shiny effect runtime state.
|
||||||
|
* 重置闪光效果运行时状态。
|
||||||
|
*
|
||||||
|
* @param effect - The shiny effect to reset | 要重置的闪光效果
|
||||||
|
*/
|
||||||
|
export function resetShinyEffect(effect: IShinyEffect): void {
|
||||||
|
effect.progress = 0;
|
||||||
|
effect.elapsedTime = 0;
|
||||||
|
effect.inDelay = false;
|
||||||
|
effect.delayRemaining = 0;
|
||||||
|
effect.initialDelayProcessed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playing the shiny effect.
|
||||||
|
* 开始播放闪光效果。
|
||||||
|
*
|
||||||
|
* @param effect - The shiny effect to start | 要开始的闪光效果
|
||||||
|
*/
|
||||||
|
export function startShinyEffect(effect: IShinyEffect): void {
|
||||||
|
resetShinyEffect(effect);
|
||||||
|
effect.play = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the shiny effect.
|
||||||
|
* 停止闪光效果。
|
||||||
|
*
|
||||||
|
* @param effect - The shiny effect to stop | 要停止的闪光效果
|
||||||
|
*/
|
||||||
|
export function stopShinyEffect(effect: IShinyEffect): void {
|
||||||
|
effect.play = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rotation in radians for shader use.
|
||||||
|
* 获取弧度制的旋转角度供着色器使用。
|
||||||
|
*
|
||||||
|
* @param effect - The shiny effect | 闪光效果
|
||||||
|
* @returns Rotation in radians | 弧度制的旋转角度
|
||||||
|
*/
|
||||||
|
export function getShinyRotationRadians(effect: IShinyEffect): number {
|
||||||
|
return effect.rotation * Math.PI / 180;
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Shiny effect animator for ES Engine.
|
||||||
|
* ES引擎闪光效果动画器。
|
||||||
|
*
|
||||||
|
* This module provides shared animation logic for shiny effects that can be used
|
||||||
|
* by both SpriteShinyEffectSystem and UIShinyEffectSystem.
|
||||||
|
* 此模块提供可由 SpriteShinyEffectSystem 和 UIShinyEffectSystem 使用的
|
||||||
|
* 共享闪光效果动画逻辑。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IShinyEffect } from './BaseShinyEffect';
|
||||||
|
import { getShinyRotationRadians } from './BaseShinyEffect';
|
||||||
|
import type { IMaterialOverridable } from '../interfaces/IMaterialOverridable';
|
||||||
|
import { BuiltInShaders } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared animator logic for shiny effect.
|
||||||
|
* 闪光效果共享的动画逻辑。
|
||||||
|
*
|
||||||
|
* This class provides static methods for updating animation state and
|
||||||
|
* applying material overrides, eliminating code duplication between
|
||||||
|
* sprite and UI shiny effect systems.
|
||||||
|
* 此类提供用于更新动画状态和应用材质覆盖的静态方法,
|
||||||
|
* 消除精灵和 UI 闪光效果系统之间的代码重复。
|
||||||
|
*/
|
||||||
|
export class ShinyEffectAnimator {
|
||||||
|
/**
|
||||||
|
* Update animation state.
|
||||||
|
* 更新动画状态。
|
||||||
|
*
|
||||||
|
* This method handles:
|
||||||
|
* - Initial delay processing
|
||||||
|
* - Delay phase countdown
|
||||||
|
* - Progress calculation
|
||||||
|
* - Loop handling
|
||||||
|
*
|
||||||
|
* 此方法处理:
|
||||||
|
* - 初始延迟处理
|
||||||
|
* - 延迟阶段倒计时
|
||||||
|
* - 进度计算
|
||||||
|
* - 循环处理
|
||||||
|
*
|
||||||
|
* @param shiny - The shiny effect component | 闪光效果组件
|
||||||
|
* @param deltaTime - Time elapsed since last frame (seconds) | 上一帧以来经过的时间(秒)
|
||||||
|
*/
|
||||||
|
static updateAnimation(shiny: IShinyEffect, deltaTime: number): void {
|
||||||
|
// Handle initial delay
|
||||||
|
// 处理初始延迟
|
||||||
|
if (!shiny.initialDelayProcessed && shiny.initialDelay > 0) {
|
||||||
|
shiny.delayRemaining = shiny.initialDelay;
|
||||||
|
shiny.inDelay = true;
|
||||||
|
shiny.initialDelayProcessed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delay phase
|
||||||
|
// 处理延迟阶段
|
||||||
|
if (shiny.inDelay) {
|
||||||
|
shiny.delayRemaining -= deltaTime;
|
||||||
|
if (shiny.delayRemaining <= 0) {
|
||||||
|
shiny.inDelay = false;
|
||||||
|
shiny.elapsedTime = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update elapsed time
|
||||||
|
// 更新已用时间
|
||||||
|
shiny.elapsedTime += deltaTime;
|
||||||
|
|
||||||
|
// Calculate progress (0 to 1)
|
||||||
|
// 计算进度(0 到 1)
|
||||||
|
shiny.progress = Math.min(shiny.elapsedTime / shiny.duration, 1.0);
|
||||||
|
|
||||||
|
// Check if animation completed
|
||||||
|
// 检查动画是否完成
|
||||||
|
if (shiny.progress >= 1.0) {
|
||||||
|
if (shiny.loop) {
|
||||||
|
// Start loop delay
|
||||||
|
// 开始循环延迟
|
||||||
|
shiny.inDelay = true;
|
||||||
|
shiny.delayRemaining = shiny.loopDelay;
|
||||||
|
shiny.progress = 0;
|
||||||
|
shiny.elapsedTime = 0;
|
||||||
|
} else {
|
||||||
|
// Stop animation
|
||||||
|
// 停止动画
|
||||||
|
shiny.play = false;
|
||||||
|
shiny.progress = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply shiny effect material overrides to a renderable component.
|
||||||
|
* 将闪光效果材质覆盖应用到可渲染组件。
|
||||||
|
*
|
||||||
|
* This method:
|
||||||
|
* - Sets the Shiny shader if not already set
|
||||||
|
* - Applies all uniform overrides for the shiny effect
|
||||||
|
*
|
||||||
|
* Note: aspectRatio is passed via vertex attribute from the rendering pipeline,
|
||||||
|
* calculated from sprite's scaleX/scaleY in the Rust engine.
|
||||||
|
*
|
||||||
|
* 此方法:
|
||||||
|
* - 如果尚未设置,则设置 Shiny 着色器
|
||||||
|
* - 应用闪光效果的所有 uniform 覆盖
|
||||||
|
*
|
||||||
|
* 注意:宽高比通过渲染管线的顶点属性传递,在 Rust 引擎中从精灵的 scaleX/scaleY 计算。
|
||||||
|
*
|
||||||
|
* @param shiny - The shiny effect component | 闪光效果组件
|
||||||
|
* @param target - The target component implementing IMaterialOverridable | 实现 IMaterialOverridable 的目标组件
|
||||||
|
*/
|
||||||
|
static applyMaterialOverrides(shiny: IShinyEffect, target: IMaterialOverridable): void {
|
||||||
|
// Ensure target uses Shiny shader
|
||||||
|
// 确保目标使用 Shiny 着色器
|
||||||
|
if (target.getMaterialId() === 0) {
|
||||||
|
target.setMaterialId(BuiltInShaders.Shiny);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply uniform overrides (aspectRatio is from vertex attribute v_aspectRatio)
|
||||||
|
// 应用 uniform 覆盖(宽高比来自顶点属性 v_aspectRatio)
|
||||||
|
target.setOverrideFloat('u_shinyProgress', shiny.progress);
|
||||||
|
target.setOverrideFloat('u_shinyWidth', shiny.width);
|
||||||
|
target.setOverrideFloat('u_shinyRotation', getShinyRotationRadians(shiny));
|
||||||
|
target.setOverrideFloat('u_shinySoftness', shiny.softness);
|
||||||
|
target.setOverrideFloat('u_shinyBrightness', shiny.brightness);
|
||||||
|
target.setOverrideFloat('u_shinyGloss', shiny.gloss);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single entity with shiny effect.
|
||||||
|
* 处理单个带有闪光效果的实体。
|
||||||
|
*
|
||||||
|
* This is a convenience method that combines updateAnimation and applyMaterialOverrides.
|
||||||
|
* 这是一个结合了 updateAnimation 和 applyMaterialOverrides 的便捷方法。
|
||||||
|
*
|
||||||
|
* @param shiny - The shiny effect component | 闪光效果组件
|
||||||
|
* @param target - The target component implementing IMaterialOverridable | 实现 IMaterialOverridable 的目标组件
|
||||||
|
* @param deltaTime - Time elapsed since last frame (seconds) | 上一帧以来经过的时间(秒)
|
||||||
|
* @returns True if the effect was processed, false if skipped | 如果效果已处理则返回 true,如果跳过则返回 false
|
||||||
|
*/
|
||||||
|
static processEffect(shiny: IShinyEffect, target: IMaterialOverridable, deltaTime: number): boolean {
|
||||||
|
if (!shiny.play) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAnimation(shiny, deltaTime);
|
||||||
|
this.applyMaterialOverrides(shiny, target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,46 @@
|
|||||||
// 类型。
|
// 类型。
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|
||||||
|
// Interfaces.
|
||||||
|
// 接口。
|
||||||
|
export type {
|
||||||
|
MaterialPropertyType,
|
||||||
|
MaterialPropertyOverride,
|
||||||
|
MaterialOverrides,
|
||||||
|
IMaterialOverridable
|
||||||
|
} from './interfaces/IMaterialOverridable';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ShaderPropertyType,
|
||||||
|
ShaderPropertyHint,
|
||||||
|
ShaderPropertyMeta,
|
||||||
|
ShaderAssetDefinition,
|
||||||
|
ShaderAssetFile
|
||||||
|
} from './interfaces/IShaderProperty';
|
||||||
|
|
||||||
|
export {
|
||||||
|
BUILTIN_SHADER_PROPERTIES,
|
||||||
|
getShaderProperties,
|
||||||
|
getShaderPropertiesById
|
||||||
|
} from './interfaces/IShaderProperty';
|
||||||
|
|
||||||
|
// Mixins.
|
||||||
|
// Mixin。
|
||||||
|
export { MaterialOverridableMixin, MaterialOverrideHelper } from './mixins/MaterialOverridableMixin';
|
||||||
|
|
||||||
|
// Effects.
|
||||||
|
// 效果。
|
||||||
|
export type { IShinyEffect } from './effects/BaseShinyEffect';
|
||||||
|
export {
|
||||||
|
SHINY_EFFECT_DEFAULTS,
|
||||||
|
SHINY_EFFECT_PROPERTIES,
|
||||||
|
resetShinyEffect,
|
||||||
|
startShinyEffect,
|
||||||
|
stopShinyEffect,
|
||||||
|
getShinyRotationRadians
|
||||||
|
} from './effects/BaseShinyEffect';
|
||||||
|
export { ShinyEffectAnimator } from './effects/ShinyEffectAnimator';
|
||||||
|
|
||||||
// Core classes.
|
// Core classes.
|
||||||
// 核心类。
|
// 核心类。
|
||||||
export { Material } from './Material';
|
export { Material } from './Material';
|
||||||
@@ -35,7 +75,8 @@ export {
|
|||||||
GRAYSCALE_FRAGMENT_SHADER,
|
GRAYSCALE_FRAGMENT_SHADER,
|
||||||
TINT_FRAGMENT_SHADER,
|
TINT_FRAGMENT_SHADER,
|
||||||
FLASH_FRAGMENT_SHADER,
|
FLASH_FRAGMENT_SHADER,
|
||||||
OUTLINE_FRAGMENT_SHADER
|
OUTLINE_FRAGMENT_SHADER,
|
||||||
|
SHINY_FRAGMENT_SHADER
|
||||||
} from './Shader';
|
} from './Shader';
|
||||||
|
|
||||||
// Manager.
|
// Manager.
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Material override interfaces for ES Engine.
|
||||||
|
* ES引擎材质覆盖接口。
|
||||||
|
*
|
||||||
|
* This module provides a unified interface for components that support
|
||||||
|
* material property overrides (SpriteComponent, UIRenderComponent, etc.).
|
||||||
|
* 此模块为支持材质属性覆盖的组件提供统一接口。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material property override value types.
|
||||||
|
* 材质属性覆盖值类型。
|
||||||
|
*/
|
||||||
|
export type MaterialPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material property override definition.
|
||||||
|
* 材质属性覆盖定义。
|
||||||
|
*/
|
||||||
|
export interface MaterialPropertyOverride {
|
||||||
|
/** Property type | 属性类型 */
|
||||||
|
type: MaterialPropertyType;
|
||||||
|
|
||||||
|
/** Property value | 属性值 */
|
||||||
|
value: number | number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material overrides record type.
|
||||||
|
* 材质覆盖记录类型。
|
||||||
|
*/
|
||||||
|
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for components that support material property overrides.
|
||||||
|
* 支持材质属性覆盖的组件接口。
|
||||||
|
*
|
||||||
|
* Both SpriteComponent and UIRenderComponent implement this interface,
|
||||||
|
* allowing unified handling by material systems and inspectors.
|
||||||
|
* SpriteComponent 和 UIRenderComponent 都实现此接口,
|
||||||
|
* 允许材质系统和检查器统一处理。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* function applyShinyEffect(target: IMaterialOverridable, progress: number): void {
|
||||||
|
* target.setMaterialId(BuiltInShaders.Shiny);
|
||||||
|
* target.setOverrideFloat('u_shinyProgress', progress);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Works with both SpriteComponent and UIRenderComponent
|
||||||
|
* applyShinyEffect(spriteComponent, 0.5);
|
||||||
|
* applyShinyEffect(uiRenderComponent, 0.5);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface IMaterialOverridable {
|
||||||
|
/**
|
||||||
|
* Material GUID for asset reference.
|
||||||
|
* 材质资产引用的 GUID。
|
||||||
|
*/
|
||||||
|
materialGuid: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current material overrides (read-only access).
|
||||||
|
* 当前材质覆盖(只读访问)。
|
||||||
|
*/
|
||||||
|
readonly materialOverrides: MaterialOverrides;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current material ID.
|
||||||
|
* 获取当前材质 ID。
|
||||||
|
*/
|
||||||
|
getMaterialId(): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set material ID.
|
||||||
|
* 设置材质 ID。
|
||||||
|
*
|
||||||
|
* @param id - Material/Shader ID from BuiltInShaders or custom shader
|
||||||
|
* 来自 BuiltInShaders 或自定义着色器的材质/着色器 ID
|
||||||
|
*/
|
||||||
|
setMaterialId(id: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a float uniform override.
|
||||||
|
* 设置浮点 uniform 覆盖。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name (e.g., 'u_shinyProgress')
|
||||||
|
* @param value - Float value
|
||||||
|
*/
|
||||||
|
setOverrideFloat(name: string, value: number): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec2 uniform override.
|
||||||
|
* 设置 vec2 uniform 覆盖。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name
|
||||||
|
* @param x - X component
|
||||||
|
* @param y - Y component
|
||||||
|
*/
|
||||||
|
setOverrideVec2(name: string, x: number, y: number): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec3 uniform override.
|
||||||
|
* 设置 vec3 uniform 覆盖。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name
|
||||||
|
* @param x - X component
|
||||||
|
* @param y - Y component
|
||||||
|
* @param z - Z component
|
||||||
|
*/
|
||||||
|
setOverrideVec3(name: string, x: number, y: number, z: number): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec4 uniform override.
|
||||||
|
* 设置 vec4 uniform 覆盖。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name
|
||||||
|
* @param x - X component
|
||||||
|
* @param y - Y component
|
||||||
|
* @param z - Z component
|
||||||
|
* @param w - W component
|
||||||
|
*/
|
||||||
|
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a color uniform override (RGBA, 0.0-1.0).
|
||||||
|
* 设置颜色 uniform 覆盖(RGBA,0.0-1.0)。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name
|
||||||
|
* @param r - Red component (0-1)
|
||||||
|
* @param g - Green component (0-1)
|
||||||
|
* @param b - Blue component (0-1)
|
||||||
|
* @param a - Alpha component (0-1), defaults to 1.0
|
||||||
|
*/
|
||||||
|
setOverrideColor(name: string, r: number, g: number, b: number, a?: number): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an integer uniform override.
|
||||||
|
* 设置整数 uniform 覆盖。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name
|
||||||
|
* @param value - Integer value
|
||||||
|
*/
|
||||||
|
setOverrideInt(name: string, value: number): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific override value.
|
||||||
|
* 获取特定覆盖值。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name
|
||||||
|
* @returns Override value or undefined if not set
|
||||||
|
*/
|
||||||
|
getOverride(name: string): MaterialPropertyOverride | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific override.
|
||||||
|
* 移除特定覆盖。
|
||||||
|
*
|
||||||
|
* @param name - Uniform name to remove
|
||||||
|
*/
|
||||||
|
removeOverride(name: string): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all overrides.
|
||||||
|
* 清除所有覆盖。
|
||||||
|
*/
|
||||||
|
clearOverrides(): this;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any overrides are set.
|
||||||
|
* 检查是否设置了任何覆盖。
|
||||||
|
*/
|
||||||
|
hasOverrides(): boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* Shader property interfaces for ES Engine.
|
||||||
|
* ES引擎着色器属性接口。
|
||||||
|
*
|
||||||
|
* This module provides interfaces for defining shader property metadata,
|
||||||
|
* enabling automatic Inspector UI generation for material editing.
|
||||||
|
* 此模块提供用于定义着色器属性元数据的接口,
|
||||||
|
* 实现材质编辑的自动 Inspector UI 生成。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader property types.
|
||||||
|
* 着色器属性类型。
|
||||||
|
*/
|
||||||
|
export type ShaderPropertyType =
|
||||||
|
| 'float'
|
||||||
|
| 'int'
|
||||||
|
| 'vec2'
|
||||||
|
| 'vec3'
|
||||||
|
| 'vec4'
|
||||||
|
| 'color'
|
||||||
|
| 'texture';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI hint for property display.
|
||||||
|
* 属性显示的 UI 提示。
|
||||||
|
*/
|
||||||
|
export type ShaderPropertyHint =
|
||||||
|
| 'range' // Show as slider | 显示为滑块
|
||||||
|
| 'angle' // Show as angle picker (degrees) | 显示为角度选择器(度)
|
||||||
|
| 'hdr' // HDR color picker | HDR 颜色选择器
|
||||||
|
| 'normal' // Normal map preview | 法线贴图预览
|
||||||
|
| 'default'; // Default input | 默认输入
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader property UI metadata.
|
||||||
|
* 着色器属性 UI 元数据。
|
||||||
|
*
|
||||||
|
* This interface defines all metadata needed to generate an Inspector UI
|
||||||
|
* for editing shader uniform values.
|
||||||
|
* 此接口定义生成用于编辑着色器 uniform 值的 Inspector UI 所需的所有元数据。
|
||||||
|
*/
|
||||||
|
export interface ShaderPropertyMeta {
|
||||||
|
/**
|
||||||
|
* Property type.
|
||||||
|
* 属性类型。
|
||||||
|
*/
|
||||||
|
type: ShaderPropertyType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display label (supports i18n key format "中文 | English").
|
||||||
|
* 显示标签(支持国际化键格式 "中文 | English")。
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property group for organization in Inspector.
|
||||||
|
* Inspector 中用于组织的属性分组。
|
||||||
|
*
|
||||||
|
* Properties with the same group will be displayed together under a collapsible header.
|
||||||
|
* 具有相同分组的属性将在可折叠标题下一起显示。
|
||||||
|
*/
|
||||||
|
group?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default value.
|
||||||
|
* 默认值。
|
||||||
|
*/
|
||||||
|
default?: number | number[] | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum value (for numeric types).
|
||||||
|
* 最小值(用于数值类型)。
|
||||||
|
*/
|
||||||
|
min?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum value (for numeric types).
|
||||||
|
* 最大值(用于数值类型)。
|
||||||
|
*/
|
||||||
|
max?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step value for numeric inputs.
|
||||||
|
* 数值输入的步长值。
|
||||||
|
*/
|
||||||
|
step?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI hint for specialized display.
|
||||||
|
* 用于特殊显示的 UI 提示。
|
||||||
|
*/
|
||||||
|
hint?: ShaderPropertyHint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tooltip description (supports i18n).
|
||||||
|
* 工具提示描述(支持国际化)。
|
||||||
|
*/
|
||||||
|
tooltip?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to hide in Inspector.
|
||||||
|
* 是否在 Inspector 中隐藏。
|
||||||
|
*
|
||||||
|
* Hidden properties are typically controlled by scripts or systems.
|
||||||
|
* 隐藏的属性通常由脚本或系统控制。
|
||||||
|
*/
|
||||||
|
hidden?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texture filter options (for texture type).
|
||||||
|
* 纹理过滤选项(用于纹理类型)。
|
||||||
|
*/
|
||||||
|
textureFilter?: 'linear' | 'nearest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texture wrap options (for texture type).
|
||||||
|
* 纹理包裹选项(用于纹理类型)。
|
||||||
|
*/
|
||||||
|
textureWrap?: 'repeat' | 'clamp' | 'mirror';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended shader definition with property metadata.
|
||||||
|
* 带属性元数据的扩展着色器定义。
|
||||||
|
*
|
||||||
|
* This interface extends the basic shader definition with UI metadata
|
||||||
|
* for Inspector generation and asset serialization.
|
||||||
|
* 此接口使用 UI 元数据扩展基本着色器定义,
|
||||||
|
* 用于 Inspector 生成和资产序列化。
|
||||||
|
*/
|
||||||
|
export interface ShaderAssetDefinition {
|
||||||
|
/**
|
||||||
|
* Shader name (unique identifier).
|
||||||
|
* 着色器名称(唯一标识符)。
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name for UI.
|
||||||
|
* UI 显示名称。
|
||||||
|
*/
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader description.
|
||||||
|
* 着色器描述。
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertex shader source (inline GLSL or relative path).
|
||||||
|
* 顶点着色器源(内联 GLSL 或相对路径)。
|
||||||
|
*/
|
||||||
|
vertexSource: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment shader source (inline GLSL or relative path).
|
||||||
|
* 片段着色器源(内联 GLSL 或相对路径)。
|
||||||
|
*/
|
||||||
|
fragmentSource: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property metadata for Inspector.
|
||||||
|
* Inspector 属性元数据。
|
||||||
|
*
|
||||||
|
* Key is the uniform name (e.g., 'u_shinyProgress').
|
||||||
|
* 键是 uniform 名称(例如 'u_shinyProgress')。
|
||||||
|
*/
|
||||||
|
properties?: Record<string, ShaderPropertyMeta>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render queue / order.
|
||||||
|
* 渲染队列/顺序。
|
||||||
|
*
|
||||||
|
* Lower values render first. Default is 2000 (opaque).
|
||||||
|
* 较低的值先渲染。默认为 2000(不透明)。
|
||||||
|
*/
|
||||||
|
renderQueue?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset blend mode.
|
||||||
|
* 预设混合模式。
|
||||||
|
*/
|
||||||
|
blendMode?: 'alpha' | 'additive' | 'multiply' | 'opaque';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this shader requires depth testing.
|
||||||
|
* 此着色器是否需要深度测试。
|
||||||
|
*/
|
||||||
|
depthTest?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this shader writes to depth buffer.
|
||||||
|
* 此着色器是否写入深度缓冲区。
|
||||||
|
*/
|
||||||
|
depthWrite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader asset file format (.shader).
|
||||||
|
* 着色器资产文件格式 (.shader)。
|
||||||
|
*/
|
||||||
|
export interface ShaderAssetFile {
|
||||||
|
/**
|
||||||
|
* Schema version for format evolution.
|
||||||
|
* 用于格式演进的模式版本。
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader definition.
|
||||||
|
* 着色器定义。
|
||||||
|
*/
|
||||||
|
shader: ShaderAssetDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in shader property definitions.
|
||||||
|
* 内置着色器属性定义。
|
||||||
|
*
|
||||||
|
* These are the property metadata for built-in shaders.
|
||||||
|
* 这些是内置着色器的属性元数据。
|
||||||
|
*/
|
||||||
|
export const BUILTIN_SHADER_PROPERTIES: Record<string, Record<string, ShaderPropertyMeta>> = {
|
||||||
|
Shiny: {
|
||||||
|
u_shinyProgress: {
|
||||||
|
type: 'float',
|
||||||
|
label: '进度 | Progress',
|
||||||
|
group: 'Animation',
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
u_shinyWidth: {
|
||||||
|
type: 'float',
|
||||||
|
label: '宽度 | Width',
|
||||||
|
group: 'Effect',
|
||||||
|
default: 0.25,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
tooltip: '闪光带宽度 | Width of the shiny band'
|
||||||
|
},
|
||||||
|
u_shinyRotation: {
|
||||||
|
type: 'float',
|
||||||
|
label: '角度 | Rotation',
|
||||||
|
group: 'Effect',
|
||||||
|
default: 0.524, // 30 degrees in radians | 30度的弧度值
|
||||||
|
min: 0,
|
||||||
|
max: 6.28, // 360 degrees | 360度
|
||||||
|
step: 0.01,
|
||||||
|
hint: 'angle',
|
||||||
|
tooltip: '闪光扫过的角度 | Angle of shine sweep'
|
||||||
|
},
|
||||||
|
u_shinySoftness: {
|
||||||
|
type: 'float',
|
||||||
|
label: '柔和度 | Softness',
|
||||||
|
group: 'Effect',
|
||||||
|
default: 1.0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01
|
||||||
|
},
|
||||||
|
u_shinyBrightness: {
|
||||||
|
type: 'float',
|
||||||
|
label: '亮度 | Brightness',
|
||||||
|
group: 'Effect',
|
||||||
|
default: 1.0,
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.01
|
||||||
|
},
|
||||||
|
u_shinyGloss: {
|
||||||
|
type: 'float',
|
||||||
|
label: '光泽度 | Gloss',
|
||||||
|
group: 'Effect',
|
||||||
|
default: 1.0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
tooltip: '0=白色高光, 1=带颜色 | 0=white shine, 1=color-tinted'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Grayscale: {
|
||||||
|
u_grayscale: {
|
||||||
|
type: 'float',
|
||||||
|
label: '灰度 | Grayscale',
|
||||||
|
default: 1.0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
hint: 'range',
|
||||||
|
tooltip: '0=彩色, 1=完全灰度 | 0=full color, 1=full grayscale'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Tint: {
|
||||||
|
u_tintColor: {
|
||||||
|
type: 'color',
|
||||||
|
label: '着色 | Tint Color',
|
||||||
|
default: [1, 1, 1, 1]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Flash: {
|
||||||
|
u_flashColor: {
|
||||||
|
type: 'color',
|
||||||
|
label: '闪光颜色 | Flash Color',
|
||||||
|
default: [1, 1, 1, 1]
|
||||||
|
},
|
||||||
|
u_flashAmount: {
|
||||||
|
type: 'float',
|
||||||
|
label: '闪光强度 | Flash Amount',
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
hint: 'range'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Outline: {
|
||||||
|
u_outlineColor: {
|
||||||
|
type: 'color',
|
||||||
|
label: '描边颜色 | Outline Color',
|
||||||
|
default: [0, 0, 0, 1]
|
||||||
|
},
|
||||||
|
u_outlineWidth: {
|
||||||
|
type: 'float',
|
||||||
|
label: '描边宽度 | Outline Width',
|
||||||
|
default: 1,
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
step: 0.5
|
||||||
|
},
|
||||||
|
u_texelSize: {
|
||||||
|
type: 'vec2',
|
||||||
|
label: '纹素大小 | Texel Size',
|
||||||
|
default: [0.01, 0.01],
|
||||||
|
hidden: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shader property metadata by shader name.
|
||||||
|
* 通过着色器名称获取着色器属性元数据。
|
||||||
|
*
|
||||||
|
* @param shaderName - Name of the shader | 着色器名称
|
||||||
|
* @returns Property metadata or undefined | 属性元数据或 undefined
|
||||||
|
*/
|
||||||
|
export function getShaderProperties(shaderName: string): Record<string, ShaderPropertyMeta> | undefined {
|
||||||
|
return BUILTIN_SHADER_PROPERTIES[shaderName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shader property metadata by shader ID.
|
||||||
|
* 通过着色器 ID 获取着色器属性元数据。
|
||||||
|
*
|
||||||
|
* @param shaderId - ID of the shader (from BuiltInShaders) | 着色器 ID(来自 BuiltInShaders)
|
||||||
|
* @returns Property metadata or undefined | 属性元数据或 undefined
|
||||||
|
*/
|
||||||
|
export function getShaderPropertiesById(shaderId: number): Record<string, ShaderPropertyMeta> | undefined {
|
||||||
|
const shaderNames = ['DefaultSprite', 'Grayscale', 'Tint', 'Flash', 'Outline', 'Shiny'];
|
||||||
|
const name = shaderNames[shaderId];
|
||||||
|
return name ? BUILTIN_SHADER_PROPERTIES[name] : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Material overridable mixin for ES Engine.
|
||||||
|
* ES引擎材质覆盖 Mixin。
|
||||||
|
*
|
||||||
|
* This mixin provides material override functionality that can be mixed into
|
||||||
|
* any component class (SpriteComponent, UIRenderComponent, etc.).
|
||||||
|
* 此 Mixin 提供材质覆盖功能,可混入任何组件类。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MaterialPropertyOverride,
|
||||||
|
MaterialOverrides,
|
||||||
|
IMaterialOverridable
|
||||||
|
} from '../interfaces/IMaterialOverridable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor type for mixin base class.
|
||||||
|
* Mixin 基类的构造函数类型。
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Constructor<T = object> = new (...args: any[]) => T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixin that provides material override functionality.
|
||||||
|
* 提供材质覆盖功能的 Mixin。
|
||||||
|
*
|
||||||
|
* This mixin adds all material override methods to a base class,
|
||||||
|
* implementing the IMaterialOverridable interface.
|
||||||
|
* 此 Mixin 将所有材质覆盖方法添加到基类,实现 IMaterialOverridable 接口。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Apply mixin to a component class
|
||||||
|
* class MySpriteComponent extends MaterialOverridableMixin(Component) {
|
||||||
|
* // ... other properties
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // The class now has all material override methods
|
||||||
|
* const sprite = new MySpriteComponent();
|
||||||
|
* sprite.setMaterialId(BuiltInShaders.Shiny);
|
||||||
|
* sprite.setOverrideFloat('u_shinyProgress', 0.5);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param Base - Base class to extend
|
||||||
|
* @returns Class with material override functionality
|
||||||
|
*/
|
||||||
|
export function MaterialOverridableMixin<TBase extends Constructor>(Base: TBase) {
|
||||||
|
return class MaterialOverridableClass extends Base implements IMaterialOverridable {
|
||||||
|
/**
|
||||||
|
* Material GUID for asset reference.
|
||||||
|
* 材质资产引用的 GUID。
|
||||||
|
*/
|
||||||
|
materialGuid: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current material ID.
|
||||||
|
* 当前材质 ID。
|
||||||
|
* @internal - Use getMaterialId() and setMaterialId() instead
|
||||||
|
*/
|
||||||
|
__materialId: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material property overrides.
|
||||||
|
* 材质属性覆盖。
|
||||||
|
* @internal - Use materialOverrides getter instead
|
||||||
|
*/
|
||||||
|
__materialOverrides: MaterialOverrides = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current material overrides.
|
||||||
|
* 获取当前材质覆盖。
|
||||||
|
*/
|
||||||
|
get materialOverrides(): MaterialOverrides {
|
||||||
|
return this.__materialOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current material ID.
|
||||||
|
* 获取当前材质 ID。
|
||||||
|
*/
|
||||||
|
getMaterialId(): number {
|
||||||
|
return this.__materialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set material ID.
|
||||||
|
* 设置材质 ID。
|
||||||
|
*/
|
||||||
|
setMaterialId(id: number): void {
|
||||||
|
this.__materialId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a float uniform override.
|
||||||
|
* 设置浮点 uniform 覆盖。
|
||||||
|
*/
|
||||||
|
setOverrideFloat(name: string, value: number): this {
|
||||||
|
this.__materialOverrides[name] = { type: 'float', value };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec2 uniform override.
|
||||||
|
* 设置 vec2 uniform 覆盖。
|
||||||
|
*/
|
||||||
|
setOverrideVec2(name: string, x: number, y: number): this {
|
||||||
|
this.__materialOverrides[name] = { type: 'vec2', value: [x, y] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec3 uniform override.
|
||||||
|
* 设置 vec3 uniform 覆盖。
|
||||||
|
*/
|
||||||
|
setOverrideVec3(name: string, x: number, y: number, z: number): this {
|
||||||
|
this.__materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec4 uniform override.
|
||||||
|
* 设置 vec4 uniform 覆盖。
|
||||||
|
*/
|
||||||
|
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
|
||||||
|
this.__materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a color uniform override (RGBA, 0.0-1.0).
|
||||||
|
* 设置颜色 uniform 覆盖(RGBA,0.0-1.0)。
|
||||||
|
*/
|
||||||
|
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
|
||||||
|
this.__materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an integer uniform override.
|
||||||
|
* 设置整数 uniform 覆盖。
|
||||||
|
*/
|
||||||
|
setOverrideInt(name: string, value: number): this {
|
||||||
|
this.__materialOverrides[name] = { type: 'int', value: Math.floor(value) };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific override value.
|
||||||
|
* 获取特定覆盖值。
|
||||||
|
*/
|
||||||
|
getOverride(name: string): MaterialPropertyOverride | undefined {
|
||||||
|
return this.__materialOverrides[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific override.
|
||||||
|
* 移除特定覆盖。
|
||||||
|
*/
|
||||||
|
removeOverride(name: string): this {
|
||||||
|
delete this.__materialOverrides[name];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all overrides.
|
||||||
|
* 清除所有覆盖。
|
||||||
|
*/
|
||||||
|
clearOverrides(): this {
|
||||||
|
this.__materialOverrides = {};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any overrides are set.
|
||||||
|
* 检查是否设置了任何覆盖。
|
||||||
|
*/
|
||||||
|
hasOverrides(): boolean {
|
||||||
|
return Object.keys(this.__materialOverrides).length > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class that can be used for composition instead of mixin.
|
||||||
|
* 可用于组合而非 Mixin 的辅助类。
|
||||||
|
*
|
||||||
|
* Use this when you cannot use mixins (e.g., class already extends another class).
|
||||||
|
* 当无法使用 Mixin 时使用此类(例如,类已继承其他类)。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class MyComponent extends Component {
|
||||||
|
* private _materialHelper = new MaterialOverrideHelper();
|
||||||
|
*
|
||||||
|
* get materialOverrides() { return this._materialHelper.materialOverrides; }
|
||||||
|
* getMaterialId() { return this._materialHelper.getMaterialId(); }
|
||||||
|
* setMaterialId(id: number) { this._materialHelper.setMaterialId(id); }
|
||||||
|
* // ... delegate other methods
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class MaterialOverrideHelper implements IMaterialOverridable {
|
||||||
|
materialGuid: string = '';
|
||||||
|
private _materialId: number = 0;
|
||||||
|
private _materialOverrides: MaterialOverrides = {};
|
||||||
|
|
||||||
|
get materialOverrides(): MaterialOverrides {
|
||||||
|
return this._materialOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaterialId(): number {
|
||||||
|
return this._materialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaterialId(id: number): void {
|
||||||
|
this._materialId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideFloat(name: string, value: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'float', value };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideVec2(name: string, x: number, y: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'vec2', value: [x, y] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideVec3(name: string, x: number, y: number, z: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
|
||||||
|
this._materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverrideInt(name: string, value: number): this {
|
||||||
|
this._materialOverrides[name] = { type: 'int', value: Math.floor(value) };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOverride(name: string): MaterialPropertyOverride | undefined {
|
||||||
|
return this._materialOverrides[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOverride(name: string): this {
|
||||||
|
delete this._materialOverrides[name];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOverrides(): this {
|
||||||
|
this._materialOverrides = {};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOverrides(): boolean {
|
||||||
|
return Object.keys(this._materialOverrides).length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,8 @@ export const BuiltInShaders = {
|
|||||||
Grayscale: 1,
|
Grayscale: 1,
|
||||||
Tint: 2,
|
Tint: 2,
|
||||||
Flash: 3,
|
Flash: 3,
|
||||||
Outline: 4
|
Outline: 4,
|
||||||
|
Shiny: 5
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { Vector2 } from './Vector2';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 3x3变换矩阵类
|
* 3x3变换矩阵类
|
||||||
|
* 3x3 Transform Matrix Class
|
||||||
*
|
*
|
||||||
* 用于2D变换(平移、旋转、缩放)的3x3矩阵
|
* 用于2D变换(平移、旋转、缩放)的3x3矩阵
|
||||||
* 矩阵布局:
|
* 使用左手坐标系(顺时针正旋转)
|
||||||
* [m00, m01, m02] [scaleX * cos, -scaleY * sin, translateX]
|
*
|
||||||
* [m10, m11, m12] = [scaleX * sin, scaleY * cos, translateY]
|
* 矩阵布局(顺时针旋转):
|
||||||
* [m20, m21, m22] [0, 0, 1]
|
* [m00, m01, m02] [scaleX * cos, scaleY * sin, translateX]
|
||||||
|
* [m10, m11, m12] = [-scaleX * sin, scaleY * cos, translateY]
|
||||||
|
* [m20, m21, m22] [0, 0, 1]
|
||||||
*/
|
*/
|
||||||
export class Matrix3 {
|
export class Matrix3 {
|
||||||
/** 矩阵元素,按行优先存储 */
|
/** 矩阵元素,按行优先存储 */
|
||||||
@@ -243,17 +246,24 @@ export class Matrix3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置为旋转矩阵
|
* 设置为旋转矩阵(顺时针为正)
|
||||||
* @param angle 旋转角度(弧度)
|
* Set as rotation matrix (clockwise positive)
|
||||||
* @returns 当前矩阵实例(链式调用)
|
*
|
||||||
*/
|
* 使用左手坐标系约定:正角度 = 顺时针旋转
|
||||||
|
* Uses left-hand coordinate system: positive angle = clockwise
|
||||||
|
*
|
||||||
|
* @param angle 旋转角度(弧度)
|
||||||
|
* @returns 当前矩阵实例(链式调用)
|
||||||
|
*/
|
||||||
makeRotation(angle: number): this {
|
makeRotation(angle: number): this {
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
|
|
||||||
|
// Clockwise rotation matrix
|
||||||
|
// 顺时针旋转矩阵
|
||||||
this.elements.set([
|
this.elements.set([
|
||||||
cos, -sin, 0,
|
cos, sin, 0,
|
||||||
sin, cos, 0,
|
-sin, cos, 0,
|
||||||
0, 0, 1
|
0, 0, 1
|
||||||
]);
|
]);
|
||||||
return this;
|
return this;
|
||||||
@@ -287,18 +297,25 @@ export class Matrix3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复合旋转
|
* 复合旋转(顺时针为正)
|
||||||
* @param angle 旋转角度(弧度)
|
* Composite rotation (clockwise positive)
|
||||||
* @returns 当前矩阵实例(链式调用)
|
*
|
||||||
*/
|
* 使用左手坐标系约定:正角度 = 顺时针旋转
|
||||||
|
* Uses left-hand coordinate system: positive angle = clockwise
|
||||||
|
*
|
||||||
|
* @param angle 旋转角度(弧度)
|
||||||
|
* @returns 当前矩阵实例(链式调用)
|
||||||
|
*/
|
||||||
rotate(angle: number): this {
|
rotate(angle: number): this {
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
|
|
||||||
const m00 = this.m00 * cos + this.m01 * sin;
|
// Clockwise rotation: multiply by [cos, sin; -sin, cos]
|
||||||
const m01 = this.m00 * -sin + this.m01 * cos;
|
// 顺时针旋转
|
||||||
const m10 = this.m10 * cos + this.m11 * sin;
|
const m00 = this.m00 * cos - this.m01 * sin;
|
||||||
const m11 = this.m10 * -sin + this.m11 * cos;
|
const m01 = this.m00 * sin + this.m01 * cos;
|
||||||
|
const m10 = this.m10 * cos - this.m11 * sin;
|
||||||
|
const m11 = this.m10 * sin + this.m11 * cos;
|
||||||
|
|
||||||
this.m00 = m00;
|
this.m00 = m00;
|
||||||
this.m01 = m01;
|
this.m01 = m01;
|
||||||
@@ -433,11 +450,15 @@ export class Matrix3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取旋转角度
|
* 获取旋转角度(顺时针为正)
|
||||||
* @returns 旋转角度(弧度)
|
* Get rotation angle (clockwise positive)
|
||||||
*/
|
* @returns 旋转角度(弧度)
|
||||||
|
*/
|
||||||
getRotation(): number {
|
getRotation(): number {
|
||||||
return Math.atan2(this.m10, this.m00);
|
// For clockwise rotation matrix [cos, sin; -sin, cos]
|
||||||
|
// m00 = cos, m01 = sin, so atan2(m01, m00) = θ
|
||||||
|
// 顺时针旋转矩阵:从 m01 和 m00 提取角度
|
||||||
|
return Math.atan2(this.m01, this.m00);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -551,19 +572,26 @@ export class Matrix3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建TRS(平移-旋转-缩放)变换矩阵
|
* 创建TRS(平移-旋转-缩放)变换矩阵(顺时针为正)
|
||||||
* @param translation 平移向量
|
* Create TRS (Translate-Rotate-Scale) matrix (clockwise positive)
|
||||||
* @param rotation 旋转角度(弧度)
|
*
|
||||||
* @param scale 缩放向量
|
* 使用左手坐标系约定:正角度 = 顺时针旋转
|
||||||
* @returns 新的TRS矩阵
|
* Uses left-hand coordinate system: positive angle = clockwise
|
||||||
*/
|
*
|
||||||
|
* @param translation 平移向量
|
||||||
|
* @param rotation 旋转角度(弧度)
|
||||||
|
* @param scale 缩放向量
|
||||||
|
* @returns 新的TRS矩阵
|
||||||
|
*/
|
||||||
static TRS(translation: Vector2, rotation: number, scale: Vector2): Matrix3 {
|
static TRS(translation: Vector2, rotation: number, scale: Vector2): Matrix3 {
|
||||||
const cos = Math.cos(rotation);
|
const cos = Math.cos(rotation);
|
||||||
const sin = Math.sin(rotation);
|
const sin = Math.sin(rotation);
|
||||||
|
|
||||||
|
// Clockwise rotation matrix with scale
|
||||||
|
// 带缩放的顺时针旋转矩阵
|
||||||
return new Matrix3([
|
return new Matrix3([
|
||||||
scale.x * cos, -scale.y * sin, translation.x,
|
scale.x * cos, scale.y * sin, translation.x,
|
||||||
scale.x * sin, scale.y * cos, translation.y,
|
-scale.x * sin, scale.y * cos, translation.y,
|
||||||
0, 0, 1
|
0, 0, 1
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,25 +282,35 @@ export class Vector2 implements IVector2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取垂直向量(逆时针旋转90度)
|
* 获取垂直向量(顺时针旋转90度)
|
||||||
* @returns 新的垂直向量
|
* Get perpendicular vector (clockwise 90 degrees)
|
||||||
*/
|
* @returns 新的垂直向量
|
||||||
|
*/
|
||||||
perpendicular(): Vector2 {
|
perpendicular(): Vector2 {
|
||||||
return new Vector2(-this.y, this.x);
|
// Clockwise 90° rotation: (x, y) -> (y, -x)
|
||||||
|
// 顺时针旋转 90°
|
||||||
|
return new Vector2(this.y, -this.x);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 变换操作
|
// 变换操作
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向量旋转
|
* 向量旋转(顺时针为正)
|
||||||
* @param angle 旋转角度(弧度)
|
* Rotate vector (clockwise positive)
|
||||||
* @returns 当前向量实例(链式调用)
|
*
|
||||||
*/
|
* 使用左手坐标系约定:正角度 = 顺时针旋转
|
||||||
|
* Uses left-hand coordinate system: positive angle = clockwise
|
||||||
|
*
|
||||||
|
* @param angle 旋转角度(弧度)
|
||||||
|
* @returns 当前向量实例(链式调用)
|
||||||
|
*/
|
||||||
rotate(angle: number): this {
|
rotate(angle: number): this {
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
const x = this.x * cos - this.y * sin;
|
// Clockwise rotation: x' = x*cos + y*sin, y' = -x*sin + y*cos
|
||||||
const y = this.x * sin + this.y * cos;
|
// 顺时针旋转公式
|
||||||
|
const x = this.x * cos + this.y * sin;
|
||||||
|
const y = -this.x * sin + this.y * cos;
|
||||||
this.x = x;
|
this.x = x;
|
||||||
this.y = y;
|
this.y = y;
|
||||||
return this;
|
return this;
|
||||||
|
|||||||
@@ -149,11 +149,13 @@ describe('Vector2', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('变换操作', () => {
|
describe('变换操作', () => {
|
||||||
test('rotate方法应正确旋转向量', () => {
|
test('rotate方法应正确旋转向量(顺时针)', () => {
|
||||||
|
// Clockwise rotation: (1, 0) rotated 90° clockwise = (0, -1)
|
||||||
|
// 顺时针旋转:(1, 0) 顺时针旋转 90° = (0, -1)
|
||||||
const v = new Vector2(1, 0);
|
const v = new Vector2(1, 0);
|
||||||
v.rotate(Math.PI / 2);
|
v.rotate(Math.PI / 2);
|
||||||
expectFloatsEqual(v.x, 0, 1e-10);
|
expectFloatsEqual(v.x, 0, 1e-10);
|
||||||
expectFloatsEqual(v.y, 1, 1e-10);
|
expectFloatsEqual(v.y, -1, 1e-10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reflect方法应正确反射向量', () => {
|
test('reflect方法应正确反射向量', () => {
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ function particleSystemGizmoProvider(
|
|||||||
const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX;
|
const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX;
|
||||||
const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY;
|
const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY;
|
||||||
const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度
|
const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度
|
||||||
const direction = ((asset?.direction ?? 90) * Math.PI / 180) + worldRotation; // 转换为弧度并应用世界旋转
|
// 转换为弧度并应用世界旋转 | Convert to radians and apply world rotation
|
||||||
|
// worldRotation 是度(顺时针),转为弧度(逆时针)用于数学计算
|
||||||
|
// worldRotation is degrees(clockwise), convert to radians(counter-clockwise) for math
|
||||||
|
const direction = ((asset?.direction ?? 90) * Math.PI / 180) - (worldRotation * Math.PI / 180);
|
||||||
|
|
||||||
// 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape
|
// 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape
|
||||||
switch (emissionShape) {
|
switch (emissionShape) {
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export class ClickFxSystem extends EntitySystem {
|
|||||||
private _checkTrigger(clickFx: ClickFxComponent): boolean {
|
private _checkTrigger(clickFx: ClickFxComponent): boolean {
|
||||||
const mode = clickFx.triggerMode;
|
const mode = clickFx.triggerMode;
|
||||||
|
|
||||||
|
// 首先检查鼠标是否在 Canvas 内
|
||||||
|
// First check if mouse is within canvas bounds
|
||||||
|
if (!this._isMouseInCanvas()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case ClickFxTriggerMode.LeftClick:
|
case ClickFxTriggerMode.LeftClick:
|
||||||
return Input.isMouseButtonJustPressed(MouseButton.Left);
|
return Input.isMouseButtonJustPressed(MouseButton.Left);
|
||||||
@@ -253,6 +259,27 @@ export class ClickFxSystem extends EntitySystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查鼠标是否在 Canvas 内
|
||||||
|
* Check if mouse is within canvas bounds
|
||||||
|
*/
|
||||||
|
private _isMouseInCanvas(): boolean {
|
||||||
|
if (!this._canvas) {
|
||||||
|
return true; // 没有 canvas 引用时,默认允许(兼容旧行为)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = this._canvas.getBoundingClientRect();
|
||||||
|
const mouseX = Input.mousePosition.x;
|
||||||
|
const mouseY = Input.mousePosition.y;
|
||||||
|
|
||||||
|
// 检查鼠标是否在 canvas 边界内
|
||||||
|
// Check if mouse is within canvas bounds
|
||||||
|
return mouseX >= rect.left &&
|
||||||
|
mouseX <= rect.right &&
|
||||||
|
mouseY >= rect.top &&
|
||||||
|
mouseY <= rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否有新的触摸开始
|
* 检查是否有新的触摸开始
|
||||||
* Check if there's a new touch start
|
* Check if there's a new touch start
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import type { IParticleAsset } from '../loaders/ParticleLoader';
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_PARTICLE_TEXTURE_ID = 99999;
|
const DEFAULT_PARTICLE_TEXTURE_ID = 99999;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角度转换常量
|
||||||
|
* Angle conversion constants
|
||||||
|
*/
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成默认粒子纹理的 Data URL(渐变圆形)
|
* 生成默认粒子纹理的 Data URL(渐变圆形)
|
||||||
* Generate default particle texture Data URL (gradient circle)
|
* Generate default particle texture Data URL (gradient circle)
|
||||||
@@ -171,9 +177,10 @@ export class ParticleUpdateSystem extends EntitySystem {
|
|||||||
worldY = pos.y;
|
worldY = pos.y;
|
||||||
|
|
||||||
// 获取旋转(2D 使用 z 分量)| Get rotation (2D uses z component)
|
// 获取旋转(2D 使用 z 分量)| Get rotation (2D uses z component)
|
||||||
|
// 转换:度(顺时针) → 弧度(逆时针) | Convert: degrees(clockwise) → radians(counter-clockwise)
|
||||||
const rot = transform.worldRotation ?? transform.rotation;
|
const rot = transform.worldRotation ?? transform.rotation;
|
||||||
if (rot) {
|
if (rot) {
|
||||||
worldRotation = rot.z;
|
worldRotation = -rot.z * DEG_TO_RAD;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取缩放 | Get scale
|
// 获取缩放 | Get scale
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
RigidbodyType2D
|
RigidbodyType2D
|
||||||
} from '@esengine/physics-rapier2d';
|
} from '@esengine/physics-rapier2d';
|
||||||
|
|
||||||
|
/** 度转弧度常量 | Degrees to radians constant */
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 物理 Gizmo 颜色配置
|
* 物理 Gizmo 颜色配置
|
||||||
*/
|
*/
|
||||||
@@ -185,17 +188,21 @@ function circleCollider2DGizmoProvider(
|
|||||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, centerMarkSize, PhysicsGizmoColors.centerMark));
|
gizmos.push(...createCenterMarkGizmo(worldX, worldY, centerMarkSize, PhysicsGizmoColors.centerMark));
|
||||||
|
|
||||||
// 半径指示线 (从中心到右边缘)
|
// 半径指示线 (从中心到右边缘)
|
||||||
const rotation = typeof transform.rotation === 'number'
|
// Radius indicator line (from center to right edge)
|
||||||
|
const rotationDeg = typeof transform.rotation === 'number'
|
||||||
? transform.rotation
|
? transform.rotation
|
||||||
: transform.rotation.z;
|
: transform.rotation.z;
|
||||||
const cos = Math.cos(rotation);
|
const rotationRad = rotationDeg * DEG_TO_RAD;
|
||||||
const sin = Math.sin(rotation);
|
const cos = Math.cos(rotationRad);
|
||||||
|
const sin = Math.sin(rotationRad);
|
||||||
|
|
||||||
|
// Clockwise rotation: use (cos, -sin) for direction
|
||||||
|
// 顺时针旋转:使用 (cos, -sin) 表示方向
|
||||||
gizmos.push({
|
gizmos.push({
|
||||||
type: 'line',
|
type: 'line',
|
||||||
points: [
|
points: [
|
||||||
{ x: worldX, y: worldY },
|
{ x: worldX, y: worldY },
|
||||||
{ x: worldX + scaledRadius * cos, y: worldY + scaledRadius * sin }
|
{ x: worldX + scaledRadius * cos, y: worldY - scaledRadius * sin }
|
||||||
],
|
],
|
||||||
color: PhysicsGizmoColors.selected,
|
color: PhysicsGizmoColors.selected,
|
||||||
closed: false
|
closed: false
|
||||||
@@ -205,7 +212,7 @@ function circleCollider2DGizmoProvider(
|
|||||||
gizmos.push({
|
gizmos.push({
|
||||||
type: 'circle',
|
type: 'circle',
|
||||||
x: worldX + scaledRadius * cos,
|
x: worldX + scaledRadius * cos,
|
||||||
y: worldY + scaledRadius * sin,
|
y: worldY - scaledRadius * sin,
|
||||||
radius: scaledRadius * 0.08,
|
radius: scaledRadius * 0.08,
|
||||||
color: PhysicsGizmoColors.selected
|
color: PhysicsGizmoColors.selected
|
||||||
} as ICircleGizmoData);
|
} as ICircleGizmoData);
|
||||||
@@ -276,21 +283,24 @@ function polygonCollider2DGizmoProvider(
|
|||||||
const gizmos: IGizmoRenderData[] = [];
|
const gizmos: IGizmoRenderData[] = [];
|
||||||
const color = getColliderColor(isSelected, collider.isTrigger);
|
const color = getColliderColor(isSelected, collider.isTrigger);
|
||||||
|
|
||||||
const rotation = typeof transform.rotation === 'number'
|
const rotationDeg = typeof transform.rotation === 'number'
|
||||||
? transform.rotation
|
? transform.rotation
|
||||||
: transform.rotation.z;
|
: transform.rotation.z;
|
||||||
const totalRotation = rotation + collider.rotationOffset;
|
// 转换为弧度 | Convert to radians
|
||||||
const cos = Math.cos(totalRotation);
|
const totalRotationRad = (rotationDeg + collider.rotationOffset) * DEG_TO_RAD;
|
||||||
const sin = Math.sin(totalRotation);
|
const cos = Math.cos(totalRotationRad);
|
||||||
|
const sin = Math.sin(totalRotationRad);
|
||||||
|
|
||||||
const worldX = transform.position.x + collider.offset.x * transform.scale.x;
|
const worldX = transform.position.x + collider.offset.x * transform.scale.x;
|
||||||
const worldY = transform.position.y + collider.offset.y * transform.scale.y;
|
const worldY = transform.position.y + collider.offset.y * transform.scale.y;
|
||||||
|
|
||||||
|
// Clockwise rotation for polygon vertices
|
||||||
|
// 多边形顶点的顺时针旋转
|
||||||
const worldPoints = collider.vertices.map(v => {
|
const worldPoints = collider.vertices.map(v => {
|
||||||
const scaledX = v.x * transform.scale.x;
|
const scaledX = v.x * transform.scale.x;
|
||||||
const scaledY = v.y * transform.scale.y;
|
const scaledY = v.y * transform.scale.y;
|
||||||
const rotatedX = scaledX * cos - scaledY * sin;
|
const rotatedX = scaledX * cos + scaledY * sin;
|
||||||
const rotatedY = scaledX * sin + scaledY * cos;
|
const rotatedY = -scaledX * sin + scaledY * cos;
|
||||||
return {
|
return {
|
||||||
x: worldX + rotatedX,
|
x: worldX + rotatedX,
|
||||||
y: worldY + rotatedY
|
y: worldY + rotatedY
|
||||||
|
|||||||
@@ -3,10 +3,35 @@
|
|||||||
* 2D 物理世界封装
|
* 2D 物理世界封装
|
||||||
*
|
*
|
||||||
* 封装 Rapier2D 物理世界,提供确定性物理模拟
|
* 封装 Rapier2D 物理世界,提供确定性物理模拟
|
||||||
|
*
|
||||||
|
* 坐标转换说明:
|
||||||
|
* - ESEngine: 左手坐标系,顺时针正旋转,角度单位为度
|
||||||
|
* - Rapier2D: 数学坐标系,逆时针正旋转,角度单位为弧度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type RAPIER from '@esengine/rapier2d';
|
import type RAPIER from '@esengine/rapier2d';
|
||||||
import type { IVector2 } from '@esengine/ecs-framework-math';
|
import type { IVector2 } from '@esengine/ecs-framework-math';
|
||||||
|
|
||||||
|
// 角度单位转换常量 | Angle unit conversion constants
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
const RAD_TO_DEG = 180 / Math.PI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将引擎旋转(度,顺时针)转换为 Rapier 旋转(弧度,逆时针)
|
||||||
|
* Convert engine rotation (degrees, clockwise) to Rapier rotation (radians, counter-clockwise)
|
||||||
|
*/
|
||||||
|
function toRapierRotation(degrees: number): number {
|
||||||
|
return -degrees * DEG_TO_RAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Rapier 旋转(弧度,逆时针)转换为引擎旋转(度,顺时针)
|
||||||
|
* Convert Rapier rotation (radians, counter-clockwise) to engine rotation (degrees, clockwise)
|
||||||
|
*/
|
||||||
|
function fromRapierRotation(radians: number): number {
|
||||||
|
return -radians * RAD_TO_DEG;
|
||||||
|
}
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Physics2DConfig,
|
Physics2DConfig,
|
||||||
RaycastHit2D,
|
RaycastHit2D,
|
||||||
@@ -223,9 +248,10 @@ export class Physics2DWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置刚体属性
|
// 设置刚体属性
|
||||||
|
// 转换旋转:引擎(度,顺时针)→ Rapier(弧度,逆时针)
|
||||||
bodyDesc
|
bodyDesc
|
||||||
.setTranslation(position.x, position.y)
|
.setTranslation(position.x, position.y)
|
||||||
.setRotation(rotation)
|
.setRotation(toRapierRotation(rotation))
|
||||||
.setLinearDamping(rigidbody.linearDamping)
|
.setLinearDamping(rigidbody.linearDamping)
|
||||||
.setAngularDamping(rigidbody.angularDamping)
|
.setAngularDamping(rigidbody.angularDamping)
|
||||||
.setGravityScale(rigidbody.gravityScale)
|
.setGravityScale(rigidbody.gravityScale)
|
||||||
@@ -306,7 +332,8 @@ export class Physics2DWorld {
|
|||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
body.setTranslation(new this._rapier.Vector2(position.x, position.y), true);
|
body.setTranslation(new this._rapier.Vector2(position.x, position.y), true);
|
||||||
body.setRotation(rotation, true);
|
// 转换旋转:引擎(度,顺时针)→ Rapier(弧度,逆时针)
|
||||||
|
body.setRotation(toRapierRotation(rotation), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,7 +360,8 @@ export class Physics2DWorld {
|
|||||||
const body = this._world.getRigidBody(handle);
|
const body = this._world.getRigidBody(handle);
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
|
|
||||||
return body.rotation();
|
// 转换旋转:Rapier(弧度,逆时针)→ 引擎(度,顺时针)
|
||||||
|
return fromRapierRotation(body.rotation());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -803,7 +831,7 @@ export class Physics2DWorld {
|
|||||||
const shape = new this._rapier.Cuboid(halfExtents.x, halfExtents.y);
|
const shape = new this._rapier.Cuboid(halfExtents.x, halfExtents.y);
|
||||||
const shapePos = new this._rapier.Vector2(center.x, center.y);
|
const shapePos = new this._rapier.Vector2(center.x, center.y);
|
||||||
|
|
||||||
this._world.intersectionsWithShape(shapePos, rotation, shape, (collider) => {
|
this._world.intersectionsWithShape(shapePos, toRapierRotation(rotation), shape, (collider) => {
|
||||||
const mapping = this._colliderMap.get(collider.handle);
|
const mapping = this._colliderMap.get(collider.handle);
|
||||||
if (mapping && (collider.collisionGroups() & collisionMask) !== 0) {
|
if (mapping && (collider.collisionGroups() & collisionMask) !== 0) {
|
||||||
entityIds.push(mapping.entityId);
|
entityIds.push(mapping.entityId);
|
||||||
@@ -1016,7 +1044,7 @@ export class Physics2DWorld {
|
|||||||
// 配置碰撞体属性
|
// 配置碰撞体属性
|
||||||
colliderDesc
|
colliderDesc
|
||||||
.setTranslation(collider.offset.x * sx, collider.offset.y * sy)
|
.setTranslation(collider.offset.x * sx, collider.offset.y * sy)
|
||||||
.setRotation(collider.rotationOffset)
|
.setRotation(toRapierRotation(collider.rotationOffset))
|
||||||
.setFriction(collider.friction)
|
.setFriction(collider.friction)
|
||||||
.setRestitution(collider.restitution)
|
.setRestitution(collider.restitution)
|
||||||
.setDensity(collider.density)
|
.setDensity(collider.density)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
TransformTypeToken,
|
TransformTypeToken,
|
||||||
CanvasElementToken
|
CanvasElementToken
|
||||||
} from '@esengine/engine-core';
|
} from '@esengine/engine-core';
|
||||||
import { AssetManager, EngineIntegration, AssetManagerToken } from '@esengine/asset-system';
|
import { AssetManager, EngineIntegration, AssetManagerToken, setGlobalAssetDatabase } from '@esengine/asset-system';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 本地服务令牌定义 | Local Service Token Definitions
|
// 本地服务令牌定义 | Local Service Token Definitions
|
||||||
@@ -347,6 +347,10 @@ export class GameRuntime {
|
|||||||
this._assetManager = new AssetManager();
|
this._assetManager = new AssetManager();
|
||||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._bridge);
|
this._engineIntegration = new EngineIntegration(this._assetManager, this._bridge);
|
||||||
|
|
||||||
|
// 设置全局资产数据库(供渲染系统查询 sprite 元数据)
|
||||||
|
// Set global asset database (for render systems to query sprite metadata)
|
||||||
|
setGlobalAssetDatabase(this._assetManager.getDatabase());
|
||||||
|
|
||||||
// 9. 加载并初始化插件(编辑器模式下跳过,由 editor-core 的 PluginManager 处理)
|
// 9. 加载并初始化插件(编辑器模式下跳过,由 editor-core 的 PluginManager 处理)
|
||||||
if (!this._config.skipPluginLoading) {
|
if (!this._config.skipPluginLoading) {
|
||||||
await this._initializePlugins();
|
await this._initializePlugins();
|
||||||
@@ -1034,6 +1038,8 @@ export class GameRuntime {
|
|||||||
if (this._assetManager) {
|
if (this._assetManager) {
|
||||||
this._assetManager.dispose();
|
this._assetManager.dispose();
|
||||||
this._assetManager = null;
|
this._assetManager = null;
|
||||||
|
// 清除全局资产数据库引用 | Clear global asset database reference
|
||||||
|
setGlobalAssetDatabase(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._engineIntegration = null;
|
this._engineIntegration = null;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@esengine/ecs-framework": "workspace:*",
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
"@esengine/asset-system": "workspace:*",
|
"@esengine/asset-system": "workspace:*",
|
||||||
"@esengine/engine-core": "workspace:*",
|
"@esengine/engine-core": "workspace:*",
|
||||||
|
"@esengine/material-system": "workspace:*",
|
||||||
"@esengine/build-config": "workspace:*",
|
"@esengine/build-config": "workspace:*",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Shiny effect component for sprite elements.
|
||||||
|
* 精灵元素的闪光效果组件。
|
||||||
|
*
|
||||||
|
* This component configures a sweeping highlight animation that moves across
|
||||||
|
* the sprite's texture.
|
||||||
|
* 此组件配置一个扫过精灵纹理的高光动画。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||||
|
import type { IShinyEffect } from '@esengine/material-system';
|
||||||
|
import {
|
||||||
|
SHINY_EFFECT_DEFAULTS,
|
||||||
|
resetShinyEffect,
|
||||||
|
startShinyEffect,
|
||||||
|
stopShinyEffect,
|
||||||
|
getShinyRotationRadians
|
||||||
|
} from '@esengine/material-system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shiny effect component.
|
||||||
|
* 闪光效果组件。
|
||||||
|
*
|
||||||
|
* Adds a sweeping highlight animation to sprites.
|
||||||
|
* 为精灵添加扫光动画效果。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Add shiny effect to an entity with SpriteComponent
|
||||||
|
* const shiny = entity.addComponent(ShinyEffectComponent);
|
||||||
|
* shiny.play = true;
|
||||||
|
* shiny.loop = true;
|
||||||
|
* shiny.duration = 2.0;
|
||||||
|
* shiny.loopDelay = 2.0;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@ECSComponent('ShinyEffect', { requires: ['Sprite'] })
|
||||||
|
@Serializable({ version: 1, typeId: 'ShinyEffect' })
|
||||||
|
export class ShinyEffectComponent extends Component implements IShinyEffect {
|
||||||
|
// ============= Effect Parameters =============
|
||||||
|
// ============= 效果参数 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the shiny band (0.0 - 1.0).
|
||||||
|
* 闪光带宽度 (0.0 - 1.0)。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 })
|
||||||
|
public width: number = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation angle in degrees.
|
||||||
|
* 旋转角度(度)。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 })
|
||||||
|
public rotation: number = 129;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge softness (0.0 - 1.0).
|
||||||
|
* 边缘柔和度 (0.0 - 1.0)。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 })
|
||||||
|
public softness: number = 1.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brightness multiplier.
|
||||||
|
* 亮度倍增器。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 })
|
||||||
|
public brightness: number = 1.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gloss intensity.
|
||||||
|
* 光泽度。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 })
|
||||||
|
public gloss: number = 1.0;
|
||||||
|
|
||||||
|
// ============= Animation Settings =============
|
||||||
|
// ============= 动画设置 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the animation is playing.
|
||||||
|
* 动画是否正在播放。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Play' })
|
||||||
|
public play: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to loop the animation.
|
||||||
|
* 是否循环动画。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'boolean', label: 'Loop' })
|
||||||
|
public loop: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation duration in seconds.
|
||||||
|
* 动画持续时间(秒)。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 })
|
||||||
|
public duration: number = 2.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay between loops in seconds.
|
||||||
|
* 循环之间的延迟(秒)。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 })
|
||||||
|
public loopDelay: number = 2.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial delay before first play in seconds.
|
||||||
|
* 首次播放前的初始延迟(秒)。
|
||||||
|
*/
|
||||||
|
@Serialize()
|
||||||
|
@Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 })
|
||||||
|
public initialDelay: number = 0;
|
||||||
|
|
||||||
|
// ============= Runtime State (not serialized) =============
|
||||||
|
// ============= 运行时状态(不序列化)=============
|
||||||
|
|
||||||
|
/** Current animation progress (0.0 - 1.0). | 当前动画进度。 */
|
||||||
|
public progress: number = 0;
|
||||||
|
|
||||||
|
/** Current elapsed time in the animation cycle. | 当前周期已用时间。 */
|
||||||
|
public elapsedTime: number = 0;
|
||||||
|
|
||||||
|
/** Whether currently in delay phase. | 是否处于延迟阶段。 */
|
||||||
|
public inDelay: boolean = false;
|
||||||
|
|
||||||
|
/** Remaining delay time. | 剩余延迟时间。 */
|
||||||
|
public delayRemaining: number = 0;
|
||||||
|
|
||||||
|
/** Whether the initial delay has been processed. | 初始延迟是否已处理。 */
|
||||||
|
public initialDelayProcessed: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the animation to the beginning.
|
||||||
|
* 重置动画到开始状态。
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
resetShinyEffect(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playing the animation.
|
||||||
|
* 开始播放动画。
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
startShinyEffect(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the animation.
|
||||||
|
* 停止动画。
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
stopShinyEffect(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rotation in radians for shader use.
|
||||||
|
* 获取弧度制的旋转角度供着色器使用。
|
||||||
|
*/
|
||||||
|
getRotationRadians(): number {
|
||||||
|
return getShinyRotationRadians(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,11 @@
|
|||||||
import type { AssetReference } from '@esengine/asset-system';
|
import type { AssetReference } from '@esengine/asset-system';
|
||||||
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||||
import { SortingLayers, type ISortable } from '@esengine/engine-core';
|
import { SortingLayers, type ISortable } from '@esengine/engine-core';
|
||||||
|
import type {
|
||||||
/**
|
IMaterialOverridable,
|
||||||
* Material property override value.
|
MaterialPropertyOverride,
|
||||||
* 材质属性覆盖值。
|
MaterialOverrides
|
||||||
*
|
} from '@esengine/material-system';
|
||||||
* Used to override specific uniform parameters on a per-instance basis
|
|
||||||
* without creating a new material instance.
|
|
||||||
* 用于在每个实例上覆盖特定的 uniform 参数,而无需创建新的材质实例。
|
|
||||||
*/
|
|
||||||
export interface MaterialPropertyOverride {
|
|
||||||
/** Uniform type. | Uniform 类型。 */
|
|
||||||
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
|
||||||
/** Uniform value. | Uniform 值。 */
|
|
||||||
value: number | number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Material property overrides map.
|
|
||||||
* 材质属性覆盖映射。
|
|
||||||
*/
|
|
||||||
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精灵组件 - 管理2D图像渲染
|
* 精灵组件 - 管理2D图像渲染
|
||||||
@@ -32,7 +16,7 @@ export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
|
|||||||
*/
|
*/
|
||||||
@ECSComponent('Sprite', { requires: ['Transform'] })
|
@ECSComponent('Sprite', { requires: ['Transform'] })
|
||||||
@Serializable({ version: 5, typeId: 'Sprite' })
|
@Serializable({ version: 5, typeId: 'Sprite' })
|
||||||
export class SpriteComponent extends Component implements ISortable {
|
export class SpriteComponent extends Component implements ISortable, IMaterialOverridable {
|
||||||
/**
|
/**
|
||||||
* 纹理资产 GUID
|
* 纹理资产 GUID
|
||||||
* Texture asset GUID
|
* Texture asset GUID
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export { SpriteComponent } from './SpriteComponent';
|
export { SpriteComponent } from './SpriteComponent';
|
||||||
export type { MaterialPropertyOverride, MaterialOverrides } from './SpriteComponent';
|
// Re-export material types from material-system for convenience
|
||||||
|
// 从 material-system 重新导出材质类型以方便使用
|
||||||
|
export type { MaterialPropertyOverride, MaterialOverrides } from '@esengine/material-system';
|
||||||
export { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
export { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
|
||||||
export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
|
export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
|
||||||
|
export { ShinyEffectComponent } from './ShinyEffectComponent';
|
||||||
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
|
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
|
||||||
|
export { ShinyEffectSystem } from './systems/ShinyEffectSystem';
|
||||||
export { SpriteRuntimeModule, SpritePlugin } from './SpriteRuntimeModule';
|
export { SpriteRuntimeModule, SpritePlugin } from './SpriteRuntimeModule';
|
||||||
|
|
||||||
// Service tokens | 服务令牌
|
// Service tokens | 服务令牌
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Shiny effect animation system.
|
||||||
|
* 闪光效果动画系统。
|
||||||
|
*
|
||||||
|
* Updates ShinyEffectComponent animations and applies material overrides
|
||||||
|
* to the associated SpriteComponent.
|
||||||
|
* 更新 ShinyEffectComponent 动画并将材质覆盖应用到关联的 SpriteComponent。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework';
|
||||||
|
import { ShinyEffectAnimator } from '@esengine/material-system';
|
||||||
|
import { ShinyEffectComponent } from '../ShinyEffectComponent';
|
||||||
|
import { SpriteComponent } from '../SpriteComponent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System that animates shiny effects on sprites.
|
||||||
|
* 为精灵动画闪光效果的系统。
|
||||||
|
*/
|
||||||
|
@ECSSystem('ShinyEffect', { updateOrder: 100 })
|
||||||
|
export class ShinyEffectSystem extends EntitySystem {
|
||||||
|
constructor() {
|
||||||
|
super(Matcher.empty().all(ShinyEffectComponent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all entities with ShinyEffectComponent.
|
||||||
|
* 处理所有带有 ShinyEffectComponent 的实体。
|
||||||
|
*/
|
||||||
|
protected override process(entities: readonly Entity[]): void {
|
||||||
|
const deltaTime = Time.deltaTime;
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (!entity.enabled) continue;
|
||||||
|
|
||||||
|
const shiny = entity.getComponent(ShinyEffectComponent);
|
||||||
|
if (!shiny || !shiny.play) continue;
|
||||||
|
|
||||||
|
const sprite = entity.getComponent(SpriteComponent);
|
||||||
|
if (!sprite) continue;
|
||||||
|
|
||||||
|
// Use shared animator logic
|
||||||
|
// 使用共享的动画器逻辑
|
||||||
|
ShinyEffectAnimator.processEffect(shiny, sprite, deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { Color } from '@esengine/ecs-framework-math';
|
|||||||
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
|
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
|
||||||
import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent';
|
import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent';
|
||||||
|
|
||||||
|
/** 度转弧度常量 | Degrees to radians constant */
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tilemap render data for a single tilemap layer
|
* Tilemap render data for a single tilemap layer
|
||||||
* 单个瓦片地图图层的渲染数据
|
* 单个瓦片地图图层的渲染数据
|
||||||
@@ -186,9 +189,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
|||||||
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
|
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
|
||||||
|
|
||||||
// Calculate rotation parameters
|
// Calculate rotation parameters
|
||||||
// 计算旋转参数
|
// 计算旋转参数(度转弧度)
|
||||||
const cos = Math.cos(transform.rotation.z);
|
const rotationRad = transform.rotation.z * DEG_TO_RAD;
|
||||||
const sin = Math.sin(transform.rotation.z);
|
const cos = Math.cos(rotationRad);
|
||||||
|
const sin = Math.sin(rotationRad);
|
||||||
|
|
||||||
// Tilemap rotation pivot
|
// Tilemap rotation pivot
|
||||||
// Tilemap 旋转中心点
|
// Tilemap 旋转中心点
|
||||||
@@ -221,10 +225,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
|||||||
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
|
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;
|
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
|
||||||
|
|
||||||
// Apply rotation transform
|
// Apply rotation transform (clockwise positive)
|
||||||
// 应用旋转变换
|
// 应用旋转变换(顺时针为正)
|
||||||
const rotatedX = localX * cos - localY * sin + pivotX;
|
const rotatedX = localX * cos + localY * sin + pivotX;
|
||||||
const rotatedY = localX * sin + localY * cos + pivotY;
|
const rotatedY = -localX * sin + localY * cos + pivotY;
|
||||||
|
|
||||||
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
|
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
|
||||||
const tOffset = idx * 7;
|
const tOffset = idx * 7;
|
||||||
@@ -301,9 +305,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Calculate rotation parameters
|
// Calculate rotation parameters
|
||||||
// 计算旋转参数
|
// 计算旋转参数(度转弧度)
|
||||||
const cos = Math.cos(transform.rotation.z);
|
const rotationRad = transform.rotation.z * DEG_TO_RAD;
|
||||||
const sin = Math.sin(transform.rotation.z);
|
const cos = Math.cos(rotationRad);
|
||||||
|
const sin = Math.sin(rotationRad);
|
||||||
|
|
||||||
// Tilemap rotation pivot
|
// Tilemap rotation pivot
|
||||||
// Tilemap 旋转中心点
|
// Tilemap 旋转中心点
|
||||||
@@ -320,10 +325,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
|||||||
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
|
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;
|
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
|
||||||
|
|
||||||
// Apply rotation transform
|
// Apply rotation transform (clockwise positive)
|
||||||
// 应用旋转变换
|
// 应用旋转变换(顺时针为正)
|
||||||
const rotatedX = localX * cos - localY * sin + pivotX;
|
const rotatedX = localX * cos + localY * sin + pivotX;
|
||||||
const rotatedY = localX * sin + localY * cos + pivotY;
|
const rotatedY = -localX * sin + localY * cos + pivotY;
|
||||||
|
|
||||||
const tOffset = idx * 7;
|
const tOffset = idx * 7;
|
||||||
renderData.transforms[tOffset] = rotatedX;
|
renderData.transforms[tOffset] = rotatedX;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@esengine/ecs-framework": "workspace:*",
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
"@esengine/editor-core": "workspace:*",
|
"@esengine/editor-core": "workspace:*",
|
||||||
"@esengine/editor-runtime": "workspace:*",
|
"@esengine/editor-runtime": "workspace:*",
|
||||||
|
"@esengine/material-system": "workspace:*",
|
||||||
"@esengine/build-config": "workspace:*",
|
"@esengine/build-config": "workspace:*",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -15,21 +15,23 @@ function uiTransformGizmoProvider(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use world coordinates (computed by UILayoutSystem) if available
|
// 使用 UILayoutSystem 计算的世界坐标
|
||||||
// Otherwise fallback to local coordinates
|
// Use world coordinates computed by UILayoutSystem
|
||||||
// 使用世界坐标(由 UILayoutSystem 计算),如果可用
|
// 如果 layoutComputed = false,说明 UILayoutSystem 还没运行,回退到本地坐标
|
||||||
// 否则回退到本地坐标
|
// If layoutComputed = false, UILayoutSystem hasn't run yet, fallback to local coordinates
|
||||||
const x = transform.worldX ?? transform.x;
|
const x = transform.layoutComputed ? transform.worldX : transform.x;
|
||||||
const y = transform.worldY ?? transform.y;
|
const y = transform.layoutComputed ? transform.worldY : transform.y;
|
||||||
// Use world scale for proper hierarchical transform inheritance
|
|
||||||
// 使用世界缩放以正确继承层级变换
|
|
||||||
const scaleX = transform.worldScaleX ?? transform.scaleX;
|
const scaleX = transform.worldScaleX ?? transform.scaleX;
|
||||||
const scaleY = transform.worldScaleY ?? transform.scaleY;
|
const scaleY = transform.worldScaleY ?? transform.scaleY;
|
||||||
const width = (transform.computedWidth ?? transform.width) * scaleX;
|
const width = (transform.layoutComputed && transform.computedWidth > 0
|
||||||
const height = (transform.computedHeight ?? transform.height) * scaleY;
|
? transform.computedWidth
|
||||||
// Use world rotation for proper hierarchical transform inheritance
|
: transform.width) * scaleX;
|
||||||
// 使用世界旋转以正确继承层级变换
|
const height = (transform.layoutComputed && transform.computedHeight > 0
|
||||||
const rotation = transform.worldRotation ?? transform.rotation;
|
? transform.computedHeight
|
||||||
|
: transform.height) * scaleY;
|
||||||
|
// 角度转弧度 | Convert degrees to radians
|
||||||
|
const rotationDegrees = transform.worldRotation ?? transform.rotation;
|
||||||
|
const rotation = (rotationDegrees * Math.PI) / 180;
|
||||||
// 使用 transform 的 pivot 作为旋转/缩放中心
|
// 使用 transform 的 pivot 作为旋转/缩放中心
|
||||||
const pivotX = transform.pivotX;
|
const pivotX = transform.pivotX;
|
||||||
const pivotY = transform.pivotY;
|
const pivotY = transform.pivotY;
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ import {
|
|||||||
UISliderComponent,
|
UISliderComponent,
|
||||||
UIScrollViewComponent
|
UIScrollViewComponent
|
||||||
} from '@esengine/ui';
|
} from '@esengine/ui';
|
||||||
import { UITransformInspector } from './inspectors';
|
import { UITransformInspector, UIRenderInspector } from './inspectors';
|
||||||
import { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
import { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
||||||
|
|
||||||
// Re-exports
|
// Re-exports
|
||||||
export { UITransformInspector } from './inspectors';
|
export { UITransformInspector, UIRenderInspector } from './inspectors';
|
||||||
export { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
export { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +76,7 @@ export class UIEditorModule implements IEditorModuleLoader {
|
|||||||
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
|
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
|
||||||
if (componentInspectorRegistry) {
|
if (componentInspectorRegistry) {
|
||||||
componentInspectorRegistry.register(new UITransformInspector());
|
componentInspectorRegistry.register(new UITransformInspector());
|
||||||
|
componentInspectorRegistry.register(new UIRenderInspector());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册 Gizmo | Register gizmo
|
// 注册 Gizmo | Register gizmo
|
||||||
|
|||||||
@@ -0,0 +1,852 @@
|
|||||||
|
/**
|
||||||
|
* UI Render Component Inspector.
|
||||||
|
* UI 渲染组件检查器。
|
||||||
|
*
|
||||||
|
* Provides unified material editing for UIRenderComponent.
|
||||||
|
* 为 UIRenderComponent 提供统一的材质编辑。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { Component, Core } from '@esengine/ecs-framework';
|
||||||
|
import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
|
||||||
|
import { MessageHub } from '@esengine/editor-core';
|
||||||
|
import { UIRenderComponent } from '@esengine/ui';
|
||||||
|
import type { ShaderPropertyMeta } from '@esengine/material-system';
|
||||||
|
import { getShaderPropertiesById } from '@esengine/material-system';
|
||||||
|
import { ChevronDown, ChevronRight, Palette, X, Plus, FileBox } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material source type.
|
||||||
|
* 材质来源类型。
|
||||||
|
*/
|
||||||
|
type MaterialSource = 'none' | 'builtin' | 'asset';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in effect options.
|
||||||
|
* 内置效果选项。
|
||||||
|
*/
|
||||||
|
const BUILTIN_EFFECTS = [
|
||||||
|
{ id: 1, name: 'Grayscale', description: 'Convert to grayscale' },
|
||||||
|
{ id: 2, name: 'Tint', description: 'Apply color tint' },
|
||||||
|
{ id: 3, name: 'Flash', description: 'Flash effect for hit feedback' },
|
||||||
|
{ id: 4, name: 'Outline', description: 'Add outline border' },
|
||||||
|
{ id: 5, name: 'Shiny', description: 'Animated shine sweep' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Uniform type display names
|
||||||
|
const UNIFORM_TYPE_LABELS: Record<string, string> = {
|
||||||
|
'float': 'Float',
|
||||||
|
'int': 'Int',
|
||||||
|
'vec2': 'Vec2',
|
||||||
|
'vec3': 'Vec3',
|
||||||
|
'vec4': 'Vec4',
|
||||||
|
'color': 'Color',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single number input with local state to prevent focus loss.
|
||||||
|
* 带本地状态的单数字输入框,防止失焦。
|
||||||
|
*/
|
||||||
|
function NumberInput({ value, onChange, min, max, step, style }: {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [localValue, setLocalValue] = useState(String(value));
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
// Sync from prop when not focused
|
||||||
|
// 未聚焦时从 prop 同步
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isFocused) {
|
||||||
|
setLocalValue(String(value));
|
||||||
|
}
|
||||||
|
}, [value, isFocused]);
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
const parsed = parseFloat(localValue);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
onChange(parsed);
|
||||||
|
} else {
|
||||||
|
setLocalValue(String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={localValue}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert radians to degrees.
|
||||||
|
* 弧度转角度。
|
||||||
|
*/
|
||||||
|
function radToDeg(rad: number): number {
|
||||||
|
return rad * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert degrees to radians.
|
||||||
|
* 角度转弧度。
|
||||||
|
*/
|
||||||
|
function degToRad(deg: number): number {
|
||||||
|
return deg * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property value editor component.
|
||||||
|
* 属性值编辑器组件。
|
||||||
|
*/
|
||||||
|
function PropertyValueEditor({ meta, value, onChange }: {
|
||||||
|
meta: ShaderPropertyMeta;
|
||||||
|
value: number | number[];
|
||||||
|
onChange: (value: number | number[]) => void;
|
||||||
|
}) {
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: 'var(--color-bg-inset)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
border: '1px solid var(--color-border-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
width: '60px'
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (meta.type) {
|
||||||
|
case 'float':
|
||||||
|
case 'int': {
|
||||||
|
// Handle 'angle' hint: display degrees, store radians
|
||||||
|
// 处理 'angle' 提示:显示角度,存储弧度
|
||||||
|
const isAngle = meta.hint === 'angle';
|
||||||
|
const numValue = typeof value === 'number' ? value : 0;
|
||||||
|
const displayValue = isAngle ? radToDeg(numValue) : numValue;
|
||||||
|
const displayMin = isAngle && meta.min !== undefined ? radToDeg(meta.min) : meta.min;
|
||||||
|
const displayMax = isAngle && meta.max !== undefined ? radToDeg(meta.max) : meta.max;
|
||||||
|
const displayStep = isAngle ? 1 : (meta.step ?? (meta.type === 'int' ? 1 : 0.01));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<NumberInput
|
||||||
|
value={displayValue}
|
||||||
|
min={displayMin}
|
||||||
|
max={displayMax}
|
||||||
|
step={displayStep}
|
||||||
|
onChange={(v) => {
|
||||||
|
const storeValue = isAngle ? degToRad(v) : v;
|
||||||
|
(onChange as (v: number) => void)(storeValue);
|
||||||
|
}}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{isAngle && (
|
||||||
|
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '10px' }}>°</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'vec2': {
|
||||||
|
const v2 = Array.isArray(value) ? value : [0, 0];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<NumberInput
|
||||||
|
value={v2[0] ?? 0}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(v) => onChange([v, v2[1] ?? 0])}
|
||||||
|
style={{ ...inputStyle, width: '50px' }}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
value={v2[1] ?? 0}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(v) => onChange([v2[0] ?? 0, v])}
|
||||||
|
style={{ ...inputStyle, width: '50px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'vec3': {
|
||||||
|
const v3 = Array.isArray(value) ? value : [0, 0, 0];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<NumberInput
|
||||||
|
key={i}
|
||||||
|
value={v3[i] ?? 0}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(v) => {
|
||||||
|
const newVal = [...v3];
|
||||||
|
newVal[i] = v;
|
||||||
|
onChange(newVal);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, width: '40px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'vec4': {
|
||||||
|
const v4 = Array.isArray(value) ? value : [0, 0, 0, 0];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<NumberInput
|
||||||
|
key={i}
|
||||||
|
value={v4[i] ?? 0}
|
||||||
|
step={meta.step ?? 0.01}
|
||||||
|
onChange={(v) => {
|
||||||
|
const newVal = [...v4];
|
||||||
|
newVal[i] = v;
|
||||||
|
onChange(newVal);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, width: '35px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'color': {
|
||||||
|
const c = Array.isArray(value) ? value : [1, 1, 1, 1];
|
||||||
|
const cr = c[0] ?? 1;
|
||||||
|
const cg = c[1] ?? 1;
|
||||||
|
const cb = c[2] ?? 1;
|
||||||
|
const ca = c[3] ?? 1;
|
||||||
|
const hexColor = `#${Math.round(cr * 255).toString(16).padStart(2, '0')}${Math.round(cg * 255).toString(16).padStart(2, '0')}${Math.round(cb * 255).toString(16).padStart(2, '0')}`;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hexColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const hex = e.target.value;
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
onChange([r, g, b, ca]);
|
||||||
|
}}
|
||||||
|
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
value={ca}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(v) => onChange([cr, cg, cb, v])}
|
||||||
|
style={{ ...inputStyle, width: '40px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <span style={{ color: 'var(--color-text-tertiary)' }}>Unsupported</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine material source from component state.
|
||||||
|
* 从组件状态确定材质来源。
|
||||||
|
*/
|
||||||
|
function getMaterialSource(render: UIRenderComponent): MaterialSource {
|
||||||
|
if (render.materialGuid && render.materialGuid.length > 0) {
|
||||||
|
return 'asset';
|
||||||
|
}
|
||||||
|
if (render.getMaterialId() !== 0) {
|
||||||
|
return 'builtin';
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Render Inspector content component.
|
||||||
|
* UI 渲染检查器内容组件。
|
||||||
|
*/
|
||||||
|
function UIRenderInspectorContent({ context }: { context: ComponentInspectorContext }) {
|
||||||
|
const render = context.component as UIRenderComponent;
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Effect', 'Default']));
|
||||||
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||||
|
const [, forceUpdate] = useState({});
|
||||||
|
|
||||||
|
// Determine current state
|
||||||
|
const materialSource = getMaterialSource(render);
|
||||||
|
const materialId = render.getMaterialId();
|
||||||
|
const properties = getShaderPropertiesById(materialId);
|
||||||
|
|
||||||
|
// Get effect name for display
|
||||||
|
const effectName = BUILTIN_EFFECTS.find(e => e.id === materialId)?.name || '';
|
||||||
|
|
||||||
|
// Group properties
|
||||||
|
const groupedProps = useMemo(() => {
|
||||||
|
if (!properties) return {};
|
||||||
|
|
||||||
|
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
|
||||||
|
for (const [name, meta] of Object.entries(properties) as [string, ShaderPropertyMeta][]) {
|
||||||
|
if (meta.hidden) continue;
|
||||||
|
const group = meta.group || 'Default';
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push([name, meta]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [properties]);
|
||||||
|
|
||||||
|
// Get available properties for override
|
||||||
|
const availableProperties = useMemo((): Array<{ name: string; meta: ShaderPropertyMeta }> => {
|
||||||
|
if (!properties) return [];
|
||||||
|
const currentOverrides = render.materialOverrides || {};
|
||||||
|
return (Object.entries(properties) as [string, ShaderPropertyMeta][])
|
||||||
|
.filter(([name, meta]) => !meta.hidden && !currentOverrides[name])
|
||||||
|
.map(([name, meta]) => ({ name, meta }));
|
||||||
|
}, [properties, render.materialOverrides]);
|
||||||
|
|
||||||
|
const toggleGroup = (group: string) => {
|
||||||
|
setExpandedGroups(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(group)) {
|
||||||
|
next.delete(group);
|
||||||
|
} else {
|
||||||
|
next.add(group);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyChange = useCallback(() => {
|
||||||
|
forceUpdate({});
|
||||||
|
const messageHub = Core.services.tryResolve(MessageHub);
|
||||||
|
if (messageHub) {
|
||||||
|
messageHub.publish('scene:modified', {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle source change
|
||||||
|
const handleSourceChange = useCallback((newSource: MaterialSource) => {
|
||||||
|
if (newSource === 'none') {
|
||||||
|
render.materialGuid = '';
|
||||||
|
render.setMaterialId(0);
|
||||||
|
render.clearOverrides();
|
||||||
|
} else if (newSource === 'builtin') {
|
||||||
|
render.materialGuid = '';
|
||||||
|
// Set to first effect if currently none
|
||||||
|
if (render.getMaterialId() === 0) {
|
||||||
|
render.setMaterialId(1); // Grayscale
|
||||||
|
}
|
||||||
|
render.clearOverrides();
|
||||||
|
} else if (newSource === 'asset') {
|
||||||
|
render.setMaterialId(0);
|
||||||
|
render.clearOverrides();
|
||||||
|
// materialGuid will be set by asset picker
|
||||||
|
}
|
||||||
|
context.onChange?.('materialGuid', render.materialGuid);
|
||||||
|
notifyChange();
|
||||||
|
}, [render, context, notifyChange]);
|
||||||
|
|
||||||
|
// Handle effect change
|
||||||
|
const handleEffectChange = useCallback((effectId: number) => {
|
||||||
|
render.setMaterialId(effectId);
|
||||||
|
render.clearOverrides();
|
||||||
|
context.onChange?.('_materialId', effectId);
|
||||||
|
notifyChange();
|
||||||
|
}, [render, context, notifyChange]);
|
||||||
|
|
||||||
|
// Handle asset change
|
||||||
|
const handleAssetChange = useCallback((assetGuid: string) => {
|
||||||
|
render.materialGuid = assetGuid;
|
||||||
|
context.onChange?.('materialGuid', assetGuid);
|
||||||
|
notifyChange();
|
||||||
|
}, [render, context, notifyChange]);
|
||||||
|
|
||||||
|
// Handle property change
|
||||||
|
const handlePropertyChange = useCallback((name: string, meta: ShaderPropertyMeta, newValue: number | number[]) => {
|
||||||
|
switch (meta.type) {
|
||||||
|
case 'float':
|
||||||
|
render.setOverrideFloat(name, newValue as number);
|
||||||
|
break;
|
||||||
|
case 'int':
|
||||||
|
render.setOverrideInt(name, newValue as number);
|
||||||
|
break;
|
||||||
|
case 'vec2': {
|
||||||
|
const v2 = newValue as number[];
|
||||||
|
render.setOverrideVec2(name, v2[0] ?? 0, v2[1] ?? 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'vec3': {
|
||||||
|
const v3 = newValue as number[];
|
||||||
|
render.setOverrideVec3(name, v3[0] ?? 0, v3[1] ?? 0, v3[2] ?? 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'vec4': {
|
||||||
|
const v4 = newValue as number[];
|
||||||
|
render.setOverrideVec4(name, v4[0] ?? 0, v4[1] ?? 0, v4[2] ?? 0, v4[3] ?? 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'color': {
|
||||||
|
const c = newValue as number[];
|
||||||
|
render.setOverrideColor(name, c[0] ?? 1, c[1] ?? 1, c[2] ?? 1, c[3] ?? 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.onChange?.('materialOverrides', render.materialOverrides);
|
||||||
|
notifyChange();
|
||||||
|
}, [render, context, notifyChange]);
|
||||||
|
|
||||||
|
const handleRemoveOverride = useCallback((name: string) => {
|
||||||
|
render.removeOverride(name);
|
||||||
|
context.onChange?.('materialOverrides', render.materialOverrides);
|
||||||
|
notifyChange();
|
||||||
|
}, [render, context, notifyChange]);
|
||||||
|
|
||||||
|
const handleAddOverride = useCallback((name: string, meta: ShaderPropertyMeta) => {
|
||||||
|
const defaultValue = meta.default ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
|
||||||
|
handlePropertyChange(name, meta, defaultValue as number | number[]);
|
||||||
|
setShowAddMenu(false);
|
||||||
|
}, [handlePropertyChange]);
|
||||||
|
|
||||||
|
const getCurrentValue = (name: string, meta: ShaderPropertyMeta): number | number[] => {
|
||||||
|
const override = render.getOverride(name);
|
||||||
|
if (override) {
|
||||||
|
return override.value as number | number[];
|
||||||
|
}
|
||||||
|
return meta.default as number | number[] ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentOverrides = render.materialOverrides || {};
|
||||||
|
const overrideKeys = Object.keys(currentOverrides);
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'var(--color-bg-inset)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
border: '1px solid var(--color-border-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '12px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
marginBottom: '4px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
marginRight: '8px',
|
||||||
|
minWidth: '60px',
|
||||||
|
fontSize: '12px'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||||
|
{/* Section header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Palette size={14} style={{ color: materialSource !== 'none' ? 'var(--color-primary)' : 'var(--color-text-tertiary)' }} />
|
||||||
|
<span style={{ fontWeight: 500, color: 'var(--color-text-primary)' }}>Material</span>
|
||||||
|
</div>
|
||||||
|
{materialSource === 'builtin' && effectName && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: 'var(--color-primary-subtle)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
borderRadius: 'var(--radius-sm)'
|
||||||
|
}}>
|
||||||
|
{effectName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{materialSource === 'asset' && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: 'var(--color-success-subtle)',
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
borderRadius: 'var(--radius-sm)'
|
||||||
|
}}>
|
||||||
|
Asset
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source selector */}
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span style={labelStyle}>Source</span>
|
||||||
|
<select
|
||||||
|
value={materialSource}
|
||||||
|
onChange={(e) => handleSourceChange(e.target.value as MaterialSource)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="none">None (Default)</option>
|
||||||
|
<option value="builtin">Built-in Effect</option>
|
||||||
|
<option value="asset">Material Asset</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* None selected hint */}
|
||||||
|
{materialSource === 'none' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
margin: '4px 8px 8px',
|
||||||
|
backgroundColor: 'var(--color-bg-subtle)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--color-text-tertiary)',
|
||||||
|
fontSize: '11px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
No material effect applied.<br />
|
||||||
|
Select a source above to add visual effects.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Built-in effect selector */}
|
||||||
|
{materialSource === 'builtin' && (
|
||||||
|
<>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span style={labelStyle}>Effect</span>
|
||||||
|
<select
|
||||||
|
value={materialId}
|
||||||
|
onChange={(e) => handleEffectChange(Number(e.target.value))}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
{BUILTIN_EFFECTS.map(effect => (
|
||||||
|
<option key={effect.id} value={effect.id}>
|
||||||
|
{effect.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effect description */}
|
||||||
|
{effectName && (
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: 'var(--color-text-tertiary)',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
{BUILTIN_EFFECTS.find(e => e.id === materialId)?.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overrides section */}
|
||||||
|
{overrideKeys.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: 'var(--color-bg-subtle)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
marginBottom: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px' }}>
|
||||||
|
Overrides ({overrideKeys.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{overrideKeys.map(key => {
|
||||||
|
const override = currentOverrides[key];
|
||||||
|
if (!override) return null;
|
||||||
|
const meta = properties?.[key];
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderBottom: '1px solid var(--color-border-muted)'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', minWidth: '80px' }} title={meta.tooltip}>
|
||||||
|
{key.replace(/^u_/, '')}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<PropertyValueEditor
|
||||||
|
meta={meta}
|
||||||
|
value={override.value as number | number[]}
|
||||||
|
onChange={(v) => handlePropertyChange(key, meta, v)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveOverride(key)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '2px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
title="Remove override"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Property groups */}
|
||||||
|
{Object.entries(groupedProps).map(([group, props]) => (
|
||||||
|
<div key={group} style={{ marginBottom: '4px' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleGroup(group)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: 'var(--color-bg-subtle)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedGroups.has(group) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
<span style={{ marginLeft: '4px', color: 'var(--color-text-secondary)', fontWeight: 500 }}>{group}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedGroups.has(group) && (
|
||||||
|
<div style={{ padding: '4px 8px' }}>
|
||||||
|
{props.map(([name, meta]) => {
|
||||||
|
const hasOverride = !!currentOverrides[name];
|
||||||
|
return (
|
||||||
|
<div key={name} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '3px 0',
|
||||||
|
borderBottom: '1px solid var(--color-border-muted)',
|
||||||
|
opacity: hasOverride ? 1 : 0.7
|
||||||
|
}}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: hasOverride ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
title={meta.tooltip || `Click to add override for ${name}`}
|
||||||
|
onClick={() => !hasOverride && handleAddOverride(name, meta)}
|
||||||
|
>
|
||||||
|
{name.replace(/^u_/, '')}
|
||||||
|
{!hasOverride && <Plus size={10} style={{ marginLeft: '4px', opacity: 0.5 }} />}
|
||||||
|
</span>
|
||||||
|
{hasOverride ? (
|
||||||
|
<PropertyValueEditor
|
||||||
|
meta={meta}
|
||||||
|
value={getCurrentValue(name, meta)}
|
||||||
|
onChange={(v) => handlePropertyChange(name, meta, v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '10px' }}>
|
||||||
|
{typeof meta.default === 'number' ? meta.default.toFixed(2) : 'default'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add override button */}
|
||||||
|
{availableProperties.length > 0 && (
|
||||||
|
<div style={{ position: 'relative', padding: '4px 8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: 'var(--color-bg-subtle)',
|
||||||
|
border: '1px solid var(--color-border-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
<span>Add Override</span>
|
||||||
|
</button>
|
||||||
|
{showAddMenu && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: '8px',
|
||||||
|
zIndex: 100,
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
border: '1px solid var(--color-border-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
minWidth: '180px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
{availableProperties.map(({ name, meta }) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => handleAddOverride(name, meta)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 8px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '1px solid var(--color-border-muted)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
textAlign: 'left'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{name.replace(/^u_/, '')}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '10px' }}>
|
||||||
|
{UNIFORM_TYPE_LABELS[meta.type] || meta.type}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state for effect without properties */}
|
||||||
|
{!properties && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
margin: '0 8px',
|
||||||
|
backgroundColor: 'var(--color-bg-subtle)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--color-text-tertiary)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
No editable properties for this effect
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Material Asset selector */}
|
||||||
|
{materialSource === 'asset' && (
|
||||||
|
<div style={{ padding: '4px 8px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: 'var(--color-bg-subtle)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px dashed var(--color-border-default)'
|
||||||
|
}}>
|
||||||
|
<FileBox size={16} style={{ color: 'var(--color-text-tertiary)' }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={render.materialGuid}
|
||||||
|
onChange={(e) => handleAssetChange(e.target.value)}
|
||||||
|
placeholder="Drag .mat file here or enter GUID"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: 'var(--color-bg-inset)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
border: '1px solid var(--color-border-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{render.materialGuid && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAssetChange('')}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '2px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-tertiary)'
|
||||||
|
}}
|
||||||
|
title="Clear"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '8px',
|
||||||
|
color: 'var(--color-text-tertiary)',
|
||||||
|
fontSize: '10px'
|
||||||
|
}}>
|
||||||
|
Material assets (.mat) define shared shader configurations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Render component inspector implementation.
|
||||||
|
* UI 渲染组件检查器实现。
|
||||||
|
*
|
||||||
|
* Uses 'append' mode to add unified material UI after default properties.
|
||||||
|
* 使用 'append' 模式在默认属性后添加统一的材质 UI。
|
||||||
|
*/
|
||||||
|
export class UIRenderInspector implements IComponentInspector<UIRenderComponent> {
|
||||||
|
readonly id = 'uirender-inspector';
|
||||||
|
readonly name = 'UIRender Inspector';
|
||||||
|
readonly priority = 100;
|
||||||
|
readonly targetComponents = ['UIRender', 'UIRenderComponent'];
|
||||||
|
readonly renderMode = 'append' as const;
|
||||||
|
|
||||||
|
canHandle(component: Component): component is UIRenderComponent {
|
||||||
|
return component instanceof UIRenderComponent ||
|
||||||
|
component.constructor.name === 'UIRenderComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
render(context: ComponentInspectorContext): React.ReactElement {
|
||||||
|
return React.createElement(UIRenderInspectorContent, {
|
||||||
|
context,
|
||||||
|
key: `uirender-${context.version}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,15 @@ const AnchorPresetGrid: React.FC<{
|
|||||||
[AnchorPreset.BottomLeft]: { x: 3, y: 17 },
|
[AnchorPreset.BottomLeft]: { x: 3, y: 17 },
|
||||||
[AnchorPreset.BottomCenter]: { x: 10, y: 17 },
|
[AnchorPreset.BottomCenter]: { x: 10, y: 17 },
|
||||||
[AnchorPreset.BottomRight]: { x: 17, y: 17 },
|
[AnchorPreset.BottomRight]: { x: 17, y: 17 },
|
||||||
|
// Stretch presets (horizontal) | 拉伸预设(水平)
|
||||||
|
[AnchorPreset.StretchTop]: { x: 10, y: 3 },
|
||||||
|
[AnchorPreset.StretchMiddle]: { x: 10, y: 10 },
|
||||||
|
[AnchorPreset.StretchBottom]: { x: 10, y: 17 },
|
||||||
|
// Stretch presets (vertical) | 拉伸预设(垂直)
|
||||||
|
[AnchorPreset.StretchLeft]: { x: 3, y: 10 },
|
||||||
|
[AnchorPreset.StretchCenter]: { x: 10, y: 10 },
|
||||||
|
[AnchorPreset.StretchRight]: { x: 17, y: 10 },
|
||||||
|
// Full stretch | 完全拉伸
|
||||||
[AnchorPreset.StretchAll]: { x: 10, y: 10 },
|
[AnchorPreset.StretchAll]: { x: 10, y: 10 },
|
||||||
};
|
};
|
||||||
return positions[preset];
|
return positions[preset];
|
||||||
@@ -320,30 +329,44 @@ export class UITransformInspector implements IComponentInspector<UITransformComp
|
|||||||
return AnchorPreset.StretchAll;
|
return AnchorPreset.StretchAll;
|
||||||
}
|
}
|
||||||
if (anchorMinX === anchorMaxX && anchorMinY === anchorMaxY) {
|
if (anchorMinX === anchorMaxX && anchorMinY === anchorMaxY) {
|
||||||
if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.TopLeft;
|
// Y-up 坐标系:anchorMinY=1 是顶部,anchorMinY=0 是底部
|
||||||
if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.TopCenter;
|
// Y-up coordinate system: anchorMinY=1 is top, anchorMinY=0 is bottom
|
||||||
if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.TopRight;
|
if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.TopLeft;
|
||||||
|
if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.TopCenter;
|
||||||
|
if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.TopRight;
|
||||||
if (anchorMinX === 0 && anchorMinY === 0.5) return AnchorPreset.MiddleLeft;
|
if (anchorMinX === 0 && anchorMinY === 0.5) return AnchorPreset.MiddleLeft;
|
||||||
if (anchorMinX === 0.5 && anchorMinY === 0.5) return AnchorPreset.MiddleCenter;
|
if (anchorMinX === 0.5 && anchorMinY === 0.5) return AnchorPreset.MiddleCenter;
|
||||||
if (anchorMinX === 1 && anchorMinY === 0.5) return AnchorPreset.MiddleRight;
|
if (anchorMinX === 1 && anchorMinY === 0.5) return AnchorPreset.MiddleRight;
|
||||||
if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.BottomLeft;
|
if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.BottomLeft;
|
||||||
if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.BottomCenter;
|
if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.BottomCenter;
|
||||||
if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.BottomRight;
|
if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.BottomRight;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePresetSelect = (preset: AnchorPreset) => {
|
const handlePresetSelect = (preset: AnchorPreset) => {
|
||||||
|
// [anchorMinX, anchorMinY, anchorMaxX, anchorMaxY]
|
||||||
|
// Y-up 坐标系:Y=1 是顶部,Y=0 是底部
|
||||||
|
// Y-up coordinate system: Y=1 is top, Y=0 is bottom
|
||||||
const presetValues: Record<AnchorPreset, [number, number, number, number]> = {
|
const presetValues: Record<AnchorPreset, [number, number, number, number]> = {
|
||||||
[AnchorPreset.TopLeft]: [0, 0, 0, 0],
|
[AnchorPreset.TopLeft]: [0, 1, 0, 1],
|
||||||
[AnchorPreset.TopCenter]: [0.5, 0, 0.5, 0],
|
[AnchorPreset.TopCenter]: [0.5, 1, 0.5, 1],
|
||||||
[AnchorPreset.TopRight]: [1, 0, 1, 0],
|
[AnchorPreset.TopRight]: [1, 1, 1, 1],
|
||||||
[AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5],
|
[AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5],
|
||||||
[AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5],
|
[AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5],
|
||||||
[AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5],
|
[AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5],
|
||||||
[AnchorPreset.BottomLeft]: [0, 1, 0, 1],
|
[AnchorPreset.BottomLeft]: [0, 0, 0, 0],
|
||||||
[AnchorPreset.BottomCenter]: [0.5, 1, 0.5, 1],
|
[AnchorPreset.BottomCenter]: [0.5, 0, 0.5, 0],
|
||||||
[AnchorPreset.BottomRight]: [1, 1, 1, 1],
|
[AnchorPreset.BottomRight]: [1, 0, 1, 0],
|
||||||
|
// Horizontal stretch | 水平拉伸
|
||||||
|
[AnchorPreset.StretchTop]: [0, 1, 1, 1],
|
||||||
|
[AnchorPreset.StretchMiddle]: [0, 0.5, 1, 0.5],
|
||||||
|
[AnchorPreset.StretchBottom]: [0, 0, 1, 0],
|
||||||
|
// Vertical stretch | 垂直拉伸
|
||||||
|
[AnchorPreset.StretchLeft]: [0, 0, 0, 1],
|
||||||
|
[AnchorPreset.StretchCenter]: [0.5, 0, 0.5, 1],
|
||||||
|
[AnchorPreset.StretchRight]: [1, 0, 1, 1],
|
||||||
|
// Full stretch | 完全拉伸
|
||||||
[AnchorPreset.StretchAll]: [0, 0, 1, 1],
|
[AnchorPreset.StretchAll]: [0, 0, 1, 1],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './UITransformInspector';
|
export * from './UITransformInspector';
|
||||||
|
export * from './UIRenderInspector';
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@esengine/ecs-framework": "workspace:*",
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
"@esengine/engine-core": "workspace:*",
|
"@esengine/engine-core": "workspace:*",
|
||||||
"@esengine/ecs-engine-bindgen": "workspace:*",
|
"@esengine/ecs-engine-bindgen": "workspace:*",
|
||||||
|
"@esengine/material-system": "workspace:*",
|
||||||
"@esengine/build-config": "workspace:*",
|
"@esengine/build-config": "workspace:*",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { UIButtonComponent } from './components/widgets/UIButtonComponent';
|
|||||||
import { UIProgressBarComponent } from './components/widgets/UIProgressBarComponent';
|
import { UIProgressBarComponent } from './components/widgets/UIProgressBarComponent';
|
||||||
import { UISliderComponent } from './components/widgets/UISliderComponent';
|
import { UISliderComponent } from './components/widgets/UISliderComponent';
|
||||||
import { UIScrollViewComponent } from './components/widgets/UIScrollViewComponent';
|
import { UIScrollViewComponent } from './components/widgets/UIScrollViewComponent';
|
||||||
|
import { UIToggleComponent, type UIToggleStyle } from './components/widgets/UIToggleComponent';
|
||||||
|
import { UIInputFieldComponent, type UIInputContentType, type UIInputLineType } from './components/widgets/UIInputFieldComponent';
|
||||||
|
import { UIDropdownComponent, type UIDropdownOption } from './components/widgets/UIDropdownComponent';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础 UI 配置
|
* 基础 UI 配置
|
||||||
@@ -125,6 +128,55 @@ export interface UIScrollViewConfig extends UIBaseConfig {
|
|||||||
backgroundColor?: number;
|
backgroundColor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开关配置
|
||||||
|
* Toggle configuration
|
||||||
|
*/
|
||||||
|
export interface UIToggleConfig extends UIBaseConfig {
|
||||||
|
isOn?: boolean;
|
||||||
|
style?: UIToggleStyle;
|
||||||
|
onColor?: number;
|
||||||
|
offColor?: number;
|
||||||
|
onChange?: (isOn: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入框配置
|
||||||
|
* Input field configuration
|
||||||
|
*/
|
||||||
|
export interface UIInputFieldConfig extends UIBaseConfig {
|
||||||
|
placeholder?: string;
|
||||||
|
text?: string;
|
||||||
|
contentType?: UIInputContentType;
|
||||||
|
lineType?: UIInputLineType;
|
||||||
|
characterLimit?: number;
|
||||||
|
textColor?: number;
|
||||||
|
placeholderColor?: number;
|
||||||
|
backgroundColor?: number;
|
||||||
|
borderColor?: number;
|
||||||
|
borderWidth?: number;
|
||||||
|
padding?: number;
|
||||||
|
onValueChanged?: (value: string) => void;
|
||||||
|
onSubmit?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下拉菜单配置
|
||||||
|
* Dropdown configuration
|
||||||
|
*/
|
||||||
|
export interface UIDropdownConfig extends UIBaseConfig {
|
||||||
|
options?: UIDropdownOption[];
|
||||||
|
selectedIndex?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
buttonColor?: number;
|
||||||
|
textColor?: number;
|
||||||
|
borderColor?: number;
|
||||||
|
listBackgroundColor?: number;
|
||||||
|
optionHeight?: number;
|
||||||
|
maxVisibleOptions?: number;
|
||||||
|
onValueChanged?: (value: string | number, index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI 构建器
|
* UI 构建器
|
||||||
* UI Builder - Simplified API for creating UI elements
|
* UI Builder - Simplified API for creating UI elements
|
||||||
@@ -390,6 +442,129 @@ export class UIBuilder {
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建开关
|
||||||
|
* Create toggle (checkbox/switch)
|
||||||
|
*/
|
||||||
|
public toggle(config: UIToggleConfig): Entity {
|
||||||
|
const entity = this.createBase({
|
||||||
|
...config,
|
||||||
|
width: config.width ?? (config.style === 'switch' ? 50 : 24),
|
||||||
|
height: config.height ?? 24
|
||||||
|
}, 'Toggle');
|
||||||
|
|
||||||
|
// 渲染组件
|
||||||
|
const render = entity.addComponent(new UIRenderComponent());
|
||||||
|
render.type = UIRenderType.Rect;
|
||||||
|
|
||||||
|
// 交互组件
|
||||||
|
const interactable = entity.addComponent(new UIInteractableComponent());
|
||||||
|
interactable.cursor = 'pointer';
|
||||||
|
|
||||||
|
// 开关组件
|
||||||
|
const toggle = entity.addComponent(new UIToggleComponent());
|
||||||
|
toggle.isOn = config.isOn ?? false;
|
||||||
|
toggle.style = config.style ?? 'checkbox';
|
||||||
|
toggle.onChange = config.onChange;
|
||||||
|
|
||||||
|
if (config.onColor !== undefined) toggle.onColor = config.onColor;
|
||||||
|
if (config.offColor !== undefined) toggle.offColor = config.offColor;
|
||||||
|
|
||||||
|
// 初始化显示状态
|
||||||
|
toggle.displayProgress = toggle.isOn ? 1 : 0;
|
||||||
|
toggle.targetProgress = toggle.displayProgress;
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文本输入框
|
||||||
|
* Create input field
|
||||||
|
*/
|
||||||
|
public inputField(config: UIInputFieldConfig): Entity {
|
||||||
|
const entity = this.createBase({
|
||||||
|
...config,
|
||||||
|
width: config.width ?? 200,
|
||||||
|
height: config.height ?? 36
|
||||||
|
}, 'InputField');
|
||||||
|
|
||||||
|
// 渲染组件
|
||||||
|
const render = entity.addComponent(new UIRenderComponent());
|
||||||
|
render.type = UIRenderType.Rect;
|
||||||
|
render.backgroundColor = config.backgroundColor ?? 0xFFFFFF;
|
||||||
|
|
||||||
|
// 交互组件
|
||||||
|
const interactable = entity.addComponent(new UIInteractableComponent());
|
||||||
|
interactable.cursor = 'text';
|
||||||
|
interactable.focusable = true;
|
||||||
|
|
||||||
|
// 输入框组件
|
||||||
|
const inputField = entity.addComponent(new UIInputFieldComponent());
|
||||||
|
inputField.placeholder = config.placeholder ?? '';
|
||||||
|
inputField.text = config.text ?? '';
|
||||||
|
inputField.contentType = config.contentType ?? 'standard';
|
||||||
|
inputField.lineType = config.lineType ?? 'singleLine';
|
||||||
|
inputField.characterLimit = config.characterLimit ?? 0;
|
||||||
|
inputField.onValueChanged = config.onValueChanged;
|
||||||
|
inputField.onSubmit = config.onSubmit;
|
||||||
|
|
||||||
|
if (config.textColor !== undefined) inputField.textColor = config.textColor;
|
||||||
|
if (config.placeholderColor !== undefined) inputField.placeholderColor = config.placeholderColor;
|
||||||
|
if (config.padding !== undefined) inputField.padding = config.padding;
|
||||||
|
|
||||||
|
// 背景和边框通过 UIRenderComponent 设置
|
||||||
|
// Background and border are set via UIRenderComponent
|
||||||
|
if (config.backgroundColor !== undefined) render.backgroundColor = config.backgroundColor;
|
||||||
|
if (config.borderColor !== undefined) render.borderColor = config.borderColor;
|
||||||
|
if (config.borderWidth !== undefined) render.borderWidth = config.borderWidth;
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建下拉菜单
|
||||||
|
* Create dropdown
|
||||||
|
*/
|
||||||
|
public dropdown(config: UIDropdownConfig): Entity {
|
||||||
|
const entity = this.createBase({
|
||||||
|
...config,
|
||||||
|
width: config.width ?? 200,
|
||||||
|
height: config.height ?? 36
|
||||||
|
}, 'Dropdown');
|
||||||
|
|
||||||
|
// 渲染组件
|
||||||
|
const render = entity.addComponent(new UIRenderComponent());
|
||||||
|
render.type = UIRenderType.Rect;
|
||||||
|
render.backgroundColor = config.buttonColor ?? 0xFFFFFF;
|
||||||
|
|
||||||
|
// 交互组件
|
||||||
|
const interactable = entity.addComponent(new UIInteractableComponent());
|
||||||
|
interactable.cursor = 'pointer';
|
||||||
|
|
||||||
|
// 下拉菜单组件
|
||||||
|
const dropdown = entity.addComponent(new UIDropdownComponent());
|
||||||
|
dropdown.placeholder = config.placeholder ?? 'Select...';
|
||||||
|
dropdown.selectedIndex = config.selectedIndex ?? -1;
|
||||||
|
dropdown.onValueChanged = config.onValueChanged;
|
||||||
|
|
||||||
|
if (config.options) {
|
||||||
|
dropdown.options = config.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.buttonColor !== undefined) dropdown.buttonColor = config.buttonColor;
|
||||||
|
if (config.textColor !== undefined) dropdown.textColor = config.textColor;
|
||||||
|
if (config.borderColor !== undefined) dropdown.borderColor = config.borderColor;
|
||||||
|
if (config.listBackgroundColor !== undefined) dropdown.listBackgroundColor = config.listBackgroundColor;
|
||||||
|
if (config.optionHeight !== undefined) dropdown.optionHeight = config.optionHeight;
|
||||||
|
if (config.maxVisibleOptions !== undefined) dropdown.maxVisibleOptions = config.maxVisibleOptions;
|
||||||
|
|
||||||
|
// 初始化颜色
|
||||||
|
dropdown.currentColor = dropdown.buttonColor;
|
||||||
|
dropdown.targetColor = dropdown.buttonColor;
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建分隔线
|
* 创建分隔线
|
||||||
* Create divider/separator
|
* Create divider/separator
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
|
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
|
||||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||||
import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
|
import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
|
||||||
|
import { EngineIntegration } from '@esengine/asset-system';
|
||||||
|
|
||||||
|
import { initializeDynamicAtlasService, registerTexturePathMapping, AtlasExpansionStrategy, type IAtlasEngineBridge } from './atlas';
|
||||||
import {
|
import {
|
||||||
UITransformComponent,
|
UITransformComponent,
|
||||||
UIRenderComponent,
|
UIRenderComponent,
|
||||||
@@ -11,13 +13,18 @@ import {
|
|||||||
UIButtonComponent,
|
UIButtonComponent,
|
||||||
UIProgressBarComponent,
|
UIProgressBarComponent,
|
||||||
UISliderComponent,
|
UISliderComponent,
|
||||||
UIScrollViewComponent
|
UIScrollViewComponent,
|
||||||
|
UIToggleComponent,
|
||||||
|
UIInputFieldComponent,
|
||||||
|
UIDropdownComponent
|
||||||
} from './components';
|
} from './components';
|
||||||
import { TextBlinkComponent } from './components/TextBlinkComponent';
|
import { TextBlinkComponent } from './components/TextBlinkComponent';
|
||||||
import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
|
import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
|
||||||
|
import { UIShinyEffectComponent } from './components/UIShinyEffectComponent';
|
||||||
import { UILayoutSystem } from './systems/UILayoutSystem';
|
import { UILayoutSystem } from './systems/UILayoutSystem';
|
||||||
import { UIInputSystem } from './systems/UIInputSystem';
|
import { UIInputSystem } from './systems/UIInputSystem';
|
||||||
import { UIAnimationSystem } from './systems/UIAnimationSystem';
|
import { UIAnimationSystem } from './systems/UIAnimationSystem';
|
||||||
|
import { UISliderFillSystem } from './systems/UISliderFillSystem';
|
||||||
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
|
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||||
import { TextBlinkSystem } from './systems/TextBlinkSystem';
|
import { TextBlinkSystem } from './systems/TextBlinkSystem';
|
||||||
import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
|
import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
|
||||||
@@ -28,7 +35,11 @@ import {
|
|||||||
UIButtonRenderSystem,
|
UIButtonRenderSystem,
|
||||||
UIProgressBarRenderSystem,
|
UIProgressBarRenderSystem,
|
||||||
UISliderRenderSystem,
|
UISliderRenderSystem,
|
||||||
UIScrollViewRenderSystem
|
UIScrollViewRenderSystem,
|
||||||
|
UIToggleRenderSystem,
|
||||||
|
UIInputFieldRenderSystem,
|
||||||
|
UIDropdownRenderSystem,
|
||||||
|
UIShinyEffectSystem
|
||||||
} from './systems/render';
|
} from './systems/render';
|
||||||
import {
|
import {
|
||||||
UILayoutSystemToken,
|
UILayoutSystemToken,
|
||||||
@@ -56,14 +67,23 @@ class UIRuntimeModule implements IRuntimeModule {
|
|||||||
registry.register(UIProgressBarComponent);
|
registry.register(UIProgressBarComponent);
|
||||||
registry.register(UISliderComponent);
|
registry.register(UISliderComponent);
|
||||||
registry.register(UIScrollViewComponent);
|
registry.register(UIScrollViewComponent);
|
||||||
|
registry.register(UIToggleComponent);
|
||||||
|
registry.register(UIInputFieldComponent);
|
||||||
|
registry.register(UIDropdownComponent);
|
||||||
registry.register(TextBlinkComponent);
|
registry.register(TextBlinkComponent);
|
||||||
registry.register(SceneLoadTriggerComponent);
|
registry.register(SceneLoadTriggerComponent);
|
||||||
|
registry.register(UIShinyEffectComponent);
|
||||||
}
|
}
|
||||||
|
|
||||||
createSystems(scene: IScene, context: SystemContext): void {
|
createSystems(scene: IScene, context: SystemContext): void {
|
||||||
// 从服务注册表获取依赖 | Get dependencies from service registry
|
// 从服务注册表获取依赖 | Get dependencies from service registry
|
||||||
const engineBridge = context.services.get(EngineBridgeToken);
|
const engineBridge = context.services.get(EngineBridgeToken);
|
||||||
|
|
||||||
|
// Slider fill control system (runs before layout to modify anchors)
|
||||||
|
// 滑块填充控制系统(在布局之前运行以修改锚点)
|
||||||
|
const sliderFillSystem = new UISliderFillSystem();
|
||||||
|
scene.addSystem(sliderFillSystem);
|
||||||
|
|
||||||
const layoutSystem = new UILayoutSystem();
|
const layoutSystem = new UILayoutSystem();
|
||||||
scene.addSystem(layoutSystem);
|
scene.addSystem(layoutSystem);
|
||||||
|
|
||||||
@@ -81,6 +101,11 @@ class UIRuntimeModule implements IRuntimeModule {
|
|||||||
const renderBeginSystem = new UIRenderBeginSystem();
|
const renderBeginSystem = new UIRenderBeginSystem();
|
||||||
scene.addSystem(renderBeginSystem);
|
scene.addSystem(renderBeginSystem);
|
||||||
|
|
||||||
|
// Shiny effect system (runs before render systems to apply material overrides)
|
||||||
|
// 闪光效果系统(在渲染系统之前运行以应用材质覆盖)
|
||||||
|
const shinyEffectSystem = new UIShinyEffectSystem();
|
||||||
|
scene.addSystem(shinyEffectSystem);
|
||||||
|
|
||||||
const rectRenderSystem = new UIRectRenderSystem();
|
const rectRenderSystem = new UIRectRenderSystem();
|
||||||
scene.addSystem(rectRenderSystem);
|
scene.addSystem(rectRenderSystem);
|
||||||
|
|
||||||
@@ -96,13 +121,46 @@ class UIRuntimeModule implements IRuntimeModule {
|
|||||||
const buttonRenderSystem = new UIButtonRenderSystem();
|
const buttonRenderSystem = new UIButtonRenderSystem();
|
||||||
scene.addSystem(buttonRenderSystem);
|
scene.addSystem(buttonRenderSystem);
|
||||||
|
|
||||||
|
const toggleRenderSystem = new UIToggleRenderSystem();
|
||||||
|
scene.addSystem(toggleRenderSystem);
|
||||||
|
|
||||||
|
const inputFieldRenderSystem = new UIInputFieldRenderSystem();
|
||||||
|
scene.addSystem(inputFieldRenderSystem);
|
||||||
|
|
||||||
|
const dropdownRenderSystem = new UIDropdownRenderSystem();
|
||||||
|
scene.addSystem(dropdownRenderSystem);
|
||||||
|
|
||||||
const textRenderSystem = new UITextRenderSystem();
|
const textRenderSystem = new UITextRenderSystem();
|
||||||
scene.addSystem(textRenderSystem);
|
scene.addSystem(textRenderSystem);
|
||||||
|
|
||||||
if (engineBridge) {
|
if (engineBridge) {
|
||||||
|
// 设置文本渲染系统的纹理回调
|
||||||
|
// Set texture callback for text render system
|
||||||
textRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
|
textRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
|
||||||
engineBridge.loadTexture(id, dataUrl);
|
engineBridge.loadTexture(id, dataUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 设置纹理就绪检查回调,用于检测异步加载的纹理是否已就绪
|
||||||
|
// Set texture ready checker callback to detect if async-loaded texture is ready
|
||||||
|
if (engineBridge.isTextureReady) {
|
||||||
|
textRenderSystem.setTextureReadyChecker((id: number) => {
|
||||||
|
return engineBridge.isTextureReady!(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置输入框渲染系统的纹理回调
|
||||||
|
// Set texture callback for input field render system
|
||||||
|
inputFieldRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
|
||||||
|
engineBridge.loadTexture(id, dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置输入框渲染系统的纹理就绪检查回调
|
||||||
|
// Set texture ready checker callback for input field render system
|
||||||
|
if (engineBridge.isTextureReady) {
|
||||||
|
inputFieldRenderSystem.setTextureReadyChecker((id: number) => {
|
||||||
|
return engineBridge.isTextureReady!(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uiRenderProvider = new UIRenderDataProvider();
|
const uiRenderProvider = new UIRenderDataProvider();
|
||||||
@@ -115,6 +173,53 @@ class UIRuntimeModule implements IRuntimeModule {
|
|||||||
context.services.register(UIRenderProviderToken, uiRenderProvider);
|
context.services.register(UIRenderProviderToken, uiRenderProvider);
|
||||||
context.services.register(UIInputSystemToken, inputSystem);
|
context.services.register(UIInputSystemToken, inputSystem);
|
||||||
context.services.register(UITextRenderSystemToken, textRenderSystem);
|
context.services.register(UITextRenderSystemToken, textRenderSystem);
|
||||||
|
|
||||||
|
// 初始化动态图集服务 | Initialize dynamic atlas service
|
||||||
|
// 需要 engineBridge 支持 createBlankTexture 和 updateTextureRegion
|
||||||
|
// Requires engineBridge to support createBlankTexture and updateTextureRegion
|
||||||
|
console.log('[UIRuntimeModule] engineBridge available:', !!engineBridge);
|
||||||
|
console.log('[UIRuntimeModule] createBlankTexture:', !!engineBridge?.createBlankTexture);
|
||||||
|
console.log('[UIRuntimeModule] updateTextureRegion:', !!engineBridge?.updateTextureRegion);
|
||||||
|
if (engineBridge?.createBlankTexture && engineBridge?.updateTextureRegion) {
|
||||||
|
// 创建适配器将 EngineBridge 适配为 IAtlasEngineBridge
|
||||||
|
// Create adapter to adapt EngineBridge to IAtlasEngineBridge
|
||||||
|
const atlasBridge: IAtlasEngineBridge = {
|
||||||
|
createBlankTexture: (width: number, height: number) => {
|
||||||
|
return engineBridge.createBlankTexture!(width, height);
|
||||||
|
},
|
||||||
|
updateTextureRegion: (
|
||||||
|
id: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8Array
|
||||||
|
) => {
|
||||||
|
engineBridge.updateTextureRegion!(id, x, y, width, height, pixels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[UIRuntimeModule] Initializing dynamic atlas service...');
|
||||||
|
initializeDynamicAtlasService(atlasBridge, {
|
||||||
|
expansionStrategy: AtlasExpansionStrategy.Fixed, // 运行时默认使用固定模式 | Runtime defaults to fixed mode
|
||||||
|
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
|
||||||
|
fixedPageSize: 1024, // 固定模式页面大小 | Fixed mode page size
|
||||||
|
maxPageSize: 2048, // 最大页面大小 | Max page size
|
||||||
|
maxPages: 4,
|
||||||
|
maxTextureSize: 512,
|
||||||
|
padding: 1
|
||||||
|
});
|
||||||
|
console.log('[UIRuntimeModule] Dynamic atlas service initialized');
|
||||||
|
|
||||||
|
// 注册纹理加载回调,当纹理通过 EngineIntegration 加载时自动注册路径映射
|
||||||
|
// Register texture load callback to automatically register path mapping
|
||||||
|
// when textures are loaded through EngineIntegration
|
||||||
|
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
|
||||||
|
registerTexturePathMapping(guid, path);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[UIRuntimeModule] Cannot initialize dynamic atlas service: engineBridge missing createBlankTexture or updateTextureRegion');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +237,9 @@ const manifest: ModuleManifest = {
|
|||||||
canContainContent: true,
|
canContainContent: true,
|
||||||
dependencies: ['core', 'math'],
|
dependencies: ['core', 'math'],
|
||||||
exports: { components: ['UICanvasComponent'] },
|
exports: { components: ['UICanvasComponent'] },
|
||||||
editorPackage: '@esengine/ui-editor'
|
editorPackage: '@esengine/ui-editor',
|
||||||
|
// Plugin export for runtime loading | 运行时加载的插件导出
|
||||||
|
pluginExport: 'UIPlugin'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UIPlugin: IRuntimePlugin = {
|
export const UIPlugin: IRuntimePlugin = {
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Bin Packing Algorithm for Dynamic Atlas
|
||||||
|
* 动态图集的矩形打包算法
|
||||||
|
*
|
||||||
|
* Implements the MaxRects algorithm for efficiently packing rectangles
|
||||||
|
* into a larger texture atlas.
|
||||||
|
* 实现 MaxRects 算法,高效地将矩形打包到更大的纹理图集中。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rectangle region within the atlas
|
||||||
|
* 图集内的矩形区域
|
||||||
|
*/
|
||||||
|
export interface PackedRect {
|
||||||
|
/** X position in atlas | 图集中的X位置 */
|
||||||
|
x: number;
|
||||||
|
/** Y position in atlas | 图集中的Y位置 */
|
||||||
|
y: number;
|
||||||
|
/** Width of the packed rectangle | 打包矩形的宽度 */
|
||||||
|
width: number;
|
||||||
|
/** Height of the packed rectangle | 打包矩形的高度 */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MaxRects Bin Packer
|
||||||
|
* MaxRects 矩形打包器
|
||||||
|
*
|
||||||
|
* Uses the MaxRects algorithm with Best Short Side Fit heuristic
|
||||||
|
* to pack rectangles into a fixed-size bin (atlas texture).
|
||||||
|
* 使用带有最佳短边适配启发式的 MaxRects 算法
|
||||||
|
* 将矩形打包到固定大小的容器(图集纹理)中。
|
||||||
|
*/
|
||||||
|
export class BinPacker {
|
||||||
|
/** Atlas width | 图集宽度 */
|
||||||
|
private readonly binWidth: number;
|
||||||
|
/** Atlas height | 图集高度 */
|
||||||
|
private readonly binHeight: number;
|
||||||
|
/** Padding between packed rectangles | 打包矩形之间的间距 */
|
||||||
|
private readonly padding: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of free rectangles available for packing
|
||||||
|
* 可用于打包的空闲矩形列表
|
||||||
|
*/
|
||||||
|
private freeRects: PackedRect[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new bin packer
|
||||||
|
* 创建新的矩形打包器
|
||||||
|
*
|
||||||
|
* @param width - Bin width (atlas texture width) | 容器宽度(图集纹理宽度)
|
||||||
|
* @param height - Bin height (atlas texture height) | 容器高度(图集纹理高度)
|
||||||
|
* @param padding - Padding between packed rectangles (default: 1) | 矩形之间的间距(默认:1)
|
||||||
|
*/
|
||||||
|
constructor(width: number, height: number, padding: number = 1) {
|
||||||
|
this.binWidth = width;
|
||||||
|
this.binHeight = height;
|
||||||
|
this.padding = padding;
|
||||||
|
|
||||||
|
// Start with one free rectangle covering the entire bin
|
||||||
|
// 从覆盖整个容器的一个空闲矩形开始
|
||||||
|
this.freeRects = [{ x: 0, y: 0, width, height }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack a rectangle into the atlas
|
||||||
|
* 将矩形打包到图集中
|
||||||
|
*
|
||||||
|
* @param width - Rectangle width | 矩形宽度
|
||||||
|
* @param height - Rectangle height | 矩形高度
|
||||||
|
* @returns Packed position, or null if no space available | 打包位置,如果没有可用空间则返回 null
|
||||||
|
*/
|
||||||
|
pack(width: number, height: number): PackedRect | null {
|
||||||
|
// Add padding | 添加间距
|
||||||
|
const paddedWidth = width + this.padding;
|
||||||
|
const paddedHeight = height + this.padding;
|
||||||
|
|
||||||
|
// Find best position using Best Short Side Fit
|
||||||
|
// 使用最佳短边适配查找最佳位置
|
||||||
|
const bestNode = this.findBestPosition(paddedWidth, paddedHeight);
|
||||||
|
|
||||||
|
if (!bestNode) {
|
||||||
|
return null; // No space available | 没有可用空间
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place the rectangle | 放置矩形
|
||||||
|
const packedRect: PackedRect = {
|
||||||
|
x: bestNode.x,
|
||||||
|
y: bestNode.y,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split free rectangles | 分割空闲矩形
|
||||||
|
this.splitFreeRects(bestNode.x, bestNode.y, paddedWidth, paddedHeight);
|
||||||
|
|
||||||
|
// Remove redundant free rectangles | 移除冗余的空闲矩形
|
||||||
|
this.pruneFreeRects();
|
||||||
|
|
||||||
|
return packedRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best position for a rectangle using Best Short Side Fit
|
||||||
|
* 使用最佳短边适配查找矩形的最佳位置
|
||||||
|
*/
|
||||||
|
private findBestPosition(width: number, height: number): PackedRect | null {
|
||||||
|
let bestNode: PackedRect | null = null;
|
||||||
|
let bestShortSideFit = Infinity;
|
||||||
|
let bestLongSideFit = Infinity;
|
||||||
|
|
||||||
|
for (const freeRect of this.freeRects) {
|
||||||
|
// Check if rectangle fits | 检查矩形是否适合
|
||||||
|
if (width <= freeRect.width && height <= freeRect.height) {
|
||||||
|
const leftoverHoriz = Math.abs(freeRect.width - width);
|
||||||
|
const leftoverVert = Math.abs(freeRect.height - height);
|
||||||
|
const shortSideFit = Math.min(leftoverHoriz, leftoverVert);
|
||||||
|
const longSideFit = Math.max(leftoverHoriz, leftoverVert);
|
||||||
|
|
||||||
|
if (shortSideFit < bestShortSideFit ||
|
||||||
|
(shortSideFit === bestShortSideFit && longSideFit < bestLongSideFit)) {
|
||||||
|
bestNode = {
|
||||||
|
x: freeRect.x,
|
||||||
|
y: freeRect.y,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
bestShortSideFit = shortSideFit;
|
||||||
|
bestLongSideFit = longSideFit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split free rectangles after placing a new rectangle
|
||||||
|
* 放置新矩形后分割空闲矩形
|
||||||
|
*/
|
||||||
|
private splitFreeRects(x: number, y: number, width: number, height: number): void {
|
||||||
|
const newFreeRects: PackedRect[] = [];
|
||||||
|
const usedRect: PackedRect = { x, y, width, height };
|
||||||
|
|
||||||
|
for (const freeRect of this.freeRects) {
|
||||||
|
// Check if the used rectangle intersects with this free rectangle
|
||||||
|
// 检查已使用矩形是否与此空闲矩形相交
|
||||||
|
if (!this.intersects(usedRect, freeRect)) {
|
||||||
|
newFreeRects.push(freeRect);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the free rectangle into up to 4 new rectangles
|
||||||
|
// 将空闲矩形分割成最多4个新矩形
|
||||||
|
|
||||||
|
// Left piece | 左侧部分
|
||||||
|
if (usedRect.x > freeRect.x) {
|
||||||
|
newFreeRects.push({
|
||||||
|
x: freeRect.x,
|
||||||
|
y: freeRect.y,
|
||||||
|
width: usedRect.x - freeRect.x,
|
||||||
|
height: freeRect.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right piece | 右侧部分
|
||||||
|
if (usedRect.x + usedRect.width < freeRect.x + freeRect.width) {
|
||||||
|
newFreeRects.push({
|
||||||
|
x: usedRect.x + usedRect.width,
|
||||||
|
y: freeRect.y,
|
||||||
|
width: freeRect.x + freeRect.width - usedRect.x - usedRect.width,
|
||||||
|
height: freeRect.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom piece | 底部部分
|
||||||
|
if (usedRect.y > freeRect.y) {
|
||||||
|
newFreeRects.push({
|
||||||
|
x: freeRect.x,
|
||||||
|
y: freeRect.y,
|
||||||
|
width: freeRect.width,
|
||||||
|
height: usedRect.y - freeRect.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top piece | 顶部部分
|
||||||
|
if (usedRect.y + usedRect.height < freeRect.y + freeRect.height) {
|
||||||
|
newFreeRects.push({
|
||||||
|
x: freeRect.x,
|
||||||
|
y: usedRect.y + usedRect.height,
|
||||||
|
width: freeRect.width,
|
||||||
|
height: freeRect.y + freeRect.height - usedRect.y - usedRect.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.freeRects = newFreeRects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove redundant free rectangles (those contained within others)
|
||||||
|
* 移除冗余的空闲矩形(被其他矩形包含的)
|
||||||
|
*/
|
||||||
|
private pruneFreeRects(): void {
|
||||||
|
const pruned: PackedRect[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.freeRects.length; i++) {
|
||||||
|
let isContained = false;
|
||||||
|
|
||||||
|
for (let j = 0; j < this.freeRects.length; j++) {
|
||||||
|
if (i !== j && this.contains(this.freeRects[j], this.freeRects[i])) {
|
||||||
|
isContained = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isContained) {
|
||||||
|
pruned.push(this.freeRects[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.freeRects = pruned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two rectangles intersect
|
||||||
|
* 检查两个矩形是否相交
|
||||||
|
*/
|
||||||
|
private intersects(a: PackedRect, b: PackedRect): boolean {
|
||||||
|
return a.x < b.x + b.width &&
|
||||||
|
a.x + a.width > b.x &&
|
||||||
|
a.y < b.y + b.height &&
|
||||||
|
a.y + a.height > b.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if rectangle a contains rectangle b
|
||||||
|
* 检查矩形 a 是否包含矩形 b
|
||||||
|
*/
|
||||||
|
private contains(a: PackedRect, b: PackedRect): boolean {
|
||||||
|
return a.x <= b.x &&
|
||||||
|
a.y <= b.y &&
|
||||||
|
a.x + a.width >= b.x + b.width &&
|
||||||
|
a.y + a.height >= b.y + b.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current occupancy ratio of the bin
|
||||||
|
* 获取容器的当前占用率
|
||||||
|
*/
|
||||||
|
getOccupancy(): number {
|
||||||
|
let usedArea = this.binWidth * this.binHeight;
|
||||||
|
|
||||||
|
for (const freeRect of this.freeRects) {
|
||||||
|
usedArea -= freeRect.width * freeRect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usedArea / (this.binWidth * this.binHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the bin is full (no more space for small allocations)
|
||||||
|
* 检查容器是否已满(没有更多空间用于小分配)
|
||||||
|
*/
|
||||||
|
isFull(): boolean {
|
||||||
|
// Consider full if we can't fit a 16x16 texture
|
||||||
|
// 如果无法容纳 16x16 纹理,则认为已满
|
||||||
|
return this.freeRects.length === 0 ||
|
||||||
|
this.freeRects.every(r => r.width < 16 || r.height < 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the packer to initial state
|
||||||
|
* 将打包器重置为初始状态
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.freeRects = [{ x: 0, y: 0, width: this.binWidth, height: this.binHeight }];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic Atlas Manager
|
||||||
|
* 动态图集管理器
|
||||||
|
*
|
||||||
|
* Manages runtime texture atlasing to enable batching of UI elements
|
||||||
|
* that use different source textures.
|
||||||
|
* 管理运行时纹理图集,以启用使用不同源纹理的 UI 元素的合批。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BinPacker, PackedRect } from './BinPacker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atlas expansion strategy
|
||||||
|
* 图集扩展策略
|
||||||
|
*/
|
||||||
|
export enum AtlasExpansionStrategy {
|
||||||
|
/**
|
||||||
|
* Dynamic expansion: Start small, expand pages when full (has rebuild cost)
|
||||||
|
* 动态扩展:从小尺寸开始,页面满时扩展(有重建开销)
|
||||||
|
*/
|
||||||
|
Dynamic = 'dynamic',
|
||||||
|
/**
|
||||||
|
* Fixed size: Use fixed page size, create new pages when full (no rebuild)
|
||||||
|
* 固定大小:使用固定页面大小,满时创建新页面(无重建)
|
||||||
|
*/
|
||||||
|
Fixed = 'fixed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored texture data for rebuild during expansion
|
||||||
|
* 存储的纹理数据,用于扩展时重建
|
||||||
|
*/
|
||||||
|
interface StoredTexture {
|
||||||
|
guid: string;
|
||||||
|
pixels: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atlas entry storing the mapping from original texture to atlas region
|
||||||
|
* 图集条目,存储从原始纹理到图集区域的映射
|
||||||
|
*/
|
||||||
|
export interface AtlasEntry {
|
||||||
|
/** Atlas texture ID | 图集纹理ID */
|
||||||
|
atlasId: number;
|
||||||
|
/** Position in atlas | 图集中的位置 */
|
||||||
|
region: PackedRect;
|
||||||
|
/** Original texture width | 原始纹理宽度 */
|
||||||
|
originalWidth: number;
|
||||||
|
/** Original texture height | 原始纹理高度 */
|
||||||
|
originalHeight: number;
|
||||||
|
/** UV coordinates in atlas [u0, v0, u1, v1] | 图集中的UV坐标 */
|
||||||
|
uv: [number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single atlas texture with its packer
|
||||||
|
* 单个图集纹理及其打包器
|
||||||
|
*/
|
||||||
|
interface AtlasPage {
|
||||||
|
/** GPU texture ID | GPU纹理ID */
|
||||||
|
textureId: number;
|
||||||
|
/** Bin packer for this page | 此页面的矩形打包器 */
|
||||||
|
packer: BinPacker;
|
||||||
|
/** Atlas width | 图集宽度 */
|
||||||
|
width: number;
|
||||||
|
/** Atlas height | 图集高度 */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine bridge interface for texture operations
|
||||||
|
* 纹理操作的引擎桥接接口
|
||||||
|
*/
|
||||||
|
export interface IAtlasEngineBridge {
|
||||||
|
/** Create a blank texture | 创建空白纹理 */
|
||||||
|
createBlankTexture(width: number, height: number): number;
|
||||||
|
/** Update a region of a texture | 更新纹理区域 */
|
||||||
|
updateTextureRegion(
|
||||||
|
id: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixels: Uint8Array
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the dynamic atlas manager
|
||||||
|
* 动态图集管理器配置
|
||||||
|
*/
|
||||||
|
export interface DynamicAtlasConfig {
|
||||||
|
/**
|
||||||
|
* Expansion strategy (default: Fixed)
|
||||||
|
* 扩展策略(默认:固定)
|
||||||
|
*
|
||||||
|
* - Dynamic: Start small (initialPageSize), expand when full. Better memory efficiency but has rebuild cost.
|
||||||
|
* - Fixed: Use fixedPageSize directly, create new pages when full. No rebuild cost but uses more memory initially.
|
||||||
|
*
|
||||||
|
* - 动态:从小尺寸开始(initialPageSize),满时扩展。内存效率更高但有重建开销。
|
||||||
|
* - 固定:直接使用 fixedPageSize,满时创建新页面。无重建开销但初始内存占用更大。
|
||||||
|
*/
|
||||||
|
expansionStrategy?: AtlasExpansionStrategy;
|
||||||
|
/** Initial atlas page size for dynamic mode (default: 256) | 动态模式的初始页面大小(默认:256) */
|
||||||
|
initialPageSize?: number;
|
||||||
|
/** Fixed atlas page size for fixed mode (default: 1024) | 固定模式的页面大小(默认:1024) */
|
||||||
|
fixedPageSize?: number;
|
||||||
|
/** Maximum atlas page size (default: 2048) | 最大图集页面大小(默认:2048) */
|
||||||
|
maxPageSize?: number;
|
||||||
|
/** Maximum number of atlas pages (default: 4) | 最大图集页数(默认:4) */
|
||||||
|
maxPages?: number;
|
||||||
|
/** Maximum individual texture size to atlas (default: 512) | 可加入图集的最大单个纹理尺寸(默认:512) */
|
||||||
|
maxTextureSize?: number;
|
||||||
|
/** Padding between textures (default: 1) | 纹理之间的间距(默认:1) */
|
||||||
|
padding?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic Atlas Manager
|
||||||
|
* 动态图集管理器
|
||||||
|
*
|
||||||
|
* Automatically packs individual textures into larger atlas textures
|
||||||
|
* at runtime to enable draw call batching.
|
||||||
|
* 在运行时自动将单个纹理打包到更大的图集纹理中,以启用绘制调用合批。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const manager = new DynamicAtlasManager(bridge);
|
||||||
|
*
|
||||||
|
* // Add texture to atlas
|
||||||
|
* const entry = await manager.addTexture('texture-guid', imageData, 64, 64);
|
||||||
|
*
|
||||||
|
* // Use atlas texture ID and remapped UV for rendering
|
||||||
|
* const atlasTextureId = entry.atlasId;
|
||||||
|
* const atlasUV = entry.uv;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class DynamicAtlasManager {
|
||||||
|
/** Engine bridge for texture operations | 纹理操作的引擎桥接 */
|
||||||
|
private bridge: IAtlasEngineBridge;
|
||||||
|
|
||||||
|
/** Atlas configuration | 图集配置 */
|
||||||
|
private config: {
|
||||||
|
expansionStrategy: AtlasExpansionStrategy;
|
||||||
|
initialPageSize: number;
|
||||||
|
fixedPageSize: number;
|
||||||
|
maxPageSize: number;
|
||||||
|
maxPages: number;
|
||||||
|
maxTextureSize: number;
|
||||||
|
padding: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Atlas pages | 图集页面 */
|
||||||
|
private pages: AtlasPage[] = [];
|
||||||
|
|
||||||
|
/** Mapping from texture GUID to atlas entry | 纹理GUID到图集条目的映射 */
|
||||||
|
private entries: Map<string, AtlasEntry> = new Map();
|
||||||
|
|
||||||
|
/** Stored textures for rebuild during expansion (only used in Dynamic mode) */
|
||||||
|
/** 存储的纹理数据,用于扩展时重建(仅在动态模式下使用) */
|
||||||
|
private storedTextures: Map<string, StoredTexture> = new Map();
|
||||||
|
|
||||||
|
/** Whether the manager has been initialized | 管理器是否已初始化 */
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new dynamic atlas manager
|
||||||
|
* 创建新的动态图集管理器
|
||||||
|
*
|
||||||
|
* @param bridge - Engine bridge for texture operations | 纹理操作的引擎桥接
|
||||||
|
* @param config - Configuration options | 配置选项
|
||||||
|
*/
|
||||||
|
constructor(bridge: IAtlasEngineBridge, config: DynamicAtlasConfig = {}) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.config = {
|
||||||
|
expansionStrategy: config.expansionStrategy ?? AtlasExpansionStrategy.Fixed,
|
||||||
|
initialPageSize: config.initialPageSize ?? 256,
|
||||||
|
fixedPageSize: config.fixedPageSize ?? 1024,
|
||||||
|
maxPageSize: config.maxPageSize ?? 2048,
|
||||||
|
maxPages: config.maxPages ?? 4,
|
||||||
|
maxTextureSize: config.maxTextureSize ?? 512,
|
||||||
|
padding: config.padding ?? 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the atlas manager (creates first atlas page)
|
||||||
|
* 初始化图集管理器(创建第一个图集页面)
|
||||||
|
*/
|
||||||
|
initialize(): void {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
// Choose initial page size based on strategy
|
||||||
|
// 根据策略选择初始页面大小
|
||||||
|
const initialSize = this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic
|
||||||
|
? this.config.initialPageSize
|
||||||
|
: this.config.fixedPageSize;
|
||||||
|
|
||||||
|
console.log('[DynamicAtlasManager] Initializing with:', {
|
||||||
|
strategy: this.config.expansionStrategy,
|
||||||
|
initialPageSize: this.config.initialPageSize,
|
||||||
|
fixedPageSize: this.config.fixedPageSize,
|
||||||
|
selectedSize: initialSize
|
||||||
|
});
|
||||||
|
|
||||||
|
this.createNewPage(initialSize);
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a texture is already in the atlas
|
||||||
|
* 检查纹理是否已在图集中
|
||||||
|
*
|
||||||
|
* @param textureGuid - Texture GUID | 纹理GUID
|
||||||
|
*/
|
||||||
|
hasTexture(textureGuid: string): boolean {
|
||||||
|
return this.entries.has(textureGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get atlas entry for a texture
|
||||||
|
* 获取纹理的图集条目
|
||||||
|
*
|
||||||
|
* @param textureGuid - Texture GUID | 纹理GUID
|
||||||
|
*/
|
||||||
|
getEntry(textureGuid: string): AtlasEntry | undefined {
|
||||||
|
return this.entries.get(textureGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a texture to the atlas
|
||||||
|
* 将纹理添加到图集
|
||||||
|
*
|
||||||
|
* @param textureGuid - Unique identifier for this texture | 此纹理的唯一标识符
|
||||||
|
* @param pixels - RGBA pixel data | RGBA像素数据
|
||||||
|
* @param width - Texture width | 纹理宽度
|
||||||
|
* @param height - Texture height | 纹理高度
|
||||||
|
* @returns Atlas entry with UV mapping, or null if texture too large | 带UV映射的图集条目,如果纹理太大则返回null
|
||||||
|
*/
|
||||||
|
addTexture(
|
||||||
|
textureGuid: string,
|
||||||
|
pixels: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): AtlasEntry | null {
|
||||||
|
// Check if already added | 检查是否已添加
|
||||||
|
const existing = this.entries.get(textureGuid);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if texture is too large for atlasing
|
||||||
|
// 检查纹理是否太大无法加入图集
|
||||||
|
if (width > this.config.maxTextureSize || height > this.config.maxTextureSize) {
|
||||||
|
return null; // Too large, should use original texture | 太大,应使用原始纹理
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure initialized | 确保已初始化
|
||||||
|
if (!this.initialized) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store texture data for potential rebuild (only in Dynamic mode)
|
||||||
|
// 存储纹理数据用于可能的重建(仅在动态模式下)
|
||||||
|
if (this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic) {
|
||||||
|
this.storedTextures.set(textureGuid, {
|
||||||
|
guid: textureGuid,
|
||||||
|
pixels: new Uint8Array(pixels), // Clone to avoid external mutation
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to pack into existing pages
|
||||||
|
// 尝试打包到现有页面
|
||||||
|
for (const page of this.pages) {
|
||||||
|
const region = page.packer.pack(width, height);
|
||||||
|
if (region) {
|
||||||
|
// Upload to atlas texture | 上传到图集纹理
|
||||||
|
this.bridge.updateTextureRegion(
|
||||||
|
page.textureId,
|
||||||
|
region.x,
|
||||||
|
region.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate UV coordinates | 计算UV坐标
|
||||||
|
const entry = this.createEntry(page, region, width, height);
|
||||||
|
this.entries.set(textureGuid, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No space in existing pages
|
||||||
|
// 现有页面没有空间
|
||||||
|
if (this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic) {
|
||||||
|
// Dynamic mode: Try to expand existing page first
|
||||||
|
// 动态模式:先尝试扩展现有页面
|
||||||
|
const expanded = this.tryExpandPage(0); // Try to expand first page
|
||||||
|
if (expanded) {
|
||||||
|
// Page expanded, try to pack again
|
||||||
|
// 页面已扩展,再次尝试打包
|
||||||
|
const page = this.pages[0];
|
||||||
|
const region = page.packer.pack(width, height);
|
||||||
|
if (region) {
|
||||||
|
this.bridge.updateTextureRegion(
|
||||||
|
page.textureId,
|
||||||
|
region.x,
|
||||||
|
region.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels
|
||||||
|
);
|
||||||
|
const entry = this.createEntry(page, region, width, height);
|
||||||
|
this.entries.set(textureGuid, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new page if allowed
|
||||||
|
// 如果允许则创建新页面
|
||||||
|
if (this.pages.length < this.config.maxPages) {
|
||||||
|
// Calculate page size based on strategy
|
||||||
|
// 根据策略计算页面大小
|
||||||
|
let newPageSize: number;
|
||||||
|
if (this.config.expansionStrategy === AtlasExpansionStrategy.Fixed) {
|
||||||
|
newPageSize = this.config.fixedPageSize;
|
||||||
|
} else {
|
||||||
|
// Dynamic mode: start with initial size for new page
|
||||||
|
// 动态模式:新页面从初始大小开始
|
||||||
|
newPageSize = this.config.initialPageSize;
|
||||||
|
while (newPageSize < Math.max(width, height) + this.config.padding * 2) {
|
||||||
|
newPageSize *= 2;
|
||||||
|
if (newPageSize > this.config.maxPageSize) {
|
||||||
|
newPageSize = this.config.maxPageSize;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = this.createNewPage(newPageSize);
|
||||||
|
const region = page.packer.pack(width, height);
|
||||||
|
|
||||||
|
if (region) {
|
||||||
|
this.bridge.updateTextureRegion(
|
||||||
|
page.textureId,
|
||||||
|
region.x,
|
||||||
|
region.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry = this.createEntry(page, region, width, height);
|
||||||
|
this.entries.set(textureGuid, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Could not fit texture (all pages full or texture too large)
|
||||||
|
// 无法容纳纹理(所有页面已满或纹理太大)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to expand a page to a larger size (Dynamic mode only)
|
||||||
|
* 尝试将页面扩展到更大尺寸(仅动态模式)
|
||||||
|
*
|
||||||
|
* @param pageIndex - Index of the page to expand | 要扩展的页面索引
|
||||||
|
* @returns True if expansion succeeded | 如果扩展成功返回true
|
||||||
|
*/
|
||||||
|
private tryExpandPage(pageIndex: number): boolean {
|
||||||
|
const page = this.pages[pageIndex];
|
||||||
|
if (!page) return false;
|
||||||
|
|
||||||
|
// Check if already at max size
|
||||||
|
// 检查是否已达到最大尺寸
|
||||||
|
if (page.width >= this.config.maxPageSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new size (double the current size)
|
||||||
|
// 计算新尺寸(当前尺寸的两倍)
|
||||||
|
const newSize = Math.min(page.width * 2, this.config.maxPageSize);
|
||||||
|
|
||||||
|
// Create new texture
|
||||||
|
// 创建新纹理
|
||||||
|
const newTextureId = this.bridge.createBlankTexture(newSize, newSize);
|
||||||
|
|
||||||
|
// Create new packer
|
||||||
|
// 创建新打包器
|
||||||
|
const newPacker = new BinPacker(newSize, newSize, this.config.padding);
|
||||||
|
|
||||||
|
// Collect all textures from this page
|
||||||
|
// 收集此页面的所有纹理
|
||||||
|
const texturesInPage: StoredTexture[] = [];
|
||||||
|
for (const [guid, entry] of this.entries) {
|
||||||
|
if (entry.atlasId === page.textureId) {
|
||||||
|
const stored = this.storedTextures.get(guid);
|
||||||
|
if (stored) {
|
||||||
|
texturesInPage.push(stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by size (larger first for better packing)
|
||||||
|
// 按大小排序(大的优先以获得更好的打包效果)
|
||||||
|
texturesInPage.sort((a, b) => (b.width * b.height) - (a.width * a.height));
|
||||||
|
|
||||||
|
// Repack all textures into the new larger page
|
||||||
|
// 将所有纹理重新打包到新的更大页面
|
||||||
|
const newEntries = new Map<string, AtlasEntry>();
|
||||||
|
for (const tex of texturesInPage) {
|
||||||
|
const region = newPacker.pack(tex.width, tex.height);
|
||||||
|
if (!region) {
|
||||||
|
// Failed to repack (shouldn't happen if new size is larger)
|
||||||
|
// 重新打包失败(如果新尺寸更大则不应发生)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload texture to new atlas
|
||||||
|
// 将纹理上传到新图集
|
||||||
|
this.bridge.updateTextureRegion(
|
||||||
|
newTextureId,
|
||||||
|
region.x,
|
||||||
|
region.y,
|
||||||
|
tex.width,
|
||||||
|
tex.height,
|
||||||
|
tex.pixels
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate new UV coordinates
|
||||||
|
// 计算新的UV坐标
|
||||||
|
const u0 = region.x / newSize;
|
||||||
|
const v0 = region.y / newSize;
|
||||||
|
const u1 = (region.x + region.width) / newSize;
|
||||||
|
const v1 = (region.y + region.height) / newSize;
|
||||||
|
|
||||||
|
newEntries.set(tex.guid, {
|
||||||
|
atlasId: newTextureId,
|
||||||
|
region,
|
||||||
|
originalWidth: tex.width,
|
||||||
|
originalHeight: tex.height,
|
||||||
|
uv: [u0, v0, u1, v1]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page
|
||||||
|
// 更新页面
|
||||||
|
page.textureId = newTextureId;
|
||||||
|
page.packer = newPacker;
|
||||||
|
page.width = newSize;
|
||||||
|
page.height = newSize;
|
||||||
|
|
||||||
|
// Update entries
|
||||||
|
// 更新条目
|
||||||
|
for (const [guid, entry] of newEntries) {
|
||||||
|
this.entries.set(guid, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new atlas page
|
||||||
|
* 创建新的图集页面
|
||||||
|
*
|
||||||
|
* @param size - Page size (default: initialPageSize) | 页面大小(默认:initialPageSize)
|
||||||
|
*/
|
||||||
|
private createNewPage(size?: number): AtlasPage {
|
||||||
|
const pageSize = size ?? this.config.initialPageSize;
|
||||||
|
const textureId = this.bridge.createBlankTexture(pageSize, pageSize);
|
||||||
|
|
||||||
|
const page: AtlasPage = {
|
||||||
|
textureId,
|
||||||
|
packer: new BinPacker(pageSize, pageSize, this.config.padding),
|
||||||
|
width: pageSize,
|
||||||
|
height: pageSize
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pages.push(page);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an atlas entry with UV coordinates
|
||||||
|
* 创建带UV坐标的图集条目
|
||||||
|
*/
|
||||||
|
private createEntry(
|
||||||
|
page: AtlasPage,
|
||||||
|
region: PackedRect,
|
||||||
|
originalWidth: number,
|
||||||
|
originalHeight: number
|
||||||
|
): AtlasEntry {
|
||||||
|
// Calculate normalized UV coordinates | 计算归一化UV坐标
|
||||||
|
const u0 = region.x / page.width;
|
||||||
|
const v0 = region.y / page.height;
|
||||||
|
const u1 = (region.x + region.width) / page.width;
|
||||||
|
const v1 = (region.y + region.height) / page.height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
atlasId: page.textureId,
|
||||||
|
region,
|
||||||
|
originalWidth,
|
||||||
|
originalHeight,
|
||||||
|
uv: [u0, v0, u1, v1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remap UV coordinates from original texture space to atlas space
|
||||||
|
* 将UV坐标从原始纹理空间重映射到图集空间
|
||||||
|
*
|
||||||
|
* @param entry - Atlas entry | 图集条目
|
||||||
|
* @param originalU0 - Original U0 | 原始U0
|
||||||
|
* @param originalV0 - Original V0 | 原始V0
|
||||||
|
* @param originalU1 - Original U1 | 原始U1
|
||||||
|
* @param originalV1 - Original V1 | 原始V1
|
||||||
|
* @returns Remapped UV coordinates [u0, v0, u1, v1] | 重映射的UV坐标
|
||||||
|
*/
|
||||||
|
remapUV(
|
||||||
|
entry: AtlasEntry,
|
||||||
|
originalU0: number,
|
||||||
|
originalV0: number,
|
||||||
|
originalU1: number,
|
||||||
|
originalV1: number
|
||||||
|
): [number, number, number, number] {
|
||||||
|
const [atlasU0, atlasV0, atlasU1, atlasV1] = entry.uv;
|
||||||
|
|
||||||
|
// Calculate the UV range in atlas space | 计算图集空间中的UV范围
|
||||||
|
const atlasURange = atlasU1 - atlasU0;
|
||||||
|
const atlasVRange = atlasV1 - atlasV0;
|
||||||
|
|
||||||
|
// Remap original UVs to atlas space | 将原始UV重映射到图集空间
|
||||||
|
const u0 = atlasU0 + originalU0 * atlasURange;
|
||||||
|
const v0 = atlasV0 + originalV0 * atlasVRange;
|
||||||
|
const u1 = atlasU0 + originalU1 * atlasURange;
|
||||||
|
const v1 = atlasV0 + originalV1 * atlasVRange;
|
||||||
|
|
||||||
|
return [u0, v0, u1, v1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all atlas texture IDs
|
||||||
|
* 获取所有图集纹理ID
|
||||||
|
*/
|
||||||
|
getAtlasTextureIds(): number[] {
|
||||||
|
return this.pages.map(p => p.textureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about atlas usage
|
||||||
|
* 获取图集使用统计信息
|
||||||
|
*/
|
||||||
|
getStats(): {
|
||||||
|
pageCount: number;
|
||||||
|
textureCount: number;
|
||||||
|
averageOccupancy: number;
|
||||||
|
} {
|
||||||
|
const occupancies = this.pages.map(p => p.packer.getOccupancy());
|
||||||
|
const avgOccupancy = occupancies.length > 0
|
||||||
|
? occupancies.reduce((a, b) => a + b, 0) / occupancies.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageCount: this.pages.length,
|
||||||
|
textureCount: this.entries.size,
|
||||||
|
averageOccupancy: avgOccupancy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all atlas entries with their GUID
|
||||||
|
* 获取所有图集条目及其 GUID
|
||||||
|
*/
|
||||||
|
getAllEntries(): Array<{ guid: string; entry: AtlasEntry }> {
|
||||||
|
const result: Array<{ guid: string; entry: AtlasEntry }> = [];
|
||||||
|
for (const [guid, entry] of this.entries) {
|
||||||
|
result.push({ guid, entry });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed info for each atlas page
|
||||||
|
* 获取每个图集页面的详细信息
|
||||||
|
*/
|
||||||
|
getPageDetails(): Array<{
|
||||||
|
pageIndex: number;
|
||||||
|
textureId: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
occupancy: number;
|
||||||
|
entries: Array<{ guid: string; entry: AtlasEntry }>;
|
||||||
|
}> {
|
||||||
|
return this.pages.map((page, index) => {
|
||||||
|
// Find all entries in this page
|
||||||
|
// 查找此页面中的所有条目
|
||||||
|
const pageEntries: Array<{ guid: string; entry: AtlasEntry }> = [];
|
||||||
|
for (const [guid, entry] of this.entries) {
|
||||||
|
if (entry.atlasId === page.textureId) {
|
||||||
|
pageEntries.push({ guid, entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageIndex: index,
|
||||||
|
textureId: page.textureId,
|
||||||
|
width: page.width,
|
||||||
|
height: page.height,
|
||||||
|
occupancy: page.packer.getOccupancy(),
|
||||||
|
entries: pageEntries
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all atlas data and reset
|
||||||
|
* 清除所有图集数据并重置
|
||||||
|
*
|
||||||
|
* Note: This does NOT delete GPU textures. Call this when switching scenes
|
||||||
|
* or when textures are no longer needed.
|
||||||
|
* 注意:这不会删除GPU纹理。在切换场景或不再需要纹理时调用此方法。
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.entries.clear();
|
||||||
|
this.storedTextures.clear();
|
||||||
|
this.pages = [];
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current expansion strategy
|
||||||
|
* 获取当前扩展策略
|
||||||
|
*/
|
||||||
|
getExpansionStrategy(): AtlasExpansionStrategy {
|
||||||
|
return this.config.expansionStrategy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance for global access
|
||||||
|
// 单例实例用于全局访问
|
||||||
|
let globalAtlasManager: DynamicAtlasManager | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global dynamic atlas manager instance
|
||||||
|
* 获取全局动态图集管理器实例
|
||||||
|
*
|
||||||
|
* @param bridge - Engine bridge (required on first call) | 引擎桥接(首次调用时必需)
|
||||||
|
*/
|
||||||
|
export function getDynamicAtlasManager(bridge?: IAtlasEngineBridge): DynamicAtlasManager | null {
|
||||||
|
if (!globalAtlasManager && bridge) {
|
||||||
|
globalAtlasManager = new DynamicAtlasManager(bridge);
|
||||||
|
}
|
||||||
|
return globalAtlasManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the global dynamic atlas manager instance
|
||||||
|
* 设置全局动态图集管理器实例
|
||||||
|
*/
|
||||||
|
export function setDynamicAtlasManager(manager: DynamicAtlasManager | null): void {
|
||||||
|
globalAtlasManager = manager;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user