From 7b14fa2da4e28abe9cb893b322d12f68b213b823 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Wed, 26 Nov 2025 11:08:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=B7=BB=E5=8A=A0=20ECS=20UI?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E5=92=8C=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BC=98=E5=8C=96=20(#238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/src/SpriteComponent.ts | 2 +- .../src/ECS/Decorators/PropertyDecorator.ts | 127 ++++- packages/core/src/ECS/Decorators/index.ts | 2 +- packages/editor-app/package.json | 2 + .../src/app/managers/PluginInstaller.ts | 4 +- .../src/app/managers/ServiceRegistry.ts | 3 + .../editor-app/src/components/AboutDialog.tsx | 61 ++- .../editor-app/src/components/MenuBar.tsx | 1 - .../src/components/PropertyInspector.tsx | 55 +- .../src/components/SceneHierarchy.tsx | 211 ++++++-- .../editor-app/src/components/StartupLogo.tsx | 223 ++++++++ .../editor-app/src/components/StartupPage.tsx | 78 ++- .../editor-app/src/components/Viewport.tsx | 94 ++-- .../inspectors/views/EntityInspector.tsx | 174 +++++-- .../editor-app/src/services/EngineService.ts | 20 + .../editor-app/src/styles/AboutDialog.css | 16 + .../editor-app/src/styles/EntityInspector.css | 85 ++- .../src/styles/PropertyInspector.css | 2 +- .../editor-app/src/styles/SceneHierarchy.css | 66 +++ .../editor-app/src/styles/StartupLogo.css | 22 + .../editor-app/src/styles/StartupPage.css | 96 ++++ packages/editor-app/src/utils/updater.ts | 55 +- .../Services/ComponentInspectorRegistry.ts | 134 +++++ .../editor-core/src/Services/IFieldEditor.ts | 8 +- .../src/Services/PropertyMetadata.ts | 29 +- packages/editor-core/src/Types/UITypes.ts | 5 + packages/editor-core/src/index.ts | 1 + .../components/panels/TilemapEditorPanel.tsx | 2 +- packages/tilemap/src/TilemapComponent.ts | 2 +- packages/ui-editor/package.json | 49 ++ packages/ui-editor/src/UIEditorPlugin.ts | 483 ++++++++++++++++++ .../ui-editor/src/gizmos/UITransformGizmo.ts | 46 ++ packages/ui-editor/src/gizmos/index.ts | 1 + packages/ui-editor/src/index.ts | 6 + .../src/inspectors/UITransformInspector.tsx | 454 ++++++++++++++++ packages/ui-editor/src/inspectors/index.ts | 1 + packages/ui-editor/tsconfig.json | 26 + packages/ui/package.json | 44 ++ packages/ui/src/UIBuilder.ts | 436 ++++++++++++++++ .../src/components/UIInteractableComponent.ts | 230 +++++++++ .../ui/src/components/UILayoutComponent.ts | 373 ++++++++++++++ .../ui/src/components/UIRenderComponent.ts | 303 +++++++++++ packages/ui/src/components/UITextComponent.ts | 344 +++++++++++++ .../ui/src/components/UITransformComponent.ts | 335 ++++++++++++ packages/ui/src/components/index.ts | 9 + .../components/widgets/UIButtonComponent.ts | 311 +++++++++++ .../widgets/UIProgressBarComponent.ts | 337 ++++++++++++ .../widgets/UIScrollViewComponent.ts | 370 ++++++++++++++ .../components/widgets/UISliderComponent.ts | 390 ++++++++++++++ packages/ui/src/components/widgets/index.ts | 4 + packages/ui/src/index.ts | 116 +++++ packages/ui/src/rendering/TextRenderer.ts | 299 +++++++++++ packages/ui/src/rendering/WebGLUIRenderer.ts | 471 +++++++++++++++++ packages/ui/src/rendering/index.ts | 2 + packages/ui/src/systems/UIAnimationSystem.ts | 282 ++++++++++ packages/ui/src/systems/UIInputSystem.ts | 435 ++++++++++++++++ packages/ui/src/systems/UILayoutSystem.ts | 444 ++++++++++++++++ .../ui/src/systems/UIRenderDataProvider.ts | 413 +++++++++++++++ packages/ui/src/systems/index.ts | 4 + packages/ui/tsconfig.json | 24 + packages/ui/vite.config.ts | 33 ++ pnpm-lock.yaml | 325 +++++++++++- 62 files changed, 8745 insertions(+), 235 deletions(-) create mode 100644 packages/editor-app/src/components/StartupLogo.tsx create mode 100644 packages/editor-app/src/styles/StartupLogo.css create mode 100644 packages/editor-core/src/Services/ComponentInspectorRegistry.ts create mode 100644 packages/ui-editor/package.json create mode 100644 packages/ui-editor/src/UIEditorPlugin.ts create mode 100644 packages/ui-editor/src/gizmos/UITransformGizmo.ts create mode 100644 packages/ui-editor/src/gizmos/index.ts create mode 100644 packages/ui-editor/src/index.ts create mode 100644 packages/ui-editor/src/inspectors/UITransformInspector.tsx create mode 100644 packages/ui-editor/src/inspectors/index.ts create mode 100644 packages/ui-editor/tsconfig.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/UIBuilder.ts create mode 100644 packages/ui/src/components/UIInteractableComponent.ts create mode 100644 packages/ui/src/components/UILayoutComponent.ts create mode 100644 packages/ui/src/components/UIRenderComponent.ts create mode 100644 packages/ui/src/components/UITextComponent.ts create mode 100644 packages/ui/src/components/UITransformComponent.ts create mode 100644 packages/ui/src/components/index.ts create mode 100644 packages/ui/src/components/widgets/UIButtonComponent.ts create mode 100644 packages/ui/src/components/widgets/UIProgressBarComponent.ts create mode 100644 packages/ui/src/components/widgets/UIScrollViewComponent.ts create mode 100644 packages/ui/src/components/widgets/UISliderComponent.ts create mode 100644 packages/ui/src/components/widgets/index.ts create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/rendering/TextRenderer.ts create mode 100644 packages/ui/src/rendering/WebGLUIRenderer.ts create mode 100644 packages/ui/src/rendering/index.ts create mode 100644 packages/ui/src/systems/UIAnimationSystem.ts create mode 100644 packages/ui/src/systems/UIInputSystem.ts create mode 100644 packages/ui/src/systems/UILayoutSystem.ts create mode 100644 packages/ui/src/systems/UIRenderDataProvider.ts create mode 100644 packages/ui/src/systems/index.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vite.config.ts diff --git a/packages/components/src/SpriteComponent.ts b/packages/components/src/SpriteComponent.ts index 3c005ad5..e03b4e7e 100644 --- a/packages/components/src/SpriteComponent.ts +++ b/packages/components/src/SpriteComponent.ts @@ -10,7 +10,7 @@ import type { AssetReference } from '@esengine/asset-system'; export class SpriteComponent extends Component { /** 纹理路径或资源ID | Texture path or asset ID */ @Serialize() - @Property({ type: 'asset', label: 'Texture', fileExtension: '.png' }) + @Property({ type: 'asset', label: 'Texture', assetType: 'texture' }) public texture: string = ''; /** diff --git a/packages/core/src/ECS/Decorators/PropertyDecorator.ts b/packages/core/src/ECS/Decorators/PropertyDecorator.ts index 5722d3fc..1150b373 100644 --- a/packages/core/src/ECS/Decorators/PropertyDecorator.ts +++ b/packages/core/src/ECS/Decorators/PropertyDecorator.ts @@ -2,6 +2,18 @@ import 'reflect-metadata'; export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips'; +/** + * 资源类型 + * Asset type for asset properties + */ +export type AssetType = 'texture' | 'audio' | 'scene' | 'prefab' | 'animation' | 'any'; + +/** + * 枚举选项 - 支持简单字符串或带标签的对象 + * Enum option - supports simple string or labeled object + */ +export type EnumOption = string | { label: string; value: any }; + /** * Action button configuration for property fields * 属性字段的操作按钮配置 @@ -28,29 +40,112 @@ export interface PropertyControl { property: string; } -export interface PropertyOptions { - /** 属性类型 */ - type: PropertyType; - /** 显示标签 */ +/** + * 属性基础选项 + * Base property options shared by all types + */ +interface PropertyOptionsBase { + /** 显示标签 | Display label */ label?: string; - /** 最小值 (number/integer) */ - min?: number; - /** 最大值 (number/integer) */ - max?: number; - /** 步进值 (number/integer) */ - step?: number; - /** 枚举选项 (enum) */ - options?: Array<{ label: string; value: any }>; - /** 是否只读 */ + /** 是否只读 | Read-only flag */ readOnly?: boolean; - /** 资源文件扩展名 (asset) */ - fileExtension?: string; - /** Action buttons for this property | 属性的操作按钮 */ + /** Action buttons | 操作按钮 */ actions?: PropertyAction[]; /** 此属性控制的其他组件属性 | Properties this field controls */ controls?: PropertyControl[]; } +/** + * 数值类型属性选项 + * Number property options + */ +interface NumberPropertyOptions extends PropertyOptionsBase { + type: 'number' | 'integer'; + min?: number; + max?: number; + step?: number; +} + +/** + * 字符串类型属性选项 + * String property options + */ +interface StringPropertyOptions extends PropertyOptionsBase { + type: 'string'; + /** 多行文本 | Multiline text */ + multiline?: boolean; +} + +/** + * 布尔类型属性选项 + * Boolean property options + */ +interface BooleanPropertyOptions extends PropertyOptionsBase { + type: 'boolean'; +} + +/** + * 颜色类型属性选项 + * Color property options + */ +interface ColorPropertyOptions extends PropertyOptionsBase { + type: 'color'; + /** 是否包含透明度 | Include alpha channel */ + alpha?: boolean; +} + +/** + * 向量类型属性选项 + * Vector property options + */ +interface VectorPropertyOptions extends PropertyOptionsBase { + type: 'vector2' | 'vector3'; +} + +/** + * 枚举类型属性选项 + * Enum property options + */ +interface EnumPropertyOptions extends PropertyOptionsBase { + type: 'enum'; + /** 枚举选项列表 | Enum options list */ + options: EnumOption[]; +} + +/** + * 资源类型属性选项 + * Asset property options + */ +interface AssetPropertyOptions extends PropertyOptionsBase { + type: 'asset'; + /** 资源类型 | Asset type */ + assetType?: AssetType; + /** 文件扩展名过滤 | File extension filter */ + extensions?: string[]; +} + +/** + * 动画剪辑类型属性选项 + * Animation clips property options + */ +interface AnimationClipsPropertyOptions extends PropertyOptionsBase { + type: 'animationClips'; +} + +/** + * 属性选项联合类型 + * Property options union type + */ +export type PropertyOptions = + | NumberPropertyOptions + | StringPropertyOptions + | BooleanPropertyOptions + | ColorPropertyOptions + | VectorPropertyOptions + | EnumPropertyOptions + | AssetPropertyOptions + | AnimationClipsPropertyOptions; + export const PROPERTY_METADATA = Symbol('property:metadata'); /** diff --git a/packages/core/src/ECS/Decorators/index.ts b/packages/core/src/ECS/Decorators/index.ts index d403fe87..f094a7fb 100644 --- a/packages/core/src/ECS/Decorators/index.ts +++ b/packages/core/src/ECS/Decorators/index.ts @@ -30,4 +30,4 @@ export { PROPERTY_METADATA } from './PropertyDecorator'; -export type { PropertyOptions, PropertyType, PropertyControl } from './PropertyDecorator'; +export type { PropertyOptions, PropertyType, PropertyControl, PropertyAction, AssetType, EnumOption } from './PropertyDecorator'; diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index 9027f467..f9641d71 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -20,6 +20,8 @@ "@esengine/ecs-components": "workspace:*", "@esengine/tilemap": "workspace:*", "@esengine/tilemap-editor": "workspace:*", + "@esengine/ui": "workspace:*", + "@esengine/ui-editor": "workspace:*", "@esengine/ecs-engine-bindgen": "workspace:*", "@esengine/ecs-framework": "workspace:*", "@esengine/editor-core": "workspace:*", diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index 3d306cbb..48ea87b7 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -4,6 +4,7 @@ import { ProfilerPlugin } from '../../plugins/ProfilerPlugin'; import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin'; import { GizmoPlugin } from '../../plugins/GizmoPlugin'; import { TilemapEditorPlugin } from '@esengine/tilemap-editor'; +import { UIEditorPlugin } from '@esengine/ui-editor'; export class PluginInstaller { async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise { @@ -12,7 +13,8 @@ export class PluginInstaller { new SceneInspectorPlugin(), new ProfilerPlugin(), new EditorAppearancePlugin(), - new TilemapEditorPlugin() + new TilemapEditorPlugin(), + new UIEditorPlugin() ]; for (const plugin of plugins) { diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index 759a9828..e6d2bd6a 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -20,6 +20,7 @@ import { PropertyRendererRegistry, FieldEditorRegistry, ComponentActionRegistry, + ComponentInspectorRegistry, IDialogService, IFileSystemService, CompilerRegistry, @@ -138,6 +139,7 @@ export class ServiceRegistry { const fileActionRegistry = new FileActionRegistry(); const entityCreationRegistry = new EntityCreationRegistry(); const componentActionRegistry = new ComponentActionRegistry(); + const componentInspectorRegistry = new ComponentInspectorRegistry(); Core.services.registerInstance(UIRegistry, uiRegistry); Core.services.registerInstance(MessageHub, messageHub); @@ -154,6 +156,7 @@ export class ServiceRegistry { Core.services.registerInstance(FileActionRegistry, fileActionRegistry); Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry); Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry); + Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry); const pluginManager = new EditorPluginManager(); pluginManager.initialize(coreInstance, Core.services); diff --git a/packages/editor-app/src/components/AboutDialog.tsx b/packages/editor-app/src/components/AboutDialog.tsx index a38b8b7e..7f015769 100644 --- a/packages/editor-app/src/components/AboutDialog.tsx +++ b/packages/editor-app/src/components/AboutDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -import { X, RefreshCw, Check, AlertCircle, Download } from 'lucide-react'; -import { checkForUpdates } from '../utils/updater'; +import { X, RefreshCw, Check, AlertCircle, Download, Loader2 } from 'lucide-react'; +import { checkForUpdates, installUpdate } from '../utils/updater'; import { getVersion } from '@tauri-apps/api/app'; import { open } from '@tauri-apps/plugin-shell'; import '../styles/AboutDialog.css'; @@ -12,7 +12,8 @@ interface AboutDialogProps { export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { const [checking, setChecking] = useState(false); - const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error'>('idle'); + const [installing, setInstalling] = useState(false); + const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error' | 'installing'>('idle'); const [version, setVersion] = useState('1.0.0'); const [newVersion, setNewVersion] = useState(''); @@ -40,7 +41,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { updateAvailable: 'New version available', latest: 'You are using the latest version', error: 'Failed to check for updates', - download: 'Download Update', + download: 'Download & Install', + installing: 'Installing...', close: 'Close', copyright: '© 2025 ESEngine. All rights reserved.', website: 'Website', @@ -55,7 +57,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { updateAvailable: '发现新版本', latest: '您正在使用最新版本', error: '检查更新失败', - download: '下载更新', + download: '下载并安装', + installing: '正在安装...', close: '关闭', copyright: '© 2025 ESEngine. 保留所有权利。', website: '官网', @@ -73,8 +76,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { const currentVersion = await getVersion(); setVersion(currentVersion); - // 使用我们的 updater 工具检查更新 - const result = await checkForUpdates(false); + // 使用我们的 updater 工具检查更新(仅检查,不自动安装) + const result = await checkForUpdates(); if (result.error) { setUpdateStatus('error'); @@ -94,12 +97,32 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { } }; + const handleInstallUpdate = async () => { + setInstalling(true); + setUpdateStatus('installing'); + + try { + const success = await installUpdate(); + if (!success) { + setUpdateStatus('error'); + setInstalling(false); + } + // 如果成功,应用会重启,不需要处理 + } catch (error) { + console.error('Install update failed:', error); + setUpdateStatus('error'); + setInstalling(false); + } + }; + const getStatusIcon = () => { switch (updateStatus) { case 'checking': return ; case 'available': return ; + case 'installing': + return ; case 'latest': return ; case 'error': @@ -115,6 +138,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { return t('checking'); case 'available': return `${t('updateAvailable')} (v${newVersion})`; + case 'installing': + return t('installing'); case 'latest': return t('latest'); case 'error': @@ -161,7 +186,7 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) { + )}
diff --git a/packages/editor-app/src/components/MenuBar.tsx b/packages/editor-app/src/components/MenuBar.tsx index 8c4c9fec..ad4c46ee 100644 --- a/packages/editor-app/src/components/MenuBar.tsx +++ b/packages/editor-app/src/components/MenuBar.tsx @@ -239,7 +239,6 @@ export function MenuBar({ help: [ { label: t('documentation'), disabled: true }, { separator: true }, - { label: t('checkForUpdates'), onClick: onOpenAbout }, { label: t('about'), onClick: onOpenAbout } ] }; diff --git a/packages/editor-app/src/components/PropertyInspector.tsx b/packages/editor-app/src/components/PropertyInspector.tsx index cdb7ff62..d6f09cf1 100644 --- a/packages/editor-app/src/components/PropertyInspector.tsx +++ b/packages/editor-app/src/components/PropertyInspector.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { Component, Core } from '@esengine/ecs-framework'; +import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework'; import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, IFileSystemService } from '@esengine/editor-core'; import type { IFileSystem } from '@esengine/editor-core'; import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react'; @@ -31,7 +31,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi const propertyMetadataService = Core.services.resolve(PropertyMetadataService); if (!propertyMetadataService) return; - const componentName = component.constructor.name; + const componentName = getComponentInstanceTypeName(component); const controlled = new Map(); // Check all components on this entity @@ -39,7 +39,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi if (otherComponent === component) continue; const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent); - const otherComponentName = otherComponent.constructor.name; + const otherComponentName = getComponentInstanceTypeName(otherComponent); // Check if any property has controls declaration for (const [, propMeta] of Object.entries(otherMetadata)) { @@ -140,16 +140,26 @@ export function PropertyInspector({ component, entity, version, onChange, onActi /> ); - case 'color': + case 'color': { + // Convert numeric color (0xRRGGBB) to hex string (#RRGGBB) + let colorValue = value ?? '#ffffff'; + if (typeof colorValue === 'number') { + colorValue = '#' + colorValue.toString(16).padStart(6, '0'); + } return ( handleChange(propertyName, newValue)} + onChange={(newValue) => { + // Convert hex string back to number for storage + const numericValue = parseInt(newValue.slice(1), 16); + handleChange(propertyName, numericValue); + }} /> ); + } case 'vector2': return ( @@ -187,12 +197,14 @@ export function PropertyInspector({ component, entity, version, onChange, onActi case 'asset': { const controlledBy = getControlledBy(propertyName); + const assetMeta = metadata as { assetType?: string; extensions?: string[] }; return ( ; + options: EnumOptionInput[]; readOnly?: boolean; onChange: (value: any) => void; } +function normalizeEnumOption(opt: EnumOptionInput): { label: string; value: any } { + if (typeof opt === 'string') { + return { label: opt, value: opt }; + } + return opt; +} + function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const selectedOption = options.find((opt) => opt.value === value); - const displayLabel = selectedOption?.label || (options.length === 0 ? 'No options' : ''); + // Ensure options is always an array and normalize them + const safeOptions = Array.isArray(options) ? options.map(normalizeEnumOption) : []; + const selectedOption = safeOptions.find((opt) => opt.value === value); + const displayLabel = selectedOption?.label || (safeOptions.length === 0 ? 'No options' : ''); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -842,7 +865,7 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps {isOpen && (
- {options.map((option, index) => ( + {safeOptions.map((option, index) => (
{contextMenu && !isShowingRemote && ( -
{ handleCreateEntity(); closeContextMenu(); }} + onCreateSprite={() => { handleCreateSpriteEntity(); closeContextMenu(); }} + onCreateAnimatedSprite={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }} + onCreateCamera={() => { handleCreateCameraEntity(); closeContextMenu(); }} + onCreateFromTemplate={async (template) => { + await template.create(contextMenu.entityId ?? undefined); + closeContextMenu(); }} - > - - - - - {pluginTemplates.length > 0 && ( - <> -
- {pluginTemplates.map((template) => ( - - ))} - - )} - {contextMenu.entityId && ( - <> -
- - - )} -
+ onDelete={() => { handleDeleteEntity(); closeContextMenu(); }} + onClose={closeContextMenu} + /> + )} +
+ ); +} + +interface ContextMenuWithSubmenuProps { + x: number; + y: number; + locale: string; + entityId: number | null; + pluginTemplates: EntityCreationTemplate[]; + onCreateEmpty: () => void; + onCreateSprite: () => void; + onCreateAnimatedSprite: () => void; + onCreateCamera: () => void; + onCreateFromTemplate: (template: EntityCreationTemplate) => void; + onDelete: () => void; + onClose: () => void; +} + +function ContextMenuWithSubmenu({ + x, y, locale, entityId, pluginTemplates, + onCreateEmpty, onCreateSprite, onCreateAnimatedSprite, onCreateCamera, + onCreateFromTemplate, onDelete +}: ContextMenuWithSubmenuProps) { + const [activeSubmenu, setActiveSubmenu] = useState(null); + const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const menuRef = useRef(null); + + const categoryLabels: Record = { + 'basic': { zh: '基础', en: 'Basic' }, + 'rendering': { zh: '渲染', en: 'Rendering' }, + 'ui': { zh: 'UI', en: 'UI' }, + 'physics': { zh: '物理', en: 'Physics' }, + 'audio': { zh: '音频', en: 'Audio' }, + 'other': { zh: '其他', en: 'Other' }, + }; + + const getCategoryLabel = (category: string) => { + const labels = categoryLabels[category]; + return labels ? (locale === 'zh' ? labels.zh : labels.en) : category; + }; + + const templatesByCategory = pluginTemplates.reduce((acc, template) => { + const cat = template.category || 'other'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(template); + return acc; + }, {} as Record); + + const hasPluginCategories = Object.keys(templatesByCategory).length > 0; + + const handleSubmenuEnter = (category: string, e: React.MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setSubmenuPosition({ x: rect.right - 4, y: rect.top }); + setActiveSubmenu(category); + }; + + return ( +
+ + +
+ +
handleSubmenuEnter('rendering', e)} + onMouseLeave={() => setActiveSubmenu(null)} + > + + {activeSubmenu === 'rendering' && ( +
setActiveSubmenu('rendering')} + > + + + +
+ )} +
+ + {hasPluginCategories && Object.entries(templatesByCategory).map(([category, templates]) => ( +
handleSubmenuEnter(category, e)} + onMouseLeave={() => setActiveSubmenu(null)} + > + + {activeSubmenu === category && ( +
setActiveSubmenu(category)} + > + {templates.map((template) => ( + + ))} +
+ )} +
+ ))} + + {entityId && ( + <> +
+ + )}
); diff --git a/packages/editor-app/src/components/StartupLogo.tsx b/packages/editor-app/src/components/StartupLogo.tsx new file mode 100644 index 00000000..1aec4950 --- /dev/null +++ b/packages/editor-app/src/components/StartupLogo.tsx @@ -0,0 +1,223 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import '../styles/StartupLogo.css'; + +interface Particle { + x: number; + y: number; + targetX: number; + targetY: number; + size: number; + alpha: number; + color: string; +} + +interface StartupLogoProps { + onAnimationComplete: () => void; +} + +// 在组件外部创建粒子数据,确保只初始化一次 +let particlesCache: Particle[] | null = null; +let cacheKey: string | null = null; + +function createParticles(width: number, height: number, text: string, fontSize: number): Particle[] { + const key = `${width}-${height}-${fontSize}`; + if (particlesCache && cacheKey === key) { + // 重置粒子位置 + for (const p of particlesCache) { + const angle = Math.random() * Math.PI * 2; + const distance = Math.random() * Math.max(width, height); + p.x = width / 2 + Math.cos(angle) * distance; + p.y = height / 2 + Math.sin(angle) * distance; + } + return particlesCache; + } + + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) return []; + + tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`; + const textMetrics = tempCtx.measureText(text); + const textWidth = textMetrics.width; + const textHeight = fontSize; + + tempCanvas.width = textWidth + 40; + tempCanvas.height = textHeight + 40; + tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`; + tempCtx.textAlign = 'center'; + tempCtx.textBaseline = 'middle'; + tempCtx.fillStyle = '#ffffff'; + tempCtx.fillText(text, tempCanvas.width / 2, tempCanvas.height / 2); + + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const pixels = imageData.data; + const particles: Particle[] = []; + const gap = 4; + + const offsetX = (width - tempCanvas.width) / 2; + const offsetY = (height - tempCanvas.height) / 2; + + const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA']; + + for (let y = 0; y < tempCanvas.height; y += gap) { + for (let x = 0; x < tempCanvas.width; x += gap) { + const index = (y * tempCanvas.width + x) * 4; + const alpha = pixels[index + 3] ?? 0; + + if (alpha > 128) { + const angle = Math.random() * Math.PI * 2; + const distance = Math.random() * Math.max(width, height); + + particles.push({ + x: width / 2 + Math.cos(angle) * distance, + y: height / 2 + Math.sin(angle) * distance, + targetX: offsetX + x, + targetY: offsetY + y, + size: Math.random() * 2 + 1.5, + alpha: Math.random() * 0.5 + 0.5, + color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6' + }); + } + } + } + + particlesCache = particles; + cacheKey = key; + return particles; +} + +export function StartupLogo({ onAnimationComplete }: StartupLogoProps) { + const canvasRef = useRef(null); + const [fadeOut, setFadeOut] = useState(false); + + const onCompleteRef = useRef(onAnimationComplete); + onCompleteRef.current = onAnimationComplete; + + const startAnimation = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return () => {}; + + const ctx = canvas.getContext('2d'); + if (!ctx) return () => {}; + + const dpr = window.devicePixelRatio || 1; + const width = window.innerWidth; + const height = window.innerHeight; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + ctx.scale(dpr, dpr); + + const text = 'ESEngine'; + const fontSize = Math.min(width / 6, 120); + const particles = createParticles(width, height, text, fontSize); + + const startTime = performance.now(); + const duration = 2000; + const glowDuration = 500; // 发光过渡时长 + const holdDuration = 800; + let animationId: number | null = null; + let glowStartTime: number | null = null; + let isCancelled = false; + let timeoutId1: ReturnType | null = null; + let timeoutId2: ReturnType | null = null; + + const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4); + const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + + const animate = (currentTime: number) => { + if (isCancelled) return; + + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easeOutQuart(progress); + + ctx.fillStyle = '#1e1e1e'; + ctx.fillRect(0, 0, width, height); + + // 计算发光进度 + let glowProgress = 0; + if (progress >= 1) { + if (glowStartTime === null) { + glowStartTime = currentTime; + } + glowProgress = Math.min((currentTime - glowStartTime) / glowDuration, 1); + glowProgress = easeOutCubic(glowProgress); + } + + for (const particle of particles) { + // 使用线性插值移动 + const moveProgress = Math.min(easedProgress * 1.2, 1); + const currentX = particle.x + (particle.targetX - particle.x) * moveProgress; + const currentY = particle.y + (particle.targetY - particle.y) * moveProgress; + + ctx.beginPath(); + ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2); + ctx.fillStyle = particle.color; + ctx.globalAlpha = particle.alpha * (1 - glowProgress * 0.3); // 粒子逐渐变淡 + ctx.fill(); + } + + ctx.globalAlpha = 1; + + // 发光文字渐变显示 + if (glowProgress > 0) { + ctx.save(); + ctx.shadowColor = '#4EC9B0'; + ctx.shadowBlur = 20 * glowProgress; + ctx.fillStyle = `rgba(255, 255, 255, ${glowProgress})`; + ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, width / 2, height / 2); + ctx.restore(); + } + + // 发光完成后开始淡出 + if (glowProgress >= 1) { + if (!timeoutId1) { + timeoutId1 = setTimeout(() => { + if (isCancelled) return; + setFadeOut(true); + timeoutId2 = setTimeout(() => { + if (isCancelled) return; + onCompleteRef.current(); + }, 500); + }, holdDuration); + } + } + + if (!isCancelled && (!timeoutId1 || glowProgress < 1)) { + animationId = requestAnimationFrame(animate); + } + }; + + animationId = requestAnimationFrame(animate); + + // 返回 cleanup 函数 + return () => { + isCancelled = true; + if (animationId !== null) { + cancelAnimationFrame(animationId); + } + if (timeoutId1 !== null) { + clearTimeout(timeoutId1); + } + if (timeoutId2 !== null) { + clearTimeout(timeoutId2); + } + }; + }, []); + + useEffect(() => { + const cleanup = startAnimation(); + return cleanup; + }, [startAnimation]); + + return ( +
+ +
+ ); +} diff --git a/packages/editor-app/src/components/StartupPage.tsx b/packages/editor-app/src/components/StartupPage.tsx index 02361163..e4343abe 100644 --- a/packages/editor-app/src/components/StartupPage.tsx +++ b/packages/editor-app/src/components/StartupPage.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useRef } from 'react'; import { getVersion } from '@tauri-apps/api/app'; -import { Globe, ChevronDown } from 'lucide-react'; +import { Globe, ChevronDown, Download, X, Loader2 } from 'lucide-react'; +import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater'; +import { StartupLogo } from './StartupLogo'; import '../styles/StartupPage.css'; type Locale = 'en' | 'zh'; @@ -21,9 +23,13 @@ const LANGUAGES = [ ]; export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, onLocaleChange, recentProjects = [], locale }: StartupPageProps) { + const [showLogo, setShowLogo] = useState(true); const [hoveredProject, setHoveredProject] = useState(null); const [appVersion, setAppVersion] = useState(''); const [showLangMenu, setShowLangMenu] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); + const [showUpdateBanner, setShowUpdateBanner] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); const langMenuRef = useRef(null); useEffect(() => { @@ -40,6 +46,16 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0')); }, []); + // 启动时检查更新 + useEffect(() => { + checkForUpdatesOnStartup().then((result) => { + if (result.available) { + setUpdateInfo(result); + setShowUpdateBanner(true); + } + }); + }, []); + const translations = { en: { title: 'ECS Framework Editor', @@ -49,7 +65,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec profilerMode: 'Profiler Mode', recentProjects: 'Recent Projects', noRecentProjects: 'No recent projects', - comingSoon: 'Coming Soon' + comingSoon: 'Coming Soon', + updateAvailable: 'New version available', + updateNow: 'Update Now', + installing: 'Installing...', + later: 'Later' }, zh: { title: 'ECS 框架编辑器', @@ -59,15 +79,33 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec profilerMode: '性能分析模式', recentProjects: '最近的项目', noRecentProjects: '没有最近的项目', - comingSoon: '即将推出' + comingSoon: '即将推出', + updateAvailable: '发现新版本', + updateNow: '立即更新', + installing: '正在安装...', + later: '稍后' } }; + const handleInstallUpdate = async () => { + setIsInstalling(true); + const success = await installUpdate(); + if (!success) { + setIsInstalling(false); + } + // 如果成功,应用会重启,不需要处理 + }; + const t = translations[locale as keyof typeof translations] || translations.en; const versionText = locale === 'zh' ? `版本 ${appVersion}` : `Version ${appVersion}`; + const handleLogoComplete = () => { + setShowLogo(false); + }; + return (
+ {showLogo && }

{t.title}

{t.subtitle}

@@ -126,6 +164,40 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
+ {/* 更新提示条 */} + {showUpdateBanner && updateInfo?.available && ( +
+
+ + + {t.updateAvailable}: v{updateInfo.version} + + + +
+
+ )} +
{versionText} {onLocaleChange && ( diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 97972ba8..37c0f590 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -6,6 +6,7 @@ import { EngineService } from '../services/EngineService'; import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework'; import { MessageHub } from '@esengine/editor-core'; import { TransformComponent, CameraComponent } from '@esengine/ecs-components'; +import { UITransformComponent } from '@esengine/ui'; import { TauriAPI } from '../api/tauri'; import { open } from '@tauri-apps/plugin-shell'; import { RuntimeResolver } from '../services/RuntimeResolver'; @@ -258,9 +259,6 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { const entity = selectedEntityRef.current; if (!entity) return; - const transform = entity.getComponent(TransformComponent); - if (!transform) return; - const worldStart = screenToWorld(lastMousePosRef.current.x, lastMousePosRef.current.y); const worldEnd = screenToWorld(e.clientX, e.clientY); const worldDelta = { @@ -269,37 +267,71 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { }; const mode = transformModeRef.current; - if (mode === 'move') { - // Update position - transform.position.x += worldDelta.x; - transform.position.y += worldDelta.y; - } else if (mode === 'rotate') { - // Horizontal mouse movement controls rotation (in radians) - const rotationSpeed = 0.01; // radians per pixel - transform.rotation.z += deltaX * rotationSpeed; - } else if (mode === 'scale') { - // Scale based on distance from center - const centerX = transform.position.x; - const centerY = transform.position.y; - const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2); - const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2); - if (startDist > 0) { - const scaleFactor = endDist / startDist; - transform.scale.x *= scaleFactor; - transform.scale.y *= scaleFactor; + + // Try standard TransformComponent first + const transform = entity.getComponent(TransformComponent); + if (transform) { + if (mode === 'move') { + transform.position.x += worldDelta.x; + transform.position.y += worldDelta.y; + } else if (mode === 'rotate') { + const rotationSpeed = 0.01; + transform.rotation.z += deltaX * rotationSpeed; + } else if (mode === 'scale') { + const centerX = transform.position.x; + const centerY = transform.position.y; + const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2); + const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2); + if (startDist > 0) { + const scaleFactor = endDist / startDist; + transform.scale.x *= scaleFactor; + transform.scale.y *= scaleFactor; + } + } + + if (messageHubRef.current) { + const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale'; + messageHubRef.current.publish('component:property:changed', { + entity, + component: transform, + propertyName, + value: transform[propertyName] + }); } } - // Notify system of transform change for real-time update - // 通知系统变换更改,用于实时更新 - if (messageHubRef.current) { - const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale'; - messageHubRef.current.publish('component:property:changed', { - entity, - component: transform, - propertyName, - value: transform[propertyName] - }); + // Try UITransformComponent + const uiTransform = entity.getComponent(UITransformComponent); + if (uiTransform) { + if (mode === 'move') { + uiTransform.x += worldDelta.x; + uiTransform.y += worldDelta.y; + } else if (mode === 'rotate') { + const rotationSpeed = 0.01; + uiTransform.rotation += deltaX * rotationSpeed; + } else if (mode === 'scale') { + const width = uiTransform.width * uiTransform.scaleX; + const height = uiTransform.height * uiTransform.scaleY; + const centerX = uiTransform.x + width * uiTransform.pivotX; + const centerY = uiTransform.y + height * uiTransform.pivotY; + const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2); + const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2); + if (startDist > 0) { + const scaleFactor = endDist / startDist; + uiTransform.scaleX *= scaleFactor; + uiTransform.scaleY *= scaleFactor; + } + } + + if (messageHubRef.current) { + const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX'; + messageHubRef.current.publish('component:property:changed', { + entity, + component: uiTransform, + propertyName, + value: uiTransform[propertyName] + }); + } } } else { return; diff --git a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx index 767f9bf3..232ef016 100644 --- a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx @@ -1,13 +1,21 @@ -import { useState } from 'react'; -import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react'; +import { useState, useRef, useEffect, useMemo } from 'react'; +import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search } from 'lucide-react'; import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework'; -import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry } from '@esengine/editor-core'; +import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core'; import { PropertyInspector } from '../../PropertyInspector'; import { NotificationService } from '../../../services/NotificationService'; import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component'; import '../../../styles/EntityInspector.css'; import * as LucideIcons from 'lucide-react'; +interface ComponentInfo { + name: string; + type?: new () => Component; + category?: string; + description?: string; + icon?: string; +} + interface EntityInspectorProps { entity: Entity; messageHub: MessageHub; @@ -19,10 +27,66 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV const [expandedComponents, setExpandedComponents] = useState>(new Set()); const [showComponentMenu, setShowComponentMenu] = useState(false); const [localVersion, setLocalVersion] = useState(0); + const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null); + const [searchQuery, setSearchQuery] = useState(''); + const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); + const addButtonRef = useRef(null); + const searchInputRef = useRef(null); const componentRegistry = Core.services.resolve(ComponentRegistry); const componentActionRegistry = Core.services.resolve(ComponentActionRegistry); - const availableComponents = componentRegistry?.getAllComponents() || []; + const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry); + const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[]; + + useEffect(() => { + if (showComponentMenu && addButtonRef.current) { + const rect = addButtonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + 4, + right: window.innerWidth - rect.right + }); + setSearchQuery(''); + setTimeout(() => searchInputRef.current?.focus(), 50); + } + }, [showComponentMenu]); + + const categoryLabels: Record = { + 'components.category.core': '核心', + 'components.category.rendering': '渲染', + 'components.category.physics': '物理', + 'components.category.audio': '音频', + 'components.category.ui': 'UI', + 'components.category.ui.core': 'UI 核心', + 'components.category.ui.widgets': 'UI 控件', + 'components.category.other': '其他', + }; + + const filteredAndGroupedComponents = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); + const filtered = query + ? availableComponents.filter(c => + c.name.toLowerCase().includes(query) || + (c.description && c.description.toLowerCase().includes(query)) + ) + : availableComponents; + + const grouped = new Map(); + filtered.forEach((info) => { + const cat = info.category || 'components.category.other'; + if (!grouped.has(cat)) grouped.set(cat, []); + grouped.get(cat)!.push(info); + }); + return grouped; + }, [availableComponents, searchQuery]); + + const toggleCategory = (category: string) => { + setCollapsedCategories(prev => { + const next = new Set(prev); + if (next.has(category)) next.delete(category); + else next.add(category); + return next; + }); + }; const toggleComponentExpanded = (index: number) => { setExpandedComponents((prev) => { @@ -146,49 +210,65 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV 组件
- {showComponentMenu && ( + {showComponentMenu && dropdownPosition && ( <>
setShowComponentMenu(false)} /> -
-
选择组件
- {availableComponents.length === 0 ? ( +
+
+ + setSearchQuery(e.target.value)} + /> +
+ {filteredAndGroupedComponents.size === 0 ? (
- 没有可用组件 + {searchQuery ? '未找到匹配的组件' : '没有可用组件'}
) : (
- {/* 按分类分组显示 */} - {(() => { - const categories = new Map(); - availableComponents.forEach((info) => { - const cat = info.category || 'components.category.other'; - if (!categories.has(cat)) { - categories.set(cat, []); - } - categories.get(cat)!.push(info); - }); - - return Array.from(categories.entries()).map(([category, components]) => ( + {Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => { + const isCollapsed = collapsedCategories.has(category) && !searchQuery; + const label = categoryLabels[category] || category; + return (
-
{category}
- {components.map((info) => ( - - ))} + + {!isCollapsed && components.map((info) => { + const IconComp = info.icon && (LucideIcons as any)[info.icon]; + return ( + + ); + })}
- )); - })()} + ); + })}
)}
@@ -244,15 +324,25 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV {isExpanded && (
- - handlePropertyChange(component, propName, value) - } - onAction={handlePropertyAction} - /> + {componentInspectorRegistry?.hasInspector(component) + ? componentInspectorRegistry.render({ + component, + entity, + version: componentVersion + localVersion, + onChange: (propName: string, value: unknown) => + handlePropertyChange(component, propName, value), + onAction: handlePropertyAction + }) + : + handlePropertyChange(component, propName, value) + } + onAction={handlePropertyAction} + /> + } {/* Dynamic component actions from plugins */} {componentActionRegistry?.getActionsForComponent(componentName).map((action) => ( +
+); + +const AnchorPresetGrid: React.FC<{ + currentPreset: string; + onSelect: (preset: AnchorPreset) => void; +}> = ({ currentPreset, onSelect }) => { + const presets: AnchorPreset[][] = [ + [AnchorPreset.TopLeft, AnchorPreset.TopCenter, AnchorPreset.TopRight], + [AnchorPreset.MiddleLeft, AnchorPreset.MiddleCenter, AnchorPreset.MiddleRight], + [AnchorPreset.BottomLeft, AnchorPreset.BottomCenter, AnchorPreset.BottomRight], + ]; + + const getAnchorPosition = (preset: AnchorPreset): { x: number; y: number } => { + const positions: Record = { + [AnchorPreset.TopLeft]: { x: 3, y: 3 }, + [AnchorPreset.TopCenter]: { x: 10, y: 3 }, + [AnchorPreset.TopRight]: { x: 17, y: 3 }, + [AnchorPreset.MiddleLeft]: { x: 3, y: 10 }, + [AnchorPreset.MiddleCenter]: { x: 10, y: 10 }, + [AnchorPreset.MiddleRight]: { x: 17, y: 10 }, + [AnchorPreset.BottomLeft]: { x: 3, y: 17 }, + [AnchorPreset.BottomCenter]: { x: 10, y: 17 }, + [AnchorPreset.BottomRight]: { x: 17, y: 17 }, + [AnchorPreset.StretchAll]: { x: 10, y: 10 }, + }; + return positions[preset]; + }; + + return ( +
+ +
+
+ {presets.flat().map((preset) => { + const pos = getAnchorPosition(preset); + const isActive = currentPreset === preset; + return ( + + ); + })} +
+ +
+
+ ); +}; + +export class UITransformInspector implements IComponentInspector { + readonly id = 'uitransform-inspector'; + readonly name = 'UITransform Inspector'; + readonly priority = 100; + readonly targetComponents = ['UITransform', 'UITransformComponent']; + + canHandle(component: Component): component is UITransformComponent { + return component instanceof UITransformComponent || + component.constructor.name === 'UITransformComponent'; + } + + render(context: ComponentInspectorContext): React.ReactElement { + const transform = context.component as UITransformComponent; + const onChange = context.onChange; + + const handleChange = (prop: string, value: number | boolean) => { + onChange?.(prop, value); + }; + + const detectCurrentPreset = (): string => { + const { anchorMinX, anchorMinY, anchorMaxX, anchorMaxY } = transform; + if (anchorMinX === 0 && anchorMinY === 0 && anchorMaxX === 1 && anchorMaxY === 1) { + return AnchorPreset.StretchAll; + } + if (anchorMinX === anchorMaxX && anchorMinY === anchorMaxY) { + if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.TopLeft; + if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.TopCenter; + if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.TopRight; + if (anchorMinX === 0 && anchorMinY === 0.5) return AnchorPreset.MiddleLeft; + if (anchorMinX === 0.5 && anchorMinY === 0.5) return AnchorPreset.MiddleCenter; + if (anchorMinX === 1 && anchorMinY === 0.5) return AnchorPreset.MiddleRight; + if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.BottomLeft; + if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.BottomCenter; + if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.BottomRight; + } + return ''; + }; + + const handlePresetSelect = (preset: AnchorPreset) => { + const presetValues: Record = { + [AnchorPreset.TopLeft]: [0, 0, 0, 0], + [AnchorPreset.TopCenter]: [0.5, 0, 0.5, 0], + [AnchorPreset.TopRight]: [1, 0, 1, 0], + [AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5], + [AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5], + [AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5], + [AnchorPreset.BottomLeft]: [0, 1, 0, 1], + [AnchorPreset.BottomCenter]: [0.5, 1, 0.5, 1], + [AnchorPreset.BottomRight]: [1, 1, 1, 1], + [AnchorPreset.StretchAll]: [0, 0, 1, 1], + }; + + const [minX, minY, maxX, maxY] = presetValues[preset]; + handleChange('anchorMinX', minX); + handleChange('anchorMinY', minY); + handleChange('anchorMaxX', maxX); + handleChange('anchorMaxY', maxY); + }; + + return ( +
+ + + handleChange('x', v)} + onChangeY={(v) => handleChange('y', v)} + /> + + handleChange('width', v)} + onChangeY={(v) => handleChange('height', v)} + min={0} + /> + + handleChange('anchorMinX', v)} + onChangeY={(v) => handleChange('anchorMinY', v)} + min={0} + max={1} + step={0.01} + /> + + handleChange('anchorMaxX', v)} + onChangeY={(v) => handleChange('anchorMaxY', v)} + min={0} + max={1} + step={0.01} + /> + + handleChange('pivotX', v)} + onChangeY={(v) => handleChange('pivotY', v)} + min={0} + max={1} + step={0.01} + /> + + handleChange('rotation', v)} + step={0.01} + /> + + handleChange('scaleX', v)} + onChangeY={(v) => handleChange('scaleY', v)} + step={0.01} + /> + + handleChange('zIndex', Math.round(v))} + step={1} + /> + + handleChange('alpha', v)} + min={0} + max={1} + step={0.01} + /> + + handleChange('visible', v)} + /> +
+ ); + } +} diff --git a/packages/ui-editor/src/inspectors/index.ts b/packages/ui-editor/src/inspectors/index.ts new file mode 100644 index 00000000..65b39e5d --- /dev/null +++ b/packages/ui-editor/src/inspectors/index.ts @@ -0,0 +1 @@ +export * from './UITransformInspector'; diff --git a/packages/ui-editor/tsconfig.json b/packages/ui-editor/tsconfig.json new file mode 100644 index 00000000..ca0db5a6 --- /dev/null +++ b/packages/ui-editor/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./bin", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "jsx": "react-jsx", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "bin"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..28c48cb9 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,44 @@ +{ + "name": "@esengine/ui", + "version": "1.0.0", + "description": "ECS-based UI system with WebGL rendering for games", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "build:watch": "vite build --watch", + "type-check": "tsc --noEmit", + "clean": "rimraf dist" + }, + "dependencies": { + "@esengine/ecs-framework": "workspace:*" + }, + "peerDependencies": { + "@esengine/ecs-framework": ">=2.0.0" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^5.0.0", + "vite-plugin-dts": "^3.7.0", + "rimraf": "^5.0.5" + }, + "keywords": [ + "ecs", + "ui", + "webgl", + "game-ui" + ], + "author": "", + "license": "MIT" +} diff --git a/packages/ui/src/UIBuilder.ts b/packages/ui/src/UIBuilder.ts new file mode 100644 index 00000000..ed2b735a --- /dev/null +++ b/packages/ui/src/UIBuilder.ts @@ -0,0 +1,436 @@ +import { Entity, Scene } from '@esengine/ecs-framework'; +import { UITransformComponent, AnchorPreset } from './components/UITransformComponent'; +import { UIRenderComponent, UIRenderType } from './components/UIRenderComponent'; +import { UIInteractableComponent } from './components/UIInteractableComponent'; +import { UITextComponent } from './components/UITextComponent'; +import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from './components/UILayoutComponent'; +import { UIButtonComponent } from './components/widgets/UIButtonComponent'; +import { UIProgressBarComponent } from './components/widgets/UIProgressBarComponent'; +import { UISliderComponent } from './components/widgets/UISliderComponent'; +import { UIScrollViewComponent } from './components/widgets/UIScrollViewComponent'; + +/** + * 基础 UI 配置 + * Base UI configuration + */ +export interface UIBaseConfig { + name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + anchor?: AnchorPreset; + visible?: boolean; + alpha?: number; + zIndex?: number; +} + +/** + * 按钮配置 + * Button configuration + */ +export interface UIButtonConfig extends UIBaseConfig { + label: string; + onClick?: () => void; + onLongPress?: () => void; + normalColor?: number; + hoverColor?: number; + pressedColor?: number; + textColor?: number; + fontSize?: number; + borderRadius?: number; + disabled?: boolean; +} + +/** + * 文本配置 + * Text configuration + */ +export interface UITextConfig extends UIBaseConfig { + text: string; + fontSize?: number; + fontFamily?: string; + color?: number; + align?: 'left' | 'center' | 'right'; + verticalAlign?: 'top' | 'middle' | 'bottom'; + wordWrap?: boolean; +} + +/** + * 图片配置 + * Image configuration + */ +export interface UIImageConfig extends UIBaseConfig { + texture: string | number; + tint?: number; +} + +/** + * 进度条配置 + * Progress bar configuration + */ +export interface UIProgressBarConfig extends UIBaseConfig { + value?: number; + maxValue?: number; + fillColor?: number; + backgroundColor?: number; + borderRadius?: number; + showText?: boolean; + transitionDuration?: number; +} + +/** + * 滑块配置 + * Slider configuration + */ +export interface UISliderConfig extends UIBaseConfig { + value?: number; + minValue?: number; + maxValue?: number; + step?: number; + onChange?: (value: number) => void; + trackColor?: number; + fillColor?: number; + handleColor?: number; +} + +/** + * 面板配置 + * Panel configuration + */ +export interface UIPanelConfig extends UIBaseConfig { + backgroundColor?: number; + backgroundAlpha?: number; + borderWidth?: number; + borderColor?: number; + borderRadius?: number; + padding?: number | { top: number; right: number; bottom: number; left: number }; + layout?: 'none' | 'horizontal' | 'vertical' | 'grid'; + gap?: number; + justifyContent?: UIJustifyContent; + alignItems?: UIAlignItems; +} + +/** + * 滚动视图配置 + * Scroll view configuration + */ +export interface UIScrollViewConfig extends UIBaseConfig { + contentWidth?: number; + contentHeight?: number; + horizontalScroll?: boolean; + verticalScroll?: boolean; + backgroundColor?: number; +} + +/** + * UI 构建器 + * UI Builder - Simplified API for creating UI elements + * + * 提供简化的 API 来创建常用 UI 元素 + * Provides simplified API for creating common UI elements + */ +export class UIBuilder { + private scene: Scene; + private idCounter: number = 0; + + constructor(scene: Scene) { + this.scene = scene; + } + + /** + * 创建基础 UI 实体 + * Create base UI entity with transform + */ + private createBase(config: UIBaseConfig, defaultName: string): Entity { + const entity = this.scene.createEntity(config.name ?? `${defaultName}_${this.idCounter++}`); + + const transform = entity.addComponent(new UITransformComponent()); + transform.x = config.x ?? 0; + transform.y = config.y ?? 0; + transform.width = config.width ?? 100; + transform.height = config.height ?? 30; + transform.visible = config.visible ?? true; + transform.alpha = config.alpha ?? 1; + transform.zIndex = config.zIndex ?? 0; + + if (config.anchor) { + transform.setAnchorPreset(config.anchor); + } + + return entity; + } + + /** + * 创建按钮 + * Create button + */ + public button(config: UIButtonConfig): Entity { + const entity = this.createBase(config, 'Button'); + + // 渲染组件 + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.RoundedRect; + render.backgroundColor = config.normalColor ?? 0x4A90D9; + render.setCornerRadius(config.borderRadius ?? 4); + + // 交互组件 + const interactable = entity.addComponent(new UIInteractableComponent()); + interactable.cursor = 'pointer'; + interactable.onClick = config.onClick; + + // 按钮组件 + const button = entity.addComponent(new UIButtonComponent()); + button.label = config.label; + button.onClick = config.onClick; + button.onLongPress = config.onLongPress; + button.disabled = config.disabled ?? false; + + if (config.normalColor !== undefined) button.normalColor = config.normalColor; + if (config.hoverColor !== undefined) button.hoverColor = config.hoverColor; + if (config.pressedColor !== undefined) button.pressedColor = config.pressedColor; + if (config.textColor !== undefined) button.textColor = config.textColor; + + button.currentColor = button.normalColor; + button.targetColor = button.normalColor; + + // 文本组件 + const text = entity.addComponent(new UITextComponent()); + text.text = config.label; + text.fontSize = config.fontSize ?? 14; + text.color = config.textColor ?? 0xFFFFFF; + text.align = 'center'; + text.verticalAlign = 'middle'; + + return entity; + } + + /** + * 创建文本 + * Create text label + */ + public text(config: UITextConfig): Entity { + const entity = this.createBase(config, 'Text'); + + const text = entity.addComponent(new UITextComponent()); + text.text = config.text; + text.fontSize = config.fontSize ?? 14; + text.fontFamily = config.fontFamily ?? 'Arial, sans-serif'; + text.color = config.color ?? 0x000000; + text.align = config.align ?? 'left'; + text.verticalAlign = config.verticalAlign ?? 'middle'; + text.wordWrap = config.wordWrap ?? false; + + return entity; + } + + /** + * 创建图片 + * Create image + */ + public image(config: UIImageConfig): Entity { + const entity = this.createBase(config, 'Image'); + + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.Image; + render.texture = config.texture; + render.textureTint = config.tint ?? 0xFFFFFF; + + return entity; + } + + /** + * 创建进度条 + * Create progress bar + */ + public progressBar(config: UIProgressBarConfig): Entity { + const entity = this.createBase({ + ...config, + height: config.height ?? 20 + }, 'ProgressBar'); + + // 渲染组件(背景) + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.RoundedRect; + render.backgroundColor = config.backgroundColor ?? 0x333333; + render.setCornerRadius(config.borderRadius ?? 4); + + // 进度条组件 + const progress = entity.addComponent(new UIProgressBarComponent()); + progress.value = config.value ?? 0; + progress.targetValue = config.value ?? 0; + progress.displayValue = config.value ?? 0; + progress.maxValue = config.maxValue ?? 100; + progress.fillColor = config.fillColor ?? 0x4CAF50; + progress.backgroundColor = config.backgroundColor ?? 0x333333; + progress.cornerRadius = config.borderRadius ?? 4; + progress.showText = config.showText ?? false; + progress.transitionDuration = config.transitionDuration ?? 0.3; + + return entity; + } + + /** + * 创建滑块 + * Create slider + */ + public slider(config: UISliderConfig): Entity { + const entity = this.createBase({ + ...config, + height: config.height ?? 20 + }, 'Slider'); + + // 渲染组件(轨道背景) + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.RoundedRect; + render.backgroundColor = config.trackColor ?? 0x444444; + render.setCornerRadius(2); + + // 交互组件 + const interactable = entity.addComponent(new UIInteractableComponent()); + interactable.cursor = 'pointer'; + + // 滑块组件 + const slider = entity.addComponent(new UISliderComponent()); + slider.value = config.value ?? 0; + slider.targetValue = config.value ?? 0; + slider.displayValue = config.value ?? 0; + slider.minValue = config.minValue ?? 0; + slider.maxValue = config.maxValue ?? 100; + slider.step = config.step ?? 0; + slider.onChange = config.onChange; + + if (config.trackColor !== undefined) slider.trackColor = config.trackColor; + if (config.fillColor !== undefined) slider.fillColor = config.fillColor; + if (config.handleColor !== undefined) slider.handleColor = config.handleColor; + + return entity; + } + + /** + * 创建面板/容器 + * Create panel/container + */ + public panel(config: UIPanelConfig): Entity { + const entity = this.createBase(config, 'Panel'); + + // 渲染组件 + const render = entity.addComponent(new UIRenderComponent()); + render.type = config.borderRadius ? UIRenderType.RoundedRect : UIRenderType.Rect; + render.backgroundColor = config.backgroundColor ?? 0xFFFFFF; + render.backgroundAlpha = config.backgroundAlpha ?? 1; + + if (config.borderWidth) { + render.setBorder(config.borderWidth, config.borderColor ?? 0x000000); + } + if (config.borderRadius) { + render.setCornerRadius(config.borderRadius); + } + + // 布局组件 + if (config.layout && config.layout !== 'none') { + const layout = entity.addComponent(new UILayoutComponent()); + + switch (config.layout) { + case 'horizontal': + layout.type = UILayoutType.Horizontal; + break; + case 'vertical': + layout.type = UILayoutType.Vertical; + break; + case 'grid': + layout.type = UILayoutType.Grid; + break; + } + + if (config.gap !== undefined) { + layout.setGap(config.gap); + } + if (config.padding !== undefined) { + layout.setPadding(config.padding); + } + if (config.justifyContent !== undefined) { + layout.justifyContent = config.justifyContent; + } + if (config.alignItems !== undefined) { + layout.alignItems = config.alignItems; + } + } + + return entity; + } + + /** + * 创建滚动视图 + * Create scroll view + */ + public scrollView(config: UIScrollViewConfig): Entity { + const entity = this.createBase(config, 'ScrollView'); + + // 渲染组件 + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.Rect; + render.backgroundColor = config.backgroundColor ?? 0xF0F0F0; + + // 交互组件 + entity.addComponent(new UIInteractableComponent()); + + // 滚动视图组件 + const scrollView = entity.addComponent(new UIScrollViewComponent()); + scrollView.contentWidth = config.contentWidth ?? (config.width ?? 100); + scrollView.contentHeight = config.contentHeight ?? (config.height ?? 100); + scrollView.horizontalScroll = config.horizontalScroll ?? false; + scrollView.verticalScroll = config.verticalScroll ?? true; + + return entity; + } + + /** + * 创建分隔线 + * Create divider/separator + */ + public divider(config: UIBaseConfig & { color?: number; horizontal?: boolean }): Entity { + const isHorizontal = config.horizontal ?? true; + const entity = this.createBase({ + ...config, + width: isHorizontal ? (config.width ?? 100) : 1, + height: isHorizontal ? 1 : (config.height ?? 100) + }, 'Divider'); + + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.Rect; + render.backgroundColor = config.color ?? 0xCCCCCC; + + return entity; + } + + /** + * 创建空白占位 + * Create spacer + */ + public spacer(config: UIBaseConfig): Entity { + const entity = this.createBase(config, 'Spacer'); + // 空白占位不需要渲染组件 + return entity; + } + + /** + * 将子元素添加到父元素 + * Add child to parent + */ + public addChild(parent: Entity, child: Entity): Entity { + parent.addChild(child); + return child; + } + + /** + * 批量添加子元素 + * Add multiple children to parent + */ + public addChildren(parent: Entity, children: Entity[]): Entity[] { + for (const child of children) { + parent.addChild(child); + } + return children; + } +} diff --git a/packages/ui/src/components/UIInteractableComponent.ts b/packages/ui/src/components/UIInteractableComponent.ts new file mode 100644 index 00000000..64a105ae --- /dev/null +++ b/packages/ui/src/components/UIInteractableComponent.ts @@ -0,0 +1,230 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 光标类型 + * Cursor types for interactive elements + */ +export type UICursorType = + | 'default' + | 'pointer' + | 'text' + | 'move' + | 'not-allowed' + | 'grab' + | 'grabbing' + | 'ew-resize' + | 'ns-resize' + | 'nesw-resize' + | 'nwse-resize'; + +/** + * UI 交互组件 + * UI Interactable Component - Handles input interaction state + * + * 管理元素的交互状态(悬停、按下、焦点等) + * Manages element interaction state (hover, pressed, focus, etc.) + */ +@ECSComponent('UIInteractable') +@Serializable({ version: 1, typeId: 'UIInteractable' }) +export class UIInteractableComponent extends Component { + /** + * 是否启用交互 + * Whether interaction is enabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Enabled' }) + public enabled: boolean = true; + + /** + * 是否阻止事件冒泡 + * Whether to block event propagation + */ + @Serialize() + @Property({ type: 'boolean', label: 'Block Events' }) + public blockEvents: boolean = true; + + // ===== 状态 State (由 UIInputSystem 更新) ===== + + /** + * 是否被鼠标悬停 + * Whether mouse is hovering over this element + */ + public hovered: boolean = false; + + /** + * 是否被按下 + * Whether element is being pressed + */ + public pressed: boolean = false; + + /** + * 是否获得焦点 + * Whether element has focus + */ + public focused: boolean = false; + + /** + * 是否被拖拽 + * Whether element is being dragged + */ + public dragging: boolean = false; + + // ===== 配置 Configuration ===== + + /** + * 是否可以获得焦点 + * Whether element can receive focus + */ + @Serialize() + @Property({ type: 'boolean', label: 'Focusable' }) + public focusable: boolean = false; + + /** + * 是否可以被拖拽 + * Whether element can be dragged + */ + @Serialize() + @Property({ type: 'boolean', label: 'Draggable' }) + public draggable: boolean = false; + + /** + * Tab 索引(用于键盘导航) + * Tab index for keyboard navigation + */ + @Serialize() + @Property({ type: 'integer', label: 'Tab Index' }) + public tabIndex: number = 0; + + /** + * 光标类型 + * Cursor type when hovering + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Cursor', + options: [ + { value: 'default', label: 'Default' }, + { value: 'pointer', label: 'Pointer' }, + { value: 'text', label: 'Text' }, + { value: 'move', label: 'Move' }, + { value: 'not-allowed', label: 'Not Allowed' }, + { value: 'grab', label: 'Grab' }, + { value: 'grabbing', label: 'Grabbing' } + ] + }) + public cursor: UICursorType = 'pointer'; + + /** + * 悬停延迟(毫秒,用于 tooltip) + * Hover delay in ms (for tooltips) + */ + @Serialize() + @Property({ type: 'number', label: 'Hover Delay', min: 0 }) + public hoverDelay: number = 0; + + /** + * 悬停计时器 + * Internal hover timer + */ + public hoverTimer: number = 0; + + /** + * 是否悬停足够长时间 + * Whether hovered long enough (past hoverDelay) + */ + public hoverReady: boolean = false; + + // ===== 事件回调 Event Callbacks ===== + + /** + * 点击回调 + * Click callback + */ + public onClick?: () => void; + + /** + * 双击回调 + * Double-click callback + */ + public onDoubleClick?: () => void; + + /** + * 鼠标进入回调 + * Mouse enter callback + */ + public onMouseEnter?: () => void; + + /** + * 鼠标离开回调 + * Mouse leave callback + */ + public onMouseLeave?: () => void; + + /** + * 按下回调 + * Press down callback + */ + public onPressDown?: () => void; + + /** + * 释放回调 + * Press up callback + */ + public onPressUp?: () => void; + + /** + * 获得焦点回调 + * Focus callback + */ + public onFocus?: () => void; + + /** + * 失去焦点回调 + * Blur callback + */ + public onBlur?: () => void; + + /** + * 拖拽开始回调 + * Drag start callback + */ + public onDragStart?: (x: number, y: number) => void; + + /** + * 拖拽中回调 + * Drag move callback + */ + public onDragMove?: (x: number, y: number, deltaX: number, deltaY: number) => void; + + /** + * 拖拽结束回调 + * Drag end callback + */ + public onDragEnd?: (x: number, y: number) => void; + + /** + * 获取当前交互状态名称(用于样式切换) + * Get current interaction state name (for style switching) + */ + public getState(): 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal' { + if (!this.enabled) return 'disabled'; + if (this.pressed) return 'pressed'; + if (this.hovered) return 'hovered'; + if (this.focused) return 'focused'; + return 'normal'; + } + + /** + * 重置所有状态 + * Reset all interaction states + */ + public resetState(): void { + this.hovered = false; + this.pressed = false; + this.focused = false; + this.dragging = false; + this.hoverTimer = 0; + this.hoverReady = false; + } +} diff --git a/packages/ui/src/components/UILayoutComponent.ts b/packages/ui/src/components/UILayoutComponent.ts new file mode 100644 index 00000000..eebf7f64 --- /dev/null +++ b/packages/ui/src/components/UILayoutComponent.ts @@ -0,0 +1,373 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 布局类型 + * Layout types for automatic child positioning + */ +export enum UILayoutType { + /** 无自动布局 No automatic layout */ + None = 'none', + /** 水平排列 Horizontal arrangement */ + Horizontal = 'horizontal', + /** 垂直排列 Vertical arrangement */ + Vertical = 'vertical', + /** 网格布局 Grid layout */ + Grid = 'grid', + /** 流式布局 Flow/Wrap layout */ + Flow = 'flow' +} + +/** + * 主轴对齐方式 + * Main axis alignment + */ +export enum UIJustifyContent { + /** 起始对齐 Align to start */ + Start = 'start', + /** 居中 Center */ + Center = 'center', + /** 末尾对齐 Align to end */ + End = 'end', + /** 两端对齐 Space between */ + SpaceBetween = 'space-between', + /** 均匀分布 Space around */ + SpaceAround = 'space-around', + /** 平均分布 Space evenly */ + SpaceEvenly = 'space-evenly' +} + +/** + * 交叉轴对齐方式 + * Cross axis alignment + */ +export enum UIAlignItems { + /** 起始对齐 Align to start */ + Start = 'start', + /** 居中 Center */ + Center = 'center', + /** 末尾对齐 Align to end */ + End = 'end', + /** 拉伸 Stretch to fill */ + Stretch = 'stretch', + /** 基线对齐 Baseline alignment */ + Baseline = 'baseline' +} + +/** + * 内边距 + * Padding configuration + */ +export interface UIPadding { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * UI 布局组件 + * UI Layout Component - Defines automatic child layout behavior + * + * 类似 CSS Flexbox 的布局系统 + * Flexbox-like layout system + */ +@ECSComponent('UILayout') +@Serializable({ version: 1, typeId: 'UILayout' }) +export class UILayoutComponent extends Component { + /** + * 布局类型 + * Layout type + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Type', + options: [ + { value: 'none', label: 'None' }, + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' }, + { value: 'grid', label: 'Grid' }, + { value: 'flow', label: 'Flow' } + ] + }) + public type: UILayoutType = UILayoutType.None; + + // ===== 间距 Spacing ===== + + /** + * 子元素间距 + * Gap between children + */ + @Serialize() + @Property({ type: 'number', label: 'Gap', min: 0 }) + public gap: number = 0; + + /** + * 水平间距(Grid 布局) + * Horizontal gap (for Grid layout) + */ + @Serialize() + @Property({ type: 'number', label: 'Gap X', min: 0 }) + public gapX: number = 0; + + /** + * 垂直间距(Grid 布局) + * Vertical gap (for Grid layout) + */ + @Serialize() + @Property({ type: 'number', label: 'Gap Y', min: 0 }) + public gapY: number = 0; + + /** + * 内边距 + * Padding + */ + @Serialize() + @Property({ type: 'number', label: 'Padding Top', min: 0 }) + public paddingTop: number = 0; + @Serialize() + @Property({ type: 'number', label: 'Padding Right', min: 0 }) + public paddingRight: number = 0; + @Serialize() + @Property({ type: 'number', label: 'Padding Bottom', min: 0 }) + public paddingBottom: number = 0; + @Serialize() + @Property({ type: 'number', label: 'Padding Left', min: 0 }) + public paddingLeft: number = 0; + + // ===== 对齐 Alignment ===== + + /** + * 主轴对齐 + * Main axis alignment (justify-content) + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Justify Content', + options: [ + { value: 'start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'end', label: 'End' }, + { value: 'space-between', label: 'Space Between' }, + { value: 'space-around', label: 'Space Around' }, + { value: 'space-evenly', label: 'Space Evenly' } + ] + }) + public justifyContent: UIJustifyContent = UIJustifyContent.Start; + + /** + * 交叉轴对齐 + * Cross axis alignment (align-items) + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Align Items', + options: [ + { value: 'start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' } + ] + }) + public alignItems: UIAlignItems = UIAlignItems.Start; + + // ===== 网格配置 Grid Configuration ===== + + /** + * 网格列数 + * Number of columns (Grid layout) + */ + @Serialize() + @Property({ type: 'integer', label: 'Columns', min: 1 }) + public columns: number = 1; + + /** + * 网格行数(0 = 自动) + * Number of rows (Grid layout, 0 = auto) + */ + @Serialize() + @Property({ type: 'integer', label: 'Rows', min: 0 }) + public rows: number = 0; + + /** + * 网格单元格宽度(0 = 自动) + * Grid cell width (0 = auto) + */ + @Serialize() + @Property({ type: 'number', label: 'Cell Width', min: 0 }) + public cellWidth: number = 0; + + /** + * 网格单元格高度(0 = 自动) + * Grid cell height (0 = auto) + */ + @Serialize() + @Property({ type: 'number', label: 'Cell Height', min: 0 }) + public cellHeight: number = 0; + + // ===== 流式布局配置 Flow Configuration ===== + + /** + * 是否换行 + * Whether to wrap items + */ + @Serialize() + @Property({ type: 'boolean', label: 'Wrap' }) + public wrap: boolean = false; + + /** + * 换行时的行间距 + * Gap between wrapped rows + */ + @Serialize() + @Property({ type: 'number', label: 'Wrap Gap', min: 0 }) + public wrapGap: number = 0; + + // ===== 方向 Direction ===== + + /** + * 是否反转方向 + * Whether to reverse direction + */ + @Serialize() + @Property({ type: 'boolean', label: 'Reverse' }) + public reverse: boolean = false; + + // ===== 尺寸控制 Size Control ===== + + /** + * 是否根据内容调整自身尺寸 + * Whether to fit size to content + */ + @Serialize() + @Property({ type: 'boolean', label: 'Fit Content' }) + public fitContent: boolean = false; + + /** + * 内容最小宽度 + * Minimum content width + */ + @Serialize() + @Property({ type: 'number', label: 'Content Min Width', min: 0 }) + public contentMinWidth: number = 0; + + /** + * 内容最小高度 + * Minimum content height + */ + @Serialize() + @Property({ type: 'number', label: 'Content Min Height', min: 0 }) + public contentMinHeight: number = 0; + + /** + * 设置布局类型 + * Set layout type + */ + public setType(type: UILayoutType): this { + this.type = type; + return this; + } + + /** + * 设置间距 + * Set gap + */ + public setGap(gap: number, gapY?: number): this { + this.gap = gap; + this.gapX = gap; + this.gapY = gapY ?? gap; + return this; + } + + /** + * 设置内边距 + * Set padding (uniform or per-side) + */ + public setPadding(padding: number | UIPadding): this { + if (typeof padding === 'number') { + this.paddingTop = padding; + this.paddingRight = padding; + this.paddingBottom = padding; + this.paddingLeft = padding; + } else { + this.paddingTop = padding.top; + this.paddingRight = padding.right; + this.paddingBottom = padding.bottom; + this.paddingLeft = padding.left; + } + return this; + } + + /** + * 设置对齐方式 + * Set alignment + */ + public setAlignment(justify: UIJustifyContent, align: UIAlignItems): this { + this.justifyContent = justify; + this.alignItems = align; + return this; + } + + /** + * 设置网格配置 + * Set grid configuration + */ + public setGrid(columns: number, cellWidth?: number, cellHeight?: number): this { + this.type = UILayoutType.Grid; + this.columns = columns; + if (cellWidth !== undefined) this.cellWidth = cellWidth; + if (cellHeight !== undefined) this.cellHeight = cellHeight; + return this; + } + + /** + * 获取有效的水平间距 + * Get effective horizontal gap + */ + public getHorizontalGap(): number { + return this.gapX || this.gap; + } + + /** + * 获取有效的垂直间距 + * Get effective vertical gap + */ + public getVerticalGap(): number { + return this.gapY || this.gap; + } + + /** + * 获取内容区域起始 X + * Get content area start X + */ + public getContentStartX(): number { + return this.paddingLeft; + } + + /** + * 获取内容区域起始 Y + * Get content area start Y + */ + public getContentStartY(): number { + return this.paddingTop; + } + + /** + * 获取内边距水平总和 + * Get total horizontal padding + */ + public getHorizontalPadding(): number { + return this.paddingLeft + this.paddingRight; + } + + /** + * 获取内边距垂直总和 + * Get total vertical padding + */ + public getVerticalPadding(): number { + return this.paddingTop + this.paddingBottom; + } +} diff --git a/packages/ui/src/components/UIRenderComponent.ts b/packages/ui/src/components/UIRenderComponent.ts new file mode 100644 index 00000000..78f31e6a --- /dev/null +++ b/packages/ui/src/components/UIRenderComponent.ts @@ -0,0 +1,303 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 渲染类型 + * Render types for different visual elements + */ +export enum UIRenderType { + /** 纯色矩形 Solid color rectangle */ + Rect = 'rect', + /** 图片 Image/Texture */ + Image = 'image', + /** 九宫格图片 Nine-patch/Nine-slice image */ + NinePatch = 'ninepatch', + /** 圆形 Circle */ + Circle = 'circle', + /** 圆角矩形 Rounded rectangle */ + RoundedRect = 'rounded-rect' +} + +/** + * 边框样式 + * Border style configuration + */ +export interface UIBorderStyle { + width: number; + color: number; + alpha: number; +} + +/** + * 阴影样式 + * Shadow style configuration + */ +export interface UIShadowStyle { + offsetX: number; + offsetY: number; + blur: number; + color: number; + alpha: number; +} + +/** + * UI 渲染组件 + * UI Render Component - Handles visual appearance of UI elements + * + * 定义元素的视觉属性,如颜色、纹理、边框等 + * Defines visual properties like color, texture, border, etc. + */ +@ECSComponent('UIRender') +@Serializable({ version: 1, typeId: 'UIRender' }) +export class UIRenderComponent extends Component { + /** + * 渲染类型 + * Type of rendering + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Type', + options: [ + { value: 'rect', label: 'Rectangle' }, + { value: 'image', label: 'Image' }, + { value: 'ninepatch', label: 'Nine Patch' }, + { value: 'circle', label: 'Circle' }, + { value: 'rounded-rect', label: 'Rounded Rect' } + ] + }) + public type: UIRenderType = UIRenderType.Rect; + + // ===== 颜色 Colors ===== + + /** + * 背景颜色 (0xRRGGBB) + * Background color in hex format + */ + @Serialize() + @Property({ type: 'color', label: 'Background Color' }) + public backgroundColor: number = 0xFFFFFF; + + /** + * 背景透明度 (0-1) + * Background alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Background Alpha', min: 0, max: 1, step: 0.01 }) + public backgroundAlpha: number = 1; + + /** + * 是否填充背景 + * Whether to fill background + */ + @Serialize() + @Property({ type: 'boolean', label: 'Fill Background' }) + public fillBackground: boolean = true; + + // ===== 纹理 Texture ===== + + /** + * 纹理路径或 ID + * Texture path or runtime ID + */ + @Serialize() + @Property({ type: 'asset', label: 'Texture', assetType: 'texture' }) + public texture: string | number | null = null; + + /** + * 纹理 UV 坐标 (用于图集) + * Texture UV coordinates (for atlas) + */ + public textureUV: { u0: number; v0: number; u1: number; v1: number } | null = null; + + /** + * 纹理色调 (0xRRGGBB) + * Texture tint color + */ + public textureTint: number = 0xFFFFFF; + + // ===== 九宫格 Nine-Patch ===== + + /** + * 九宫格边距 [top, right, bottom, left] + * Nine-patch margins + */ + public ninePatchMargins: [number, number, number, number] = [0, 0, 0, 0]; + + // ===== 边框 Border ===== + + /** + * 边框宽度 + * Border width + */ + @Property({ type: 'number', label: 'Border Width', min: 0 }) + public borderWidth: number = 0; + + /** + * 边框颜色 + * Border color + */ + @Property({ type: 'color', label: 'Border Color' }) + public borderColor: number = 0x000000; + + /** + * 边框透明度 + * Border alpha + */ + @Property({ type: 'number', label: 'Border Alpha', min: 0, max: 1, step: 0.01 }) + public borderAlpha: number = 1; + + /** + * 圆角半径 [topLeft, topRight, bottomRight, bottomLeft] + * Corner radius for each corner + */ + public borderRadius: [number, number, number, number] = [0, 0, 0, 0]; + + // ===== 阴影 Shadow ===== + + /** + * 是否启用阴影 + * Whether shadow is enabled + */ + @Property({ type: 'boolean', label: 'Shadow Enabled' }) + public shadowEnabled: boolean = false; + + /** + * 阴影 X 偏移 + * Shadow X offset + */ + @Property({ type: 'number', label: 'Shadow Offset X' }) + public shadowOffsetX: number = 0; + + /** + * 阴影 Y 偏移 + * Shadow Y offset + */ + @Property({ type: 'number', label: 'Shadow Offset Y' }) + public shadowOffsetY: number = 2; + + /** + * 阴影模糊半径 + * Shadow blur radius + */ + @Property({ type: 'number', label: 'Shadow Blur', min: 0 }) + public shadowBlur: number = 4; + + /** + * 阴影颜色 + * Shadow color + */ + @Property({ type: 'color', label: 'Shadow Color' }) + public shadowColor: number = 0x000000; + + /** + * 阴影透明度 + * Shadow alpha + */ + @Property({ type: 'number', label: 'Shadow Alpha', min: 0, max: 1, step: 0.01 }) + public shadowAlpha: number = 0.3; + + // ===== 渐变 Gradient ===== + + /** + * 渐变类型 + * Gradient type + */ + public gradientType: 'none' | 'linear' | 'radial' = 'none'; + + /** + * 渐变角度(线性渐变) + * Gradient angle for linear gradient + */ + public gradientAngle: number = 0; + + /** + * 渐变颜色停止点 [[position, color, alpha], ...] + * Gradient color stops + */ + public gradientStops: Array<[number, number, number]> = []; + + /** + * 设置纯色背景 + * Set solid color background + */ + public setColor(color: number, alpha: number = 1): this { + this.backgroundColor = color; + this.backgroundAlpha = alpha; + this.fillBackground = true; + return this; + } + + /** + * 设置图片 + * Set image texture + */ + public setImage(texture: string | number): this { + this.type = UIRenderType.Image; + this.texture = texture; + return this; + } + + /** + * 设置九宫格 + * Set nine-patch image + */ + public setNinePatch(texture: string | number, margins: [number, number, number, number]): this { + this.type = UIRenderType.NinePatch; + this.texture = texture; + this.ninePatchMargins = margins; + return this; + } + + /** + * 设置边框 + * Set border style + */ + public setBorder(width: number, color: number, alpha: number = 1): this { + this.borderWidth = width; + this.borderColor = color; + this.borderAlpha = alpha; + return this; + } + + /** + * 设置圆角 + * Set corner radius (uniform or per-corner) + */ + public setCornerRadius(radius: number | [number, number, number, number]): this { + if (typeof radius === 'number') { + this.borderRadius = [radius, radius, radius, radius]; + } else { + this.borderRadius = radius; + } + const hasRadius = typeof radius === 'number' ? radius > 0 : radius.some(r => r > 0); + if (hasRadius) { + this.type = UIRenderType.RoundedRect; + } + return this; + } + + /** + * 设置阴影 + * Set shadow style + */ + public setShadow(offsetX: number, offsetY: number, blur: number, color: number, alpha: number = 0.3): this { + this.shadowEnabled = true; + this.shadowOffsetX = offsetX; + this.shadowOffsetY = offsetY; + this.shadowBlur = blur; + this.shadowColor = color; + this.shadowAlpha = alpha; + return this; + } + + /** + * 设置线性渐变 + * Set linear gradient + */ + public setLinearGradient(angle: number, stops: Array<[number, number, number]>): this { + this.gradientType = 'linear'; + this.gradientAngle = angle; + this.gradientStops = stops; + return this; + } +} diff --git a/packages/ui/src/components/UITextComponent.ts b/packages/ui/src/components/UITextComponent.ts new file mode 100644 index 00000000..793181ec --- /dev/null +++ b/packages/ui/src/components/UITextComponent.ts @@ -0,0 +1,344 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 文本对齐方式 + * Text alignment options + */ +export type UITextAlign = 'left' | 'center' | 'right'; + +/** + * 文本垂直对齐方式 + * Text vertical alignment options + */ +export type UITextVerticalAlign = 'top' | 'middle' | 'bottom'; + +/** + * 文本溢出处理 + * Text overflow handling + */ +export type UITextOverflow = 'visible' | 'hidden' | 'ellipsis' | 'clip'; + +/** + * 字体粗细 + * Font weight options + */ +export type UIFontWeight = 'normal' | 'bold' | 'lighter' | 'bolder' | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + +/** + * UI 文本组件 + * UI Text Component - Handles text rendering + * + * 定义文本内容和样式 + * Defines text content and style + */ +@ECSComponent('UIText') +@Serializable({ version: 1, typeId: 'UIText' }) +export class UITextComponent extends Component { + /** + * 文本内容 + * Text content + */ + @Serialize() + @Property({ type: 'string', label: 'Text' }) + public text: string = ''; + + // ===== 字体 Font ===== + + /** + * 字体大小(像素) + * Font size in pixels + */ + @Serialize() + @Property({ type: 'number', label: 'Font Size', min: 1 }) + public fontSize: number = 14; + + /** + * 字体族 + * Font family + */ + @Serialize() + @Property({ type: 'string', label: 'Font Family' }) + public fontFamily: string = 'Arial, sans-serif'; + + /** + * 字体粗细 + * Font weight + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Font Weight', + options: [ + { value: 'normal', label: 'Normal' }, + { value: 'bold', label: 'Bold' }, + { value: 'lighter', label: 'Lighter' }, + { value: 'bolder', label: 'Bolder' } + ] + }) + public fontWeight: UIFontWeight = 'normal'; + + /** + * 是否斜体 + * Whether italic + */ + @Serialize() + @Property({ type: 'boolean', label: 'Italic' }) + public italic: boolean = false; + + // ===== 颜色 Color ===== + + /** + * 文本颜色 (0xRRGGBB) + * Text color + */ + @Serialize() + @Property({ type: 'color', label: 'Color' }) + public color: number = 0x000000; + + /** + * 文本透明度 + * Text alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 }) + public alpha: number = 1; + + // ===== 对齐 Alignment ===== + + /** + * 水平对齐 + * Horizontal alignment + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Align', + options: [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' } + ] + }) + public align: UITextAlign = 'left'; + + /** + * 垂直对齐 + * Vertical alignment + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Vertical Align', + options: [ + { value: 'top', label: 'Top' }, + { value: 'middle', label: 'Middle' }, + { value: 'bottom', label: 'Bottom' } + ] + }) + public verticalAlign: UITextVerticalAlign = 'middle'; + + // ===== 换行 Wrapping ===== + + /** + * 是否自动换行 + * Whether to wrap text + */ + @Serialize() + @Property({ type: 'boolean', label: 'Word Wrap' }) + public wordWrap: boolean = false; + + /** + * 换行宽度(0 = 使用父元素宽度) + * Wrap width (0 = use parent width) + */ + @Serialize() + @Property({ type: 'number', label: 'Wrap Width', min: 0 }) + public wrapWidth: number = 0; + + /** + * 行高(倍数,1 = 正常) + * Line height multiplier + */ + @Serialize() + @Property({ type: 'number', label: 'Line Height', min: 0.1, step: 0.1 }) + public lineHeight: number = 1.2; + + /** + * 字间距 + * Letter spacing + */ + @Serialize() + @Property({ type: 'number', label: 'Letter Spacing' }) + public letterSpacing: number = 0; + + // ===== 溢出 Overflow ===== + + /** + * 文本溢出处理 + * Text overflow handling + */ + @Property({ + type: 'enum', + label: 'Overflow', + options: [ + { value: 'visible', label: 'Visible' }, + { value: 'hidden', label: 'Hidden' }, + { value: 'ellipsis', label: 'Ellipsis' }, + { value: 'clip', label: 'Clip' } + ] + }) + public overflow: UITextOverflow = 'visible'; + + /** + * 最大显示行数(0 = 无限制) + * Maximum number of lines (0 = unlimited) + */ + @Property({ type: 'integer', label: 'Max Lines', min: 0 }) + public maxLines: number = 0; + + // ===== 装饰 Decoration ===== + + /** + * 下划线 + * Underline + */ + @Property({ type: 'boolean', label: 'Underline' }) + public underline: boolean = false; + + /** + * 删除线 + * Strikethrough + */ + @Property({ type: 'boolean', label: 'Strikethrough' }) + public strikethrough: boolean = false; + + // ===== 描边 Stroke ===== + + /** + * 描边宽度 + * Stroke width + */ + @Property({ type: 'number', label: 'Stroke Width', min: 0 }) + public strokeWidth: number = 0; + + /** + * 描边颜色 + * Stroke color + */ + @Property({ type: 'color', label: 'Stroke Color' }) + public strokeColor: number = 0x000000; + + // ===== 阴影 Shadow ===== + + /** + * 文本阴影启用 + * Text shadow enabled + */ + @Property({ type: 'boolean', label: 'Shadow' }) + public shadowEnabled: boolean = false; + + /** + * 阴影 X 偏移 + * Shadow X offset + */ + public shadowOffsetX: number = 1; + + /** + * 阴影 Y 偏移 + * Shadow Y offset + */ + public shadowOffsetY: number = 1; + + /** + * 阴影颜色 + * Shadow color + */ + public shadowColor: number = 0x000000; + + /** + * 阴影透明度 + * Shadow alpha + */ + public shadowAlpha: number = 0.5; + + // ===== 计算属性 Computed ===== + + /** + * 计算后的文本行(由渲染系统填充) + * Computed text lines (filled by render system) + */ + public computedLines: string[] = []; + + /** + * 计算后的文本宽度 + * Computed text width + */ + public computedWidth: number = 0; + + /** + * 计算后的文本高度 + * Computed text height + */ + public computedHeight: number = 0; + + /** + * 文本是否需要重新计算 + * Whether text needs recomputation + */ + public dirty: boolean = true; + + /** + * 设置文本 + * Set text content + */ + public setText(text: string): this { + if (this.text !== text) { + this.text = text; + this.dirty = true; + } + return this; + } + + /** + * 设置字体 + * Set font properties + */ + public setFont(size: number, family?: string, weight?: UIFontWeight): this { + this.fontSize = size; + if (family !== undefined) this.fontFamily = family; + if (weight !== undefined) this.fontWeight = weight; + this.dirty = true; + return this; + } + + /** + * 设置颜色 + * Set text color + */ + public setColor(color: number, alpha: number = 1): this { + this.color = color; + this.alpha = alpha; + return this; + } + + /** + * 获取 CSS 字体字符串 + * Get CSS font string + */ + public getCSSFont(): string { + const style = this.italic ? 'italic ' : ''; + const weight = typeof this.fontWeight === 'number' ? this.fontWeight : this.fontWeight; + return `${style}${weight} ${this.fontSize}px ${this.fontFamily}`; + } + + /** + * 获取颜色的 CSS 字符串 + * Get color as CSS string + */ + public getCSSColor(): string { + const r = (this.color >> 16) & 0xFF; + const g = (this.color >> 8) & 0xFF; + const b = this.color & 0xFF; + return `rgba(${r}, ${g}, ${b}, ${this.alpha})`; + } +} diff --git a/packages/ui/src/components/UITransformComponent.ts b/packages/ui/src/components/UITransformComponent.ts new file mode 100644 index 00000000..af243ca9 --- /dev/null +++ b/packages/ui/src/components/UITransformComponent.ts @@ -0,0 +1,335 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 锚点预设 + * Anchor presets for common positioning scenarios + */ +export enum AnchorPreset { + TopLeft = 'top-left', + TopCenter = 'top-center', + TopRight = 'top-right', + MiddleLeft = 'middle-left', + MiddleCenter = 'middle-center', + MiddleRight = 'middle-right', + BottomLeft = 'bottom-left', + BottomCenter = 'bottom-center', + BottomRight = 'bottom-right', + StretchAll = 'stretch-all' +} + +/** + * UI 变换组件 + * UI Transform Component - Handles position, size, and hierarchy for UI elements + * + * 基于父元素的相对定位系统,支持锚点、轴心点和拉伸模式 + * Relative positioning system based on parent, supports anchors, pivots, and stretch modes + */ +@ECSComponent('UITransform') +@Serializable({ version: 1, typeId: 'UITransform' }) +export class UITransformComponent extends Component { + // ===== 位置 Position ===== + + /** + * 相对于锚点的 X 偏移 + * X offset relative to anchor point + */ + @Serialize() + @Property({ type: 'number', label: 'X' }) + public x: number = 0; + + /** + * 相对于锚点的 Y 偏移 + * Y offset relative to anchor point + */ + @Serialize() + @Property({ type: 'number', label: 'Y' }) + public y: number = 0; + + // ===== 尺寸 Size ===== + + /** + * 宽度(像素或百分比,取决于 widthMode) + * Width in pixels or percentage depending on widthMode + */ + @Serialize() + @Property({ type: 'number', label: 'Width', min: 0 }) + public width: number = 100; + + /** + * 高度(像素或百分比,取决于 heightMode) + * Height in pixels or percentage depending on heightMode + */ + @Serialize() + @Property({ type: 'number', label: 'Height', min: 0 }) + public height: number = 30; + + /** + * 最小宽度限制 + * Minimum width constraint + */ + @Serialize() + @Property({ type: 'number', label: 'Min Width', min: 0 }) + public minWidth: number = 0; + + /** + * 最大宽度限制(0 = 无限制) + * Maximum width constraint (0 = no limit) + */ + @Serialize() + @Property({ type: 'number', label: 'Max Width', min: 0 }) + public maxWidth: number = 0; + + /** + * 最小高度限制 + * Minimum height constraint + */ + @Serialize() + @Property({ type: 'number', label: 'Min Height', min: 0 }) + public minHeight: number = 0; + + /** + * 最大高度限制(0 = 无限制) + * Maximum height constraint (0 = no limit) + */ + @Serialize() + @Property({ type: 'number', label: 'Max Height', min: 0 }) + public maxHeight: number = 0; + + // ===== 锚点 Anchors ===== + + /** + * 锚点 X 最小值 (0-1),相对于父元素 + * Anchor X minimum (0-1), relative to parent + */ + @Serialize() + @Property({ type: 'number', label: 'Anchor Min X', min: 0, max: 1, step: 0.01 }) + public anchorMinX: number = 0; + + /** + * 锚点 Y 最小值 (0-1),相对于父元素 + * Anchor Y minimum (0-1), relative to parent + */ + @Serialize() + @Property({ type: 'number', label: 'Anchor Min Y', min: 0, max: 1, step: 0.01 }) + public anchorMinY: number = 0; + + /** + * 锚点 X 最大值 (0-1),相对于父元素 + * Anchor X maximum (0-1), relative to parent + */ + @Serialize() + @Property({ type: 'number', label: 'Anchor Max X', min: 0, max: 1, step: 0.01 }) + public anchorMaxX: number = 0; + + /** + * 锚点 Y 最大值 (0-1),相对于父元素 + * Anchor Y maximum (0-1), relative to parent + */ + @Serialize() + @Property({ type: 'number', label: 'Anchor Max Y', min: 0, max: 1, step: 0.01 }) + public anchorMaxY: number = 0; + + // ===== 轴心 Pivot ===== + + /** + * 轴心点 X (0-1),元素自身的旋转/缩放中心 + * Pivot X (0-1), element's own rotation/scale center + */ + @Serialize() + @Property({ type: 'number', label: 'Pivot X', min: 0, max: 1, step: 0.01 }) + public pivotX: number = 0.5; + + /** + * 轴心点 Y (0-1),元素自身的旋转/缩放中心 + * Pivot Y (0-1), element's own rotation/scale center + */ + @Serialize() + @Property({ type: 'number', label: 'Pivot Y', min: 0, max: 1, step: 0.01 }) + public pivotY: number = 0.5; + + // ===== 变换 Transform ===== + + /** + * 旋转角度(弧度) + * Rotation angle in radians + */ + @Serialize() + @Property({ type: 'number', label: 'Rotation', step: 0.01 }) + public rotation: number = 0; + + /** + * X 轴缩放 + * Scale on X axis + */ + @Serialize() + @Property({ type: 'number', label: 'Scale X', step: 0.01 }) + public scaleX: number = 1; + + /** + * Y 轴缩放 + * Scale on Y axis + */ + @Serialize() + @Property({ type: 'number', label: 'Scale Y', step: 0.01 }) + public scaleY: number = 1; + + // ===== 显示 Display ===== + + /** + * 是否可见 + * Visibility flag + */ + @Serialize() + @Property({ type: 'boolean', label: 'Visible' }) + public visible: boolean = true; + + /** + * 渲染层级,值越大越靠前 + * Render order, higher values render on top + */ + @Serialize() + @Property({ type: 'integer', label: 'Z Index' }) + public zIndex: number = 0; + + /** + * 透明度 (0-1) + * Opacity (0-1) + */ + @Serialize() + @Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 }) + public alpha: number = 1; + + // ===== 计算后的世界坐标(由 UILayoutSystem 填充)===== + // Computed world coordinates (filled by UILayoutSystem) + + /** + * 计算后的世界 X 坐标 + * Computed world X position + */ + public worldX: number = 0; + + /** + * 计算后的世界 Y 坐标 + * Computed world Y position + */ + public worldY: number = 0; + + /** + * 计算后的实际宽度 + * Computed actual width + */ + public computedWidth: number = 0; + + /** + * 计算后的实际高度 + * Computed actual height + */ + public computedHeight: number = 0; + + /** + * 计算后的世界透明度(考虑父元素透明度) + * Computed world alpha (considering parent alpha) + */ + public worldAlpha: number = 1; + + /** + * 布局是否需要更新 + * Flag indicating layout needs update + */ + public layoutDirty: boolean = true; + + /** + * 设置锚点预设 + * Set anchor preset for quick positioning + */ + public setAnchorPreset(preset: AnchorPreset): this { + switch (preset) { + case AnchorPreset.TopLeft: + this.anchorMinX = 0; this.anchorMinY = 0; + this.anchorMaxX = 0; this.anchorMaxY = 0; + break; + case AnchorPreset.TopCenter: + this.anchorMinX = 0.5; this.anchorMinY = 0; + this.anchorMaxX = 0.5; this.anchorMaxY = 0; + break; + case AnchorPreset.TopRight: + this.anchorMinX = 1; this.anchorMinY = 0; + this.anchorMaxX = 1; this.anchorMaxY = 0; + break; + case AnchorPreset.MiddleLeft: + this.anchorMinX = 0; this.anchorMinY = 0.5; + this.anchorMaxX = 0; this.anchorMaxY = 0.5; + break; + case AnchorPreset.MiddleCenter: + this.anchorMinX = 0.5; this.anchorMinY = 0.5; + this.anchorMaxX = 0.5; this.anchorMaxY = 0.5; + break; + case AnchorPreset.MiddleRight: + this.anchorMinX = 1; this.anchorMinY = 0.5; + this.anchorMaxX = 1; this.anchorMaxY = 0.5; + break; + case AnchorPreset.BottomLeft: + this.anchorMinX = 0; this.anchorMinY = 1; + this.anchorMaxX = 0; this.anchorMaxY = 1; + break; + case AnchorPreset.BottomCenter: + this.anchorMinX = 0.5; this.anchorMinY = 1; + this.anchorMaxX = 0.5; this.anchorMaxY = 1; + break; + case AnchorPreset.BottomRight: + this.anchorMinX = 1; this.anchorMinY = 1; + this.anchorMaxX = 1; this.anchorMaxY = 1; + break; + case AnchorPreset.StretchAll: + this.anchorMinX = 0; this.anchorMinY = 0; + this.anchorMaxX = 1; this.anchorMaxY = 1; + break; + } + this.layoutDirty = true; + return this; + } + + /** + * 设置位置 + * Set position + */ + public setPosition(x: number, y: number): this { + this.x = x; + this.y = y; + this.layoutDirty = true; + return this; + } + + /** + * 设置尺寸 + * Set size + */ + public setSize(width: number, height: number): this { + this.width = width; + this.height = height; + this.layoutDirty = true; + return this; + } + + /** + * 设置轴心点 + * Set pivot point + */ + public setPivot(x: number, y: number): this { + this.pivotX = x; + this.pivotY = y; + this.layoutDirty = true; + return this; + } + + /** + * 检测点是否在元素内 + * Test if a point is inside this element + */ + public containsPoint(worldX: number, worldY: number): boolean { + return worldX >= this.worldX && + worldX <= this.worldX + this.computedWidth && + worldY >= this.worldY && + worldY <= this.worldY + this.computedHeight; + } +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts new file mode 100644 index 00000000..61bf9988 --- /dev/null +++ b/packages/ui/src/components/index.ts @@ -0,0 +1,9 @@ +// Core components +export * from './UITransformComponent'; +export * from './UIRenderComponent'; +export * from './UIInteractableComponent'; +export * from './UITextComponent'; +export * from './UILayoutComponent'; + +// Widget components +export * from './widgets'; diff --git a/packages/ui/src/components/widgets/UIButtonComponent.ts b/packages/ui/src/components/widgets/UIButtonComponent.ts new file mode 100644 index 00000000..66bb8e0f --- /dev/null +++ b/packages/ui/src/components/widgets/UIButtonComponent.ts @@ -0,0 +1,311 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 按钮状态样式 + * Button state style configuration + */ +export interface UIButtonStyle { + backgroundColor: number; + backgroundAlpha: number; + textColor: number; + borderColor: number; + borderWidth: number; + texture?: string; +} + +/** + * 按钮显示模式 + * Button display mode + */ +export type UIButtonDisplayMode = 'color' | 'texture' | 'both'; + +/** + * UI 按钮组件 + * UI Button Component - Button-specific state and callbacks + */ +@ECSComponent('UIButton') +@Serializable({ version: 1, typeId: 'UIButton' }) +export class UIButtonComponent extends Component { + /** + * 按钮文本 + * Button label text + */ + @Serialize() + @Property({ type: 'string', label: 'Label' }) + public label: string = 'Button'; + + // ===== 显示模式 Display Mode ===== + + /** + * 显示模式:纯颜色、纯纹理、或两者结合 + * Display mode: color only, texture only, or both + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Display Mode', + options: ['color', 'texture', 'both'] + }) + public displayMode: UIButtonDisplayMode = 'color'; + + // ===== 状态纹理 State Textures ===== + + /** + * 正常状态纹理 + * Normal state texture + */ + @Serialize() + @Property({ type: 'asset', label: 'Normal Texture', assetType: 'texture' }) + public normalTexture: string = ''; + + /** + * 悬停状态纹理 + * Hover state texture + */ + @Serialize() + @Property({ type: 'asset', label: 'Hover Texture', assetType: 'texture' }) + public hoverTexture: string = ''; + + /** + * 按下状态纹理 + * Pressed state texture + */ + @Serialize() + @Property({ type: 'asset', label: 'Pressed Texture', assetType: 'texture' }) + public pressedTexture: string = ''; + + /** + * 禁用状态纹理 + * Disabled state texture + */ + @Serialize() + @Property({ type: 'asset', label: 'Disabled Texture', assetType: 'texture' }) + public disabledTexture: string = ''; + + // ===== 状态样式 State Styles ===== + + /** + * 正常状态颜色 + * Normal state background color + */ + @Serialize() + @Property({ type: 'color', label: 'Normal Color' }) + public normalColor: number = 0x4A90D9; + + /** + * 悬停状态颜色 + * Hover state background color + */ + @Serialize() + @Property({ type: 'color', label: 'Hover Color' }) + public hoverColor: number = 0x5BA0E9; + + /** + * 按下状态颜色 + * Pressed state background color + */ + @Serialize() + @Property({ type: 'color', label: 'Pressed Color' }) + public pressedColor: number = 0x3A80C9; + + /** + * 禁用状态颜色 + * Disabled state background color + */ + @Serialize() + @Property({ type: 'color', label: 'Disabled Color' }) + public disabledColor: number = 0x888888; + + /** + * 聚焦状态颜色 + * Focused state background color + */ + @Serialize() + @Property({ type: 'color', label: 'Focused Color' }) + public focusedColor: number = 0x4A90D9; + + /** + * 文本颜色 + * Text color + */ + @Serialize() + @Property({ type: 'color', label: 'Text Color' }) + public textColor: number = 0xFFFFFF; + + /** + * 禁用时文本颜色 + * Disabled text color + */ + @Serialize() + @Property({ type: 'color', label: 'Disabled Text Color' }) + public disabledTextColor: number = 0xCCCCCC; + + // ===== 动画 Animation ===== + + /** + * 颜色过渡时长(秒) + * Color transition duration in seconds + */ + @Serialize() + @Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 }) + public transitionDuration: number = 0.1; + + /** + * 当前显示颜色(动画插值用) + * Current display color (for animation) + */ + public currentColor: number = 0x4A90D9; + + /** + * 目标颜色 + * Target color + */ + public targetColor: number = 0x4A90D9; + + // ===== 回调 Callbacks ===== + + /** + * 点击回调 + * Click callback + */ + public onClick?: () => void; + + /** + * 长按回调 + * Long press callback + */ + public onLongPress?: () => void; + + /** + * 长按阈值(毫秒) + * Long press threshold in milliseconds + */ + @Serialize() + @Property({ type: 'number', label: 'Long Press Threshold', min: 0 }) + public longPressThreshold: number = 500; + + /** + * 长按计时器 + * Long press timer + */ + public pressTimer: number = 0; + + /** + * 是否已触发长按 + * Whether long press has been triggered + */ + public longPressTriggered: boolean = false; + + // ===== 配置 Configuration ===== + + /** + * 是否禁用 + * Whether button is disabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Disabled' }) + public disabled: boolean = false; + + /** + * 是否显示涟漪效果 + * Whether to show ripple effect + */ + @Serialize() + @Property({ type: 'boolean', label: 'Show Ripple' }) + public showRipple: boolean = false; + + /** + * 涟漪颜色 + * Ripple color + */ + @Serialize() + @Property({ type: 'color', label: 'Ripple Color' }) + public rippleColor: number = 0xFFFFFF; + + /** + * 涟漪透明度 + * Ripple alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Ripple Alpha', min: 0, max: 1, step: 0.01 }) + public rippleAlpha: number = 0.3; + + /** + * 获取当前应该显示的背景颜色 + * Get the background color that should be displayed based on state + */ + public getStateColor(state: 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal'): number { + if (this.disabled) return this.disabledColor; + switch (state) { + case 'pressed': return this.pressedColor; + case 'hovered': return this.hoverColor; + case 'focused': return this.focusedColor; + default: return this.normalColor; + } + } + + /** + * 获取当前应该显示的纹理 + * Get the texture that should be displayed based on state + */ + public getStateTexture(state: 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal'): string { + if (this.disabled && this.disabledTexture) return this.disabledTexture; + switch (state) { + case 'pressed': return this.pressedTexture || this.normalTexture; + case 'hovered': return this.hoverTexture || this.normalTexture; + case 'focused': return this.normalTexture; + default: return this.normalTexture; + } + } + + /** + * 是否使用纹理渲染 + * Whether to use texture for rendering + */ + public useTexture(): boolean { + return (this.displayMode === 'texture' || this.displayMode === 'both') && !!this.normalTexture; + } + + /** + * 是否使用颜色渲染 + * Whether to use color for rendering + */ + public useColor(): boolean { + return this.displayMode === 'color' || this.displayMode === 'both'; + } + + /** + * 获取当前应该显示的文本颜色 + * Get the text color that should be displayed based on state + */ + public getTextColor(): number { + return this.disabled ? this.disabledTextColor : this.textColor; + } + + /** + * 设置颜色主题 + * Set color theme + */ + public setColors(normal: number, hover: number, pressed: number, disabled?: number): this { + this.normalColor = normal; + this.hoverColor = hover; + this.pressedColor = pressed; + if (disabled !== undefined) this.disabledColor = disabled; + this.currentColor = normal; + this.targetColor = normal; + return this; + } + + /** + * 设置纹理 + * Set textures for different states + */ + public setTextures(normal: string, hover?: string, pressed?: string, disabled?: string): this { + this.normalTexture = normal; + if (hover) this.hoverTexture = hover; + if (pressed) this.pressedTexture = pressed; + if (disabled) this.disabledTexture = disabled; + this.displayMode = 'texture'; + return this; + } +} diff --git a/packages/ui/src/components/widgets/UIProgressBarComponent.ts b/packages/ui/src/components/widgets/UIProgressBarComponent.ts new file mode 100644 index 00000000..96362dfa --- /dev/null +++ b/packages/ui/src/components/widgets/UIProgressBarComponent.ts @@ -0,0 +1,337 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 进度条方向 + * Progress bar direction + */ +export enum UIProgressDirection { + /** 从左到右 Left to right */ + LeftToRight = 'left-to-right', + /** 从右到左 Right to left */ + RightToLeft = 'right-to-left', + /** 从下到上 Bottom to top */ + BottomToTop = 'bottom-to-top', + /** 从上到下 Top to bottom */ + TopToBottom = 'top-to-bottom' +} + +/** + * 进度条填充模式 + * Progress bar fill mode + */ +export enum UIProgressFillMode { + /** 水平填充 Horizontal fill */ + Horizontal = 'horizontal', + /** 垂直填充 Vertical fill */ + Vertical = 'vertical', + /** 圆形填充 Radial fill */ + Radial = 'radial' +} + +/** + * UI 进度条组件 + * UI ProgressBar Component - Progress indicator + */ +@ECSComponent('UIProgressBar') +@Serializable({ version: 1, typeId: 'UIProgressBar' }) +export class UIProgressBarComponent extends Component { + // ===== 数值 Values ===== + + /** + * 当前值 + * Current value + */ + @Serialize() + @Property({ type: 'number', label: 'Value' }) + public value: number = 0; + + /** + * 最小值 + * Minimum value + */ + @Serialize() + @Property({ type: 'number', label: 'Min Value' }) + public minValue: number = 0; + + /** + * 最大值 + * Maximum value + */ + @Serialize() + @Property({ type: 'number', label: 'Max Value' }) + public maxValue: number = 100; + + /** + * 目标值(用于动画) + * Target value (for animation) + */ + public targetValue: number = 0; + + /** + * 显示值(动画插值后的值) + * Display value (interpolated for animation) + */ + public displayValue: number = 0; + + // ===== 样式 Style ===== + + /** + * 填充颜色 + * Fill color + */ + @Serialize() + @Property({ type: 'color', label: 'Fill Color' }) + public fillColor: number = 0x4CAF50; + + /** + * 填充透明度 + * Fill alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Fill Alpha', min: 0, max: 1, step: 0.01 }) + public fillAlpha: number = 1; + + /** + * 背景颜色 + * Background color + */ + @Serialize() + @Property({ type: 'color', label: 'Background Color' }) + public backgroundColor: number = 0x333333; + + /** + * 背景透明度 + * Background alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Background Alpha', min: 0, max: 1, step: 0.01 }) + public backgroundAlpha: number = 1; + + /** + * 边框颜色 + * Border color + */ + @Serialize() + @Property({ type: 'color', label: 'Border Color' }) + public borderColor: number = 0x000000; + + /** + * 边框宽度 + * Border width + */ + @Serialize() + @Property({ type: 'number', label: 'Border Width', min: 0 }) + public borderWidth: number = 0; + + /** + * 圆角半径 + * Corner radius + */ + @Serialize() + @Property({ type: 'number', label: 'Corner Radius', min: 0 }) + public cornerRadius: number = 0; + + // ===== 方向和填充 Direction & Fill ===== + + /** + * 进度方向 + * Progress direction + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Direction', + options: [ + { value: 'left-to-right', label: 'Left to Right' }, + { value: 'right-to-left', label: 'Right to Left' }, + { value: 'bottom-to-top', label: 'Bottom to Top' }, + { value: 'top-to-bottom', label: 'Top to Bottom' } + ] + }) + public direction: UIProgressDirection = UIProgressDirection.LeftToRight; + + /** + * 填充模式 + * Fill mode + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Fill Mode', + options: [ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' }, + { value: 'radial', label: 'Radial' } + ] + }) + public fillMode: UIProgressFillMode = UIProgressFillMode.Horizontal; + + // ===== 动画 Animation ===== + + /** + * 过渡时长(秒) + * Transition duration in seconds + */ + @Serialize() + @Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 }) + public transitionDuration: number = 0.3; + + /** + * 缓动函数 + * Easing function name + */ + @Serialize() + @Property({ type: 'string', label: 'Easing' }) + public easing: string = 'easeOut'; + + // ===== 分段 Segments ===== + + /** + * 是否分段显示 + * Whether to show segments + */ + @Serialize() + @Property({ type: 'boolean', label: 'Show Segments' }) + public showSegments: boolean = false; + + /** + * 分段数量 + * Number of segments + */ + @Serialize() + @Property({ type: 'integer', label: 'Segments', min: 1 }) + public segments: number = 10; + + /** + * 分段间隙 + * Gap between segments + */ + @Serialize() + @Property({ type: 'number', label: 'Segment Gap', min: 0 }) + public segmentGap: number = 2; + + // ===== 渐变 Gradient ===== + + /** + * 是否使用渐变 + * Whether to use gradient fill + */ + @Serialize() + @Property({ type: 'boolean', label: 'Use Gradient' }) + public useGradient: boolean = false; + + /** + * 渐变起始颜色 + * Gradient start color + */ + @Serialize() + @Property({ type: 'color', label: 'Gradient Start Color' }) + public gradientStartColor: number = 0x4CAF50; + + /** + * 渐变结束颜色 + * Gradient end color + */ + @Serialize() + @Property({ type: 'color', label: 'Gradient End Color' }) + public gradientEndColor: number = 0x8BC34A; + + // ===== 文本 Text ===== + + /** + * 是否显示文本 + * Whether to show text + */ + @Serialize() + @Property({ type: 'boolean', label: 'Show Text' }) + public showText: boolean = false; + + /** + * 文本格式({value}, {percent}, {min}, {max}) + * Text format template + */ + @Serialize() + @Property({ type: 'string', label: 'Text Format' }) + public textFormat: string = '{percent}%'; + + /** + * 文本颜色 + * Text color + */ + @Serialize() + @Property({ type: 'color', label: 'Text Color' }) + public textColor: number = 0xFFFFFF; + + /** + * 获取进度百分比 (0-1) + * Get progress as percentage (0-1) + */ + public getProgress(): number { + const range = this.maxValue - this.minValue; + if (range <= 0) return 0; + return Math.max(0, Math.min(1, (this.displayValue - this.minValue) / range)); + } + + /** + * 获取格式化的文本 + * Get formatted text + */ + public getFormattedText(): string { + const percent = Math.round(this.getProgress() * 100); + return this.textFormat + .replace('{value}', this.displayValue.toFixed(0)) + .replace('{percent}', percent.toString()) + .replace('{min}', this.minValue.toString()) + .replace('{max}', this.maxValue.toString()); + } + + /** + * 设置值(带动画) + * Set value (with animation) + */ + public setValue(value: number, animate: boolean = true): this { + this.targetValue = Math.max(this.minValue, Math.min(this.maxValue, value)); + if (!animate) { + this.value = this.targetValue; + this.displayValue = this.targetValue; + } + return this; + } + + /** + * 设置颜色 + * Set colors + */ + public setColors(fill: number, background: number): this { + this.fillColor = fill; + this.backgroundColor = background; + return this; + } + + /** + * 设置渐变 + * Set gradient colors + */ + public setGradient(startColor: number, endColor: number): this { + this.useGradient = true; + this.gradientStartColor = startColor; + this.gradientEndColor = endColor; + return this; + } + + /** + * 增加值 + * Increase value + */ + public increase(amount: number = 1): this { + return this.setValue(this.targetValue + amount); + } + + /** + * 减少值 + * Decrease value + */ + public decrease(amount: number = 1): this { + return this.setValue(this.targetValue - amount); + } +} diff --git a/packages/ui/src/components/widgets/UIScrollViewComponent.ts b/packages/ui/src/components/widgets/UIScrollViewComponent.ts new file mode 100644 index 00000000..0974b636 --- /dev/null +++ b/packages/ui/src/components/widgets/UIScrollViewComponent.ts @@ -0,0 +1,370 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 滚动条可见性 + * Scrollbar visibility mode + */ +export enum UIScrollbarVisibility { + /** 总是显示 Always visible */ + Always = 'always', + /** 自动显示(内容超出时)Auto show when content exceeds */ + Auto = 'auto', + /** 总是隐藏 Always hidden */ + Hidden = 'hidden' +} + +/** + * UI 滚动视图组件 + * UI ScrollView Component - Scrollable container + */ +@ECSComponent('UIScrollView') +@Serializable({ version: 1, typeId: 'UIScrollView' }) +export class UIScrollViewComponent extends Component { + // ===== 滚动位置 Scroll Position ===== + + /** + * 水平滚动位置 + * Horizontal scroll position + */ + public scrollX: number = 0; + + /** + * 垂直滚动位置 + * Vertical scroll position + */ + public scrollY: number = 0; + + /** + * 目标水平滚动位置(动画用) + * Target horizontal scroll position (for animation) + */ + public targetScrollX: number = 0; + + /** + * 目标垂直滚动位置(动画用) + * Target vertical scroll position (for animation) + */ + public targetScrollY: number = 0; + + // ===== 内容尺寸 Content Size ===== + + /** + * 内容宽度 + * Content width + */ + public contentWidth: number = 0; + + /** + * 内容高度 + * Content height + */ + public contentHeight: number = 0; + + // ===== 滚动配置 Scroll Configuration ===== + + /** + * 是否启用水平滚动 + * Whether horizontal scroll is enabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Horizontal Scroll' }) + public horizontalScroll: boolean = false; + + /** + * 是否启用垂直滚动 + * Whether vertical scroll is enabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Vertical Scroll' }) + public verticalScroll: boolean = true; + + /** + * 滚动条可见性 + * Scrollbar visibility mode + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Scrollbar Visibility', + options: [ + { value: 'always', label: 'Always' }, + { value: 'auto', label: 'Auto' }, + { value: 'hidden', label: 'Hidden' } + ] + }) + public scrollbarVisibility: UIScrollbarVisibility = UIScrollbarVisibility.Auto; + + /** + * 是否启用惯性滚动 + * Whether inertia scrolling is enabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Inertia' }) + public inertia: boolean = true; + + /** + * 惯性减速率 + * Inertia deceleration rate + */ + @Serialize() + @Property({ type: 'number', label: 'Deceleration Rate', min: 0, max: 1, step: 0.001 }) + public decelerationRate: number = 0.135; + + /** + * 是否启用弹性边界 + * Whether elastic bounds are enabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Elastic Bounds' }) + public elasticBounds: boolean = true; + + /** + * 弹性系数 + * Elasticity coefficient + */ + @Serialize() + @Property({ type: 'number', label: 'Elasticity', min: 0, max: 1, step: 0.01 }) + public elasticity: number = 0.1; + + // ===== 滚动条样式 Scrollbar Style ===== + + /** + * 滚动条宽度 + * Scrollbar width + */ + @Serialize() + @Property({ type: 'number', label: 'Scrollbar Width', min: 1 }) + public scrollbarWidth: number = 8; + + /** + * 滚动条颜色 + * Scrollbar color + */ + @Serialize() + @Property({ type: 'color', label: 'Scrollbar Color' }) + public scrollbarColor: number = 0x888888; + + /** + * 滚动条透明度 + * Scrollbar alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Scrollbar Alpha', min: 0, max: 1, step: 0.01 }) + public scrollbarAlpha: number = 0.5; + + /** + * 滚动条悬停透明度 + * Scrollbar hover alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Scrollbar Hover Alpha', min: 0, max: 1, step: 0.01 }) + public scrollbarHoverAlpha: number = 0.8; + + /** + * 滚动条圆角 + * Scrollbar corner radius + */ + @Serialize() + @Property({ type: 'number', label: 'Scrollbar Radius', min: 0 }) + public scrollbarRadius: number = 4; + + /** + * 滚动条轨道颜色 + * Scrollbar track color + */ + @Serialize() + @Property({ type: 'color', label: 'Scrollbar Track Color' }) + public scrollbarTrackColor: number = 0x333333; + + /** + * 滚动条轨道透明度 + * Scrollbar track alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Scrollbar Track Alpha', min: 0, max: 1, step: 0.01 }) + public scrollbarTrackAlpha: number = 0.3; + + // ===== 交互状态 Interaction State ===== + + /** + * 是否正在拖拽滚动 + * Whether currently dragging to scroll + */ + public dragging: boolean = false; + + /** + * 拖拽起始滚动位置 X + * Drag start scroll X + */ + public dragStartScrollX: number = 0; + + /** + * 拖拽起始滚动位置 Y + * Drag start scroll Y + */ + public dragStartScrollY: number = 0; + + /** + * 滚动速度 X(用于惯性) + * Scroll velocity X (for inertia) + */ + public velocityX: number = 0; + + /** + * 滚动速度 Y(用于惯性) + * Scroll velocity Y (for inertia) + */ + public velocityY: number = 0; + + /** + * 水平滚动条是否被悬停 + * Whether horizontal scrollbar is hovered + */ + public horizontalScrollbarHovered: boolean = false; + + /** + * 垂直滚动条是否被悬停 + * Whether vertical scrollbar is hovered + */ + public verticalScrollbarHovered: boolean = false; + + /** + * 是否正在拖拽滚动条 + * Whether dragging scrollbar + */ + public draggingScrollbar: boolean = false; + + // ===== 滚轮配置 Wheel Configuration ===== + + /** + * 滚轮滚动速度 + * Mouse wheel scroll speed + */ + @Serialize() + @Property({ type: 'number', label: 'Wheel Speed', min: 1 }) + public wheelSpeed: number = 40; + + /** + * 是否平滑滚动 + * Whether to use smooth scrolling + */ + @Serialize() + @Property({ type: 'boolean', label: 'Smooth Scroll' }) + public smoothScroll: boolean = true; + + /** + * 平滑滚动时长(秒) + * Smooth scroll duration in seconds + */ + @Serialize() + @Property({ type: 'number', label: 'Smooth Scroll Duration', min: 0, step: 0.01 }) + public smoothScrollDuration: number = 0.2; + + /** + * 获取最大水平滚动位置 + * Get maximum horizontal scroll position + */ + public getMaxScrollX(viewportWidth: number): number { + return Math.max(0, this.contentWidth - viewportWidth); + } + + /** + * 获取最大垂直滚动位置 + * Get maximum vertical scroll position + */ + public getMaxScrollY(viewportHeight: number): number { + return Math.max(0, this.contentHeight - viewportHeight); + } + + /** + * 设置滚动位置 + * Set scroll position + */ + public setScroll(x: number, y: number, animate: boolean = true): this { + this.targetScrollX = x; + this.targetScrollY = y; + if (!animate) { + this.scrollX = x; + this.scrollY = y; + } + return this; + } + + /** + * 滚动到顶部 + * Scroll to top + */ + public scrollToTop(animate: boolean = true): this { + return this.setScroll(this.scrollX, 0, animate); + } + + /** + * 滚动到底部 + * Scroll to bottom + */ + public scrollToBottom(viewportHeight: number, animate: boolean = true): this { + return this.setScroll(this.scrollX, this.getMaxScrollY(viewportHeight), animate); + } + + /** + * 滚动到指定位置(百分比) + * Scroll to position by percentage + */ + public scrollToPercent(percentX: number, percentY: number, viewportWidth: number, viewportHeight: number, animate: boolean = true): this { + const x = this.getMaxScrollX(viewportWidth) * Math.max(0, Math.min(1, percentX)); + const y = this.getMaxScrollY(viewportHeight) * Math.max(0, Math.min(1, percentY)); + return this.setScroll(x, y, animate); + } + + /** + * 是否需要显示水平滚动条 + * Whether horizontal scrollbar should be visible + */ + public needsHorizontalScrollbar(viewportWidth: number): boolean { + if (!this.horizontalScroll) return false; + if (this.scrollbarVisibility === UIScrollbarVisibility.Hidden) return false; + if (this.scrollbarVisibility === UIScrollbarVisibility.Always) return true; + return this.contentWidth > viewportWidth; + } + + /** + * 是否需要显示垂直滚动条 + * Whether vertical scrollbar should be visible + */ + public needsVerticalScrollbar(viewportHeight: number): boolean { + if (!this.verticalScroll) return false; + if (this.scrollbarVisibility === UIScrollbarVisibility.Hidden) return false; + if (this.scrollbarVisibility === UIScrollbarVisibility.Always) return true; + return this.contentHeight > viewportHeight; + } + + /** + * 获取垂直滚动条手柄尺寸和位置 + * Get vertical scrollbar handle size and position + */ + public getVerticalScrollbarMetrics(viewportHeight: number): { size: number; position: number } { + const maxScroll = this.getMaxScrollY(viewportHeight); + if (maxScroll <= 0) return { size: viewportHeight, position: 0 }; + + const size = Math.max(20, (viewportHeight / this.contentHeight) * viewportHeight); + const availableTrack = viewportHeight - size; + const position = (this.scrollY / maxScroll) * availableTrack; + + return { size, position }; + } + + /** + * 获取水平滚动条手柄尺寸和位置 + * Get horizontal scrollbar handle size and position + */ + public getHorizontalScrollbarMetrics(viewportWidth: number): { size: number; position: number } { + const maxScroll = this.getMaxScrollX(viewportWidth); + if (maxScroll <= 0) return { size: viewportWidth, position: 0 }; + + const size = Math.max(20, (viewportWidth / this.contentWidth) * viewportWidth); + const availableTrack = viewportWidth - size; + const position = (this.scrollX / maxScroll) * availableTrack; + + return { size, position }; + } +} diff --git a/packages/ui/src/components/widgets/UISliderComponent.ts b/packages/ui/src/components/widgets/UISliderComponent.ts new file mode 100644 index 00000000..eb429933 --- /dev/null +++ b/packages/ui/src/components/widgets/UISliderComponent.ts @@ -0,0 +1,390 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 滑块方向 + * Slider orientation + */ +export enum UISliderOrientation { + Horizontal = 'horizontal', + Vertical = 'vertical' +} + +/** + * UI 滑块组件 + * UI Slider Component - Value slider with handle + */ +@ECSComponent('UISlider') +@Serializable({ version: 1, typeId: 'UISlider' }) +export class UISliderComponent extends Component { + // ===== 数值 Values ===== + + /** + * 当前值 + * Current value + */ + @Serialize() + @Property({ type: 'number', label: 'Value' }) + public value: number = 0; + + /** + * 最小值 + * Minimum value + */ + @Serialize() + @Property({ type: 'number', label: 'Min Value' }) + public minValue: number = 0; + + /** + * 最大值 + * Maximum value + */ + @Serialize() + @Property({ type: 'number', label: 'Max Value' }) + public maxValue: number = 100; + + /** + * 步进值(0 = 连续) + * Step value (0 = continuous) + */ + @Serialize() + @Property({ type: 'number', label: 'Step', min: 0 }) + public step: number = 0; + + /** + * 目标值(用于动画) + * Target value (for animation) + */ + public targetValue: number = 0; + + /** + * 显示值(动画插值后) + * Display value (interpolated) + */ + public displayValue: number = 0; + + // ===== 方向 Orientation ===== + + /** + * 滑块方向 + * Slider orientation + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Orientation', + options: [ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' } + ] + }) + public orientation: UISliderOrientation = UISliderOrientation.Horizontal; + + // ===== 轨道样式 Track Style ===== + + /** + * 轨道颜色 + * Track color + */ + @Serialize() + @Property({ type: 'color', label: 'Track Color' }) + public trackColor: number = 0x444444; + + /** + * 轨道透明度 + * Track alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Track Alpha', min: 0, max: 1, step: 0.01 }) + public trackAlpha: number = 1; + + /** + * 轨道高度(水平)或宽度(垂直) + * Track thickness + */ + @Serialize() + @Property({ type: 'number', label: 'Track Thickness', min: 1 }) + public trackThickness: number = 4; + + /** + * 轨道圆角 + * Track corner radius + */ + @Serialize() + @Property({ type: 'number', label: 'Track Radius', min: 0 }) + public trackRadius: number = 2; + + // ===== 填充样式 Fill Style ===== + + /** + * 填充颜色(已滑过的部分) + * Fill color (passed portion) + */ + @Serialize() + @Property({ type: 'color', label: 'Fill Color' }) + public fillColor: number = 0x4A90D9; + + /** + * 填充透明度 + * Fill alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Fill Alpha', min: 0, max: 1, step: 0.01 }) + public fillAlpha: number = 1; + + // ===== 手柄样式 Handle Style ===== + + /** + * 手柄宽度 + * Handle width + */ + @Serialize() + @Property({ type: 'number', label: 'Handle Width', min: 1 }) + public handleWidth: number = 16; + + /** + * 手柄高度 + * Handle height + */ + @Serialize() + @Property({ type: 'number', label: 'Handle Height', min: 1 }) + public handleHeight: number = 16; + + /** + * 手柄颜色 + * Handle color + */ + @Serialize() + @Property({ type: 'color', label: 'Handle Color' }) + public handleColor: number = 0xFFFFFF; + + /** + * 手柄悬停颜色 + * Handle hover color + */ + @Serialize() + @Property({ type: 'color', label: 'Handle Hover Color' }) + public handleHoverColor: number = 0xE0E0E0; + + /** + * 手柄按下颜色 + * Handle pressed color + */ + @Serialize() + @Property({ type: 'color', label: 'Handle Pressed Color' }) + public handlePressedColor: number = 0xCCCCCC; + + /** + * 手柄圆角 + * Handle corner radius + */ + @Serialize() + @Property({ type: 'number', label: 'Handle Radius', min: 0 }) + public handleRadius: number = 8; + + /** + * 手柄边框宽度 + * Handle border width + */ + @Serialize() + @Property({ type: 'number', label: 'Handle Border Width', min: 0 }) + public handleBorderWidth: number = 0; + + /** + * 手柄边框颜色 + * Handle border color + */ + @Serialize() + @Property({ type: 'color', label: 'Handle Border Color' }) + public handleBorderColor: number = 0x000000; + + /** + * 手柄阴影 + * Handle shadow enabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Handle Shadow' }) + public handleShadow: boolean = true; + + // ===== 交互状态 Interaction State ===== + + /** + * 手柄是否被悬停 + * Whether handle is hovered + */ + public handleHovered: boolean = false; + + /** + * 是否正在拖拽 + * Whether currently dragging + */ + public dragging: boolean = false; + + /** + * 拖拽起始值 + * Drag start value + */ + public dragStartValue: number = 0; + + /** + * 拖拽起始位置 + * Drag start position + */ + public dragStartPosition: number = 0; + + // ===== 动画 Animation ===== + + /** + * 过渡时长(秒) + * Transition duration in seconds + */ + @Serialize() + @Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 }) + public transitionDuration: number = 0.1; + + // ===== 刻度 Ticks ===== + + /** + * 是否显示刻度 + * Whether to show ticks + */ + @Serialize() + @Property({ type: 'boolean', label: 'Show Ticks' }) + public showTicks: boolean = false; + + /** + * 刻度数量(不包括首尾) + * Number of ticks (excluding ends) + */ + @Serialize() + @Property({ type: 'integer', label: 'Tick Count', min: 0 }) + public tickCount: number = 4; + + /** + * 刻度颜色 + * Tick color + */ + @Serialize() + @Property({ type: 'color', label: 'Tick Color' }) + public tickColor: number = 0x666666; + + /** + * 刻度大小 + * Tick size + */ + @Serialize() + @Property({ type: 'number', label: 'Tick Size', min: 1 }) + public tickSize: number = 4; + + // ===== 文本 Text ===== + + /** + * 是否显示值文本 + * Whether to show value text + */ + @Serialize() + @Property({ type: 'boolean', label: 'Show Value' }) + public showValue: boolean = false; + + /** + * 值文本格式 + * Value text format + */ + @Serialize() + @Property({ type: 'string', label: 'Value Format' }) + public valueFormat: string = '{value}'; + + /** + * 小数位数 + * Decimal places + */ + @Serialize() + @Property({ type: 'integer', label: 'Decimal Places', min: 0 }) + public decimalPlaces: number = 0; + + // ===== 回调 Callbacks ===== + + /** + * 值改变回调 + * Value change callback + */ + public onChange?: (value: number) => void; + + /** + * 拖拽开始回调 + * Drag start callback + */ + public onDragStart?: (value: number) => void; + + /** + * 拖拽结束回调 + * Drag end callback + */ + public onDragEnd?: (value: number) => void; + + /** + * 获取进度百分比 (0-1) + * Get progress as percentage (0-1) + */ + public getProgress(): number { + const range = this.maxValue - this.minValue; + if (range <= 0) return 0; + return Math.max(0, Math.min(1, (this.displayValue - this.minValue) / range)); + } + + /** + * 从百分比设置值 + * Set value from percentage + */ + public setProgress(progress: number): this { + const range = this.maxValue - this.minValue; + return this.setValue(this.minValue + range * Math.max(0, Math.min(1, progress))); + } + + /** + * 设置值 + * Set value + */ + public setValue(value: number, animate: boolean = true): this { + let newValue = Math.max(this.minValue, Math.min(this.maxValue, value)); + + // 应用步进 + if (this.step > 0) { + newValue = Math.round((newValue - this.minValue) / this.step) * this.step + this.minValue; + } + + this.targetValue = newValue; + if (!animate) { + this.value = newValue; + this.displayValue = newValue; + } + + return this; + } + + /** + * 获取格式化的值文本 + * Get formatted value text + */ + public getFormattedValue(): string { + const formattedValue = this.displayValue.toFixed(this.decimalPlaces); + return this.valueFormat.replace('{value}', formattedValue); + } + + /** + * 计算手柄位置(归一化 0-1) + * Calculate handle position (normalized 0-1) + */ + public getHandlePosition(): number { + return this.getProgress(); + } + + /** + * 获取当前手柄颜色 + * Get current handle color based on state + */ + public getCurrentHandleColor(): number { + if (this.dragging) return this.handlePressedColor; + if (this.handleHovered) return this.handleHoverColor; + return this.handleColor; + } +} diff --git a/packages/ui/src/components/widgets/index.ts b/packages/ui/src/components/widgets/index.ts new file mode 100644 index 00000000..952d610f --- /dev/null +++ b/packages/ui/src/components/widgets/index.ts @@ -0,0 +1,4 @@ +export * from './UIButtonComponent'; +export * from './UIProgressBarComponent'; +export * from './UISliderComponent'; +export * from './UIScrollViewComponent'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 00000000..aa13d46f --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,116 @@ +/** + * @esengine/ui - ECS-based UI System + * + * 基于 ECS 架构的 UI 系统,支持 WebGL 渲染 + * ECS-based UI system with WebGL rendering support + * + * @example + * ```typescript + * import { UIBuilder, UILayoutSystem, UIInputSystem, UIAnimationSystem } from '@esengine/ui'; + * + * // 创建 UI Scene + * const uiScene = world.createScene('ui'); + * + * // 添加 UI 系统 + * uiScene.addSystem(new UILayoutSystem()); + * uiScene.addSystem(new UIInputSystem()); + * uiScene.addSystem(new UIAnimationSystem()); + * + * // 使用 UIBuilder 创建元素 + * const ui = new UIBuilder(uiScene); + * + * const button = ui.button({ + * x: 100, y: 100, + * width: 120, height: 40, + * label: 'Click Me', + * onClick: () => console.log('Clicked!') + * }); + * + * const progressBar = ui.progressBar({ + * x: 100, y: 160, + * width: 200, height: 20, + * value: 75, + * maxValue: 100 + * }); + * ``` + */ + +// Components - Core +export { + UITransformComponent, + AnchorPreset +} from './components/UITransformComponent'; + +export { + UIRenderComponent, + UIRenderType, + type UIBorderStyle, + type UIShadowStyle +} from './components/UIRenderComponent'; + +export { + UIInteractableComponent, + type UICursorType +} from './components/UIInteractableComponent'; + +export { + UITextComponent, + type UITextAlign, + type UITextVerticalAlign, + type UITextOverflow, + type UIFontWeight +} from './components/UITextComponent'; + +export { + UILayoutComponent, + UILayoutType, + UIJustifyContent, + UIAlignItems, + type UIPadding +} from './components/UILayoutComponent'; + +// Components - Widgets +export { + UIButtonComponent, + type UIButtonStyle, + type UIButtonDisplayMode +} from './components/widgets/UIButtonComponent'; + +export { + UIProgressBarComponent, + UIProgressDirection, + UIProgressFillMode +} from './components/widgets/UIProgressBarComponent'; + +export { + UISliderComponent, + UISliderOrientation +} from './components/widgets/UISliderComponent'; + +export { + UIScrollViewComponent, + UIScrollbarVisibility +} from './components/widgets/UIScrollViewComponent'; + +// Systems +export { UILayoutSystem } from './systems/UILayoutSystem'; +export { UIInputSystem, MouseButton, type UIInputEvent } from './systems/UIInputSystem'; +export { UIAnimationSystem, Easing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem'; +export { UIRenderDataProvider, type UIRenderData } from './systems/UIRenderDataProvider'; + +// Rendering +export { WebGLUIRenderer } from './rendering/WebGLUIRenderer'; +export { TextRenderer, type TextMeasurement, type TextRenderOptions } from './rendering/TextRenderer'; + +// Builder API +export { + UIBuilder, + type UIBaseConfig, + type UIButtonConfig, + type UITextConfig, + type UIImageConfig, + type UIProgressBarConfig, + type UISliderConfig, + type UIPanelConfig, + type UIScrollViewConfig +} from './UIBuilder'; diff --git a/packages/ui/src/rendering/TextRenderer.ts b/packages/ui/src/rendering/TextRenderer.ts new file mode 100644 index 00000000..d69c99c1 --- /dev/null +++ b/packages/ui/src/rendering/TextRenderer.ts @@ -0,0 +1,299 @@ +/** + * 文本渲染器 + * Text Renderer - Renders text to textures for WebGL + * + * 使用 Canvas 2D API 渲染文本到纹理 + * Uses Canvas 2D API to render text to textures + */ + +export interface TextMeasurement { + width: number; + height: number; + lines: string[]; + lineHeights: number[]; +} + +export interface TextRenderOptions { + fontSize: number; + fontFamily: string; + fontWeight: string | number; + italic: boolean; + color: number; + alpha: number; + align: 'left' | 'center' | 'right'; + verticalAlign: 'top' | 'middle' | 'bottom'; + wordWrap: boolean; + wrapWidth: number; + lineHeight: number; + letterSpacing: number; + strokeWidth: number; + strokeColor: number; + shadowEnabled: boolean; + shadowOffsetX: number; + shadowOffsetY: number; + shadowColor: number; + shadowAlpha: number; +} + +export class TextRenderer { + private gl: WebGLRenderingContext; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private textureCache: Map = new Map(); + + constructor(gl: WebGLRenderingContext) { + this.gl = gl; + + // 创建离屏 Canvas + this.canvas = document.createElement('canvas'); + this.ctx = this.canvas.getContext('2d')!; + } + + /** + * 测量文本尺寸 + * Measure text dimensions + */ + public measureText(text: string, options: Partial): TextMeasurement { + const opts = this.getDefaultOptions(options); + this.setupContext(opts); + + let lines: string[]; + if (opts.wordWrap && opts.wrapWidth > 0) { + lines = this.wrapText(text, opts.wrapWidth); + } else { + lines = text.split('\n'); + } + + const lineHeight = opts.fontSize * opts.lineHeight; + let maxWidth = 0; + + for (const line of lines) { + const metrics = this.ctx.measureText(line); + maxWidth = Math.max(maxWidth, metrics.width); + } + + return { + width: maxWidth, + height: lines.length * lineHeight, + lines, + lineHeights: lines.map(() => lineHeight) + }; + } + + /** + * 渲染文本到纹理 + * Render text to texture + */ + public renderToTexture( + text: string, + options: Partial, + width?: number, + height?: number + ): WebGLTexture | null { + const opts = this.getDefaultOptions(options); + const measurement = this.measureText(text, options); + + // 使用指定尺寸或测量尺寸 + const canvasWidth = Math.ceil(width ?? measurement.width) + opts.strokeWidth * 2; + const canvasHeight = Math.ceil(height ?? measurement.height) + opts.strokeWidth * 2; + + if (canvasWidth <= 0 || canvasHeight <= 0) return null; + + // 调整 Canvas 尺寸 + this.canvas.width = canvasWidth; + this.canvas.height = canvasHeight; + + // 清除背景 + this.ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + // 设置绘制样式 + this.setupContext(opts); + + // 计算起始位置 + const lineHeight = opts.fontSize * opts.lineHeight; + let startY = opts.strokeWidth; + + if (opts.verticalAlign === 'middle') { + startY = (canvasHeight - measurement.height) / 2; + } else if (opts.verticalAlign === 'bottom') { + startY = canvasHeight - measurement.height - opts.strokeWidth; + } + + // 绘制每行 + for (let i = 0; i < measurement.lines.length; i++) { + const line = measurement.lines[i]!; + let x = opts.strokeWidth; + + if (opts.align === 'center') { + const lineWidth = this.ctx.measureText(line).width; + x = (canvasWidth - lineWidth) / 2; + } else if (opts.align === 'right') { + const lineWidth = this.ctx.measureText(line).width; + x = canvasWidth - lineWidth - opts.strokeWidth; + } + + const y = startY + (i + 0.8) * lineHeight; + + // 绘制阴影 + if (opts.shadowEnabled) { + this.ctx.save(); + this.ctx.fillStyle = this.colorToCSS(opts.shadowColor, opts.shadowAlpha); + this.ctx.fillText(line, x + opts.shadowOffsetX, y + opts.shadowOffsetY); + this.ctx.restore(); + } + + // 绘制描边 + if (opts.strokeWidth > 0) { + this.ctx.strokeStyle = this.colorToCSS(opts.strokeColor, opts.alpha); + this.ctx.lineWidth = opts.strokeWidth; + this.ctx.strokeText(line, x, y); + } + + // 绘制文本 + this.ctx.fillStyle = this.colorToCSS(opts.color, opts.alpha); + this.ctx.fillText(line, x, y); + } + + // 创建纹理 + return this.createTextureFromCanvas(); + } + + /** + * 从缓存获取或创建纹理 + * Get from cache or create texture + */ + public getOrCreateTexture( + text: string, + options: Partial, + width?: number, + height?: number + ): WebGLTexture | null { + const cacheKey = this.getCacheKey(text, options, width, height); + + if (this.textureCache.has(cacheKey)) { + return this.textureCache.get(cacheKey)!; + } + + const texture = this.renderToTexture(text, options, width, height); + if (texture) { + this.textureCache.set(cacheKey, texture); + } + + return texture; + } + + /** + * 清除纹理缓存 + * Clear texture cache + */ + public clearCache(): void { + for (const texture of this.textureCache.values()) { + this.gl.deleteTexture(texture); + } + this.textureCache.clear(); + } + + /** + * 从缓存移除指定纹理 + * Remove specific texture from cache + */ + public invalidateCache(text: string, options: Partial): void { + const cacheKey = this.getCacheKey(text, options); + const texture = this.textureCache.get(cacheKey); + if (texture) { + this.gl.deleteTexture(texture); + this.textureCache.delete(cacheKey); + } + } + + private getDefaultOptions(options: Partial): TextRenderOptions { + return { + fontSize: options.fontSize ?? 14, + fontFamily: options.fontFamily ?? 'Arial, sans-serif', + fontWeight: options.fontWeight ?? 'normal', + italic: options.italic ?? false, + color: options.color ?? 0x000000, + alpha: options.alpha ?? 1, + align: options.align ?? 'left', + verticalAlign: options.verticalAlign ?? 'top', + wordWrap: options.wordWrap ?? false, + wrapWidth: options.wrapWidth ?? 0, + lineHeight: options.lineHeight ?? 1.2, + letterSpacing: options.letterSpacing ?? 0, + strokeWidth: options.strokeWidth ?? 0, + strokeColor: options.strokeColor ?? 0x000000, + shadowEnabled: options.shadowEnabled ?? false, + shadowOffsetX: options.shadowOffsetX ?? 1, + shadowOffsetY: options.shadowOffsetY ?? 1, + shadowColor: options.shadowColor ?? 0x000000, + shadowAlpha: options.shadowAlpha ?? 0.5 + }; + } + + private setupContext(opts: TextRenderOptions): void { + const style = opts.italic ? 'italic ' : ''; + const weight = opts.fontWeight; + this.ctx.font = `${style}${weight} ${opts.fontSize}px ${opts.fontFamily}`; + this.ctx.textBaseline = 'top'; + } + + private wrapText(text: string, maxWidth: number): string[] { + const lines: string[] = []; + const paragraphs = text.split('\n'); + + for (const paragraph of paragraphs) { + const words = paragraph.split(' '); + let currentLine = ''; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const metrics = this.ctx.measureText(testLine); + + if (metrics.width > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + } + + return lines; + } + + private colorToCSS(color: number, alpha: number): string { + const r = (color >> 16) & 0xFF; + const g = (color >> 8) & 0xFF; + const b = color & 0xFF; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + + private createTextureFromCanvas(): WebGLTexture | null { + const gl = this.gl; + const texture = gl.createTexture(); + if (!texture) return null; + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.canvas); + + // 设置纹理参数 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + return texture; + } + + private getCacheKey(text: string, options: Partial, width?: number, height?: number): string { + return JSON.stringify({ text, options, width, height }); + } + + public dispose(): void { + this.clearCache(); + } +} diff --git a/packages/ui/src/rendering/WebGLUIRenderer.ts b/packages/ui/src/rendering/WebGLUIRenderer.ts new file mode 100644 index 00000000..be3b1229 --- /dev/null +++ b/packages/ui/src/rendering/WebGLUIRenderer.ts @@ -0,0 +1,471 @@ +/** + * WebGL UI 渲染器 + * WebGL UI Renderer - Low-level WebGL rendering for UI elements + * + * 支持批处理渲染以提高性能 + * Supports batch rendering for better performance + */ + +/** + * 顶点数据结构 + * Vertex data structure + * position (2) + texcoord (2) + color (4) + */ +const VERTEX_SIZE = 8; +const VERTICES_PER_QUAD = 4; +const INDICES_PER_QUAD = 6; +const MAX_BATCH_QUADS = 2000; + +/** + * 着色器源码 + * Shader sources + */ +const VERTEX_SHADER_SOURCE = ` + attribute vec2 a_position; + attribute vec2 a_texcoord; + attribute vec4 a_color; + + uniform mat4 u_projection; + + varying vec2 v_texcoord; + varying vec4 v_color; + + void main() { + gl_Position = u_projection * vec4(a_position, 0.0, 1.0); + v_texcoord = a_texcoord; + v_color = a_color; + } +`; + +const FRAGMENT_SHADER_SOURCE = ` + precision mediump float; + + varying vec2 v_texcoord; + varying vec4 v_color; + + uniform sampler2D u_texture; + uniform bool u_useTexture; + + void main() { + if (u_useTexture) { + gl_FragColor = texture2D(u_texture, v_texcoord) * v_color; + } else { + gl_FragColor = v_color; + } + } +`; + +export class WebGLUIRenderer { + private gl: WebGLRenderingContext; + private program: WebGLProgram | null = null; + + // Buffers + private vertexBuffer: WebGLBuffer | null = null; + private indexBuffer: WebGLBuffer | null = null; + private vertexData: Float32Array; + private indexData: Uint16Array; + + // Batch state + private quadCount: number = 0; + private currentTexture: WebGLTexture | null = null; + + // Uniform locations + private projectionLocation: WebGLUniformLocation | null = null; + private textureLocation: WebGLUniformLocation | null = null; + private useTextureLocation: WebGLUniformLocation | null = null; + + // Attribute locations + private positionLocation: number = -1; + private texcoordLocation: number = -1; + private colorLocation: number = -1; + + // Viewport + private viewportWidth: number = 0; + private viewportHeight: number = 0; + + // 白色纹理(用于纯色绘制) + private whiteTexture: WebGLTexture | null = null; + + constructor(gl: WebGLRenderingContext) { + this.gl = gl; + + // 分配顶点和索引数据 + this.vertexData = new Float32Array(MAX_BATCH_QUADS * VERTICES_PER_QUAD * VERTEX_SIZE); + this.indexData = new Uint16Array(MAX_BATCH_QUADS * INDICES_PER_QUAD); + + // 预填充索引数据 + for (let i = 0; i < MAX_BATCH_QUADS; i++) { + const vi = i * 4; + const ii = i * 6; + this.indexData[ii + 0] = vi + 0; + this.indexData[ii + 1] = vi + 1; + this.indexData[ii + 2] = vi + 2; + this.indexData[ii + 3] = vi + 2; + this.indexData[ii + 4] = vi + 3; + this.indexData[ii + 5] = vi + 0; + } + + this.initShaders(); + this.initBuffers(); + this.createWhiteTexture(); + } + + private initShaders(): void { + const gl = this.gl; + + // 编译着色器 + const vertexShader = this.compileShader(gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE); + const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE); + + if (!vertexShader || !fragmentShader) { + throw new Error('Failed to compile shaders'); + } + + // 链接程序 + this.program = gl.createProgram(); + if (!this.program) { + throw new Error('Failed to create shader program'); + } + + gl.attachShader(this.program, vertexShader); + gl.attachShader(this.program, fragmentShader); + gl.linkProgram(this.program); + + if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) { + throw new Error('Failed to link shader program: ' + gl.getProgramInfoLog(this.program)); + } + + // 获取 attribute 位置 + this.positionLocation = gl.getAttribLocation(this.program, 'a_position'); + this.texcoordLocation = gl.getAttribLocation(this.program, 'a_texcoord'); + this.colorLocation = gl.getAttribLocation(this.program, 'a_color'); + + // 获取 uniform 位置 + this.projectionLocation = gl.getUniformLocation(this.program, 'u_projection'); + this.textureLocation = gl.getUniformLocation(this.program, 'u_texture'); + this.useTextureLocation = gl.getUniformLocation(this.program, 'u_useTexture'); + } + + private compileShader(type: number, source: string): WebGLShader | null { + const gl = this.gl; + const shader = gl.createShader(type); + if (!shader) return null; + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('Shader compile error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; + } + + private initBuffers(): void { + const gl = this.gl; + + // 创建顶点缓冲 + this.vertexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this.vertexData, gl.DYNAMIC_DRAW); + + // 创建索引缓冲 + this.indexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indexData, gl.STATIC_DRAW); + } + + private createWhiteTexture(): void { + const gl = this.gl; + + this.whiteTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.whiteTexture); + + // 1x1 白色像素 + const pixel = new Uint8Array([255, 255, 255, 255]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + } + + /** + * 设置视口尺寸 + * Set viewport size + */ + public setViewport(width: number, height: number): void { + this.viewportWidth = width; + this.viewportHeight = height; + } + + /** + * 开始渲染批次 + * Begin render batch + */ + public begin(): void { + const gl = this.gl; + + gl.viewport(0, 0, this.viewportWidth, this.viewportHeight); + + // 启用混合 + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + // 禁用深度测试 + gl.disable(gl.DEPTH_TEST); + + // 使用程序 + gl.useProgram(this.program); + + // 设置投影矩阵(正交投影) + const projection = this.createOrthographicMatrix(0, this.viewportWidth, this.viewportHeight, 0, -1, 1); + gl.uniformMatrix4fv(this.projectionLocation, false, projection); + + // 绑定纹理单元 + gl.activeTexture(gl.TEXTURE0); + gl.uniform1i(this.textureLocation, 0); + + // 绑定缓冲 + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + + // 设置顶点属性 + const stride = VERTEX_SIZE * 4; + gl.enableVertexAttribArray(this.positionLocation); + gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, stride, 0); + + gl.enableVertexAttribArray(this.texcoordLocation); + gl.vertexAttribPointer(this.texcoordLocation, 2, gl.FLOAT, false, stride, 8); + + gl.enableVertexAttribArray(this.colorLocation); + gl.vertexAttribPointer(this.colorLocation, 4, gl.FLOAT, false, stride, 16); + + this.quadCount = 0; + this.currentTexture = null; + } + + /** + * 结束渲染批次 + * End render batch + */ + public end(): void { + this.flush(); + } + + /** + * 刷新当前批次 + * Flush current batch + */ + public flush(): void { + if (this.quadCount === 0) return; + + const gl = this.gl; + + // 上传顶点数据 + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertexData.subarray(0, this.quadCount * VERTICES_PER_QUAD * VERTEX_SIZE)); + + // 绑定纹理 + if (this.currentTexture) { + gl.bindTexture(gl.TEXTURE_2D, this.currentTexture); + gl.uniform1i(this.useTextureLocation, 1); + } else { + gl.bindTexture(gl.TEXTURE_2D, this.whiteTexture); + gl.uniform1i(this.useTextureLocation, 0); + } + + // 绘制 + gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_SHORT, 0); + + this.quadCount = 0; + } + + /** + * 绘制矩形 + * Draw rectangle + */ + public drawRect( + x: number, + y: number, + width: number, + height: number, + color: number, + alpha: number = 1 + ): void { + this.drawQuad(x, y, width, height, 0, 0, 1, 1, color, alpha, null); + } + + /** + * 绘制纹理 + * Draw texture + */ + public drawTexture( + texture: WebGLTexture, + x: number, + y: number, + width: number, + height: number, + u0: number = 0, + v0: number = 0, + u1: number = 1, + v1: number = 1, + tint: number = 0xFFFFFF, + alpha: number = 1 + ): void { + this.drawQuad(x, y, width, height, u0, v0, u1, v1, tint, alpha, texture); + } + + /** + * 绘制四边形 + * Draw quad + */ + private drawQuad( + x: number, + y: number, + width: number, + height: number, + u0: number, + v0: number, + u1: number, + v1: number, + color: number, + alpha: number, + texture: WebGLTexture | null + ): void { + // 检查是否需要刷新 + if (this.quadCount >= MAX_BATCH_QUADS) { + this.flush(); + } + + if (texture !== this.currentTexture) { + this.flush(); + this.currentTexture = texture; + } + + // 颜色分解 + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + const a = alpha; + + // 计算顶点 + const x2 = x + width; + const y2 = y + height; + + // 填充顶点数据 + const offset = this.quadCount * VERTICES_PER_QUAD * VERTEX_SIZE; + + // 左上 + this.vertexData[offset + 0] = x; + this.vertexData[offset + 1] = y; + this.vertexData[offset + 2] = u0; + this.vertexData[offset + 3] = v0; + this.vertexData[offset + 4] = r; + this.vertexData[offset + 5] = g; + this.vertexData[offset + 6] = b; + this.vertexData[offset + 7] = a; + + // 右上 + this.vertexData[offset + 8] = x2; + this.vertexData[offset + 9] = y; + this.vertexData[offset + 10] = u1; + this.vertexData[offset + 11] = v0; + this.vertexData[offset + 12] = r; + this.vertexData[offset + 13] = g; + this.vertexData[offset + 14] = b; + this.vertexData[offset + 15] = a; + + // 右下 + this.vertexData[offset + 16] = x2; + this.vertexData[offset + 17] = y2; + this.vertexData[offset + 18] = u1; + this.vertexData[offset + 19] = v1; + this.vertexData[offset + 20] = r; + this.vertexData[offset + 21] = g; + this.vertexData[offset + 22] = b; + this.vertexData[offset + 23] = a; + + // 左下 + this.vertexData[offset + 24] = x; + this.vertexData[offset + 25] = y2; + this.vertexData[offset + 26] = u0; + this.vertexData[offset + 27] = v1; + this.vertexData[offset + 28] = r; + this.vertexData[offset + 29] = g; + this.vertexData[offset + 30] = b; + this.vertexData[offset + 31] = a; + + this.quadCount++; + } + + /** + * 创建正交投影矩阵 + * Create orthographic projection matrix + */ + private createOrthographicMatrix( + left: number, + right: number, + bottom: number, + top: number, + near: number, + far: number + ): Float32Array { + const matrix = new Float32Array(16); + + const lr = 1 / (left - right); + const bt = 1 / (bottom - top); + const nf = 1 / (near - far); + + matrix[0] = -2 * lr; + matrix[1] = 0; + matrix[2] = 0; + matrix[3] = 0; + + matrix[4] = 0; + matrix[5] = -2 * bt; + matrix[6] = 0; + matrix[7] = 0; + + matrix[8] = 0; + matrix[9] = 0; + matrix[10] = 2 * nf; + matrix[11] = 0; + + matrix[12] = (left + right) * lr; + matrix[13] = (top + bottom) * bt; + matrix[14] = (far + near) * nf; + matrix[15] = 1; + + return matrix; + } + + /** + * 销毁渲染器 + * Dispose renderer + */ + public dispose(): void { + const gl = this.gl; + + if (this.program) { + gl.deleteProgram(this.program); + this.program = null; + } + + if (this.vertexBuffer) { + gl.deleteBuffer(this.vertexBuffer); + this.vertexBuffer = null; + } + + if (this.indexBuffer) { + gl.deleteBuffer(this.indexBuffer); + this.indexBuffer = null; + } + + if (this.whiteTexture) { + gl.deleteTexture(this.whiteTexture); + this.whiteTexture = null; + } + } +} diff --git a/packages/ui/src/rendering/index.ts b/packages/ui/src/rendering/index.ts new file mode 100644 index 00000000..a33c131b --- /dev/null +++ b/packages/ui/src/rendering/index.ts @@ -0,0 +1,2 @@ +export * from './WebGLUIRenderer'; +export * from './TextRenderer'; diff --git a/packages/ui/src/systems/UIAnimationSystem.ts b/packages/ui/src/systems/UIAnimationSystem.ts new file mode 100644 index 00000000..6c5f0d42 --- /dev/null +++ b/packages/ui/src/systems/UIAnimationSystem.ts @@ -0,0 +1,282 @@ +import { EntitySystem, Matcher, Entity, Time, ECSSystem } from '@esengine/ecs-framework'; +import { UIProgressBarComponent } from '../components/widgets/UIProgressBarComponent'; +import { UISliderComponent } from '../components/widgets/UISliderComponent'; +import { UIButtonComponent } from '../components/widgets/UIButtonComponent'; + +/** + * 缓动函数类型 + * Easing function type + */ +export type EasingFunction = (t: number) => number; + +/** + * 预定义缓动函数 + * Predefined easing functions + */ +export const Easing = { + linear: (t: number) => t, + + // Quad + easeInQuad: (t: number) => t * t, + easeOutQuad: (t: number) => t * (2 - t), + easeInOutQuad: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, + + // Cubic + easeInCubic: (t: number) => t * t * t, + easeOutCubic: (t: number) => (--t) * t * t + 1, + easeInOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, + + // Quart + easeInQuart: (t: number) => t * t * t * t, + easeOutQuart: (t: number) => 1 - (--t) * t * t * t, + easeInOutQuart: (t: number) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t, + + // Quint + easeInQuint: (t: number) => t * t * t * t * t, + easeOutQuint: (t: number) => 1 + (--t) * t * t * t * t, + easeInOutQuint: (t: number) => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t, + + // Sine + easeInSine: (t: number) => 1 - Math.cos(t * Math.PI / 2), + easeOutSine: (t: number) => Math.sin(t * Math.PI / 2), + easeInOutSine: (t: number) => -(Math.cos(Math.PI * t) - 1) / 2, + + // Expo + easeInExpo: (t: number) => t === 0 ? 0 : Math.pow(2, 10 * (t - 1)), + easeOutExpo: (t: number) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t), + easeInOutExpo: (t: number) => { + if (t === 0) return 0; + if (t === 1) return 1; + if (t < 0.5) return Math.pow(2, 20 * t - 10) / 2; + return (2 - Math.pow(2, -20 * t + 10)) / 2; + }, + + // Circ + easeInCirc: (t: number) => 1 - Math.sqrt(1 - t * t), + easeOutCirc: (t: number) => Math.sqrt(1 - (--t) * t), + easeInOutCirc: (t: number) => t < 0.5 + ? (1 - Math.sqrt(1 - 4 * t * t)) / 2 + : (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2, + + // Back + easeInBack: (t: number) => { + const c1 = 1.70158; + const c3 = c1 + 1; + return c3 * t * t * t - c1 * t * t; + }, + easeOutBack: (t: number) => { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + }, + easeInOutBack: (t: number) => { + const c1 = 1.70158; + const c2 = c1 * 1.525; + return t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; + }, + + // Elastic + easeInElastic: (t: number) => { + if (t === 0) return 0; + if (t === 1) return 1; + return -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * ((2 * Math.PI) / 3)); + }, + easeOutElastic: (t: number) => { + if (t === 0) return 0; + if (t === 1) return 1; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1; + }, + easeInOutElastic: (t: number) => { + if (t === 0) return 0; + if (t === 1) return 1; + const c5 = (2 * Math.PI) / 4.5; + return t < 0.5 + ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1; + }, + + // Bounce + easeInBounce: (t: number) => 1 - Easing.easeOutBounce(1 - t), + easeOutBounce: (t: number) => { + const n1 = 7.5625; + const d1 = 2.75; + if (t < 1 / d1) { + return n1 * t * t; + } else if (t < 2 / d1) { + return n1 * (t -= 1.5 / d1) * t + 0.75; + } else if (t < 2.5 / d1) { + return n1 * (t -= 2.25 / d1) * t + 0.9375; + } else { + return n1 * (t -= 2.625 / d1) * t + 0.984375; + } + }, + easeInOutBounce: (t: number) => t < 0.5 + ? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 + : (1 + Easing.easeOutBounce(2 * t - 1)) / 2, + + // 简化别名 + easeIn: (t: number) => t * t, + easeOut: (t: number) => t * (2 - t), + easeInOut: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t +}; + +/** + * 缓动函数名称映射 + * Easing function name mapping + */ +export type EasingName = keyof typeof Easing; + +/** + * UI 动画系统 + * UI Animation System - Handles value interpolation and animations + */ +@ECSSystem('UIAnimation') +export class UIAnimationSystem extends EntitySystem { + constructor() { + // 匹配任何可能有动画的组件 + super(Matcher.empty()); + } + + /** + * 获取缓动函数 + * Get easing function by name + */ + public getEasingFunction(name: string): EasingFunction { + return (Easing as Record)[name] ?? Easing.linear; + } + + protected process(entities: readonly Entity[]): void { + const dt = Time.deltaTime; + + for (const entity of entities) { + // 处理进度条动画 + this.updateProgressBar(entity, dt); + + // 处理滑块动画 + this.updateSlider(entity, dt); + + // 处理按钮颜色动画 + this.updateButtonColor(entity, dt); + } + } + + /** + * 更新进度条动画 + * Update progress bar animation + */ + private updateProgressBar(entity: Entity, dt: number): void { + const progress = entity.getComponent(UIProgressBarComponent); + if (!progress) return; + + // 如果目标值和显示值不同,进行插值 + if (progress.displayValue !== progress.targetValue) { + const easingFn = this.getEasingFunction(progress.easing); + const range = progress.maxValue - progress.minValue; + const speed = range / progress.transitionDuration; + + const diff = progress.targetValue - progress.displayValue; + const direction = Math.sign(diff); + const step = Math.min(Math.abs(diff), speed * dt); + + progress.displayValue += direction * step; + + // 接近目标时直接设置 + if (Math.abs(progress.displayValue - progress.targetValue) < 0.01) { + progress.displayValue = progress.targetValue; + } + + progress.value = progress.displayValue; + } + } + + /** + * 更新滑块动画 + * Update slider animation + */ + private updateSlider(entity: Entity, dt: number): void { + const slider = entity.getComponent(UISliderComponent); + if (!slider) return; + + // 如果正在拖拽,直接设置(不做动画) + if (slider.dragging) { + slider.displayValue = slider.targetValue; + slider.value = slider.targetValue; + return; + } + + // 平滑插值 + if (slider.displayValue !== slider.targetValue) { + const range = slider.maxValue - slider.minValue; + const speed = range / slider.transitionDuration; + + const diff = slider.targetValue - slider.displayValue; + const direction = Math.sign(diff); + const step = Math.min(Math.abs(diff), speed * dt); + + slider.displayValue += direction * step; + + if (Math.abs(slider.displayValue - slider.targetValue) < 0.01) { + slider.displayValue = slider.targetValue; + } + + slider.value = slider.displayValue; + } + } + + /** + * 更新按钮颜色动画 + * Update button color animation + */ + private updateButtonColor(entity: Entity, dt: number): void { + const button = entity.getComponent(UIButtonComponent); + if (!button) return; + + if (button.currentColor !== button.targetColor) { + // 颜色插值 + button.currentColor = this.lerpColor( + button.currentColor, + button.targetColor, + Math.min(1, dt / button.transitionDuration) + ); + } + } + + /** + * 颜色线性插值 + * Linear interpolate between two colors + */ + private lerpColor(from: number, to: number, t: number): number { + const fromR = (from >> 16) & 0xFF; + const fromG = (from >> 8) & 0xFF; + const fromB = from & 0xFF; + + const toR = (to >> 16) & 0xFF; + const toG = (to >> 8) & 0xFF; + const toB = to & 0xFF; + + const r = Math.round(fromR + (toR - fromR) * t); + const g = Math.round(fromG + (toG - fromG) * t); + const b = Math.round(fromB + (toB - fromB) * t); + + return (r << 16) | (g << 8) | b; + } + + /** + * 数值线性插值 + * Linear interpolate between two values + */ + public lerp(from: number, to: number, t: number): number { + return from + (to - from) * t; + } + + /** + * 应用缓动的插值 + * Interpolate with easing + */ + public ease(from: number, to: number, t: number, easing: EasingName = 'linear'): number { + const easingFn = this.getEasingFunction(easing); + return this.lerp(from, to, easingFn(t)); + } +} diff --git a/packages/ui/src/systems/UIInputSystem.ts b/packages/ui/src/systems/UIInputSystem.ts new file mode 100644 index 00000000..7e83e183 --- /dev/null +++ b/packages/ui/src/systems/UIInputSystem.ts @@ -0,0 +1,435 @@ +import { EntitySystem, Matcher, Entity, Time, ECSSystem } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../components/UITransformComponent'; +import { UIInteractableComponent } from '../components/UIInteractableComponent'; +import { UIButtonComponent } from '../components/widgets/UIButtonComponent'; +import { UISliderComponent } from '../components/widgets/UISliderComponent'; + +/** + * 鼠标按钮 + * Mouse buttons + */ +export enum MouseButton { + Left = 0, + Middle = 1, + Right = 2 +} + +/** + * 输入事件数据 + * Input event data + */ +export interface UIInputEvent { + x: number; + y: number; + button: MouseButton; + deltaX?: number; + deltaY?: number; + wheelDelta?: number; +} + +/** + * UI 输入系统 + * UI Input System - Handles mouse/touch input for UI elements + */ +@ECSSystem('UIInput') +export class UIInputSystem extends EntitySystem { + // ===== 鼠标状态 Mouse State ===== + + private mouseX: number = 0; + private mouseY: number = 0; + private prevMouseX: number = 0; + private prevMouseY: number = 0; + private mouseButtons: boolean[] = [false, false, false]; + private prevMouseButtons: boolean[] = [false, false, false]; + + // ===== 拖拽状态 Drag State ===== + + private dragStartX: number = 0; + private dragStartY: number = 0; + private dragTarget: Entity | null = null; + + // ===== 焦点状态 Focus State ===== + + private focusedEntity: Entity | null = null; + + // ===== 双击检测 Double Click Detection ===== + + private lastClickTime: number = 0; + private lastClickEntity: Entity | null = null; + private doubleClickThreshold: number = 300; // ms + + // ===== 事件监听器 Event Listeners ===== + + private canvas: HTMLCanvasElement | null = null; + private boundMouseMove: (e: MouseEvent) => void; + private boundMouseDown: (e: MouseEvent) => void; + private boundMouseUp: (e: MouseEvent) => void; + private boundWheel: (e: WheelEvent) => void; + + constructor() { + super(Matcher.empty().all(UITransformComponent, UIInteractableComponent)); + + this.boundMouseMove = this.onMouseMove.bind(this); + this.boundMouseDown = this.onMouseDown.bind(this); + this.boundMouseUp = this.onMouseUp.bind(this); + this.boundWheel = this.onWheel.bind(this); + } + + /** + * 绑定到 Canvas 元素 + * Bind to canvas element + */ + public bindToCanvas(canvas: HTMLCanvasElement): void { + this.unbind(); + this.canvas = canvas; + + canvas.addEventListener('mousemove', this.boundMouseMove); + canvas.addEventListener('mousedown', this.boundMouseDown); + canvas.addEventListener('mouseup', this.boundMouseUp); + canvas.addEventListener('wheel', this.boundWheel); + + // 阻止右键菜单 + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + } + + /** + * 解绑事件 + * Unbind events + */ + public unbind(): void { + if (this.canvas) { + this.canvas.removeEventListener('mousemove', this.boundMouseMove); + this.canvas.removeEventListener('mousedown', this.boundMouseDown); + this.canvas.removeEventListener('mouseup', this.boundMouseUp); + this.canvas.removeEventListener('wheel', this.boundWheel); + this.canvas = null; + } + } + + /** + * 手动设置鼠标位置(用于非 DOM 环境) + * Manually set mouse position (for non-DOM environments) + */ + public setMousePosition(x: number, y: number): void { + this.prevMouseX = this.mouseX; + this.prevMouseY = this.mouseY; + this.mouseX = x; + this.mouseY = y; + } + + /** + * 手动设置鼠标按钮状态 + * Manually set mouse button state + */ + public setMouseButton(button: MouseButton, pressed: boolean): void { + this.prevMouseButtons[button] = this.mouseButtons[button]!; + this.mouseButtons[button] = pressed; + } + + private onMouseMove(e: MouseEvent): void { + const rect = this.canvas!.getBoundingClientRect(); + this.setMousePosition(e.clientX - rect.left, e.clientY - rect.top); + } + + private onMouseDown(e: MouseEvent): void { + this.setMouseButton(e.button as MouseButton, true); + } + + private onMouseUp(e: MouseEvent): void { + this.setMouseButton(e.button as MouseButton, false); + } + + private onWheel(_e: WheelEvent): void { + // TODO: 处理滚轮事件 + } + + protected process(entities: readonly Entity[]): void { + const dt = Time.deltaTime; + + // 按 zIndex 从高到低排序,确保上层元素优先处理 + const sorted = [...entities].sort((a, b) => { + const ta = a.getComponent(UITransformComponent)!; + const tb = b.getComponent(UITransformComponent)!; + return tb.zIndex - ta.zIndex; + }); + + let consumed = false; + let hoveredEntity: Entity | null = null; + + // 处理悬停和点击 + for (const entity of sorted) { + const transform = entity.getComponent(UITransformComponent)!; + const interactable = entity.getComponent(UIInteractableComponent)!; + + // 跳过不可见或禁用的元素 + if (!transform.visible || !interactable.enabled) { + // 如果之前悬停,触发离开 + if (interactable.hovered) { + this.handleMouseLeave(entity, interactable); + } + continue; + } + + // 更新悬停计时器 + if (interactable.hovered && interactable.hoverDelay > 0) { + interactable.hoverTimer += dt * 1000; + if (interactable.hoverTimer >= interactable.hoverDelay && !interactable.hoverReady) { + interactable.hoverReady = true; + } + } + + // 命中测试 + const hit = !consumed && transform.containsPoint(this.mouseX, this.mouseY); + + if (hit) { + hoveredEntity = entity; + + // 处理鼠标进入 + if (!interactable.hovered) { + this.handleMouseEnter(entity, interactable); + } + + interactable.hovered = true; + + // 处理按下状态 + const wasPressed = interactable.pressed; + interactable.pressed = this.mouseButtons[MouseButton.Left]!; + + // 处理按下事件 + if (!wasPressed && interactable.pressed) { + this.handlePressDown(entity, interactable); + } + + // 处理释放事件(点击) + if (wasPressed && !interactable.pressed) { + this.handlePressUp(entity, interactable); + this.handleClick(entity, interactable); + } + + // 处理拖拽 + if (interactable.draggable) { + this.handleDrag(entity, interactable); + } + + // 处理特殊控件 + this.handleSlider(entity); + this.handleButton(entity, interactable); + + // 阻止事件传递到下层 + if (interactable.blockEvents) { + consumed = true; + } + } else { + // 鼠标不在元素上 + if (interactable.hovered) { + this.handleMouseLeave(entity, interactable); + } + interactable.hovered = false; + + // 如果按下状态但鼠标移开,保持按下直到释放 + if (interactable.pressed && !this.mouseButtons[MouseButton.Left]) { + interactable.pressed = false; + } + } + } + + // 更新光标 + this.updateCursor(hoveredEntity); + + // 保存上一帧状态 + this.prevMouseButtons = [...this.mouseButtons]; + } + + private handleMouseEnter(entity: Entity, interactable: UIInteractableComponent): void { + interactable.hoverTimer = 0; + interactable.hoverReady = false; + interactable.onMouseEnter?.(); + } + + private handleMouseLeave(_entity: Entity, interactable: UIInteractableComponent): void { + interactable.hovered = false; + interactable.hoverTimer = 0; + interactable.hoverReady = false; + interactable.onMouseLeave?.(); + } + + private handlePressDown(entity: Entity, interactable: UIInteractableComponent): void { + interactable.onPressDown?.(); + + // 设置焦点 + if (interactable.focusable) { + this.setFocus(entity); + } + + // 开始拖拽 + if (interactable.draggable) { + this.dragTarget = entity; + this.dragStartX = this.mouseX; + this.dragStartY = this.mouseY; + interactable.dragging = true; + interactable.onDragStart?.(this.mouseX, this.mouseY); + } + } + + private handlePressUp(_entity: Entity, interactable: UIInteractableComponent): void { + interactable.onPressUp?.(); + + // 结束拖拽 + if (interactable.dragging) { + interactable.dragging = false; + interactable.onDragEnd?.(this.mouseX, this.mouseY); + this.dragTarget = null; + } + } + + private handleClick(entity: Entity, interactable: UIInteractableComponent): void { + // 检测双击 + const now = Date.now(); + if (this.lastClickEntity === entity && now - this.lastClickTime < this.doubleClickThreshold) { + interactable.onDoubleClick?.(); + this.lastClickEntity = null; + this.lastClickTime = 0; + } else { + interactable.onClick?.(); + this.lastClickEntity = entity; + this.lastClickTime = now; + } + } + + private handleDrag(entity: Entity, interactable: UIInteractableComponent): void { + if (interactable.dragging && this.dragTarget === entity) { + const deltaX = this.mouseX - this.prevMouseX; + const deltaY = this.mouseY - this.prevMouseY; + + if (deltaX !== 0 || deltaY !== 0) { + interactable.onDragMove?.(this.mouseX, this.mouseY, deltaX, deltaY); + } + } + } + + private handleSlider(entity: Entity): void { + const slider = entity.getComponent(UISliderComponent); + if (!slider) return; + + const transform = entity.getComponent(UITransformComponent)!; + + // 更新手柄悬停状态 + // TODO: 更精确的手柄命中测试 + + // 处理拖拽 + if (this.mouseButtons[MouseButton.Left] && transform.containsPoint(this.mouseX, this.mouseY)) { + if (!slider.dragging) { + slider.dragging = true; + slider.dragStartValue = slider.value; + slider.dragStartPosition = this.mouseX; + slider.onDragStart?.(slider.value); + } + + // 计算新值 + const relativeX = this.mouseX - transform.worldX; + const progress = Math.max(0, Math.min(1, relativeX / transform.computedWidth)); + const newValue = slider.minValue + progress * (slider.maxValue - slider.minValue); + + if (newValue !== slider.targetValue) { + slider.setValue(newValue); + slider.onChange?.(slider.targetValue); + } + } else if (slider.dragging && !this.mouseButtons[MouseButton.Left]) { + slider.dragging = false; + slider.onDragEnd?.(slider.value); + } + } + + private handleButton(entity: Entity, interactable: UIInteractableComponent): void { + const button = entity.getComponent(UIButtonComponent); + if (!button || button.disabled) return; + + // 更新目标颜色 + button.targetColor = button.getStateColor(interactable.getState()); + + // 处理长按 + if (interactable.pressed) { + button.pressTimer += Time.deltaTime * 1000; + if (button.pressTimer >= button.longPressThreshold && !button.longPressTriggered) { + button.longPressTriggered = true; + button.onLongPress?.(); + } + } else { + button.pressTimer = 0; + button.longPressTriggered = false; + } + + // 处理点击 + if (interactable.getState() === 'normal' && this.prevMouseButtons[MouseButton.Left] && !this.mouseButtons[MouseButton.Left]) { + // 点击在 handleClick 中处理 + } + } + + private updateCursor(hoveredEntity: Entity | null): void { + if (!this.canvas) return; + + if (hoveredEntity) { + const interactable = hoveredEntity.getComponent(UIInteractableComponent); + if (interactable) { + this.canvas.style.cursor = interactable.cursor; + return; + } + } + + this.canvas.style.cursor = 'default'; + } + + /** + * 设置焦点到指定元素 + * Set focus to specified element + */ + public setFocus(entity: Entity | null): void { + // 移除旧焦点 + if (this.focusedEntity && this.focusedEntity !== entity) { + const oldInteractable = this.focusedEntity.getComponent(UIInteractableComponent); + if (oldInteractable) { + oldInteractable.focused = false; + oldInteractable.onBlur?.(); + } + } + + this.focusedEntity = entity; + + // 设置新焦点 + if (entity) { + const interactable = entity.getComponent(UIInteractableComponent); + if (interactable && interactable.focusable) { + interactable.focused = true; + interactable.onFocus?.(); + } + } + } + + /** + * 获取当前焦点元素 + * Get currently focused element + */ + public getFocusedEntity(): Entity | null { + return this.focusedEntity; + } + + /** + * 获取鼠标位置 + * Get mouse position + */ + public getMousePosition(): { x: number; y: number } { + return { x: this.mouseX, y: this.mouseY }; + } + + /** + * 检查鼠标按钮是否按下 + * Check if mouse button is pressed + */ + public isMouseButtonPressed(button: MouseButton): boolean { + return this.mouseButtons[button] ?? false; + } + + protected onDestroy(): void { + this.unbind(); + } +} diff --git a/packages/ui/src/systems/UILayoutSystem.ts b/packages/ui/src/systems/UILayoutSystem.ts new file mode 100644 index 00000000..dc8b418e --- /dev/null +++ b/packages/ui/src/systems/UILayoutSystem.ts @@ -0,0 +1,444 @@ +import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../components/UITransformComponent'; +import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from '../components/UILayoutComponent'; + +/** + * UI 布局系统 + * UI Layout System - Computes layout for UI elements + * + * 计算 UI 元素的世界坐标和尺寸 + * Computes world coordinates and sizes for UI elements + */ +@ECSSystem('UILayout') +export class UILayoutSystem extends EntitySystem { + /** + * 视口宽度 + * Viewport width + */ + public viewportWidth: number = 1920; + + /** + * 视口高度 + * Viewport height + */ + public viewportHeight: number = 1080; + + constructor() { + super(Matcher.empty().all(UITransformComponent)); + } + + /** + * 设置视口尺寸 + * Set viewport size + */ + public setViewport(width: number, height: number): void { + this.viewportWidth = width; + this.viewportHeight = height; + + // 标记所有元素需要重新布局 + for (const entity of this.entities) { + const transform = entity.getComponent(UITransformComponent); + if (transform) { + transform.layoutDirty = true; + } + } + } + + protected process(entities: readonly Entity[]): void { + // 首先处理根元素(没有父元素的) + const rootEntities = entities.filter(e => !e.parent || !e.parent.hasComponent(UITransformComponent)); + + for (const entity of rootEntities) { + this.layoutEntity(entity, 0, 0, this.viewportWidth, this.viewportHeight, 1); + } + } + + /** + * 递归布局实体及其子元素 + * Recursively layout entity and its children + */ + private layoutEntity( + entity: Entity, + parentX: number, + parentY: number, + parentWidth: number, + parentHeight: number, + parentAlpha: number + ): void { + const transform = entity.getComponent(UITransformComponent); + if (!transform) return; + + // 计算锚点位置 + const anchorMinX = parentX + parentWidth * transform.anchorMinX; + const anchorMinY = parentY + parentHeight * transform.anchorMinY; + const anchorMaxX = parentX + parentWidth * transform.anchorMaxX; + const anchorMaxY = parentY + parentHeight * transform.anchorMaxY; + + // 计算元素尺寸 + let width: number; + let height: number; + + // 如果锚点 min 和 max 相同,使用固定尺寸 + if (transform.anchorMinX === transform.anchorMaxX) { + width = transform.width; + } else { + // 拉伸模式:尺寸由锚点决定 + width = anchorMaxX - anchorMinX - transform.x; + } + + if (transform.anchorMinY === transform.anchorMaxY) { + height = transform.height; + } else { + height = anchorMaxY - anchorMinY - transform.y; + } + + // 应用尺寸约束 + if (transform.minWidth > 0) width = Math.max(width, transform.minWidth); + if (transform.maxWidth > 0) width = Math.min(width, transform.maxWidth); + if (transform.minHeight > 0) height = Math.max(height, transform.minHeight); + if (transform.maxHeight > 0) height = Math.min(height, transform.maxHeight); + + // 计算世界位置 + let worldX: number; + let worldY: number; + + if (transform.anchorMinX === transform.anchorMaxX) { + // 固定锚点模式 + worldX = anchorMinX + transform.x - width * transform.pivotX; + } else { + // 拉伸模式 + worldX = anchorMinX + transform.x; + } + + if (transform.anchorMinY === transform.anchorMaxY) { + worldY = anchorMinY + transform.y - height * transform.pivotY; + } else { + worldY = anchorMinY + transform.y; + } + + // 更新计算后的值 + transform.worldX = worldX; + transform.worldY = worldY; + transform.computedWidth = width; + transform.computedHeight = height; + transform.worldAlpha = parentAlpha * transform.alpha; + transform.layoutDirty = false; + + // 如果元素不可见,跳过子元素 + if (!transform.visible) return; + + // 处理子元素布局 + const children = entity.children.filter(c => c.hasComponent(UITransformComponent)); + if (children.length === 0) return; + + // 检查是否有布局组件 + const layout = entity.getComponent(UILayoutComponent); + if (layout && layout.type !== UILayoutType.None) { + this.layoutChildren(layout, transform, children); + } else { + // 无布局组件,直接递归处理子元素 + for (const child of children) { + this.layoutEntity( + child, + worldX, + worldY, + width, + height, + transform.worldAlpha + ); + } + } + } + + /** + * 根据布局组件布局子元素 + * Layout children according to layout component + */ + private layoutChildren( + layout: UILayoutComponent, + parentTransform: UITransformComponent, + children: Entity[] + ): void { + const contentStartX = parentTransform.worldX + layout.paddingLeft; + const contentStartY = parentTransform.worldY + layout.paddingTop; + const contentWidth = parentTransform.computedWidth - layout.getHorizontalPadding(); + const contentHeight = parentTransform.computedHeight - layout.getVerticalPadding(); + + switch (layout.type) { + case UILayoutType.Horizontal: + this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight); + break; + case UILayoutType.Vertical: + this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight); + break; + case UILayoutType.Grid: + this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight); + break; + default: + // 默认按正常方式递归 + for (const child of children) { + this.layoutEntity( + child, + parentTransform.worldX, + parentTransform.worldY, + parentTransform.computedWidth, + parentTransform.computedHeight, + parentTransform.worldAlpha + ); + } + } + } + + /** + * 水平布局 + * Horizontal layout + */ + private layoutHorizontal( + layout: UILayoutComponent, + parentTransform: UITransformComponent, + children: Entity[], + startX: number, + startY: number, + contentWidth: number, + contentHeight: number + ): void { + // 计算总子元素宽度 + const childSizes = children.map(child => { + const t = child.getComponent(UITransformComponent)!; + return { entity: child, width: t.width, height: t.height }; + }); + + const totalChildWidth = childSizes.reduce((sum, c) => sum + c.width, 0); + const totalGap = layout.gap * (children.length - 1); + const totalWidth = totalChildWidth + totalGap; + + // 计算起始位置(基于 justifyContent) + let offsetX = startX; + let gap = layout.gap; + + switch (layout.justifyContent) { + case UIJustifyContent.Center: + offsetX = startX + (contentWidth - totalWidth) / 2; + break; + case UIJustifyContent.End: + offsetX = startX + contentWidth - totalWidth; + break; + case UIJustifyContent.SpaceBetween: + if (children.length > 1) { + gap = (contentWidth - totalChildWidth) / (children.length - 1); + } + break; + case UIJustifyContent.SpaceAround: + if (children.length > 0) { + const space = (contentWidth - totalChildWidth) / children.length; + gap = space; + offsetX = startX + space / 2; + } + break; + case UIJustifyContent.SpaceEvenly: + if (children.length > 0) { + const space = (contentWidth - totalChildWidth) / (children.length + 1); + gap = space; + offsetX = startX + space; + } + break; + } + + // 布局每个子元素 + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + const childTransform = child.getComponent(UITransformComponent)!; + const size = childSizes[i]!; + + // 计算 Y 位置(基于 alignItems) + let childY = startY; + let childHeight = size.height; + + switch (layout.alignItems) { + case UIAlignItems.Center: + childY = startY + (contentHeight - childHeight) / 2; + break; + case UIAlignItems.End: + childY = startY + contentHeight - childHeight; + break; + case UIAlignItems.Stretch: + childHeight = contentHeight; + break; + } + + // 直接设置子元素的世界坐标 + childTransform.worldX = offsetX; + childTransform.worldY = childY; + childTransform.computedWidth = size.width; + childTransform.computedHeight = childHeight; + childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha; + childTransform.layoutDirty = false; + + // 递归处理子元素的子元素 + this.processChildrenRecursive(child, childTransform); + + offsetX += size.width + gap; + } + } + + /** + * 垂直布局 + * Vertical layout + */ + private layoutVertical( + layout: UILayoutComponent, + parentTransform: UITransformComponent, + children: Entity[], + startX: number, + startY: number, + contentWidth: number, + contentHeight: number + ): void { + // 计算总子元素高度 + const childSizes = children.map(child => { + const t = child.getComponent(UITransformComponent)!; + return { entity: child, width: t.width, height: t.height }; + }); + + const totalChildHeight = childSizes.reduce((sum, c) => sum + c.height, 0); + const totalGap = layout.gap * (children.length - 1); + const totalHeight = totalChildHeight + totalGap; + + // 计算起始位置 + let offsetY = startY; + let gap = layout.gap; + + switch (layout.justifyContent) { + case UIJustifyContent.Center: + offsetY = startY + (contentHeight - totalHeight) / 2; + break; + case UIJustifyContent.End: + offsetY = startY + contentHeight - totalHeight; + break; + case UIJustifyContent.SpaceBetween: + if (children.length > 1) { + gap = (contentHeight - totalChildHeight) / (children.length - 1); + } + break; + case UIJustifyContent.SpaceAround: + if (children.length > 0) { + const space = (contentHeight - totalChildHeight) / children.length; + gap = space; + offsetY = startY + space / 2; + } + break; + case UIJustifyContent.SpaceEvenly: + if (children.length > 0) { + const space = (contentHeight - totalChildHeight) / (children.length + 1); + gap = space; + offsetY = startY + space; + } + break; + } + + // 布局每个子元素 + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + const childTransform = child.getComponent(UITransformComponent)!; + const size = childSizes[i]!; + + // 计算 X 位置 + let childX = startX; + let childWidth = size.width; + + switch (layout.alignItems) { + case UIAlignItems.Center: + childX = startX + (contentWidth - childWidth) / 2; + break; + case UIAlignItems.End: + childX = startX + contentWidth - childWidth; + break; + case UIAlignItems.Stretch: + childWidth = contentWidth; + break; + } + + childTransform.worldX = childX; + childTransform.worldY = offsetY; + childTransform.computedWidth = childWidth; + childTransform.computedHeight = size.height; + childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha; + childTransform.layoutDirty = false; + + this.processChildrenRecursive(child, childTransform); + + offsetY += size.height + gap; + } + } + + /** + * 网格布局 + * Grid layout + */ + private layoutGrid( + layout: UILayoutComponent, + parentTransform: UITransformComponent, + children: Entity[], + startX: number, + startY: number, + contentWidth: number, + _contentHeight: number + ): void { + const columns = layout.columns; + const gapX = layout.getHorizontalGap(); + const gapY = layout.getVerticalGap(); + + // 计算单元格尺寸 + const cellWidth = layout.cellWidth > 0 + ? layout.cellWidth + : (contentWidth - gapX * (columns - 1)) / columns; + const cellHeight = layout.cellHeight > 0 + ? layout.cellHeight + : cellWidth; // 默认正方形 + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + const childTransform = child.getComponent(UITransformComponent)!; + + const col = i % columns; + const row = Math.floor(i / columns); + + const x = startX + col * (cellWidth + gapX); + const y = startY + row * (cellHeight + gapY); + + childTransform.worldX = x; + childTransform.worldY = y; + childTransform.computedWidth = cellWidth; + childTransform.computedHeight = cellHeight; + childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha; + childTransform.layoutDirty = false; + + this.processChildrenRecursive(child, childTransform); + } + } + + /** + * 递归处理子元素 + * Recursively process children + */ + private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent): void { + const children = entity.children.filter(c => c.hasComponent(UITransformComponent)); + if (children.length === 0) return; + + const layout = entity.getComponent(UILayoutComponent); + if (layout && layout.type !== UILayoutType.None) { + this.layoutChildren(layout, parentTransform, children); + } else { + for (const child of children) { + this.layoutEntity( + child, + parentTransform.worldX, + parentTransform.worldY, + parentTransform.computedWidth, + parentTransform.computedHeight, + parentTransform.worldAlpha + ); + } + } + } +} diff --git a/packages/ui/src/systems/UIRenderDataProvider.ts b/packages/ui/src/systems/UIRenderDataProvider.ts new file mode 100644 index 00000000..76e7e53a --- /dev/null +++ b/packages/ui/src/systems/UIRenderDataProvider.ts @@ -0,0 +1,413 @@ +import { Core, Entity } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../components/UITransformComponent'; +import { UIRenderComponent } from '../components/UIRenderComponent'; +import { UITextComponent } from '../components/UITextComponent'; +import { UIButtonComponent } from '../components/widgets/UIButtonComponent'; + +export interface UIRenderData { + x: number; + y: number; + width: number; + height: number; + rotation: number; + originX: number; + originY: number; + backgroundColor: number; + backgroundAlpha: number; + borderColor: number; + borderWidth: number; + cornerRadius: number; + zIndex: number; + visible: boolean; + text?: { + content: string; + fontSize: number; + fontFamily: string; + color: number; + alpha: number; + align: string; + verticalAlign: string; + }; +} + +export interface ProviderRenderData { + transforms: Float32Array; + textureIds: Uint32Array; + uvs: Float32Array; + colors: Uint32Array; + tileCount: number; + sortingOrder: number; + texturePath?: string; +} + +export interface IRenderDataProvider { + getRenderData(): readonly ProviderRenderData[]; +} + +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; +} + +export class UIRenderDataProvider implements IRenderDataProvider { + private textCanvas: HTMLCanvasElement | null = null; + private textCtx: CanvasRenderingContext2D | null = null; + private textTextureCache: Map = new Map(); + private nextTextureId = 90000; + private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null; + + setTextureCallback(callback: (id: number, dataUrl: string) => void): void { + this.onTextureCreated = callback; + } + + 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 }; + } + + getRenderData(): readonly ProviderRenderData[] { + const scene = Core.scene; + if (!scene) return []; + + const uiEntities: Entity[] = []; + for (const entity of scene.entities.buffer) { + if (entity.hasComponent(UITransformComponent)) { + uiEntities.push(entity); + } + } + + if (uiEntities.length === 0) return []; + + uiEntities.sort((a, b) => { + const ta = a.getComponent(UITransformComponent); + const tb = b.getComponent(UITransformComponent); + return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0); + }); + + const renderDataList: ProviderRenderData[] = []; + + for (const entity of uiEntities) { + const transform = entity.getComponent(UITransformComponent); + const render = entity.getComponent(UIRenderComponent); + const text = entity.getComponent(UITextComponent); + const button = entity.getComponent(UIButtonComponent); + + if (!transform || !transform.visible) continue; + + const width = transform.width * transform.scaleX; + const height = transform.height * transform.scaleY; + const centerX = transform.x + width * transform.pivotX; + const centerY = transform.y + height * transform.pivotY; + + // Button with texture support + if (button && button.useTexture()) { + const texture = button.getStateTexture('normal'); + if (texture) { + const transforms = new Float32Array(7); + transforms[0] = centerX; + transforms[1] = centerY; + transforms[2] = transform.rotation; + transforms[3] = width; + transforms[4] = height; + transforms[5] = transform.pivotX; + transforms[6] = transform.pivotY; + + const colors = new Uint32Array(1); + const a = Math.round(transform.alpha * 255); + colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF; + + renderDataList.push({ + transforms, + textureIds: new Uint32Array([0]), + uvs: new Float32Array([0, 0, 1, 1]), + colors, + tileCount: 1, + sortingOrder: 100 + transform.zIndex, + texturePath: texture + }); + } + } + + // Background color rendering (for buttons in 'color' or 'both' mode, or regular UI elements) + const shouldRenderColor = button + ? button.useColor() && render && render.backgroundAlpha > 0 + : render && render.backgroundAlpha > 0; + + if (shouldRenderColor && render) { + const transforms = new Float32Array(7); + transforms[0] = centerX; + transforms[1] = centerY; + transforms[2] = transform.rotation; + transforms[3] = width; + transforms[4] = height; + transforms[5] = transform.pivotX; + transforms[6] = transform.pivotY; + + const colors = new Uint32Array(1); + const bgColor = button ? button.currentColor : render.backgroundColor; + const r = (bgColor >> 16) & 0xFF; + const g = (bgColor >> 8) & 0xFF; + const b = bgColor & 0xFF; + const a = Math.round(render.backgroundAlpha * transform.alpha * 255); + colors[0] = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF); + + renderDataList.push({ + transforms, + textureIds: new Uint32Array([0]), + uvs: new Float32Array([0, 0, 1, 1]), + colors, + tileCount: 1, + sortingOrder: 100 + transform.zIndex + }); + } + + if (text && text.text) { + const textRenderData = this.createTextRenderData( + entity.id, + text, + centerX, + centerY, + width, + height, + transform + ); + if (textRenderData) { + renderDataList.push(textRenderData); + } + } + } + + return renderDataList; + } + + private createTextRenderData( + entityId: number, + text: UITextComponent, + centerX: number, + centerY: number, + width: number, + height: number, + transform: UITransformComponent + ): ProviderRenderData | null { + const canvasData = this.getTextCanvas(); + if (!canvasData) return null; + + const { canvas, ctx } = canvasData; + + const cacheKey = entityId; + const cached = this.textTextureCache.get(cacheKey); + + 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 !== Math.ceil(width) || + cached.height !== Math.ceil(height); + + if (needsUpdate) { + const canvasWidth = Math.max(1, Math.ceil(width)); + const canvasHeight = Math.max(1, Math.ceil(height)); + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + ctx.font = text.getCSSFont(); + ctx.fillStyle = text.getCSSColor(); + ctx.textBaseline = 'top'; + + 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; + } + + const metrics = ctx.measureText(text.text); + 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; + } + + if (text.wordWrap) { + this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight); + } else { + ctx.fillText(text.text, textX, textY); + } + + const textureId = cached?.textureId ?? this.nextTextureId++; + + const dataUrl = canvas.toDataURL('image/png'); + + if (this.onTextureCreated) { + this.onTextureCreated(textureId, dataUrl); + } + + this.textTextureCache.set(cacheKey, { + 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: canvasWidth, + height: canvasHeight, + dataUrl + }); + } + + const cachedData = this.textTextureCache.get(cacheKey); + if (!cachedData) return null; + + const transforms = new Float32Array(7); + transforms[0] = centerX; + transforms[1] = centerY; + transforms[2] = transform.rotation; + transforms[3] = width; + transforms[4] = height; + transforms[5] = transform.pivotX; + transforms[6] = transform.pivotY; + + const colors = new Uint32Array(1); + const a = Math.round(transform.alpha * 255); + colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF; + + return { + transforms, + textureIds: new Uint32Array([cachedData.textureId]), + uvs: new Float32Array([0, 0, 1, 1]), + colors, + tileCount: 1, + sortingOrder: 101 + transform.zIndex + }; + } + + 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); + } + } + + collectUIRenderData(): UIRenderData[] { + const scene = Core.scene; + if (!scene) return []; + + const result: UIRenderData[] = []; + + for (const entity of scene.entities.buffer) { + const transform = entity.getComponent(UITransformComponent); + if (!transform || !transform.visible) continue; + + const render = entity.getComponent(UIRenderComponent); + const text = entity.getComponent(UITextComponent); + + const data: UIRenderData = { + x: transform.x, + y: transform.y, + width: transform.width * transform.scaleX, + height: transform.height * transform.scaleY, + rotation: transform.rotation, + originX: transform.pivotX, + originY: transform.pivotY, + backgroundColor: render?.backgroundColor ?? 0, + backgroundAlpha: (render?.backgroundAlpha ?? 0) * transform.alpha, + borderColor: render?.borderColor ?? 0, + borderWidth: render?.borderWidth ?? 0, + cornerRadius: render?.borderRadius?.[0] ?? 0, + zIndex: transform.zIndex, + visible: transform.visible + }; + + if (text && text.text) { + data.text = { + content: text.text, + fontSize: text.fontSize, + fontFamily: text.fontFamily, + color: text.color, + alpha: text.alpha, + align: text.align, + verticalAlign: text.verticalAlign + }; + } + + result.push(data); + } + + result.sort((a, b) => a.zIndex - b.zIndex); + + return result; + } + + clearTextCache(): void { + this.textTextureCache.clear(); + } + + dispose(): void { + this.textCanvas = null; + this.textCtx = null; + this.textTextureCache.clear(); + this.onTextureCreated = null; + } +} diff --git a/packages/ui/src/systems/index.ts b/packages/ui/src/systems/index.ts new file mode 100644 index 00000000..8f6ae65b --- /dev/null +++ b/packages/ui/src/systems/index.ts @@ -0,0 +1,4 @@ +export * from './UILayoutSystem'; +export * from './UIInputSystem'; +export * from './UIAnimationSystem'; +export * from './UIRenderDataProvider'; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 00000000..effaa77f --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "allowImportingTsExtensions": false, + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "composite": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts new file mode 100644 index 00000000..b907b3e3 --- /dev/null +++ b/packages/ui/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + outDir: 'dist', + rollupTypes: true + }) + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + formats: ['es'], + fileName: () => 'index.js' + }, + rollupOptions: { + external: [ + '@esengine/ecs-framework', + /^@esengine\// + ], + output: { + exports: 'named', + preserveModules: false + } + }, + target: 'es2020', + minify: false, + sourcemap: true + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b7dd539..d2500e26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,7 +140,7 @@ importers: version: 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) unplugin-icons: specifier: ^22.3.0 - version: 22.5.0(@vue/compiler-sfc@3.5.24) + version: 22.5.0(@vue/compiler-sfc@3.5.24)(vue-template-compiler@2.7.16) vitepress: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.44.0)(@types/node@20.19.25)(@types/react@18.3.27)(axios@1.13.2)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(terser@5.44.1)(typescript@5.9.3) @@ -421,6 +421,12 @@ importers: '@esengine/tilemap-editor': specifier: workspace:* version: link:../tilemap-editor + '@esengine/ui': + specifier: workspace:* + version: link:../ui + '@esengine/ui-editor': + specifier: workspace:* + version: link:../ui-editor '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -867,6 +873,53 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/ui: + dependencies: + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + devDependencies: + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.19.25)(terser@5.44.1) + vite-plugin-dts: + specifier: ^3.7.0 + version: 3.9.1(@types/node@20.19.25)(rollup@4.53.3)(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) + + packages/ui-editor: + dependencies: + '@esengine/ecs-framework': + specifier: ^2.2.8 + version: link:../core + '@esengine/editor-core': + specifier: workspace:* + version: link:../editor-core + '@esengine/ui': + specifier: workspace:* + version: link:../ui + lucide-react: + specifier: ^0.545.0 + version: 0.545.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.27 + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages: '@algolia/abtesting@1.10.0': @@ -2276,16 +2329,29 @@ packages: resolution: {integrity: sha512-A8AlzetnS2WIuhijdAzKUyFpR5YbLLfV3luQ4lzBgIBgRfuoBDZeF+RSZPhra+7A6/zTUlrbhKZIOi/MNhqgvQ==} engines: {node: '>=18.0.0'} + '@microsoft/api-extractor-model@7.28.13': + resolution: {integrity: sha512-39v/JyldX4MS9uzHcdfmjjfS6cYGAoXV+io8B5a338pkHiSt+gy2eXQ0Q7cGFJ7quSa1VqqlMdlPrB6sLR/cAw==} + '@microsoft/api-extractor-model@7.32.1': resolution: {integrity: sha512-u4yJytMYiUAnhcNQcZDTh/tVtlrzKlyKrQnLOV+4Qr/5gV+cpufWzCYAB1Q23URFqD6z2RoL2UYncM9xJVGNKA==} + '@microsoft/api-extractor@7.43.0': + resolution: {integrity: sha512-GFhTcJpB+MI6FhvXEI9b2K0snulNLWHqC/BbcJtyNYcKUiw7l3Lgis5ApsYncJ0leALX7/of4XfmXk+maT111w==} + hasBin: true + '@microsoft/api-extractor@7.55.1': resolution: {integrity: sha512-l8Z+8qrLkZFM3HM95Dbpqs6G39fpCa7O5p8A7AkA6hSevxkgwsOlLrEuPv0ADOyj5dI1Af5WVDiwpKG/ya5G3w==} hasBin: true + '@microsoft/tsdoc-config@0.16.2': + resolution: {integrity: sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==} + '@microsoft/tsdoc-config@0.18.0': resolution: {integrity: sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==} + '@microsoft/tsdoc@0.14.2': + resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} + '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} @@ -2815,6 +2881,14 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@4.0.2': + resolution: {integrity: sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + '@rushstack/node-core-library@5.19.0': resolution: {integrity: sha512-BxAopbeWBvNJ6VGiUL+5lbJXywTdsnMeOS8j57Cn/xY10r6sV/gbsTlfYKjzVCUBZATX2eRzJHSMCchsMTGN6A==} peerDependencies: @@ -2831,9 +2905,20 @@ packages: '@types/node': optional: true + '@rushstack/rig-package@0.5.2': + resolution: {integrity: sha512-mUDecIJeH3yYGZs2a48k+pbhM6JYwWlgjs2Ca5f2n1G2/kgdgP9D/07oglEGf6mRyXEnazhEENeYTSNDRCwdqA==} + '@rushstack/rig-package@0.6.0': resolution: {integrity: sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==} + '@rushstack/terminal@0.10.0': + resolution: {integrity: sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + '@rushstack/terminal@0.19.4': resolution: {integrity: sha512-f4XQk02CrKfrMgyOfhYd3qWI944dLC21S4I/LUhrlAP23GTMDNG6EK5effQtFkISwUKCgD9vMBrJZaPSUquxWQ==} peerDependencies: @@ -2842,6 +2927,9 @@ packages: '@types/node': optional: true + '@rushstack/ts-command-line@4.19.1': + resolution: {integrity: sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==} + '@rushstack/ts-command-line@5.1.4': resolution: {integrity: sha512-H0I6VdJ6sOUbktDFpP2VW5N29w8v4hRoNZOQz02vtEi6ZTYL1Ju8u+TcFiFawUDrUsx/5MQTUhd79uwZZVwVlA==} @@ -3446,12 +3534,21 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@volar/language-core@1.11.1': + resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==} + '@volar/language-core@2.4.24': resolution: {integrity: sha512-eQEFG3A4f8zSDSKlcfKgQMhO5vCJogyPU1BPqYmov9uRgN5Uax3LuBZie0imfQ8uSx2JQJ1ESLhJy8hIPzqfng==} + '@volar/source-map@1.11.1': + resolution: {integrity: sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==} + '@volar/source-map@2.4.24': resolution: {integrity: sha512-H+M5K7n7AEvISvsBoBj0miZN5EJUs+ArbL41DxlyUPA0mLMGxkbQNKTf+9DgPUYntr+AYCdZz/N81eGQYYwj+A==} + '@volar/typescript@1.11.1': + resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==} + '@volar/typescript@2.4.24': resolution: {integrity: sha512-FBSCL02hcJhk92bGUm2Q0Q5i6vqa5aq1WpcFfnFxwH8OHyI+WflZBW69Z5PFtJwtmyKzuEf+370voJehfQr+6w==} @@ -3479,6 +3576,14 @@ packages: '@vue/devtools-shared@7.7.9': resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + '@vue/language-core@1.8.27': + resolution: {integrity: sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@vue/language-core@2.2.0': resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} peerDependencies: @@ -4043,6 +4148,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -4055,6 +4164,9 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + computeds@0.0.1: + resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -4759,6 +4871,10 @@ packages: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + fs-extra@8.1.0: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} @@ -5724,6 +5840,14 @@ packages: lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isfunction@3.0.9: resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} @@ -6164,6 +6288,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -7222,6 +7349,9 @@ packages: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} + resolve@1.19.0: + resolution: {integrity: sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -7881,6 +8011,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -8055,6 +8190,10 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validator@13.15.23: + resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} + engines: {node: '>= 0.10'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -8064,6 +8203,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-dts@3.9.1: + resolution: {integrity: sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite-plugin-dts@4.5.4: resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} peerDependencies: @@ -8179,6 +8328,15 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + + vue-tsc@1.8.27: + resolution: {integrity: sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==} + hasBin: true + peerDependencies: + typescript: '*' + vue@3.5.24: resolution: {integrity: sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==} peerDependencies: @@ -8365,6 +8523,11 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + zustand@5.0.8: resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} engines: {node: '>=12.20.0'} @@ -10045,6 +10208,14 @@ snapshots: - supports-color - typescript + '@microsoft/api-extractor-model@7.28.13(@types/node@20.19.25)': + dependencies: + '@microsoft/tsdoc': 0.14.2 + '@microsoft/tsdoc-config': 0.16.2 + '@rushstack/node-core-library': 4.0.2(@types/node@20.19.25) + transitivePeerDependencies: + - '@types/node' + '@microsoft/api-extractor-model@7.32.1(@types/node@20.19.25)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -10053,6 +10224,24 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/api-extractor@7.43.0(@types/node@20.19.25)': + dependencies: + '@microsoft/api-extractor-model': 7.28.13(@types/node@20.19.25) + '@microsoft/tsdoc': 0.14.2 + '@microsoft/tsdoc-config': 0.16.2 + '@rushstack/node-core-library': 4.0.2(@types/node@20.19.25) + '@rushstack/rig-package': 0.5.2 + '@rushstack/terminal': 0.10.0(@types/node@20.19.25) + '@rushstack/ts-command-line': 4.19.1(@types/node@20.19.25) + lodash: 4.17.21 + minimatch: 3.0.5 + resolve: 1.22.11 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.4.2 + transitivePeerDependencies: + - '@types/node' + '@microsoft/api-extractor@7.55.1(@types/node@20.19.25)': dependencies: '@microsoft/api-extractor-model': 7.32.1(@types/node@20.19.25) @@ -10072,6 +10261,13 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/tsdoc-config@0.16.2': + dependencies: + '@microsoft/tsdoc': 0.14.2 + ajv: 6.12.6 + jju: 1.4.0 + resolve: 1.19.0 + '@microsoft/tsdoc-config@0.18.0': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -10079,6 +10275,8 @@ snapshots: jju: 1.4.0 resolve: 1.22.11 + '@microsoft/tsdoc@0.14.2': {} + '@microsoft/tsdoc@0.16.0': {} '@monaco-editor/loader@1.7.0': @@ -10617,6 +10815,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@rushstack/node-core-library@4.0.2(@types/node@20.19.25)': + dependencies: + fs-extra: 7.0.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.11 + semver: 7.5.4 + z-schema: 5.0.5 + optionalDependencies: + '@types/node': 20.19.25 + '@rushstack/node-core-library@5.19.0(@types/node@20.19.25)': dependencies: ajv: 8.13.0 @@ -10634,11 +10843,23 @@ snapshots: optionalDependencies: '@types/node': 20.19.25 + '@rushstack/rig-package@0.5.2': + dependencies: + resolve: 1.22.11 + strip-json-comments: 3.1.1 + '@rushstack/rig-package@0.6.0': dependencies: resolve: 1.22.11 strip-json-comments: 3.1.1 + '@rushstack/terminal@0.10.0(@types/node@20.19.25)': + dependencies: + '@rushstack/node-core-library': 4.0.2(@types/node@20.19.25) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 20.19.25 + '@rushstack/terminal@0.19.4(@types/node@20.19.25)': dependencies: '@rushstack/node-core-library': 5.19.0(@types/node@20.19.25) @@ -10647,6 +10868,15 @@ snapshots: optionalDependencies: '@types/node': 20.19.25 + '@rushstack/ts-command-line@4.19.1(@types/node@20.19.25)': + dependencies: + '@rushstack/terminal': 0.10.0(@types/node@20.19.25) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@rushstack/ts-command-line@5.1.4(@types/node@20.19.25)': dependencies: '@rushstack/terminal': 0.19.4(@types/node@20.19.25) @@ -11391,12 +11621,25 @@ snapshots: vite: 5.4.21(@types/node@20.19.25)(terser@5.44.1) vue: 3.5.24(typescript@5.9.3) + '@volar/language-core@1.11.1': + dependencies: + '@volar/source-map': 1.11.1 + '@volar/language-core@2.4.24': dependencies: '@volar/source-map': 2.4.24 + '@volar/source-map@1.11.1': + dependencies: + muggle-string: 0.3.1 + '@volar/source-map@2.4.24': {} + '@volar/typescript@1.11.1': + dependencies: + '@volar/language-core': 1.11.1 + path-browserify: 1.0.1 + '@volar/typescript@2.4.24': dependencies: '@volar/language-core': 2.4.24 @@ -11456,6 +11699,20 @@ snapshots: dependencies: rfdc: 1.4.1 + '@vue/language-core@1.8.27(typescript@5.9.3)': + dependencies: + '@volar/language-core': 1.11.1 + '@volar/source-map': 1.11.1 + '@vue/compiler-dom': 3.5.24 + '@vue/shared': 3.5.24 + computeds: 0.0.1 + minimatch: 9.0.5 + muggle-string: 0.3.1 + path-browserify: 1.0.1 + vue-template-compiler: 2.7.16 + optionalDependencies: + typescript: 5.9.3 + '@vue/language-core@2.2.0(typescript@5.9.3)': dependencies: '@volar/language-core': 2.4.24 @@ -12048,6 +12305,9 @@ snapshots: commander@7.2.0: {} + commander@9.5.0: + optional: true + common-ancestor-path@1.0.1: {} commondir@1.0.1: {} @@ -12059,6 +12319,8 @@ snapshots: compare-versions@6.1.1: {} + computeds@0.0.1: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -12863,6 +13125,12 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fs-extra@8.1.0: dependencies: graceful-fs: 4.2.11 @@ -14191,6 +14459,10 @@ snapshots: lodash.escaperegexp@4.1.2: {} + lodash.get@4.4.2: {} + + lodash.isequal@4.5.0: {} + lodash.isfunction@3.0.9: {} lodash.ismatch@4.4.0: {} @@ -14826,6 +15098,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.3.1: {} + muggle-string@0.4.1: {} multimatch@5.0.0: @@ -15879,6 +16153,11 @@ snapshots: resolve.exports@2.0.3: {} + resolve@1.19.0: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -16619,6 +16898,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.4.2: {} + typescript@5.8.2: {} typescript@5.9.3: {} @@ -16708,7 +16989,7 @@ snapshots: universalify@2.0.1: {} - unplugin-icons@22.5.0(@vue/compiler-sfc@3.5.24): + unplugin-icons@22.5.0(@vue/compiler-sfc@3.5.24)(vue-template-compiler@2.7.16): dependencies: '@antfu/install-pkg': 1.1.0 '@iconify/utils': 3.0.2 @@ -16717,6 +16998,7 @@ snapshots: unplugin: 2.3.11 optionalDependencies: '@vue/compiler-sfc': 3.5.24 + vue-template-compiler: 2.7.16 transitivePeerDependencies: - supports-color @@ -16767,6 +17049,8 @@ snapshots: validate-npm-package-name@5.0.1: {} + validator@13.15.23: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -16782,6 +17066,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-dts@3.9.1(@types/node@20.19.25)(rollup@4.53.3)(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)): + dependencies: + '@microsoft/api-extractor': 7.43.0(@types/node@20.19.25) + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + '@vue/language-core': 1.8.27(typescript@5.9.3) + debug: 4.4.3 + kolorist: 1.8.0 + magic-string: 0.30.21 + typescript: 5.9.3 + vue-tsc: 1.8.27(typescript@5.9.3) + optionalDependencies: + vite: 5.4.21(@types/node@20.19.25)(terser@5.44.1) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-dts@4.5.4(@types/node@20.19.25)(rollup@4.53.3)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)): dependencies: '@microsoft/api-extractor': 7.55.1(@types/node@20.19.25) @@ -16903,6 +17204,18 @@ snapshots: vscode-uri@3.1.0: {} + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tsc@1.8.27(typescript@5.9.3): + dependencies: + '@volar/typescript': 1.11.1 + '@vue/language-core': 1.8.27(typescript@5.9.3) + semver: 7.7.3 + typescript: 5.9.3 + vue@3.5.24(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.24 @@ -17089,6 +17402,14 @@ snapshots: yoctocolors@2.1.2: {} + z-schema@5.0.5: + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.15.23 + optionalDependencies: + commander: 9.5.0 + zustand@5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.27