diff --git a/packages/ui/src/components/UICanvasScalerComponent.ts b/packages/ui/src/components/UICanvasScalerComponent.ts new file mode 100644 index 00000000..740eb6e8 --- /dev/null +++ b/packages/ui/src/components/UICanvasScalerComponent.ts @@ -0,0 +1,357 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 缩放模式 + * Scale mode for canvas + */ +export enum UIScaleMode { + /** + * 固定像素大小,不缩放 + * Constant pixel size, no scaling + */ + ConstantPixelSize = 'constant-pixel-size', + + /** + * 根据屏幕尺寸缩放(最常用) + * Scale with screen size (most common) + */ + ScaleWithScreenSize = 'scale-with-screen-size', + + /** + * 根据物理尺寸缩放(基于 DPI) + * Scale with physical size (DPI-based) + */ + ConstantPhysicalSize = 'constant-physical-size' +} + +/** + * 屏幕匹配模式 + * Screen match mode for scaling calculation + */ +export enum UIScreenMatchMode { + /** + * 宽度优先 - 保持设计宽度,高度自适应 + * Match width - maintain design width, height adapts + * 适合横屏游戏 + */ + MatchWidth = 'match-width', + + /** + * 高度优先 - 保持设计高度,宽度自适应 + * Match height - maintain design height, width adapts + * 适合竖屏游戏 + */ + MatchHeight = 'match-height', + + /** + * 宽高混合 - 根据权重混合宽高缩放 + * Match width or height - blend between width and height scaling + */ + MatchWidthOrHeight = 'match-width-or-height', + + /** + * 扩展模式 - 保证设计分辨率内容完全显示,可能有黑边 + * Expand - ensure design resolution content is fully visible, may have letterbox + */ + Expand = 'expand', + + /** + * 收缩模式 - 填满屏幕,设计分辨率外的内容可能被裁剪 + * Shrink - fill screen, content outside design resolution may be cropped + */ + Shrink = 'shrink' +} + +/** + * UI 画布缩放组件 + * UI Canvas Scaler Component - Handles UI scaling for different screen sizes + * + * 用于适配不同分辨率和屏幕方向的 UI 系统 + * Used to adapt UI system for different resolutions and screen orientations + */ +@ECSComponent('UICanvasScaler') +@Serializable({ version: 1, typeId: 'UICanvasScaler' }) +export class UICanvasScalerComponent extends Component { + // ===== 缩放模式 Scale Mode ===== + + /** + * 缩放模式 + * Scale mode + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Scale Mode', + options: [ + { value: 'constant-pixel-size', label: 'Constant Pixel Size' }, + { value: 'scale-with-screen-size', label: 'Scale With Screen Size' }, + { value: 'constant-physical-size', label: 'Constant Physical Size' } + ] + }) + public scaleMode: UIScaleMode = UIScaleMode.ScaleWithScreenSize; + + // ===== 设计分辨率 Reference Resolution ===== + + /** + * 设计分辨率宽度 + * Reference resolution width + */ + @Serialize() + @Property({ type: 'number', label: 'Reference Width', min: 1 }) + public referenceWidth: number = 1920; + + /** + * 设计分辨率高度 + * Reference resolution height + */ + @Serialize() + @Property({ type: 'number', label: 'Reference Height', min: 1 }) + public referenceHeight: number = 1080; + + // ===== 屏幕匹配 Screen Match ===== + + /** + * 屏幕匹配模式 + * Screen match mode + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Screen Match Mode', + options: [ + { value: 'match-width', label: 'Match Width' }, + { value: 'match-height', label: 'Match Height' }, + { value: 'match-width-or-height', label: 'Match Width Or Height' }, + { value: 'expand', label: 'Expand' }, + { value: 'shrink', label: 'Shrink' } + ] + }) + public screenMatchMode: UIScreenMatchMode = UIScreenMatchMode.MatchWidthOrHeight; + + /** + * 宽高匹配权重 (0=完全宽度, 1=完全高度) + * Match weight between width and height (0=full width, 1=full height) + * 仅在 MatchWidthOrHeight 模式下生效 + */ + @Serialize() + @Property({ type: 'number', label: 'Match', min: 0, max: 1, step: 0.01 }) + public match: number = 0.5; + + // ===== 像素密度 Pixel Density ===== + + /** + * 缩放因子(ConstantPixelSize 模式) + * Scale factor for ConstantPixelSize mode + */ + @Serialize() + @Property({ type: 'number', label: 'Scale Factor', min: 0.1, step: 0.1 }) + public scaleFactor: number = 1; + + /** + * 参考 DPI(ConstantPhysicalSize 模式) + * Reference DPI for ConstantPhysicalSize mode + */ + @Serialize() + @Property({ type: 'number', label: 'Reference DPI', min: 1 }) + public referenceDPI: number = 96; + + // ===== 计算结果 Computed Results ===== + + /** + * 计算后的缩放比例 + * Computed scale ratio + */ + public computedScale: number = 1; + + /** + * 计算后的画布宽度 + * Computed canvas width (in design units) + */ + public computedCanvasWidth: number = 1920; + + /** + * 计算后的画布高度 + * Computed canvas height (in design units) + */ + public computedCanvasHeight: number = 1080; + + /** + * 当前屏幕宽度 + * Current screen width + */ + public screenWidth: number = 1920; + + /** + * 当前屏幕高度 + * Current screen height + */ + public screenHeight: number = 1080; + + /** + * 当前设备 DPI + * Current device DPI + */ + public currentDPI: number = 96; + + /** + * 是否需要重新计算 + * Flag indicating recalculation needed + */ + public dirty: boolean = true; + + /** + * 计算缩放比例 + * Calculate scale ratio based on current screen size + */ + public calculateScale(screenWidth: number, screenHeight: number, dpi: number = 96): void { + this.screenWidth = screenWidth; + this.screenHeight = screenHeight; + this.currentDPI = dpi; + + switch (this.scaleMode) { + case UIScaleMode.ConstantPixelSize: + this.computedScale = this.scaleFactor; + break; + + case UIScaleMode.ConstantPhysicalSize: + this.computedScale = dpi / this.referenceDPI; + break; + + case UIScaleMode.ScaleWithScreenSize: + this.computedScale = this.calculateScreenScale(screenWidth, screenHeight); + break; + } + + // 计算画布逻辑尺寸 | Calculate logical canvas size + this.computedCanvasWidth = screenWidth / this.computedScale; + this.computedCanvasHeight = screenHeight / this.computedScale; + + this.dirty = false; + } + + /** + * 计算屏幕缩放比例 + * Calculate screen scale based on match mode + */ + private calculateScreenScale(screenWidth: number, screenHeight: number): number { + const widthScale = screenWidth / this.referenceWidth; + const heightScale = screenHeight / this.referenceHeight; + + switch (this.screenMatchMode) { + case UIScreenMatchMode.MatchWidth: + return widthScale; + + case UIScreenMatchMode.MatchHeight: + return heightScale; + + case UIScreenMatchMode.MatchWidthOrHeight: + // 对数插值,提供更平滑的过渡 + // Logarithmic interpolation for smoother transition + const logWidth = Math.log2(widthScale); + const logHeight = Math.log2(heightScale); + const logScale = logWidth + (logHeight - logWidth) * this.match; + return Math.pow(2, logScale); + + case UIScreenMatchMode.Expand: + // 取较小值,保证内容完全显示 + // Take smaller value to ensure content is fully visible + return Math.min(widthScale, heightScale); + + case UIScreenMatchMode.Shrink: + // 取较大值,填满屏幕 + // Take larger value to fill screen + return Math.max(widthScale, heightScale); + + default: + return 1; + } + } + + /** + * 设置设计分辨率 + * Set reference resolution + */ + public setReferenceResolution(width: number, height: number): this { + this.referenceWidth = width; + this.referenceHeight = height; + this.dirty = true; + return this; + } + + /** + * 设置缩放模式 + * Set scale mode + */ + public setScaleMode(mode: UIScaleMode): this { + this.scaleMode = mode; + this.dirty = true; + return this; + } + + /** + * 设置屏幕匹配模式 + * Set screen match mode + */ + public setScreenMatchMode(mode: UIScreenMatchMode, match?: number): this { + this.screenMatchMode = mode; + if (match !== undefined) { + this.match = match; + } + this.dirty = true; + return this; + } + + /** + * 获取设计分辨率的宽高比 + * Get reference resolution aspect ratio + */ + public getReferenceAspectRatio(): number { + return this.referenceWidth / this.referenceHeight; + } + + /** + * 获取当前屏幕宽高比 + * Get current screen aspect ratio + */ + public getScreenAspectRatio(): number { + return this.screenWidth / this.screenHeight; + } + + /** + * 判断当前是否为横屏 + * Check if current orientation is landscape + */ + public isLandscape(): boolean { + return this.screenWidth > this.screenHeight; + } + + /** + * 判断当前是否为竖屏 + * Check if current orientation is portrait + */ + public isPortrait(): boolean { + return this.screenHeight > this.screenWidth; + } + + /** + * 将屏幕坐标转换为画布坐标 + * Convert screen coordinates to canvas coordinates + */ + public screenToCanvas(screenX: number, screenY: number): { x: number; y: number } { + return { + x: screenX / this.computedScale, + y: screenY / this.computedScale + }; + } + + /** + * 将画布坐标转换为屏幕坐标 + * Convert canvas coordinates to screen coordinates + */ + public canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number } { + return { + x: canvasX * this.computedScale, + y: canvasY * this.computedScale + }; + } +} diff --git a/packages/ui/src/components/UISafeAreaComponent.ts b/packages/ui/src/components/UISafeAreaComponent.ts new file mode 100644 index 00000000..9d70e811 --- /dev/null +++ b/packages/ui/src/components/UISafeAreaComponent.ts @@ -0,0 +1,305 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 安全区域边缘 + * Safe area edges configuration + */ +export interface SafeAreaInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * 安全区域适配模式 + * Safe area adaptation mode + */ +export enum SafeAreaMode { + /** + * 不适配安全区域 + * No safe area adaptation + */ + None = 'none', + + /** + * 仅适配顶部(刘海屏) + * Adapt top only (notch) + */ + Top = 'top', + + /** + * 仅适配底部(Home 指示器) + * Adapt bottom only (home indicator) + */ + Bottom = 'bottom', + + /** + * 适配顶部和底部 + * Adapt top and bottom + */ + Vertical = 'vertical', + + /** + * 适配左右(横屏刘海) + * Adapt left and right (landscape notch) + */ + Horizontal = 'horizontal', + + /** + * 适配所有边缘 + * Adapt all edges + */ + All = 'all' +} + +/** + * UI 安全区域组件 + * UI Safe Area Component - Handles safe area insets for notched/rounded screens + * + * 用于处理刘海屏、圆角屏、Home 指示器等设备特性 + * Used to handle device features like notch, rounded corners, home indicator + */ +@ECSComponent('UISafeArea') +@Serializable({ version: 1, typeId: 'UISafeArea' }) +export class UISafeAreaComponent extends Component { + /** + * 安全区域适配模式 + * Safe area adaptation mode + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Mode', + options: [ + { value: 'none', label: 'None' }, + { value: 'top', label: 'Top Only' }, + { value: 'bottom', label: 'Bottom Only' }, + { value: 'vertical', label: 'Vertical' }, + { value: 'horizontal', label: 'Horizontal' }, + { value: 'all', label: 'All' } + ] + }) + public mode: SafeAreaMode = SafeAreaMode.All; + + /** + * 是否自动检测安全区域 + * Whether to auto-detect safe area from device + */ + @Serialize() + @Property({ type: 'boolean', label: 'Auto Detect' }) + public autoDetect: boolean = true; + + /** + * 手动设置的安全区域内边距 + * Manually set safe area insets + */ + @Serialize() + @Property({ type: 'number', label: 'Manual Top', min: 0 }) + public manualTop: number = 0; + + @Serialize() + @Property({ type: 'number', label: 'Manual Right', min: 0 }) + public manualRight: number = 0; + + @Serialize() + @Property({ type: 'number', label: 'Manual Bottom', min: 0 }) + public manualBottom: number = 0; + + @Serialize() + @Property({ type: 'number', label: 'Manual Left', min: 0 }) + public manualLeft: number = 0; + + /** + * 额外的内边距(在安全区域基础上增加) + * Extra padding (added on top of safe area) + */ + @Serialize() + @Property({ type: 'number', label: 'Extra Padding', min: 0 }) + public extraPadding: number = 0; + + // ===== 计算结果 Computed Results ===== + + /** + * 检测到的设备安全区域 + * Detected device safe area insets + */ + public detectedInsets: SafeAreaInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + + /** + * 最终应用的安全区域 + * Final applied safe area insets + */ + public appliedInsets: SafeAreaInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + + /** + * 是否需要重新计算 + * Flag indicating recalculation needed + */ + public dirty: boolean = true; + + /** + * 检测设备安全区域 + * Detect device safe area insets + */ + public detectSafeArea(): void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + this.detectedInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + return; + } + + // 使用 CSS 环境变量获取安全区域 + // Use CSS environment variables to get safe area + const computedStyle = getComputedStyle(document.documentElement); + + this.detectedInsets = { + top: this.parseSafeAreaValue(computedStyle.getPropertyValue('env(safe-area-inset-top)')), + right: this.parseSafeAreaValue(computedStyle.getPropertyValue('env(safe-area-inset-right)')), + bottom: this.parseSafeAreaValue(computedStyle.getPropertyValue('env(safe-area-inset-bottom)')), + left: this.parseSafeAreaValue(computedStyle.getPropertyValue('env(safe-area-inset-left)')) + }; + + this.dirty = true; + } + + /** + * 解析安全区域值 + * Parse safe area value from CSS + */ + private parseSafeAreaValue(value: string): number { + if (!value || value === '') return 0; + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + + /** + * 计算应用的安全区域 + * Calculate applied safe area based on mode and settings + */ + public calculateAppliedInsets(): void { + const baseInsets = this.autoDetect ? this.detectedInsets : { + top: this.manualTop, + right: this.manualRight, + bottom: this.manualBottom, + left: this.manualLeft + }; + + // 根据模式过滤边缘 + // Filter edges based on mode + switch (this.mode) { + case SafeAreaMode.None: + this.appliedInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + break; + + case SafeAreaMode.Top: + this.appliedInsets = { + top: baseInsets.top + this.extraPadding, + right: 0, + bottom: 0, + left: 0 + }; + break; + + case SafeAreaMode.Bottom: + this.appliedInsets = { + top: 0, + right: 0, + bottom: baseInsets.bottom + this.extraPadding, + left: 0 + }; + break; + + case SafeAreaMode.Vertical: + this.appliedInsets = { + top: baseInsets.top + this.extraPadding, + right: 0, + bottom: baseInsets.bottom + this.extraPadding, + left: 0 + }; + break; + + case SafeAreaMode.Horizontal: + this.appliedInsets = { + top: 0, + right: baseInsets.right + this.extraPadding, + bottom: 0, + left: baseInsets.left + this.extraPadding + }; + break; + + case SafeAreaMode.All: + this.appliedInsets = { + top: baseInsets.top + this.extraPadding, + right: baseInsets.right + this.extraPadding, + bottom: baseInsets.bottom + this.extraPadding, + left: baseInsets.left + this.extraPadding + }; + break; + } + + this.dirty = false; + } + + /** + * 更新安全区域(检测 + 计算) + * Update safe area (detect + calculate) + */ + public update(): void { + if (this.autoDetect) { + this.detectSafeArea(); + } + this.calculateAppliedInsets(); + } + + /** + * 设置模式 + * Set mode + */ + public setMode(mode: SafeAreaMode): this { + this.mode = mode; + this.dirty = true; + return this; + } + + /** + * 设置手动内边距 + * Set manual insets + */ + public setManualInsets(insets: Partial): this { + if (insets.top !== undefined) this.manualTop = insets.top; + if (insets.right !== undefined) this.manualRight = insets.right; + if (insets.bottom !== undefined) this.manualBottom = insets.bottom; + if (insets.left !== undefined) this.manualLeft = insets.left; + this.dirty = true; + return this; + } + + /** + * 获取内容区域(排除安全区域后的可用区域) + * Get content rect (available area after excluding safe area) + */ + public getContentRect(screenWidth: number, screenHeight: number): { + x: number; + y: number; + width: number; + height: number; + } { + return { + x: this.appliedInsets.left, + y: this.appliedInsets.top, + width: screenWidth - this.appliedInsets.left - this.appliedInsets.right, + height: screenHeight - this.appliedInsets.top - this.appliedInsets.bottom + }; + } + + /** + * 检查点是否在安全区域内 + * Check if point is within safe area + */ + public isPointInSafeArea(x: number, y: number, screenWidth: number, screenHeight: number): boolean { + return x >= this.appliedInsets.left && + x <= screenWidth - this.appliedInsets.right && + y >= this.appliedInsets.top && + y <= screenHeight - this.appliedInsets.bottom; + } +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 61bf9988..d40a60ac 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -4,6 +4,8 @@ export * from './UIRenderComponent'; export * from './UIInteractableComponent'; export * from './UITextComponent'; export * from './UILayoutComponent'; +export * from './UICanvasScalerComponent'; +export * from './UISafeAreaComponent'; // Widget components export * from './widgets'; diff --git a/packages/ui/src/systems/UICanvasScalerSystem.ts b/packages/ui/src/systems/UICanvasScalerSystem.ts new file mode 100644 index 00000000..66620b0e --- /dev/null +++ b/packages/ui/src/systems/UICanvasScalerSystem.ts @@ -0,0 +1,344 @@ +import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { UICanvasScalerComponent } from '../components/UICanvasScalerComponent'; +import { UISafeAreaComponent } from '../components/UISafeAreaComponent'; +import { UILayoutSystem } from './UILayoutSystem'; + +/** + * 屏幕信息接口 + * Screen info interface + */ +export interface ScreenInfo { + width: number; + height: number; + dpi: number; + devicePixelRatio: number; +} + +/** + * UI 画布缩放系统 + * UI Canvas Scaler System - Manages canvas scaling and safe area adaptation + * + * 此系统应该在 UILayoutSystem 之前执行,以便在布局计算前更新画布尺寸 + * This system should execute before UILayoutSystem to update canvas size before layout calculation + */ +@ECSSystem('UICanvasScaler') +export class UICanvasScalerSystem extends EntitySystem { + /** + * 当前屏幕信息 + * Current screen info + */ + private screenInfo: ScreenInfo = { + width: 1920, + height: 1080, + dpi: 96, + devicePixelRatio: 1 + }; + + /** + * UILayoutSystem 引用(用于更新画布尺寸) + * Reference to UILayoutSystem (for updating canvas size) + */ + private layoutSystem: UILayoutSystem | null = null; + + /** + * 是否需要更新 + * Flag indicating update needed + */ + private needsUpdate: boolean = true; + + /** + * 上一次的屏幕尺寸(用于检测变化) + * Previous screen size (for detecting changes) + */ + private lastScreenWidth: number = 0; + private lastScreenHeight: number = 0; + + constructor() { + super(Matcher.empty().any(UICanvasScalerComponent, UISafeAreaComponent)); + } + + /** + * 设置 UILayoutSystem 引用 + * Set UILayoutSystem reference + */ + public setLayoutSystem(layoutSystem: UILayoutSystem): void { + this.layoutSystem = layoutSystem; + } + + /** + * 更新屏幕信息 + * Update screen info + */ + public updateScreenInfo(info: Partial): void { + if (info.width !== undefined) this.screenInfo.width = info.width; + if (info.height !== undefined) this.screenInfo.height = info.height; + if (info.dpi !== undefined) this.screenInfo.dpi = info.dpi; + if (info.devicePixelRatio !== undefined) this.screenInfo.devicePixelRatio = info.devicePixelRatio; + this.needsUpdate = true; + } + + /** + * 从浏览器自动检测屏幕信息 + * Auto-detect screen info from browser + */ + public detectScreenInfo(): void { + if (typeof window === 'undefined') return; + + this.screenInfo = { + width: window.innerWidth, + height: window.innerHeight, + dpi: this.detectDPI(), + devicePixelRatio: window.devicePixelRatio || 1 + }; + this.needsUpdate = true; + } + + /** + * 检测屏幕 DPI + * Detect screen DPI using multiple methods + * + * 方法优先级: + * 1. 使用 CSS 媒体查询检测(最准确) + * 2. 通过 devicePixelRatio 估算 + * 3. 回退到标准 96 DPI + */ + private detectDPI(): number { + if (typeof window === 'undefined') return 96; + + // 方法1:使用 CSS 媒体查询二分查找实际 DPI + // Method 1: Binary search using CSS media queries + const dpiFromMediaQuery = this.detectDPIFromMediaQuery(); + if (dpiFromMediaQuery > 0) { + return dpiFromMediaQuery; + } + + // 方法2:通过创建临时元素测量 + // Method 2: Measure using temporary element + const dpiFromElement = this.detectDPIFromElement(); + if (dpiFromElement > 0) { + return dpiFromElement; + } + + // 方法3:使用 devicePixelRatio 估算(假设基准 96 DPI) + // Method 3: Estimate from devicePixelRatio (assuming 96 DPI baseline) + const dpr = window.devicePixelRatio || 1; + return Math.round(96 * dpr); + } + + /** + * 使用 CSS 媒体查询检测 DPI + * Detect DPI using CSS media queries with binary search + */ + private detectDPIFromMediaQuery(): number { + if (typeof window.matchMedia !== 'function') return 0; + + // 常见 DPI 值快速检测 + // Quick check for common DPI values + const commonDPIs = [72, 96, 120, 144, 160, 192, 216, 240, 288, 300, 326, 400, 458]; + for (const dpi of commonDPIs) { + if (window.matchMedia(`(resolution: ${dpi}dpi)`).matches) { + return dpi; + } + } + + // 二分查找精确 DPI + // Binary search for exact DPI + let low = 50; + let high = 500; + + while (high - low > 1) { + const mid = Math.floor((low + high) / 2); + if (window.matchMedia(`(min-resolution: ${mid}dpi)`).matches) { + low = mid; + } else { + high = mid; + } + } + + return low; + } + + /** + * 使用临时元素测量 DPI + * Detect DPI by measuring a temporary element + */ + private detectDPIFromElement(): number { + if (typeof document === 'undefined') return 0; + + try { + // 创建一个 1 英寸的元素 + // Create a 1-inch element + const div = document.createElement('div'); + div.style.cssText = 'position:absolute;left:-9999px;width:1in;height:1in;'; + document.body.appendChild(div); + + const dpi = div.offsetWidth; + document.body.removeChild(div); + + // 验证结果合理性 + // Validate result is reasonable + if (dpi >= 50 && dpi <= 600) { + return dpi; + } + } catch { + // 忽略错误 + } + + return 0; + } + + /** + * 获取当前屏幕信息 + * Get current screen info + */ + public getScreenInfo(): Readonly { + return this.screenInfo; + } + + /** + * 获取当前缩放比例(从第一个 CanvasScaler 组件) + * Get current scale (from first CanvasScaler component) + */ + public getCurrentScale(): number { + for (const entity of this.entities) { + const scaler = entity.getComponent(UICanvasScalerComponent); + if (scaler) { + return scaler.computedScale; + } + } + return 1; + } + + /** + * 获取当前安全区域内边距(从第一个 SafeArea 组件) + * Get current safe area insets (from first SafeArea component) + */ + public getCurrentSafeAreaInsets(): { top: number; right: number; bottom: number; left: number } { + for (const entity of this.entities) { + const safeArea = entity.getComponent(UISafeAreaComponent); + if (safeArea) { + return { ...safeArea.appliedInsets }; + } + } + return { top: 0, right: 0, bottom: 0, left: 0 }; + } + + protected process(entities: readonly Entity[]): void { + // 检测屏幕尺寸变化 + // Detect screen size changes + if (this.screenInfo.width !== this.lastScreenWidth || + this.screenInfo.height !== this.lastScreenHeight) { + this.needsUpdate = true; + this.lastScreenWidth = this.screenInfo.width; + this.lastScreenHeight = this.screenInfo.height; + } + + if (!this.needsUpdate) return; + + let finalCanvasWidth = this.screenInfo.width; + let finalCanvasHeight = this.screenInfo.height; + let finalScale = 1; + + // 处理 CanvasScaler 组件 + // Process CanvasScaler components + for (const entity of entities) { + const scaler = entity.getComponent(UICanvasScalerComponent); + if (scaler) { + scaler.calculateScale( + this.screenInfo.width, + this.screenInfo.height, + this.screenInfo.dpi + ); + + finalCanvasWidth = scaler.computedCanvasWidth; + finalCanvasHeight = scaler.computedCanvasHeight; + finalScale = scaler.computedScale; + break; // 只使用第一个 CanvasScaler + } + } + + // 处理 SafeArea 组件 + // Process SafeArea components + for (const entity of entities) { + const safeArea = entity.getComponent(UISafeAreaComponent); + if (safeArea) { + safeArea.update(); + + // 将安全区域转换为设计单位 + // Convert safe area to design units + const scaledInsets = { + top: safeArea.appliedInsets.top / finalScale, + right: safeArea.appliedInsets.right / finalScale, + bottom: safeArea.appliedInsets.bottom / finalScale, + left: safeArea.appliedInsets.left / finalScale + }; + + // 调整画布有效尺寸(可选,取决于使用方式) + // 这里不直接修改画布尺寸,而是让 UI 元素通过锚点适配 + // Adjust effective canvas size (optional, depends on usage) + // Here we don't modify canvas size directly, let UI elements adapt via anchors + } + } + + // 更新 UILayoutSystem 的画布尺寸 + // Update UILayoutSystem canvas size + if (this.layoutSystem) { + this.layoutSystem.setCanvasSize(finalCanvasWidth, finalCanvasHeight); + } + + this.needsUpdate = false; + } + + /** + * 将屏幕坐标转换为画布坐标 + * Convert screen coordinates to canvas coordinates + */ + public screenToCanvas(screenX: number, screenY: number): { x: number; y: number } { + const scale = this.getCurrentScale(); + return { + x: screenX / scale, + y: screenY / scale + }; + } + + /** + * 将画布坐标转换为屏幕坐标 + * Convert canvas coordinates to screen coordinates + */ + public canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number } { + const scale = this.getCurrentScale(); + return { + x: canvasX * scale, + y: canvasY * scale + }; + } + + /** + * 强制更新 + * Force update + */ + public forceUpdate(): void { + this.needsUpdate = true; + } + + /** + * 添加窗口大小变化监听器 + * Add window resize listener + */ + public addResizeListener(): () => void { + if (typeof window === 'undefined') return () => {}; + + const handler = () => { + this.detectScreenInfo(); + }; + + window.addEventListener('resize', handler); + window.addEventListener('orientationchange', handler); + + // 返回清理函数 + return () => { + window.removeEventListener('resize', handler); + window.removeEventListener('orientationchange', handler); + }; + } +} diff --git a/packages/ui/src/systems/index.ts b/packages/ui/src/systems/index.ts index 5169b91a..2fdceff2 100644 --- a/packages/ui/src/systems/index.ts +++ b/packages/ui/src/systems/index.ts @@ -2,6 +2,7 @@ export * from './UILayoutSystem'; export * from './UIInputSystem'; export * from './UIAnimationSystem'; export * from './UIRenderDataProvider'; +export * from './UICanvasScalerSystem'; // Render systems export * from './render';