Files
esengine/packages/particle/src/systems/ClickFxSystem.ts
YHH 536c4c5593 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 的位置和锚点值映射
2025-12-19 15:33:36 +08:00

444 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 点击特效系统 - 处理点击输入并生成粒子效果
* Click FX System - Handles click input and spawns particle effects
*
* 监听用户点击/触摸事件,在点击位置创建粒子效果实体。
* Listens for user click/touch events and creates particle effect entities at click position.
*/
import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
// ============================================================================
// 本地服务令牌定义 | Local Service Token Definitions
// ============================================================================
// 使用 createServiceToken() 本地定义(与 runtime-core 相同策略)
// createServiceToken() 使用 Symbol.for(),确保运行时与源模块令牌匹配
//
// Local token definitions using createServiceToken() (same strategy as runtime-core)
// createServiceToken() uses Symbol.for(), ensuring runtime match with source module tokens
// ============================================================================
/**
* EngineBridge 接口(最小定义,用于坐标转换)
* EngineBridge interface (minimal definition for coordinate conversion)
*/
interface IEngineBridge {
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
}
/**
* EngineRenderSystem 接口(最小定义,用于获取 UI Canvas 尺寸)
* EngineRenderSystem interface (minimal definition for getting UI canvas size)
*/
interface IEngineRenderSystem {
getUICanvasSize(): { width: number; height: number };
}
// EngineBridge 令牌(与 engine-core 中的一致)
// EngineBridge token (consistent with engine-core)
const EngineBridgeToken = createServiceToken<IEngineBridge>('engineBridge');
// RenderSystem 令牌(与 ecs-engine-bindgen 中的一致)
// RenderSystem token (consistent with ecs-engine-bindgen)
const RenderSystemToken = createServiceToken<IEngineRenderSystem>('renderSystem');
/**
* 点击特效系统
* Click FX System
*
* @example
* ```typescript
* // 在场景中添加系统
* scene.addSystem(new ClickFxSystem());
*
* // 创建带有 ClickFxComponent 的实体
* const clickFxEntity = scene.createEntity('ClickFx');
* const clickFx = clickFxEntity.addComponent(new ClickFxComponent());
* clickFx.particleAssets = ['particle-guid-1', 'particle-guid-2'];
* ```
*/
@ECSSystem('ClickFx', { updateOrder: 100 })
export class ClickFxSystem extends EntitySystem {
private _engineBridge: IEngineBridge | null = null;
private _renderSystem: IEngineRenderSystem | null = null;
private _entitiesToDestroy: Entity[] = [];
private _canvas: HTMLCanvasElement | null = null;
constructor() {
super(Matcher.empty().all(ClickFxComponent));
}
/**
* 设置服务注册表(用于获取 EngineBridge 和 RenderSystem
* Set service registry (for getting EngineBridge and RenderSystem)
*/
setServiceRegistry(services: PluginServiceRegistry): void {
this._engineBridge = services.get(EngineBridgeToken) ?? null;
this._renderSystem = services.get(RenderSystemToken) ?? null;
}
/**
* 设置 EngineBridge直接注入
* Set EngineBridge (direct injection)
*/
setEngineBridge(bridge: IEngineBridge): void {
this._engineBridge = bridge;
}
/**
* 设置 RenderSystem直接注入
* Set RenderSystem (direct injection)
*/
setRenderSystem(renderSystem: IEngineRenderSystem): void {
this._renderSystem = renderSystem;
}
/**
* 设置 Canvas 元素(用于计算相对坐标)
* Set canvas element (for calculating relative coordinates)
*/
setCanvas(canvas: HTMLCanvasElement): void {
this._canvas = canvas;
}
/**
* 检查是否应该处理
* Check if should process
*
* 只在运行时模式(非编辑器模式)下处理点击事件
* Only process click events in runtime mode (not editor mode)
*/
protected override onCheckProcessing(): boolean {
// 编辑器模式下不处理(预览时也不处理,只有 Play 模式才处理)
// Don't process in editor mode (including preview, only in Play mode)
if (this.scene?.isEditorMode) {
return false;
}
return super.onCheckProcessing();
}
protected override process(entities: readonly Entity[]): void {
// 处理延迟销毁 | Process delayed destruction
if (this._entitiesToDestroy.length > 0 && this.scene) {
this.scene.destroyEntities(this._entitiesToDestroy);
this._entitiesToDestroy = [];
}
for (const entity of entities) {
const clickFx = entity.getComponent(ClickFxComponent);
if (!clickFx || !clickFx.fxEnabled) continue;
// 清理过期的特效 | Clean up expired effects
this._cleanupExpiredEffects(clickFx);
// 检查触发条件 | Check trigger conditions
const triggered = this._checkTrigger(clickFx);
if (!triggered) continue;
// 检查是否可以添加新特效 | Check if can add new effect
if (!clickFx.canAddEffect()) continue;
// 获取点击/触摸位置 | Get click/touch position
const screenPos = this._getInputPosition(clickFx);
if (!screenPos) continue;
// 转换为 canvas 相对坐标 | Convert to canvas-relative coordinates
const canvasPos = this._windowToCanvas(screenPos.x, screenPos.y);
// 应用偏移 | Apply offset
canvasPos.x += clickFx.positionOffset.x;
canvasPos.y += clickFx.positionOffset.y;
// 创建粒子效果(使用屏幕空间坐标)
// Create particle effect (using screen space coordinates)
this._spawnEffect(clickFx, canvasPos.x, canvasPos.y);
}
}
/**
* 窗口坐标转 canvas 相对坐标
* Window to canvas-relative coordinate conversion
*
* 将窗口坐标转换为 UI Canvas 的像素坐标。
* Converts window coordinates to UI canvas pixel coordinates.
*/
private _windowToCanvas(windowX: number, windowY: number): { x: number; y: number } {
// 获取 UI Canvas 尺寸 | Get UI canvas size
const canvasSize = this._renderSystem?.getUICanvasSize();
const uiCanvasWidth = canvasSize?.width ?? 1920;
const uiCanvasHeight = canvasSize?.height ?? 1080;
let canvasX = windowX;
let canvasY = windowY;
if (this._canvas) {
const rect = this._canvas.getBoundingClientRect();
// 计算 CSS 坐标 | Calculate CSS coordinates
canvasX = windowX - rect.left;
canvasY = windowY - rect.top;
// 将 CSS 坐标映射到 UI Canvas 坐标
// Map CSS coordinates to UI canvas coordinates
// UI Canvas 保持宽高比,可能会有 letterbox/pillarbox
// UI Canvas maintains aspect ratio, may have letterbox/pillarbox
const cssWidth = rect.width;
const cssHeight = rect.height;
// 计算 UI Canvas 在 CSS 坐标中的实际显示区域
// Calculate actual display area of UI Canvas in CSS coordinates
const uiAspect = uiCanvasWidth / uiCanvasHeight;
const cssAspect = cssWidth / cssHeight;
let displayWidth: number;
let displayHeight: number;
let offsetX = 0;
let offsetY = 0;
if (cssAspect > uiAspect) {
// CSS 更宽pillarbox左右黑边
// CSS is wider, pillarbox (black bars on sides)
displayHeight = cssHeight;
displayWidth = cssHeight * uiAspect;
offsetX = (cssWidth - displayWidth) / 2;
} else {
// CSS 更高letterbox上下黑边
// CSS is taller, letterbox (black bars on top/bottom)
displayWidth = cssWidth;
displayHeight = cssWidth / uiAspect;
offsetY = (cssHeight - displayHeight) / 2;
}
// 转换为 UI Canvas 坐标
// Convert to UI canvas coordinates
canvasX = ((canvasX - offsetX) / displayWidth) * uiCanvasWidth;
canvasY = ((canvasY - offsetY) / displayHeight) * uiCanvasHeight;
}
return { x: canvasX, y: canvasY };
}
/**
* 检查触发条件
* Check trigger conditions
*/
private _checkTrigger(clickFx: ClickFxComponent): boolean {
const mode = clickFx.triggerMode;
// 首先检查鼠标是否在 Canvas 内
// First check if mouse is within canvas bounds
if (!this._isMouseInCanvas()) {
return false;
}
switch (mode) {
case ClickFxTriggerMode.LeftClick:
return Input.isMouseButtonJustPressed(MouseButton.Left);
case ClickFxTriggerMode.RightClick:
return Input.isMouseButtonJustPressed(MouseButton.Right);
case ClickFxTriggerMode.AnyClick:
return Input.isMouseButtonJustPressed(MouseButton.Left) ||
Input.isMouseButtonJustPressed(MouseButton.Middle) ||
Input.isMouseButtonJustPressed(MouseButton.Right);
case ClickFxTriggerMode.Touch:
return this._checkTouchStart();
case ClickFxTriggerMode.All:
return Input.isMouseButtonJustPressed(MouseButton.Left) ||
Input.isMouseButtonJustPressed(MouseButton.Middle) ||
Input.isMouseButtonJustPressed(MouseButton.Right) ||
this._checkTouchStart();
default:
return false;
}
}
/**
* 检查鼠标是否在 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
*/
private _checkTouchStart(): boolean {
for (const [id] of Input.touches) {
if (Input.isTouchJustStarted(id)) {
return true;
}
}
return false;
}
/**
* 获取输入位置
* Get input position
*/
private _getInputPosition(clickFx: ClickFxComponent): { x: number; y: number } | null {
const mode = clickFx.triggerMode;
// 优先检查触摸 | Check touch first
if (mode === ClickFxTriggerMode.Touch || mode === ClickFxTriggerMode.All) {
for (const [id, touch] of Input.touches) {
if (Input.isTouchJustStarted(id)) {
return { x: touch.x, y: touch.y };
}
}
}
// 检查鼠标 | Check mouse
if (mode !== ClickFxTriggerMode.Touch) {
return { x: Input.mousePosition.x, y: Input.mousePosition.y };
}
return null;
}
/**
* 生成粒子效果
* Spawn particle effect
*
* 点击特效使用屏幕空间渲染,坐标相对于 UI Canvas 中心。
* Click effects use screen space rendering, coordinates relative to UI canvas center.
*/
private _spawnEffect(clickFx: ClickFxComponent, screenX: number, screenY: number): void {
const particleGuid = clickFx.getNextParticleAsset();
if (!particleGuid) {
console.warn('[ClickFxSystem] No particle assets configured');
return;
}
if (!this.scene) {
console.warn('[ClickFxSystem] No scene available');
return;
}
// 获取 UI Canvas 尺寸 | Get UI canvas size
const canvasSize = this._renderSystem?.getUICanvasSize();
const canvasWidth = canvasSize?.width ?? 1920;
const canvasHeight = canvasSize?.height ?? 1080;
// 将屏幕坐标转换为屏幕空间坐标(相对于 UI Canvas 中心)
// Convert screen coords to screen space coords (relative to UI canvas center)
// 屏幕空间坐标系:中心为 (0, 0)Y 轴向上
// Screen space coordinate system: center at (0, 0), Y-axis up
const screenSpaceX = screenX - canvasWidth / 2;
const screenSpaceY = canvasHeight / 2 - screenY; // Y 翻转
// 创建特效实体 | Create effect entity
const effectEntity = this.scene.createEntity(`ClickFx_${Date.now()}`);
// 添加 Transform使用屏幕空间坐标| Add Transform (using screen space coords)
const transform = effectEntity.addComponent(new TransformComponent(screenSpaceX, screenSpaceY));
transform.setScale(clickFx.scale, clickFx.scale, 1);
// 创建 ParticleSystemComponent 并预先设置 GUID在添加到实体前
// Create ParticleSystemComponent and set GUID before adding to entity
// 这样 ParticleUpdateSystem.onAdded 触发时已经有 GUID 了
// So ParticleUpdateSystem.onAdded has the GUID when triggered
const particleSystem = new ParticleSystemComponent();
particleSystem.particleAssetGuid = particleGuid;
particleSystem.autoPlay = true;
// 使用 ScreenOverlay 层和屏幕空间渲染
// Use ScreenOverlay layer and screen space rendering
particleSystem.sortingLayer = SortingLayers.ScreenOverlay;
particleSystem.orderInLayer = 0;
particleSystem.renderSpace = RenderSpace.Screen;
// 添加组件到实体(触发 ParticleUpdateSystem 的初始化和资产加载)
// Add component to entity (triggers ParticleUpdateSystem initialization and asset loading)
effectEntity.addComponent(particleSystem);
// 记录活跃特效 | Record active effect
clickFx.addActiveEffect(effectEntity.id);
}
/**
* 清理过期的特效
* Clean up expired effects
*/
private _cleanupExpiredEffects(clickFx: ClickFxComponent): void {
if (!this.scene) return;
const now = Date.now();
const lifetimeMs = clickFx.effectLifetime * 1000;
const effectsToRemove: number[] = [];
for (const effect of clickFx.getActiveEffects()) {
const age = now - effect.startTime;
if (age >= lifetimeMs) {
// 标记为需要移除 | Mark for removal
effectsToRemove.push(effect.entityId);
// 查找并销毁实体 | Find and destroy entity
const entity = this.scene.findEntityById(effect.entityId);
if (entity) {
// 停止粒子系统 | Stop particle system
const particleSystem = entity.getComponent(ParticleSystemComponent);
if (particleSystem) {
particleSystem.stop(true);
}
// 添加到销毁队列 | Add to destroy queue
this._entitiesToDestroy.push(entity);
}
}
}
// 从记录中移除 | Remove from records
for (const entityId of effectsToRemove) {
clickFx.removeActiveEffect(entityId);
}
}
protected override onDestroy(): void {
// 清理所有特效 | Clean up all effects
if (this.scene) {
const entities = this.scene.entities.buffer;
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
const clickFx = entity.getComponent(ClickFxComponent);
if (clickFx) {
for (const effect of clickFx.getActiveEffects()) {
const effectEntity = this.scene.findEntityById(effect.entityId);
if (effectEntity) {
this._entitiesToDestroy.push(effectEntity);
}
}
clickFx.clearActiveEffects();
}
}
// 立即销毁 | Destroy immediately
if (this._entitiesToDestroy.length > 0) {
this.scene.destroyEntities(this._entitiesToDestroy);
this._entitiesToDestroy = [];
}
}
}
}