feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea)
This commit is contained in:
357
packages/ui/src/components/UICanvasScalerComponent.ts
Normal file
357
packages/ui/src/components/UICanvasScalerComponent.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
305
packages/ui/src/components/UISafeAreaComponent.ts
Normal file
305
packages/ui/src/components/UISafeAreaComponent.ts
Normal file
@@ -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<SafeAreaInsets>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ export * from './UIRenderComponent';
|
|||||||
export * from './UIInteractableComponent';
|
export * from './UIInteractableComponent';
|
||||||
export * from './UITextComponent';
|
export * from './UITextComponent';
|
||||||
export * from './UILayoutComponent';
|
export * from './UILayoutComponent';
|
||||||
|
export * from './UICanvasScalerComponent';
|
||||||
|
export * from './UISafeAreaComponent';
|
||||||
|
|
||||||
// Widget components
|
// Widget components
|
||||||
export * from './widgets';
|
export * from './widgets';
|
||||||
|
|||||||
344
packages/ui/src/systems/UICanvasScalerSystem.ts
Normal file
344
packages/ui/src/systems/UICanvasScalerSystem.ts
Normal file
@@ -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<ScreenInfo>): 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<ScreenInfo> {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export * from './UILayoutSystem';
|
|||||||
export * from './UIInputSystem';
|
export * from './UIInputSystem';
|
||||||
export * from './UIAnimationSystem';
|
export * from './UIAnimationSystem';
|
||||||
export * from './UIRenderDataProvider';
|
export * from './UIRenderDataProvider';
|
||||||
|
export * from './UICanvasScalerSystem';
|
||||||
|
|
||||||
// Render systems
|
// Render systems
|
||||||
export * from './render';
|
export * from './render';
|
||||||
|
|||||||
Reference in New Issue
Block a user