feat(editor): 添加 ECS UI 系统和编辑器更新优化 (#238)
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -12,7 +13,8 @@ export class PluginInstaller {
|
||||
new SceneInspectorPlugin(),
|
||||
new ProfilerPlugin(),
|
||||
new EditorAppearancePlugin(),
|
||||
new TilemapEditorPlugin()
|
||||
new TilemapEditorPlugin(),
|
||||
new UIEditorPlugin()
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>('1.0.0');
|
||||
const [newVersion, setNewVersion] = useState<string>('');
|
||||
|
||||
@@ -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 <RefreshCw size={16} className="animate-spin" />;
|
||||
case 'available':
|
||||
return <Download size={16} className="status-available" />;
|
||||
case 'installing':
|
||||
return <Loader2 size={16} className="animate-spin" />;
|
||||
case 'latest':
|
||||
return <Check size={16} className="status-latest" />;
|
||||
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) {
|
||||
<button
|
||||
className="update-btn"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking}
|
||||
disabled={checking || installing}
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
@@ -182,6 +207,26 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
|
||||
<span>{getStatusText()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatus === 'available' && (
|
||||
<button
|
||||
className="update-btn install-btn"
|
||||
onClick={handleInstallUpdate}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>{t('installing')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
<span>{t('download')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="about-links">
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
// 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 (
|
||||
<ColorField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? '#ffffff'}
|
||||
value={colorValue}
|
||||
readOnly={metadata.readOnly}
|
||||
onChange={(newValue) => 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 (
|
||||
<AssetDropField
|
||||
key={propertyName}
|
||||
label={label}
|
||||
value={value ?? ''}
|
||||
fileExtension={metadata.fileExtension}
|
||||
assetType={assetMeta.assetType}
|
||||
extensions={assetMeta.extensions}
|
||||
readOnly={metadata.readOnly || !!controlledBy}
|
||||
controlledBy={controlledBy}
|
||||
entityId={entity?.id?.toString()}
|
||||
@@ -800,20 +812,31 @@ function Vector3Field({ label, value, readOnly, onChange }: Vector3FieldProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type EnumOptionInput = string | { label: string; value: any };
|
||||
|
||||
interface EnumFieldProps {
|
||||
label: string;
|
||||
value: any;
|
||||
options: Array<{ label: string; value: any }>;
|
||||
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<HTMLDivElement>(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
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="property-dropdown-menu">
|
||||
{options.map((option, index) => (
|
||||
{safeOptions.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`property-dropdown-item ${option.value === value ? 'selected' : ''}`}
|
||||
@@ -865,18 +888,22 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
|
||||
interface AssetDropFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
fileExtension?: string;
|
||||
assetType?: string;
|
||||
extensions?: string[];
|
||||
readOnly?: boolean;
|
||||
controlledBy?: string;
|
||||
entityId?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
|
||||
function AssetDropField({ label, value, assetType, extensions, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
const canCreate = fileExtension && ['.tilemap.json', '.btree'].includes(fileExtension);
|
||||
// Determine if this asset type can be created
|
||||
const creatableExtensions = ['.tilemap.json', '.btree'];
|
||||
const canCreate = extensions?.some(ext => creatableExtensions.includes(ext));
|
||||
const fileExtension = extensions?.[0];
|
||||
|
||||
const handleCreate = () => {
|
||||
setShowSaveDialog(true);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Entity, Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react';
|
||||
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film, ChevronRight } from 'lucide-react';
|
||||
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { CreateEntityCommand, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
|
||||
@@ -532,58 +532,163 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
</div>
|
||||
|
||||
{contextMenu && !isShowingRemote && (
|
||||
<div
|
||||
className="context-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 1000
|
||||
<ContextMenuWithSubmenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
locale={locale}
|
||||
entityId={contextMenu.entityId}
|
||||
pluginTemplates={pluginTemplates}
|
||||
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
|
||||
onCreateSprite={() => { handleCreateSpriteEntity(); closeContextMenu(); }}
|
||||
onCreateAnimatedSprite={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}
|
||||
onCreateCamera={() => { handleCreateCameraEntity(); closeContextMenu(); }}
|
||||
onCreateFromTemplate={async (template) => {
|
||||
await template.create(contextMenu.entityId ?? undefined);
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<button onClick={() => { handleCreateEntity(); closeContextMenu(); }}>
|
||||
<Plus size={12} />
|
||||
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
|
||||
</button>
|
||||
<button onClick={() => { handleCreateSpriteEntity(); closeContextMenu(); }}>
|
||||
<Image size={12} />
|
||||
<span>{locale === 'zh' ? '创建 Sprite' : 'Create Sprite'}</span>
|
||||
</button>
|
||||
<button onClick={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}>
|
||||
<Film size={12} />
|
||||
<span>{locale === 'zh' ? '创建动画 Sprite' : 'Create Animated Sprite'}</span>
|
||||
</button>
|
||||
<button onClick={() => { handleCreateCameraEntity(); closeContextMenu(); }}>
|
||||
<Camera size={12} />
|
||||
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
|
||||
</button>
|
||||
{pluginTemplates.length > 0 && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
{pluginTemplates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={async () => {
|
||||
await template.create(contextMenu.entityId ?? undefined);
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
{template.icon || <Plus size={12} />}
|
||||
<span>{template.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{contextMenu.entityId && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<button onClick={() => { handleDeleteEntity(); closeContextMenu(); }}>
|
||||
<Trash2 size={12} />
|
||||
<span>{locale === 'zh' ? '删除实体' : 'Delete Entity'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
onDelete={() => { handleDeleteEntity(); closeContextMenu(); }}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const categoryLabels: Record<string, { zh: string; en: string }> = {
|
||||
'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<string, EntityCreationTemplate[]>);
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu"
|
||||
style={{ position: 'fixed', left: x, top: y, zIndex: 1000 }}
|
||||
>
|
||||
<button onClick={onCreateEmpty}>
|
||||
<Plus size={12} />
|
||||
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
|
||||
</button>
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
<div
|
||||
className="context-menu-item-with-submenu"
|
||||
onMouseEnter={(e) => handleSubmenuEnter('rendering', e)}
|
||||
onMouseLeave={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<button>
|
||||
<Image size={12} />
|
||||
<span>{locale === 'zh' ? '2D 对象' : '2D Objects'}</span>
|
||||
<ChevronRight size={12} className="submenu-arrow" />
|
||||
</button>
|
||||
{activeSubmenu === 'rendering' && (
|
||||
<div
|
||||
className="context-submenu"
|
||||
style={{ left: submenuPosition.x, top: submenuPosition.y }}
|
||||
onMouseEnter={() => setActiveSubmenu('rendering')}
|
||||
>
|
||||
<button onClick={onCreateSprite}>
|
||||
<Image size={12} />
|
||||
<span>Sprite</span>
|
||||
</button>
|
||||
<button onClick={onCreateAnimatedSprite}>
|
||||
<Film size={12} />
|
||||
<span>{locale === 'zh' ? '动画 Sprite' : 'Animated Sprite'}</span>
|
||||
</button>
|
||||
<button onClick={onCreateCamera}>
|
||||
<Camera size={12} />
|
||||
<span>{locale === 'zh' ? '相机' : 'Camera'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasPluginCategories && Object.entries(templatesByCategory).map(([category, templates]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="context-menu-item-with-submenu"
|
||||
onMouseEnter={(e) => handleSubmenuEnter(category, e)}
|
||||
onMouseLeave={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<button>
|
||||
{templates[0]?.icon || <Plus size={12} />}
|
||||
<span>{getCategoryLabel(category)}</span>
|
||||
<ChevronRight size={12} className="submenu-arrow" />
|
||||
</button>
|
||||
{activeSubmenu === category && (
|
||||
<div
|
||||
className="context-submenu"
|
||||
style={{ left: submenuPosition.x, top: submenuPosition.y }}
|
||||
onMouseEnter={() => setActiveSubmenu(category)}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
|
||||
{template.icon || <Plus size={12} />}
|
||||
<span>{template.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{entityId && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<button onClick={onDelete} className="context-menu-danger">
|
||||
<Trash2 size={12} />
|
||||
<span>{locale === 'zh' ? '删除实体' : 'Delete Entity'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLCanvasElement>(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<typeof setTimeout> | null = null;
|
||||
let timeoutId2: ReturnType<typeof setTimeout> | 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 (
|
||||
<div className={`startup-logo-container ${fadeOut ? 'fade-out' : ''}`}>
|
||||
<canvas ref={canvasRef} className="startup-logo-canvas" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string>('');
|
||||
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
|
||||
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const langMenuRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="startup-page">
|
||||
{showLogo && <StartupLogo onAnimationComplete={handleLogoComplete} />}
|
||||
<div className="startup-header">
|
||||
<h1 className="startup-title">{t.title}</h1>
|
||||
<p className="startup-subtitle">{t.subtitle}</p>
|
||||
@@ -126,6 +164,40 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新提示条 */}
|
||||
{showUpdateBanner && updateInfo?.available && (
|
||||
<div className="startup-update-banner">
|
||||
<div className="update-banner-content">
|
||||
<Download size={16} />
|
||||
<span className="update-banner-text">
|
||||
{t.updateAvailable}: v{updateInfo.version}
|
||||
</span>
|
||||
<button
|
||||
className="update-banner-btn primary"
|
||||
onClick={handleInstallUpdate}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t.installing}
|
||||
</>
|
||||
) : (
|
||||
t.updateNow
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="update-banner-close"
|
||||
onClick={() => setShowUpdateBanner(false)}
|
||||
disabled={isInstalling}
|
||||
title={t.later}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="startup-footer">
|
||||
<span className="startup-version">{versionText}</span>
|
||||
{onLocaleChange && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Set<number>>(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<Set<string>>(new Set());
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(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<string, string> = {
|
||||
'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<string, ComponentInfo[]>();
|
||||
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
|
||||
<span>组件</span>
|
||||
<div className="component-menu-container">
|
||||
<button
|
||||
ref={addButtonRef}
|
||||
className="add-component-trigger"
|
||||
onClick={() => setShowComponentMenu(!showComponentMenu)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
添加
|
||||
</button>
|
||||
{showComponentMenu && (
|
||||
{showComponentMenu && dropdownPosition && (
|
||||
<>
|
||||
<div className="component-dropdown-overlay" onClick={() => setShowComponentMenu(false)} />
|
||||
<div className="component-dropdown">
|
||||
<div className="component-dropdown-header">选择组件</div>
|
||||
{availableComponents.length === 0 ? (
|
||||
<div
|
||||
className="component-dropdown"
|
||||
style={{ top: dropdownPosition.top, right: dropdownPosition.right }}
|
||||
>
|
||||
<div className="component-dropdown-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="搜索组件..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredAndGroupedComponents.size === 0 ? (
|
||||
<div className="component-dropdown-empty">
|
||||
没有可用组件
|
||||
{searchQuery ? '未找到匹配的组件' : '没有可用组件'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-dropdown-list">
|
||||
{/* 按分类分组显示 */}
|
||||
{(() => {
|
||||
const categories = new Map<string, typeof availableComponents>();
|
||||
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 (
|
||||
<div key={category} className="component-category-group">
|
||||
<div className="component-category-label">{category}</div>
|
||||
{components.map((info) => (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -244,15 +324,25 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
{isExpanded && (
|
||||
<div className="component-item-content">
|
||||
<PropertyInspector
|
||||
component={component}
|
||||
entity={entity}
|
||||
version={componentVersion + localVersion}
|
||||
onChange={(propName: string, value: unknown) =>
|
||||
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
|
||||
})
|
||||
: <PropertyInspector
|
||||
component={component}
|
||||
entity={entity}
|
||||
version={componentVersion + localVersion}
|
||||
onChange={(propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value)
|
||||
}
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
}
|
||||
{/* Dynamic component actions from plugins */}
|
||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => (
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorSystem, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { TilemapComponent, TilemapRenderingSystem } from '@esengine/tilemap';
|
||||
import { UIRenderDataProvider } from '@esengine/ui';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, ProjectService } from '@esengine/editor-core';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ export class EngineService {
|
||||
private renderSystem: EngineRenderSystem | null = null;
|
||||
private animatorSystem: SpriteAnimatorSystem | null = null;
|
||||
private tilemapSystem: TilemapRenderingSystem | null = null;
|
||||
private uiRenderProvider: UIRenderDataProvider | null = null;
|
||||
private initialized = false;
|
||||
private running = false;
|
||||
private animationFrameId: number | null = null;
|
||||
@@ -121,6 +123,17 @@ export class EngineService {
|
||||
// 将瓦片地图系统注册为渲染数据提供者
|
||||
this.renderSystem.addRenderDataProvider(this.tilemapSystem);
|
||||
|
||||
// Register UI render data provider
|
||||
// 注册 UI 渲染数据提供者
|
||||
this.uiRenderProvider = new UIRenderDataProvider();
|
||||
this.renderSystem.addRenderDataProvider(this.uiRenderProvider);
|
||||
|
||||
// Set up texture callback for UI text rendering
|
||||
// 设置 UI 文本渲染的纹理回调
|
||||
this.uiRenderProvider.setTextureCallback((id: number, dataUrl: string) => {
|
||||
this.bridge!.loadTexture(id, dataUrl);
|
||||
});
|
||||
|
||||
// Inject GizmoRegistry into render system
|
||||
// 将 GizmoRegistry 注入渲染系统
|
||||
this.renderSystem.setGizmoRegistry(
|
||||
@@ -702,6 +715,13 @@ export class EngineService {
|
||||
this.tilemapSystem.clearCache();
|
||||
}
|
||||
|
||||
// Clear UI text cache before restoring
|
||||
// 恢复前清除 UI 文本缓存
|
||||
if (this.uiRenderProvider) {
|
||||
console.log('[EngineService] Clearing UI text cache before restore');
|
||||
this.uiRenderProvider.clearTextCache();
|
||||
}
|
||||
|
||||
// Use SceneSerializer from core library
|
||||
console.log('[EngineService] Deserializing scene snapshot');
|
||||
SceneSerializer.deserialize(this.scene, this.sceneSnapshot, {
|
||||
|
||||
@@ -150,6 +150,17 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.update-btn.install-btn {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-btn.install-btn:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.update-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -179,6 +190,11 @@
|
||||
color: #CE9178;
|
||||
}
|
||||
|
||||
.update-status.status-installing {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.update-status .status-available {
|
||||
color: #4EC9B0;
|
||||
}
|
||||
|
||||
@@ -515,9 +515,7 @@
|
||||
}
|
||||
|
||||
.component-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
background: var(--color-bg-elevated);
|
||||
@@ -540,17 +538,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
.component-dropdown-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
.component-dropdown-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.component-dropdown-search svg {
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.component-dropdown-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.component-dropdown-search input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.component-dropdown-empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
@@ -589,6 +603,41 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.component-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-base);
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.component-category-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.component-category-header svg {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.component-category-count {
|
||||
margin-left: auto;
|
||||
padding: 1px 6px;
|
||||
background: var(--color-bg-inset);
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.component-category-label {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 10px;
|
||||
@@ -600,10 +649,11 @@
|
||||
|
||||
.component-dropdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
padding: 6px 12px 6px 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
@@ -612,6 +662,11 @@
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.component-dropdown-item svg {
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.component-dropdown-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
@@ -637,7 +692,7 @@
|
||||
background: #2a2a2f;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
transition: none;
|
||||
border-left: 3px solid #4a4a50;
|
||||
}
|
||||
@@ -729,7 +784,7 @@
|
||||
padding: 6px 8px 8px 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: #1e1e23;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
gap: 8px;
|
||||
transition: background-color var(--transition-fast);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.property-field:hover {
|
||||
|
||||
@@ -466,3 +466,69 @@
|
||||
background-color: var(--color-border-default);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.context-menu-item-with-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.context-menu-item-with-submenu > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-menu-item-with-submenu > button:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.context-menu-item-with-submenu .submenu-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
position: fixed;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--spacing-xs);
|
||||
min-width: 150px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.context-submenu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-submenu button:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.context-menu-danger {
|
||||
color: var(--color-error) !important;
|
||||
}
|
||||
|
||||
.context-menu-danger:hover {
|
||||
background-color: rgba(244, 135, 113, 0.1) !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.startup-logo-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #1e1e1e;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.startup-logo-container.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.startup-logo-canvas {
|
||||
display: block;
|
||||
}
|
||||
@@ -250,3 +250,99 @@
|
||||
.startup-locale-item.active:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 更新提示条样式 */
|
||||
.startup-update-banner {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.update-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(135deg, #1e5a2f 0%, #1a4a28 100%);
|
||||
border: 1px solid #2e8b4a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.update-banner-content svg {
|
||||
color: #4ade80;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-banner-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.update-banner-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.update-banner-btn.primary {
|
||||
background: #22c55e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.update-banner-btn.primary:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.update-banner-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.update-banner-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.update-banner-close:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.update-banner-close:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { check } from '@tauri-apps/plugin-updater';
|
||||
import { check, Update } from '@tauri-apps/plugin-updater';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
available: boolean;
|
||||
@@ -7,33 +7,33 @@ export interface UpdateCheckResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 全局存储更新对象,以便后续安装
|
||||
let pendingUpdate: Update | null = null;
|
||||
|
||||
/**
|
||||
* 检查应用更新
|
||||
* 检查应用更新(仅检查,不安装)
|
||||
*
|
||||
* 自动检查 GitHub Releases 是否有新版本
|
||||
* 如果有更新,提示用户并可选择安装
|
||||
* 返回检查结果,由调用者决定是否安装
|
||||
*/
|
||||
export async function checkForUpdates(silent: boolean = false): Promise<UpdateCheckResult> {
|
||||
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const update = await check();
|
||||
|
||||
if (update?.available) {
|
||||
if (!silent) {
|
||||
// Tauri 会自动显示更新对话框(因为配置了 dialog: true)
|
||||
// 用户点击确认后会自动下载并安装,安装完成后会自动重启
|
||||
await update.downloadAndInstall();
|
||||
}
|
||||
|
||||
pendingUpdate = update;
|
||||
return {
|
||||
available: true,
|
||||
version: update.version,
|
||||
currentVersion: update.currentVersion
|
||||
};
|
||||
} else {
|
||||
pendingUpdate = null;
|
||||
return { available: false };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
pendingUpdate = null;
|
||||
return {
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : '检查更新失败'
|
||||
@@ -42,11 +42,34 @@ export async function checkForUpdates(silent: boolean = false): Promise<UpdateCh
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时静默检查更新
|
||||
* 安装待处理的更新
|
||||
* 需要先调用 checkForUpdates 检测到更新
|
||||
*/
|
||||
export async function checkForUpdatesOnStartup(): Promise<void> {
|
||||
// 延迟 3 秒后检查,避免影响启动速度
|
||||
setTimeout(() => {
|
||||
checkForUpdates(true);
|
||||
}, 3000);
|
||||
export async function installUpdate(): Promise<boolean> {
|
||||
if (!pendingUpdate) {
|
||||
console.error('没有待安装的更新');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await pendingUpdate.downloadAndInstall();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('安装更新失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时静默检查更新
|
||||
* 返回 Promise 以便调用者可以获取结果
|
||||
*/
|
||||
export async function checkForUpdatesOnStartup(): Promise<UpdateCheckResult> {
|
||||
// 延迟 2 秒后检查,避免影响启动速度
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(async () => {
|
||||
const result = await checkForUpdates();
|
||||
resolve(result);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { Component, IService, createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('ComponentInspectorRegistry');
|
||||
|
||||
/**
|
||||
* 组件检查器上下文
|
||||
* Context passed to component inspectors
|
||||
*/
|
||||
export interface ComponentInspectorContext {
|
||||
/** 被检查的组件 */
|
||||
component: Component;
|
||||
/** 所属实体 */
|
||||
entity: any;
|
||||
/** 版本号(用于触发重渲染) */
|
||||
version?: number;
|
||||
/** 属性变更回调 */
|
||||
onChange?: (propertyName: string, value: any) => void;
|
||||
/** 动作回调 */
|
||||
onAction?: (actionId: string, propertyName: string, component: Component) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件检查器接口
|
||||
* Interface for custom component inspectors
|
||||
*/
|
||||
export interface IComponentInspector<T extends Component = Component> {
|
||||
/** 唯一标识符 */
|
||||
readonly id: string;
|
||||
/** 显示名称 */
|
||||
readonly name: string;
|
||||
/** 优先级(数字越大优先级越高) */
|
||||
readonly priority?: number;
|
||||
/** 目标组件类型名称列表 */
|
||||
readonly targetComponents: string[];
|
||||
|
||||
/**
|
||||
* 判断是否可以处理该组件
|
||||
*/
|
||||
canHandle(component: Component): component is T;
|
||||
|
||||
/**
|
||||
* 渲染组件检查器
|
||||
*/
|
||||
render(context: ComponentInspectorContext): React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件检查器注册表
|
||||
* Registry for custom component inspectors
|
||||
*/
|
||||
export class ComponentInspectorRegistry implements IService {
|
||||
private inspectors: Map<string, IComponentInspector> = new Map();
|
||||
|
||||
/**
|
||||
* 注册组件检查器
|
||||
*/
|
||||
register(inspector: IComponentInspector): void {
|
||||
if (this.inspectors.has(inspector.id)) {
|
||||
logger.warn(`Overwriting existing component inspector: ${inspector.id}`);
|
||||
}
|
||||
this.inspectors.set(inspector.id, inspector);
|
||||
logger.debug(`Registered component inspector: ${inspector.name} (${inspector.id})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销组件检查器
|
||||
*/
|
||||
unregister(inspectorId: string): void {
|
||||
if (this.inspectors.delete(inspectorId)) {
|
||||
logger.debug(`Unregistered component inspector: ${inspectorId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可以处理指定组件的检查器
|
||||
*/
|
||||
findInspector(component: Component): IComponentInspector | undefined {
|
||||
const inspectors = Array.from(this.inspectors.values())
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
|
||||
for (const inspector of inspectors) {
|
||||
try {
|
||||
if (inspector.canHandle(component)) {
|
||||
return inspector;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in canHandle for inspector ${inspector.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有自定义检查器
|
||||
*/
|
||||
hasInspector(component: Component): boolean {
|
||||
return this.findInspector(component) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染组件
|
||||
*/
|
||||
render(context: ComponentInspectorContext): React.ReactElement | null {
|
||||
const inspector = this.findInspector(context.component);
|
||||
if (!inspector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return inspector.render(context);
|
||||
} catch (error) {
|
||||
logger.error(`Error rendering with inspector ${inspector.id}:`, error);
|
||||
return React.createElement(
|
||||
'span',
|
||||
{ style: { color: '#f87171', fontStyle: 'italic' } },
|
||||
'[Inspector Render Error]'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的检查器
|
||||
*/
|
||||
getAllInspectors(): IComponentInspector[] {
|
||||
return Array.from(this.inspectors.values());
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.inspectors.clear();
|
||||
logger.debug('ComponentInspectorRegistry disposed');
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,12 @@ export interface IFieldEditorRegistry {
|
||||
export interface FieldMetadata {
|
||||
type: string;
|
||||
options?: {
|
||||
fileExtension?: string;
|
||||
enumValues?: Array<{ value: string; label: string }>;
|
||||
/** 资源类型 | Asset type */
|
||||
assetType?: string;
|
||||
/** 文件扩展名过滤 | File extension filter */
|
||||
extensions?: string[];
|
||||
/** 枚举选项 | Enum values */
|
||||
enumValues?: Array<string | { value: string; label: string }>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
import type { IService } from '@esengine/ecs-framework';
|
||||
import type { IService, PropertyOptions, PropertyAction, PropertyControl, AssetType, EnumOption } from '@esengine/ecs-framework';
|
||||
import { Injectable, Component, getPropertyMetadata } from '@esengine/ecs-framework';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
|
||||
const logger = createLogger('PropertyMetadata');
|
||||
|
||||
export type { PropertyOptions, PropertyAction, PropertyControl, AssetType, EnumOption };
|
||||
export type PropertyMetadata = PropertyOptions;
|
||||
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips';
|
||||
|
||||
export interface PropertyAction {
|
||||
id: string;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PropertyControl {
|
||||
component: string;
|
||||
property: string;
|
||||
}
|
||||
|
||||
export interface PropertyMetadata {
|
||||
type: PropertyType;
|
||||
label?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
readOnly?: boolean;
|
||||
fileExtension?: string;
|
||||
actions?: PropertyAction[];
|
||||
controls?: PropertyControl[];
|
||||
}
|
||||
|
||||
export interface ComponentMetadata {
|
||||
properties: Record<string, PropertyMetadata>;
|
||||
}
|
||||
|
||||
@@ -183,6 +183,11 @@ export interface EntityCreationTemplate {
|
||||
*/
|
||||
icon?: any;
|
||||
|
||||
/**
|
||||
* 分类 (如 'basic', 'rendering', 'ui', 'physics' 等)
|
||||
*/
|
||||
category?: string;
|
||||
|
||||
/**
|
||||
* 排序权重(数字越小越靠前)
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,7 @@ export * from './Services/PropertyRendererRegistry';
|
||||
export * from './Services/IFieldEditor';
|
||||
export * from './Services/FieldEditorRegistry';
|
||||
export * from './Services/ComponentActionRegistry';
|
||||
export * from './Services/ComponentInspectorRegistry';
|
||||
|
||||
export * from './Gizmos';
|
||||
|
||||
|
||||
@@ -761,7 +761,7 @@ export const TilemapEditorPanel: React.FC<TilemapEditorPanelProps> = ({ messageH
|
||||
const jsonContent = JSON.stringify(tilemapData, null, 2);
|
||||
|
||||
// Get the tilemap asset path from component
|
||||
let tilemapAssetPath = tilemap.tilemapAssetGuid;
|
||||
const tilemapAssetPath = tilemap.tilemapAssetGuid;
|
||||
if (!tilemapAssetPath) {
|
||||
console.warn('Tilemap asset path not set, cannot save');
|
||||
return;
|
||||
|
||||
@@ -124,7 +124,7 @@ export interface ITilemapData {
|
||||
export class TilemapComponent extends Component implements IResourceComponent {
|
||||
/** Tilemap asset GUID reference | 瓦片地图资源GUID引用 */
|
||||
@Serialize()
|
||||
@Property({ type: 'asset', label: 'Tilemap', fileExtension: '.tilemap.json' })
|
||||
@Property({ type: 'asset', label: 'Tilemap', extensions: ['.tilemap.json'] })
|
||||
public tilemapAssetGuid: string = '';
|
||||
|
||||
@Serialize()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@esengine/ui-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "UI editor plugin for ECS Framework Editor",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build:watch": "tsc --watch",
|
||||
"clean": "rimraf bin"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/ecs-framework.git",
|
||||
"directory": "packages/ui-editor"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"ui",
|
||||
"editor",
|
||||
"game-engine"
|
||||
],
|
||||
"author": "ESEngine Team",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "^2.2.8",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"lucide-react": "^0.545.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"typescript": "^5.8.3",
|
||||
"rimraf": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* UI Editor Plugin
|
||||
* 为编辑器提供 UI 组件的创建和编辑功能
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LayoutGrid, Square, Type, MousePointer2, Sliders, BarChart3, ScrollText, PanelTop } from 'lucide-react';
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorPlugin,
|
||||
EntityCreationTemplate,
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
EditorPluginCategory,
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
ComponentRegistry,
|
||||
ComponentInspectorRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { UITransformInspector } from './inspectors';
|
||||
import { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
||||
|
||||
// UI Components from @esengine/ui
|
||||
import {
|
||||
UITransformComponent,
|
||||
UIRenderComponent,
|
||||
UIInteractableComponent,
|
||||
UITextComponent,
|
||||
UILayoutComponent,
|
||||
UILayoutType,
|
||||
UIJustifyContent,
|
||||
UIAlignItems,
|
||||
UIButtonComponent,
|
||||
UIProgressBarComponent,
|
||||
UISliderComponent,
|
||||
UIScrollViewComponent
|
||||
} from '@esengine/ui';
|
||||
|
||||
/**
|
||||
* UI 编辑器插件
|
||||
*/
|
||||
export class UIEditorPlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/ui-editor';
|
||||
readonly version = '1.0.0';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
|
||||
get displayName(): string {
|
||||
return 'UI Editor';
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return 'UI components and tools for creating game user interfaces';
|
||||
}
|
||||
|
||||
async install(_core: Core, services: ServiceContainer): Promise<void> {
|
||||
// Register UI components to component registry
|
||||
const componentRegistry = services.resolve(ComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
// Core UI Components
|
||||
componentRegistry.register({
|
||||
name: 'UITransform',
|
||||
type: UITransformComponent,
|
||||
category: 'components.category.ui',
|
||||
description: 'UI element positioning and sizing'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UIRender',
|
||||
type: UIRenderComponent,
|
||||
category: 'components.category.ui',
|
||||
description: 'UI element visual appearance'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UIInteractable',
|
||||
type: UIInteractableComponent,
|
||||
category: 'components.category.ui',
|
||||
description: 'UI element interaction handling'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UIText',
|
||||
type: UITextComponent,
|
||||
category: 'components.category.ui',
|
||||
description: 'Text rendering component'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UILayout',
|
||||
type: UILayoutComponent,
|
||||
category: 'components.category.ui',
|
||||
description: 'Automatic child layout (Flexbox-like)'
|
||||
});
|
||||
|
||||
// Widget Components
|
||||
componentRegistry.register({
|
||||
name: 'UIButton',
|
||||
type: UIButtonComponent,
|
||||
category: 'components.category.ui.widgets',
|
||||
description: 'Interactive button component'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UIProgressBar',
|
||||
type: UIProgressBarComponent,
|
||||
category: 'components.category.ui.widgets',
|
||||
description: 'Progress indicator component'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UISlider',
|
||||
type: UISliderComponent,
|
||||
category: 'components.category.ui.widgets',
|
||||
description: 'Value slider component'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'UIScrollView',
|
||||
type: UIScrollViewComponent,
|
||||
category: 'components.category.ui.widgets',
|
||||
description: 'Scrollable container component'
|
||||
});
|
||||
}
|
||||
|
||||
// Register custom component inspectors
|
||||
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
|
||||
if (componentInspectorRegistry) {
|
||||
componentInspectorRegistry.register(new UITransformInspector());
|
||||
}
|
||||
|
||||
// Register gizmo providers
|
||||
registerUITransformGizmo();
|
||||
|
||||
console.log('[UIEditorPlugin] Installed');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
unregisterUITransformGizmo();
|
||||
console.log('[UIEditorPlugin] Uninstalled');
|
||||
}
|
||||
|
||||
registerEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
// UI Canvas (Root container)
|
||||
{
|
||||
id: 'create-ui-canvas',
|
||||
label: 'UI Canvas',
|
||||
icon: React.createElement(PanelTop, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 200,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('UI Canvas', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 1920;
|
||||
transform.height = 1080;
|
||||
transform.anchorMinX = 0;
|
||||
transform.anchorMinY = 0;
|
||||
transform.anchorMaxX = 1;
|
||||
transform.anchorMaxY = 1;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Panel
|
||||
{
|
||||
id: 'create-ui-panel',
|
||||
label: 'Panel',
|
||||
icon: React.createElement(Square, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 201,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('Panel', (entity) => {
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundColor = 0x2D2D2D;
|
||||
render.backgroundAlpha = 0.9;
|
||||
render.setCornerRadius(8);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Text
|
||||
{
|
||||
id: 'create-ui-text',
|
||||
label: 'Text',
|
||||
icon: React.createElement(Type, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 202,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('Text', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 30;
|
||||
|
||||
// Make background transparent for text
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
|
||||
// Add text component
|
||||
const text = new UITextComponent();
|
||||
text.text = 'Hello World';
|
||||
text.fontSize = 16;
|
||||
text.color = 0xFFFFFF;
|
||||
entity.addComponent(text);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Button
|
||||
{
|
||||
id: 'create-ui-button',
|
||||
label: 'Button',
|
||||
icon: React.createElement(MousePointer2, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 203,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('Button', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 120;
|
||||
transform.height = 40;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.setCornerRadius(4);
|
||||
|
||||
// Add button component
|
||||
const button = new UIButtonComponent();
|
||||
button.label = 'Button';
|
||||
entity.addComponent(button);
|
||||
|
||||
// Make interactable
|
||||
const interactable = entity.getComponent(UIInteractableComponent)!;
|
||||
interactable.enabled = true;
|
||||
interactable.cursor = 'pointer';
|
||||
|
||||
// Add text for button label
|
||||
const text = new UITextComponent();
|
||||
text.text = 'Button';
|
||||
text.fontSize = 14;
|
||||
text.color = 0xFFFFFF;
|
||||
text.align = 'center';
|
||||
text.verticalAlign = 'middle';
|
||||
entity.addComponent(text);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Slider
|
||||
{
|
||||
id: 'create-ui-slider',
|
||||
label: 'Slider',
|
||||
icon: React.createElement(Sliders, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 204,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('Slider', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 20;
|
||||
|
||||
// Remove default render, slider renders itself
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
if (render) {
|
||||
entity.removeComponent(render);
|
||||
}
|
||||
|
||||
// Add slider component
|
||||
const slider = new UISliderComponent();
|
||||
slider.value = 50;
|
||||
slider.minValue = 0;
|
||||
slider.maxValue = 100;
|
||||
entity.addComponent(slider);
|
||||
|
||||
// Make interactable
|
||||
const interactable = entity.getComponent(UIInteractableComponent)!;
|
||||
interactable.enabled = true;
|
||||
interactable.cursor = 'pointer';
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Progress Bar
|
||||
{
|
||||
id: 'create-ui-progressbar',
|
||||
label: 'ProgressBar',
|
||||
icon: React.createElement(BarChart3, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 205,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('ProgressBar', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 20;
|
||||
|
||||
// Remove default render, progressbar renders itself
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
if (render) {
|
||||
entity.removeComponent(render);
|
||||
}
|
||||
|
||||
// Add progress bar component
|
||||
const progress = new UIProgressBarComponent();
|
||||
progress.value = 50;
|
||||
progress.minValue = 0;
|
||||
progress.maxValue = 100;
|
||||
progress.cornerRadius = 4;
|
||||
entity.addComponent(progress);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI ScrollView
|
||||
{
|
||||
id: 'create-ui-scrollview',
|
||||
label: 'ScrollView',
|
||||
icon: React.createElement(ScrollText, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 206,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('ScrollView', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 300;
|
||||
transform.height = 400;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundColor = 0x1A1A1A;
|
||||
render.setCornerRadius(4);
|
||||
|
||||
// Add scroll view component
|
||||
const scrollView = new UIScrollViewComponent();
|
||||
scrollView.verticalScroll = true;
|
||||
scrollView.horizontalScroll = false;
|
||||
scrollView.contentHeight = 800;
|
||||
entity.addComponent(scrollView);
|
||||
|
||||
// Make interactable for scroll
|
||||
const interactable = entity.getComponent(UIInteractableComponent)!;
|
||||
interactable.enabled = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Layout Container (Horizontal)
|
||||
{
|
||||
id: 'create-ui-hlayout',
|
||||
label: 'HLayout',
|
||||
icon: React.createElement(LayoutGrid, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 207,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('HLayout', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 400;
|
||||
transform.height = 100;
|
||||
|
||||
// Add layout component
|
||||
const layout = new UILayoutComponent();
|
||||
layout.type = UILayoutType.Horizontal;
|
||||
layout.gap = 10;
|
||||
layout.justifyContent = UIJustifyContent.Start;
|
||||
layout.alignItems = UIAlignItems.Center;
|
||||
entity.addComponent(layout);
|
||||
|
||||
// Make background transparent
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Layout Container (Vertical)
|
||||
{
|
||||
id: 'create-ui-vlayout',
|
||||
label: 'VLayout',
|
||||
icon: React.createElement(LayoutGrid, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 208,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('VLayout', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 400;
|
||||
|
||||
// Add layout component
|
||||
const layout = new UILayoutComponent();
|
||||
layout.type = UILayoutType.Vertical;
|
||||
layout.gap = 10;
|
||||
layout.justifyContent = UIJustifyContent.Start;
|
||||
layout.alignItems = UIAlignItems.Stretch;
|
||||
entity.addComponent(layout);
|
||||
|
||||
// Make background transparent
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Grid Layout
|
||||
{
|
||||
id: 'create-ui-grid',
|
||||
label: 'Grid',
|
||||
icon: React.createElement(LayoutGrid, { size: 12 }),
|
||||
category: 'ui',
|
||||
order: 209,
|
||||
create: (_parentEntityId?: number): number => {
|
||||
return this.createUIEntity('Grid', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 400;
|
||||
transform.height = 400;
|
||||
|
||||
// Add layout component
|
||||
const layout = new UILayoutComponent();
|
||||
layout.type = UILayoutType.Grid;
|
||||
layout.columns = 3;
|
||||
layout.gap = 10;
|
||||
entity.addComponent(layout);
|
||||
|
||||
// Make background transparent
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 UI 实体的辅助方法
|
||||
*/
|
||||
private createUIEntity(baseName: string, configure?: (entity: Entity) => void): number {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
// Count existing entities with same base name
|
||||
const existingCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith(baseName)).length;
|
||||
const entityName = existingCount > 0 ? `${baseName} ${existingCount + 1}` : baseName;
|
||||
|
||||
// Create entity via scene
|
||||
const entity = scene.createEntity(entityName);
|
||||
|
||||
// Add base UI components
|
||||
const transform = new UITransformComponent();
|
||||
transform.width = 100;
|
||||
transform.height = 100;
|
||||
entity.addComponent(transform);
|
||||
|
||||
const render = new UIRenderComponent();
|
||||
render.backgroundColor = 0x4A90D9;
|
||||
entity.addComponent(render);
|
||||
|
||||
const interactable = new UIInteractableComponent();
|
||||
entity.addComponent(interactable);
|
||||
|
||||
// Apply custom configuration
|
||||
if (configure) {
|
||||
configure(entity);
|
||||
}
|
||||
|
||||
// Register with entity store
|
||||
entityStore.addEntity(entity);
|
||||
|
||||
// Notify
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
|
||||
// Select the new entity
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
|
||||
export const uiEditorPlugin = new UIEditorPlugin();
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { IGizmoRenderData, IRectGizmoData, GizmoColor } from '@esengine/editor-core';
|
||||
import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
|
||||
const UI_GIZMO_COLOR: GizmoColor = { r: 0.2, g: 0.6, b: 1, a: 0.8 };
|
||||
const UI_GIZMO_COLOR_UNSELECTED: GizmoColor = { r: 0.2, g: 0.6, b: 1, a: 0.3 };
|
||||
|
||||
function uiTransformGizmoProvider(
|
||||
transform: UITransformComponent,
|
||||
_entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
if (!transform.visible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const gizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
width,
|
||||
height,
|
||||
rotation: transform.rotation,
|
||||
originX: transform.pivotX,
|
||||
originY: transform.pivotY,
|
||||
color: isSelected ? UI_GIZMO_COLOR : UI_GIZMO_COLOR_UNSELECTED,
|
||||
showHandles: isSelected
|
||||
};
|
||||
|
||||
return [gizmo];
|
||||
}
|
||||
|
||||
export function registerUITransformGizmo(): void {
|
||||
GizmoRegistry.register(UITransformComponent, uiTransformGizmoProvider);
|
||||
}
|
||||
|
||||
export function unregisterUITransformGizmo(): void {
|
||||
GizmoRegistry.unregister(UITransformComponent);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './UITransformGizmo';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @esengine/ui-editor
|
||||
* UI Editor Plugin for ECS Framework Editor
|
||||
*/
|
||||
|
||||
export { UIEditorPlugin, uiEditorPlugin } from './UIEditorPlugin';
|
||||
@@ -0,0 +1,454 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Component } from '@esengine/ecs-framework';
|
||||
import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
|
||||
import { UITransformComponent, AnchorPreset } from '@esengine/ui';
|
||||
|
||||
const DraggableNumberInput: React.FC<{
|
||||
axis?: 'x' | 'y' | 'z' | 'w';
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
}> = ({ axis, label, value, onChange, min, max, step = 0.1, readOnly }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
const sensitivity = e.shiftKey ? 0.01 : step;
|
||||
let newValue = dragStartRef.current.value + delta * sensitivity;
|
||||
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
onChange(Math.round(newValue * 1000) / 1000);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, onChange, step, min, max]);
|
||||
|
||||
const axisClass = axis ? `property-vector-axis-${axis}` : '';
|
||||
const displayLabel = label || (axis ? axis.toUpperCase() : '');
|
||||
|
||||
return (
|
||||
<div className="property-vector-axis-compact">
|
||||
<span
|
||||
className={`property-vector-axis-label ${axisClass}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value ?? 0}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Vector2Row: React.FC<{
|
||||
label: string;
|
||||
valueX: number;
|
||||
valueY: number;
|
||||
onChangeX: (value: number) => void;
|
||||
onChangeY: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
}> = ({ label, valueX, valueY, onChangeX, onChangeY, min, max, step, readOnly }) => (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-vector-compact">
|
||||
<DraggableNumberInput axis="x" value={valueX} onChange={onChangeX} min={min} max={max} step={step} readOnly={readOnly} />
|
||||
<DraggableNumberInput axis="y" value={valueY} onChange={onChangeY} min={min} max={max} step={step} readOnly={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NumberRow: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
}> = ({ label, value, onChange, min, max, step = 0.1, readOnly }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartValue(value);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartX;
|
||||
const sensitivity = e.shiftKey ? 0.01 : step;
|
||||
let newValue = dragStartValue + delta * sensitivity;
|
||||
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
onChange(parseFloat(newValue.toFixed(3)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label
|
||||
className="property-label property-label-draggable"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value ?? 0}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BooleanRow: React.FC<{
|
||||
label: string;
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
}> = ({ label, value, onChange, readOnly }) => (
|
||||
<div className="property-field property-field-boolean">
|
||||
<label className="property-label">{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${value ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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, { x: number; y: number }> = {
|
||||
[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 (
|
||||
<div className="property-field" style={{ alignItems: 'flex-start' }}>
|
||||
<label className="property-label">Anchor</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 24px)',
|
||||
gridTemplateRows: 'repeat(3, 24px)',
|
||||
gap: '2px',
|
||||
padding: '4px',
|
||||
background: 'var(--color-bg-inset)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
}}>
|
||||
{presets.flat().map((preset) => {
|
||||
const pos = getAnchorPosition(preset);
|
||||
const isActive = currentPreset === preset;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => onSelect(preset)}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0,
|
||||
border: '1px solid',
|
||||
borderColor: isActive ? 'var(--color-primary)' : 'var(--color-border-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isActive ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
title={preset}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20">
|
||||
<rect
|
||||
x="2" y="2" width="16" height="16"
|
||||
fill="none"
|
||||
stroke={isActive ? 'var(--color-primary)' : 'var(--color-text-tertiary)'}
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
<circle
|
||||
cx={pos.x} cy={pos.y} r="3"
|
||||
fill={isActive ? 'var(--color-primary)' : 'var(--color-text-secondary)'}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelect(AnchorPreset.StretchAll)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '22px',
|
||||
padding: '0 8px',
|
||||
border: '1px solid',
|
||||
borderColor: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary)' : 'var(--color-border-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
|
||||
color: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
title="Stretch All"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14">
|
||||
<rect x="1" y="1" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" strokeWidth="1.5" />
|
||||
<line x1="7" y1="3" x2="7" y2="11" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
Stretch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class UITransformInspector implements IComponentInspector<UITransformComponent> {
|
||||
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, [number, number, number, number]> = {
|
||||
[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 (
|
||||
<div className="property-inspector">
|
||||
<AnchorPresetGrid
|
||||
currentPreset={detectCurrentPreset()}
|
||||
onSelect={handlePresetSelect}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Position"
|
||||
valueX={transform.x}
|
||||
valueY={transform.y}
|
||||
onChangeX={(v) => handleChange('x', v)}
|
||||
onChangeY={(v) => handleChange('y', v)}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Size"
|
||||
valueX={transform.width}
|
||||
valueY={transform.height}
|
||||
onChangeX={(v) => handleChange('width', v)}
|
||||
onChangeY={(v) => handleChange('height', v)}
|
||||
min={0}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Anchor Min"
|
||||
valueX={transform.anchorMinX}
|
||||
valueY={transform.anchorMinY}
|
||||
onChangeX={(v) => handleChange('anchorMinX', v)}
|
||||
onChangeY={(v) => handleChange('anchorMinY', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Anchor Max"
|
||||
valueX={transform.anchorMaxX}
|
||||
valueY={transform.anchorMaxY}
|
||||
onChangeX={(v) => handleChange('anchorMaxX', v)}
|
||||
onChangeY={(v) => handleChange('anchorMaxY', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Pivot"
|
||||
valueX={transform.pivotX}
|
||||
valueY={transform.pivotY}
|
||||
onChangeX={(v) => handleChange('pivotX', v)}
|
||||
onChangeY={(v) => handleChange('pivotY', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="Rotation"
|
||||
value={transform.rotation}
|
||||
onChange={(v) => handleChange('rotation', v)}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Scale"
|
||||
valueX={transform.scaleX}
|
||||
valueY={transform.scaleY}
|
||||
onChangeX={(v) => handleChange('scaleX', v)}
|
||||
onChangeY={(v) => handleChange('scaleY', v)}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="Z Index"
|
||||
value={transform.zIndex}
|
||||
onChange={(v) => handleChange('zIndex', Math.round(v))}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="Alpha"
|
||||
value={transform.alpha}
|
||||
onChange={(v) => handleChange('alpha', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<BooleanRow
|
||||
label="Visible"
|
||||
value={transform.visible}
|
||||
onChange={(v) => handleChange('visible', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './UITransformInspector';
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './UIButtonComponent';
|
||||
export * from './UIProgressBarComponent';
|
||||
export * from './UISliderComponent';
|
||||
export * from './UIScrollViewComponent';
|
||||
@@ -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';
|
||||
@@ -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<string, WebGLTexture> = 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<TextRenderOptions>): 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<TextRenderOptions>,
|
||||
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<TextRenderOptions>,
|
||||
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<TextRenderOptions>): 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>): 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<TextRenderOptions>, width?: number, height?: number): string {
|
||||
return JSON.stringify({ text, options, width, height });
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './WebGLUIRenderer';
|
||||
export * from './TextRenderer';
|
||||
@@ -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<string, EasingFunction>)[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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number, TextTextureCache> = 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './UILayoutSystem';
|
||||
export * from './UIInputSystem';
|
||||
export * from './UIAnimationSystem';
|
||||
export * from './UIRenderDataProvider';
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Generated
+323
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user