* refactor: 编辑器/运行时架构拆分与构建系统升级 * feat(core): 层级系统重构与UI变换矩阵修复 * refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题 * fix(physics): 修复跨包组件类引用问题 * feat: 统一运行时架构与浏览器运行支持 * feat(asset): 实现浏览器运行时资产加载系统 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误 * test: 补齐核心模块测试用例,修复CI构建配置 * fix: 修复测试用例中的类型错误和断言问题 * fix: 修复 turbo build:npm 任务的依赖顺序问题 * fix: 修复 CI 构建错误并优化构建性能
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
/**
|
|
* UI Text Render System
|
|
* UI 文本渲染系统
|
|
*
|
|
* Renders UITextComponent entities by generating text textures
|
|
* and submitting them to the shared UIRenderCollector.
|
|
* 通过生成文本纹理并提交到共享的 UIRenderCollector 来渲染 UITextComponent 实体。
|
|
*/
|
|
|
|
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
|
import { UITransformComponent } from '../../components/UITransformComponent';
|
|
import { UITextComponent } from '../../components/UITextComponent';
|
|
import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector';
|
|
|
|
/**
|
|
* Text texture cache entry
|
|
* 文本纹理缓存条目
|
|
*/
|
|
interface TextTextureCache {
|
|
textureId: number;
|
|
text: string;
|
|
fontSize: number;
|
|
fontFamily: string;
|
|
fontWeight: string | number;
|
|
italic: boolean;
|
|
color: number;
|
|
alpha: number;
|
|
align: string;
|
|
verticalAlign: string;
|
|
lineHeight: number;
|
|
width: number;
|
|
height: number;
|
|
dataUrl: string;
|
|
}
|
|
|
|
/**
|
|
* UI Text Render System
|
|
* UI 文本渲染系统
|
|
*
|
|
* Handles rendering of text components by:
|
|
* 1. Generating text textures using Canvas 2D
|
|
* 2. Caching textures to avoid regeneration every frame
|
|
* 3. Submitting texture render primitives to the collector
|
|
*
|
|
* 处理文本组件的渲染:
|
|
* 1. 使用 Canvas 2D 生成文本纹理
|
|
* 2. 缓存纹理以避免每帧重新生成
|
|
* 3. 向收集器提交纹理渲染原语
|
|
*/
|
|
@ECSSystem('UITextRender', { updateOrder: 120 })
|
|
export class UITextRenderSystem extends EntitySystem {
|
|
private textCanvas: HTMLCanvasElement | null = null;
|
|
private textCtx: CanvasRenderingContext2D | null = null;
|
|
private textTextureCache: Map<number, TextTextureCache> = new Map();
|
|
private nextTextureId = 90000;
|
|
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
|
|
private cacheInvalidationBound: () => void;
|
|
|
|
constructor() {
|
|
super(Matcher.empty().all(UITransformComponent, UITextComponent));
|
|
// Bind the method for cache invalidation callback
|
|
this.cacheInvalidationBound = this.clearTextCache.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Called when system is added to scene
|
|
* 系统添加到场景时调用
|
|
*/
|
|
public override initialize(): void {
|
|
super.initialize();
|
|
// Register for cache invalidation events
|
|
registerCacheInvalidationCallback(this.cacheInvalidationBound);
|
|
}
|
|
|
|
/**
|
|
* Called when system is destroyed
|
|
* 系统销毁时调用
|
|
*/
|
|
protected override onDestroy(): void {
|
|
super.onDestroy();
|
|
// Unregister cache invalidation callback
|
|
unregisterCacheInvalidationCallback(this.cacheInvalidationBound);
|
|
}
|
|
|
|
/**
|
|
* Set callback for when a new text texture is created
|
|
* 设置创建新文本纹理时的回调
|
|
*/
|
|
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
|
|
this.onTextureCreated = callback;
|
|
}
|
|
|
|
protected process(entities: readonly Entity[]): void {
|
|
const collector = getUIRenderCollector();
|
|
|
|
for (const entity of entities) {
|
|
const transform = entity.getComponent(UITransformComponent)!;
|
|
const text = entity.getComponent(UITextComponent)!;
|
|
|
|
if (!transform.visible || !text.text) continue;
|
|
|
|
const x = transform.worldX ?? transform.x;
|
|
const y = transform.worldY ?? transform.y;
|
|
// 使用世界缩放和旋转
|
|
const scaleX = transform.worldScaleX ?? transform.scaleX;
|
|
const scaleY = transform.worldScaleY ?? transform.scaleY;
|
|
const rotation = transform.worldRotation ?? transform.rotation;
|
|
const width = (transform.computedWidth ?? transform.width) * scaleX;
|
|
const height = (transform.computedHeight ?? transform.height) * scaleY;
|
|
const alpha = transform.worldAlpha ?? transform.alpha;
|
|
const baseOrder = 100 + transform.zIndex;
|
|
// 使用 transform 的 pivot 作为旋转/缩放中心
|
|
const pivotX = transform.pivotX;
|
|
const pivotY = transform.pivotY;
|
|
// 渲染位置 = 左下角 + pivot 偏移
|
|
const renderX = x + width * pivotX;
|
|
const renderY = y + height * pivotY;
|
|
|
|
// Generate or retrieve cached texture
|
|
// 生成或获取缓存的纹理
|
|
const textureId = this.getOrCreateTextTexture(
|
|
entity.id, text, Math.ceil(width), Math.ceil(height)
|
|
);
|
|
|
|
if (textureId === null) continue;
|
|
|
|
// Use pivot position with transform's pivot values
|
|
// 使用 transform 的 pivot 值作为旋转中心
|
|
collector.addRect(
|
|
renderX, renderY,
|
|
width, height,
|
|
0xFFFFFF, // White tint (color is baked into texture)
|
|
alpha,
|
|
baseOrder + 1, // Text renders above background
|
|
{
|
|
rotation,
|
|
pivotX,
|
|
pivotY,
|
|
textureId
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create text texture
|
|
* 获取或创建文本纹理
|
|
*/
|
|
private getOrCreateTextTexture(
|
|
entityId: number,
|
|
text: UITextComponent,
|
|
width: number,
|
|
height: number
|
|
): number | null {
|
|
const canvasData = this.getTextCanvas();
|
|
if (!canvasData) return null;
|
|
|
|
const { canvas, ctx } = canvasData;
|
|
|
|
const cached = this.textTextureCache.get(entityId);
|
|
|
|
// Check if we need to regenerate the texture
|
|
// 检查是否需要重新生成纹理
|
|
const needsUpdate = !cached ||
|
|
cached.text !== text.text ||
|
|
cached.fontSize !== text.fontSize ||
|
|
cached.fontFamily !== text.fontFamily ||
|
|
cached.fontWeight !== text.fontWeight ||
|
|
cached.italic !== text.italic ||
|
|
cached.color !== text.color ||
|
|
cached.alpha !== text.alpha ||
|
|
cached.align !== text.align ||
|
|
cached.verticalAlign !== text.verticalAlign ||
|
|
cached.lineHeight !== text.lineHeight ||
|
|
cached.width !== width ||
|
|
cached.height !== height;
|
|
|
|
if (needsUpdate) {
|
|
const canvasWidth = Math.max(1, width);
|
|
const canvasHeight = Math.max(1, height);
|
|
|
|
canvas.width = canvasWidth;
|
|
canvas.height = canvasHeight;
|
|
|
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
ctx.font = text.getCSSFont();
|
|
ctx.fillStyle = text.getCSSColor();
|
|
ctx.textBaseline = 'top';
|
|
|
|
// Handle horizontal alignment
|
|
// 处理水平对齐
|
|
let textX = 0;
|
|
if (text.align === 'center') {
|
|
ctx.textAlign = 'center';
|
|
textX = canvasWidth / 2;
|
|
} else if (text.align === 'right') {
|
|
ctx.textAlign = 'right';
|
|
textX = canvasWidth;
|
|
} else {
|
|
ctx.textAlign = 'left';
|
|
textX = 0;
|
|
}
|
|
|
|
// Handle vertical alignment
|
|
// 处理垂直对齐
|
|
const textHeight = text.fontSize * text.lineHeight;
|
|
let textY = 0;
|
|
|
|
if (text.verticalAlign === 'middle') {
|
|
textY = (canvasHeight - textHeight) / 2;
|
|
} else if (text.verticalAlign === 'bottom') {
|
|
textY = canvasHeight - textHeight;
|
|
}
|
|
|
|
// Draw text (with or without word wrap)
|
|
// 绘制文本(带或不带自动换行)
|
|
if (text.wordWrap) {
|
|
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
|
|
} else {
|
|
ctx.fillText(text.text, textX, textY);
|
|
}
|
|
|
|
// Get or create texture ID
|
|
// 获取或创建纹理 ID
|
|
const textureId = cached?.textureId ?? this.nextTextureId++;
|
|
|
|
const dataUrl = canvas.toDataURL('image/png');
|
|
|
|
// Notify callback of new texture
|
|
// 通知回调新纹理
|
|
if (this.onTextureCreated) {
|
|
this.onTextureCreated(textureId, dataUrl);
|
|
}
|
|
|
|
// Update cache
|
|
// 更新缓存
|
|
this.textTextureCache.set(entityId, {
|
|
textureId,
|
|
text: text.text,
|
|
fontSize: text.fontSize,
|
|
fontFamily: text.fontFamily,
|
|
fontWeight: text.fontWeight,
|
|
italic: text.italic,
|
|
color: text.color,
|
|
alpha: text.alpha,
|
|
align: text.align,
|
|
verticalAlign: text.verticalAlign,
|
|
lineHeight: text.lineHeight,
|
|
width,
|
|
height,
|
|
dataUrl
|
|
});
|
|
}
|
|
|
|
return this.textTextureCache.get(entityId)?.textureId ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get or create text canvas
|
|
* 获取或创建文本画布
|
|
*/
|
|
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
|
|
if (!this.textCanvas) {
|
|
this.textCanvas = document.createElement('canvas');
|
|
this.textCtx = this.textCanvas.getContext('2d');
|
|
}
|
|
if (!this.textCtx) return null;
|
|
return { canvas: this.textCanvas, ctx: this.textCtx };
|
|
}
|
|
|
|
/**
|
|
* Draw text with word wrapping
|
|
* 绘制带自动换行的文本
|
|
*/
|
|
private drawWrappedText(
|
|
ctx: CanvasRenderingContext2D,
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
maxWidth: number,
|
|
lineHeight: number
|
|
): void {
|
|
const words = text.split(' ');
|
|
let line = '';
|
|
let currentY = y;
|
|
|
|
for (const word of words) {
|
|
const testLine = line + word + ' ';
|
|
const metrics = ctx.measureText(testLine);
|
|
|
|
if (metrics.width > maxWidth && line !== '') {
|
|
ctx.fillText(line.trim(), x, currentY);
|
|
line = word + ' ';
|
|
currentY += lineHeight;
|
|
} else {
|
|
line = testLine;
|
|
}
|
|
}
|
|
|
|
if (line.trim()) {
|
|
ctx.fillText(line.trim(), x, currentY);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear text texture cache
|
|
* 清除文本纹理缓存
|
|
*/
|
|
clearTextCache(): void {
|
|
this.textTextureCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Clear cache for a specific entity
|
|
* 清除特定实体的缓存
|
|
*/
|
|
clearEntityTextCache(entityId: number): void {
|
|
this.textTextureCache.delete(entityId);
|
|
}
|
|
|
|
/**
|
|
* Dispose resources
|
|
* 释放资源
|
|
*/
|
|
dispose(): void {
|
|
this.textCanvas = null;
|
|
this.textCtx = null;
|
|
this.textTextureCache.clear();
|
|
this.onTextureCreated = null;
|
|
}
|
|
}
|