Feature/physics and tilemap enhancement (#247)

* feat(behavior-tree,tilemap): 修复编辑器连线缩放问题并增强插件系统

* feat(node-editor,blueprint): 新增通用节点编辑器和蓝图可视化脚本系统

* feat(editor,tilemap): 优化编辑器UI样式和Tilemap编辑器功能

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误

* fix: 修复CodeQL安全警告和CI类型检查错误
This commit is contained in:
YHH
2025-11-29 23:00:48 +08:00
committed by GitHub
parent f03b73b58e
commit 359886c72f
198 changed files with 33879 additions and 13121 deletions

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect, useRef } from 'react';
import { Component } from '@esengine/ecs-framework';
import { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/ecs-components';
import { ChevronDown, Lock, Unlock } from 'lucide-react';
import '../../../styles/TransformInspector.css';
interface AxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
onChange: (value: number) => void;
suffix?: string;
}
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [inputValue, setInputValue] = useState(String(value ?? 0));
const dragStartRef = useRef({ x: 0, value: 0 });
useEffect(() => {
setInputValue(String(value ?? 0));
}, [value]);
const handleBarMouseDown = (e: React.MouseEvent) => {
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 : e.ctrlKey ? 1 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
const rounded = Math.round(newValue * 1000) / 1000;
onChange(rounded);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputBlur = () => {
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
onChange(parsed);
} else {
setInputValue(String(value ?? 0));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setInputValue(String(value ?? 0));
(e.target as HTMLInputElement).blur();
}
};
return (
<div className={`tf-axis-input ${isDragging ? 'dragging' : ''}`}>
<div
className={`tf-axis-bar tf-axis-${axis}`}
onMouseDown={handleBarMouseDown}
/>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
onFocus={(e) => e.target.select()}
/>
{suffix && <span className="tf-axis-suffix">{suffix}</span>}
</div>
);
}
// 双向箭头重置图标
function ResetIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 6H11M1 6L3 4M1 6L3 8M11 6L9 4M11 6L9 8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
interface TransformRowProps {
label: string;
value: { x: number; y: number; z: number };
showLock?: boolean;
isLocked?: boolean;
onLockChange?: (locked: boolean) => void;
onChange: (value: { x: number; y: number; z: number }) => void;
onReset?: () => void;
suffix?: string;
showDivider?: boolean;
}
function TransformRow({
label,
value,
showLock = false,
isLocked = false,
onLockChange,
onChange,
onReset,
suffix,
showDivider = true
}: TransformRowProps) {
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
if (isLocked && showLock) {
const oldVal = value[axis];
if (oldVal !== 0) {
const ratio = newValue / oldVal;
onChange({
x: axis === 'x' ? newValue : value.x * ratio,
y: axis === 'y' ? newValue : value.y * ratio,
z: axis === 'z' ? newValue : value.z * ratio
});
} else {
onChange({ ...value, [axis]: newValue });
}
} else {
onChange({ ...value, [axis]: newValue });
}
};
return (
<>
<div className="tf-row">
<button className="tf-label-btn">
{label}
<ChevronDown size={10} />
</button>
<div className="tf-inputs">
<AxisInput
axis="x"
value={value?.x ?? 0}
onChange={(v) => handleAxisChange('x', v)}
suffix={suffix}
/>
<AxisInput
axis="y"
value={value?.y ?? 0}
onChange={(v) => handleAxisChange('y', v)}
suffix={suffix}
/>
<AxisInput
axis="z"
value={value?.z ?? 0}
onChange={(v) => handleAxisChange('z', v)}
suffix={suffix}
/>
</div>
{showLock && (
<button
className={`tf-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => onLockChange?.(!isLocked)}
title={isLocked ? 'Unlock' : 'Lock'}
>
{isLocked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
)}
<button
className="tf-reset-btn"
onClick={onReset}
title="Reset"
>
<ResetIcon />
</button>
</div>
{showDivider && <div className="tf-divider" />}
</>
);
}
interface MobilityRowProps {
value: 'static' | 'stationary' | 'movable';
onChange: (value: 'static' | 'stationary' | 'movable') => void;
}
function MobilityRow({ value, onChange }: MobilityRowProps) {
return (
<div className="tf-mobility-row">
<span className="tf-mobility-label">Mobility</span>
<div className="tf-mobility-buttons">
<button
className={`tf-mobility-btn ${value === 'static' ? 'active' : ''}`}
onClick={() => onChange('static')}
>
Static
</button>
<button
className={`tf-mobility-btn ${value === 'stationary' ? 'active' : ''}`}
onClick={() => onChange('stationary')}
>
Stationary
</button>
<button
className={`tf-mobility-btn ${value === 'movable' ? 'active' : ''}`}
onClick={() => onChange('movable')}
>
Movable
</button>
</div>
</div>
);
}
function TransformInspectorContent({ context }: { context: ComponentInspectorContext }) {
const transform = context.component as TransformComponent;
const [isScaleLocked, setIsScaleLocked] = useState(false);
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
const [, forceUpdate] = useState({});
const handlePositionChange = (value: { x: number; y: number; z: number }) => {
transform.position = value;
context.onChange?.('position', value);
forceUpdate({});
};
const handleRotationChange = (value: { x: number; y: number; z: number }) => {
transform.rotation = value;
context.onChange?.('rotation', value);
forceUpdate({});
};
const handleScaleChange = (value: { x: number; y: number; z: number }) => {
transform.scale = value;
context.onChange?.('scale', value);
forceUpdate({});
};
return (
<div className="tf-inspector">
<TransformRow
label="Location"
value={transform.position}
onChange={handlePositionChange}
onReset={() => handlePositionChange({ x: 0, y: 0, z: 0 })}
/>
<TransformRow
label="Rotation"
value={transform.rotation}
onChange={handleRotationChange}
onReset={() => handleRotationChange({ x: 0, y: 0, z: 0 })}
suffix="°"
/>
<TransformRow
label="Scale"
value={transform.scale}
showLock
isLocked={isScaleLocked}
onLockChange={setIsScaleLocked}
onChange={handleScaleChange}
onReset={() => handleScaleChange({ x: 1, y: 1, z: 1 })}
showDivider={false}
/>
<div className="tf-divider" />
<MobilityRow value={mobility} onChange={setMobility} />
</div>
);
}
export class TransformComponentInspector implements IComponentInspector<TransformComponent> {
readonly id = 'transform-component-inspector';
readonly name = 'Transform Component Inspector';
readonly priority = 100;
readonly targetComponents = ['Transform', 'TransformComponent'];
canHandle(component: Component): component is TransformComponent {
return component instanceof TransformComponent ||
component.constructor.name === 'TransformComponent' ||
(component.constructor as any).componentName === 'Transform';
}
render(context: ComponentInspectorContext): React.ReactElement {
return React.createElement(TransformInspectorContent, {
context,
key: `transform-${context.version}`
});
}
}

View File

@@ -1,198 +1,147 @@
/* 资产选择框 */
/* Asset Field - Design System Style */
.asset-field {
margin-bottom: 6px;
min-width: 0; /* 允许在flex容器中收缩 */
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.asset-field__label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.asset-field__container {
/* Main content container */
.asset-field__content {
display: flex;
align-items: center;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
/* Thumbnail Preview */
.asset-field__thumbnail {
width: 44px;
height: 44px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
transition: all 0.15s ease;
position: relative;
min-width: 0; /* 允许在flex容器中收缩 */
overflow: hidden; /* 防止内容溢出 */
}
.asset-field__container.hovered {
border-color: #444;
}
.asset-field__container.dragging {
border-color: #4ade80;
background: #1a2a1a;
box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2);
}
/* 资产图标区域 */
.asset-field__icon {
border: 1px solid #3a3a3a;
border-radius: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: #262626;
border-right: 1px solid #333;
color: #888;
flex-shrink: 0; /* 图标不收缩 */
overflow: hidden;
transition: border-color 0.15s ease;
}
.asset-field__container.hovered .asset-field__icon {
color: #aaa;
.asset-field__thumbnail:hover {
border-color: #4a4a4a;
}
/* 资产输入区域 */
.asset-field__input {
.asset-field__thumbnail.dragging {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.asset-field__thumbnail img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.asset-field__thumbnail-icon {
color: #555;
}
/* Right side container */
.asset-field__right {
flex: 1;
padding: 0 8px;
height: 26px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
/* Dropdown selector */
.asset-field__dropdown {
display: flex;
align-items: center;
height: 22px;
padding: 0 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
cursor: pointer;
user-select: none;
min-width: 0; /* 关键允许flex项收缩到小于内容宽度 */
overflow: hidden; /* 配合min-width: 0防止溢出 */
transition: border-color 0.15s ease;
min-width: 0;
}
.asset-field__input:hover {
background: rgba(255, 255, 255, 0.02);
.asset-field__dropdown:hover {
border-color: #4a4a4a;
}
.asset-field__dropdown.dragging {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.asset-field__value {
flex: 1;
font-size: 11px;
color: #e0e0e0;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%; /* 确保不超出父容器 */
display: block; /* 让text-overflow生效 */
}
.asset-field__input.empty .asset-field__value {
color: #666;
font-style: italic;
}
/* 操作按钮组 */
.asset-field__dropdown.has-value .asset-field__value {
color: #ddd;
font-style: normal;
}
.asset-field__dropdown-arrow {
color: #666;
flex-shrink: 0;
margin-left: 4px;
}
/* Action buttons row */
.asset-field__actions {
display: flex;
align-items: center;
gap: 1px;
padding: 0 1px;
flex-shrink: 0; /* 操作按钮不收缩 */
gap: 2px;
}
.asset-field__button {
.asset-field__btn {
width: 20px;
height: 20px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: none;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 2px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.asset-field__button:hover {
background: #333;
color: #e0e0e0;
.asset-field__btn:hover {
background: #3a3a3a;
border-color: #4a4a4a;
color: #ccc;
}
.asset-field__button:active {
background: #444;
}
/* 清除按钮特殊样式 */
.asset-field__button--clear:hover {
.asset-field__btn--clear:hover {
background: #4a2020;
border-color: #5a3030;
color: #f87171;
}
/* 创建按钮特殊样式 */
.asset-field__button--create {
color: #4ade80;
}
.asset-field__button--create:hover {
background: #1a3a1a;
color: #4ade80;
}
/* 禁用状态 */
.asset-field__container[disabled] {
/* Disabled state */
.asset-field[disabled] {
opacity: 0.6;
pointer-events: none;
}
/* 下拉菜单样式(如果需要) */
.asset-field__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.asset-field__dropdown-item {
padding: 6px 8px;
font-size: 11px;
color: #e0e0e0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.asset-field__dropdown-item:hover {
background: #262626;
}
.asset-field__dropdown-item-icon {
color: #888;
}
/* 动画效果 */
@keyframes highlight {
0% {
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
}
100% {
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
}
}
.asset-field__container.dragging {
animation: highlight 0.5s ease;
}
/* 响应式调整 */
@media (max-width: 768px) {
.asset-field__button {
width: 20px;
height: 20px;
}
.asset-field__icon {
width: 26px;
}
}

View File

@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package, Plus } from 'lucide-react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Image, X, Navigation, ChevronDown, Copy } from 'lucide-react';
import { convertFileSrc } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import { ProjectService } from '@esengine/editor-core';
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
import './AssetField.css';
@@ -7,11 +10,11 @@ interface AssetFieldProps {
label?: string;
value: string | null;
onChange: (value: string | null) => void;
fileExtension?: string; // 例如: '.btree'
fileExtension?: string;
placeholder?: string;
readonly?: boolean;
onNavigate?: (path: string) => void; // 导航到资产
onCreate?: () => void; // 创建新资产
onNavigate?: (path: string) => void;
onCreate?: () => void;
}
export function AssetField({
@@ -25,10 +28,51 @@ export function AssetField({
onCreate
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const inputRef = useRef<HTMLDivElement>(null);
// 检测是否是图片资源
const isImageAsset = useCallback((path: string | null) => {
if (!path) return false;
return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].some(ext =>
path.toLowerCase().endsWith(ext)
);
}, []);
// 加载缩略图
useEffect(() => {
if (value && isImageAsset(value)) {
// 获取项目路径并构建完整路径
const projectService = Core.services.tryResolve(ProjectService);
const projectPath = projectService?.getCurrentProject()?.path;
if (projectPath) {
// 构建完整的文件路径
const fullPath = value.startsWith('/') || value.includes(':')
? value
: `${projectPath}/${value}`;
try {
const url = convertFileSrc(fullPath);
setThumbnailUrl(url);
} catch {
setThumbnailUrl(null);
}
} else {
// 没有项目路径时,尝试直接使用 value
try {
const url = convertFileSrc(value);
setThumbnailUrl(url);
} catch {
setThumbnailUrl(null);
}
}
} else {
setThumbnailUrl(null);
}
}, [value, isImageAsset]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -55,26 +99,22 @@ export function AssetField({
if (readonly) return;
// 处理从文件系统拖入的文件
const files = Array.from(e.dataTransfer.files);
const file = files.find((f) =>
!fileExtension || f.name.endsWith(fileExtension)
);
if (file) {
// Web File API 没有 path 属性,使用 name
onChange(file.name);
return;
}
// 处理从资产面板拖入的文件路径
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
onChange(assetPath);
return;
}
// 兼容纯文本拖拽
const text = e.dataTransfer.getData('text/plain');
if (text && (!fileExtension || text.endsWith(fileExtension))) {
onChange(text);
@@ -105,99 +145,85 @@ export function AssetField({
return (
<div className="asset-field">
{label && <label className="asset-field__label">{label}</label>}
<div
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 资产图标 */}
<div className="asset-field__icon">
{value ? (
fileExtension === '.btree' ?
<FileText size={14} /> :
<Package size={14} />
) : (
<Package size={14} style={{ opacity: 0.5 }} />
)}
</div>
{/* 资产选择框 */}
<div className="asset-field__content">
{/* 缩略图预览 */}
<div
ref={inputRef}
className={`asset-field__input ${value ? 'has-value' : 'empty'}`}
className={`asset-field__thumbnail ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={!readonly ? handleBrowse : undefined}
title={value || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
</span>
{thumbnailUrl ? (
<img src={thumbnailUrl} alt="" />
) : (
<Image size={18} className="asset-field__thumbnail-icon" />
)}
</div>
{/* 操作按钮组 */}
<div className="asset-field__actions">
{/* 创建按钮 */}
{onCreate && !readonly && !value && (
<button
className="asset-field__button asset-field__button--create"
onClick={(e) => {
e.stopPropagation();
onCreate();
}}
title="创建新资产"
>
<Plus size={12} />
</button>
)}
{/* 右侧区域 */}
<div className="asset-field__right">
{/* 下拉选择框 */}
<div
ref={inputRef}
className={`asset-field__dropdown ${value ? 'has-value' : ''} ${isDragging ? 'dragging' : ''}`}
onClick={!readonly ? handleBrowse : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
title={value || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
</span>
<ChevronDown size={12} className="asset-field__dropdown-arrow" />
</div>
{/* 浏览按钮 */}
{!readonly && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
handleBrowse();
}}
title="浏览..."
>
<Search size={12} />
</button>
)}
{/* 导航/定位按钮 */}
{onNavigate && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
if (value) {
{/* 操作按钮 */}
<div className="asset-field__actions">
{/* 定位按钮 */}
{value && onNavigate && (
<button
className="asset-field__btn"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
} else {
handleBrowse();
}
}}
title={value ? '在资产浏览器中显示' : '选择资产'}
>
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
</button>
)}
}}
title="Locate in Asset Browser"
>
<Navigation size={12} />
</button>
)}
{/* 清除按钮 */}
{value && !readonly && (
<button
className="asset-field__button asset-field__button--clear"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
title="清除"
>
<X size={12} />
</button>
)}
{/* 复制路径按钮 */}
{value && (
<button
className="asset-field__btn"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(value);
}}
title="Copy Path"
>
<Copy size={12} />
</button>
)}
{/* 清除按钮 */}
{value && !readonly && (
<button
className="asset-field__btn asset-field__btn--clear"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
title="Clear"
>
<X size={12} />
</button>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,344 @@
/**
* Collision Layer Field Component
* 碰撞层字段组件 - 支持 16 层选择
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
/**
* 碰撞层配置接口(用于获取自定义层名称)
*/
interface CollisionLayerConfigAPI {
getLayers(): Array<{ name: string }>;
addListener(callback: () => void): void;
removeListener(callback: () => void): void;
}
/**
* 默认层名称(当 CollisionLayerConfig 不可用时使用)
*/
const DEFAULT_LAYER_NAMES = [
'Default', 'Player', 'Enemy', 'Projectile',
'Ground', 'Platform', 'Trigger', 'Item',
'Layer8', 'Layer9', 'Layer10', 'Layer11',
'Layer12', 'Layer13', 'Layer14', 'Layer15',
];
let cachedConfig: CollisionLayerConfigAPI | null = null;
/**
* 尝试获取 CollisionLayerConfig 实例
*/
function getCollisionConfig(): CollisionLayerConfigAPI | null {
if (cachedConfig) return cachedConfig;
try {
// 动态导入以避免循环依赖
const physicsModule = (window as any).__PHYSICS_RAPIER2D__;
if (physicsModule?.CollisionLayerConfig) {
cachedConfig = physicsModule.CollisionLayerConfig.getInstance();
return cachedConfig;
}
} catch {
// 忽略错误
}
return null;
}
interface CollisionLayerFieldProps {
label: string;
value: number;
multiple?: boolean;
readOnly?: boolean;
onChange: (value: number) => void;
}
export const CollisionLayerField: React.FC<CollisionLayerFieldProps> = ({
label,
value,
multiple = false,
readOnly = false,
onChange
}) => {
const [isOpen, setIsOpen] = useState(false);
const [layerNames, setLayerNames] = useState<string[]>(DEFAULT_LAYER_NAMES);
const dropdownRef = useRef<HTMLDivElement>(null);
// 从配置服务获取层名称
useEffect(() => {
const config = getCollisionConfig();
if (config) {
const updateNames = () => {
const layers = config.getLayers();
setLayerNames(layers.map(l => l.name));
};
updateNames();
config.addListener(updateNames);
return () => config.removeListener(updateNames);
}
}, []);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getLayerIndex = useCallback((layerBit: number): number => {
for (let i = 0; i < 16; i++) {
if (layerBit === (1 << i)) {
return i;
}
}
for (let i = 0; i < 16; i++) {
if ((layerBit & (1 << i)) !== 0) {
return i;
}
}
return 0;
}, []);
const getSelectedCount = useCallback((): number => {
let count = 0;
for (let i = 0; i < 16; i++) {
if ((value & (1 << i)) !== 0) count++;
}
return count;
}, [value]);
const getSelectedLayerNames = useCallback((): string => {
if (!multiple) {
const index = getLayerIndex(value);
return `${index}: ${layerNames[index] ?? 'Unknown'}`;
}
const count = getSelectedCount();
if (count === 0) return 'None';
if (count === 16) return 'All (16)';
if (count > 3) return `${count} layers`;
const names: string[] = [];
for (let i = 0; i < 16; i++) {
if ((value & (1 << i)) !== 0) {
names.push(layerNames[i] ?? `Layer${i}`);
}
}
return names.join(', ');
}, [value, multiple, layerNames, getLayerIndex, getSelectedCount]);
const handleLayerToggle = (index: number) => {
if (readOnly) return;
if (multiple) {
const bit = 1 << index;
const newValue = (value & bit) ? (value & ~bit) : (value | bit);
onChange(newValue);
} else {
onChange(1 << index);
setIsOpen(false);
}
};
const handleSelectAll = () => {
if (!readOnly) onChange(0xFFFF);
};
const handleSelectNone = () => {
if (!readOnly) onChange(0);
};
return (
<div className="property-field clayer-field" ref={dropdownRef}>
<label className="property-label">{label}</label>
<div className="clayer-selector">
<button
className={`clayer-btn ${readOnly ? 'readonly' : ''}`}
onClick={() => !readOnly && setIsOpen(!isOpen)}
type="button"
disabled={readOnly}
>
<span className="clayer-text">{getSelectedLayerNames()}</span>
<span className="clayer-arrow">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && !readOnly && (
<div className="clayer-dropdown">
{multiple && (
<div className="clayer-actions">
<button onClick={handleSelectAll} type="button"></button>
<button onClick={handleSelectNone} type="button"></button>
</div>
)}
<div className="clayer-list">
{layerNames.map((layerName, index) => {
const isSelected = multiple
? (value & (1 << index)) !== 0
: getLayerIndex(value) === index;
return (
<div
key={index}
className={`clayer-item ${isSelected ? 'selected' : ''}`}
onClick={() => handleLayerToggle(index)}
>
{multiple && (
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="clayer-check"
/>
)}
<span className="clayer-idx">{index}</span>
<span className="clayer-name">{layerName}</span>
</div>
);
})}
</div>
</div>
)}
</div>
<style>{`
.clayer-field {
position: relative;
}
.clayer-selector {
position: relative;
flex: 1;
}
.clayer-btn {
width: 100%;
padding: 3px 6px;
border: 1px solid var(--input-border, #3c3c3c);
border-radius: 3px;
background: var(--input-bg, #1e1e1e);
color: var(--text-primary, #ccc);
font-size: 11px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
min-height: 22px;
}
.clayer-btn:hover:not(.readonly) {
border-color: var(--accent-color, #007acc);
}
.clayer-btn.readonly {
cursor: not-allowed;
opacity: 0.6;
}
.clayer-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clayer-arrow {
font-size: 7px;
margin-left: 6px;
color: var(--text-tertiary, #666);
}
.clayer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: var(--dropdown-bg, #252526);
border: 1px solid var(--border-color, #3c3c3c);
border-radius: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 1000;
max-height: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.clayer-actions {
display: flex;
gap: 4px;
padding: 4px;
border-bottom: 1px solid var(--border-color, #3c3c3c);
}
.clayer-actions button {
flex: 1;
padding: 2px 6px;
border: none;
border-radius: 2px;
background: var(--button-bg, #333);
color: var(--text-secondary, #aaa);
font-size: 10px;
cursor: pointer;
}
.clayer-actions button:hover {
background: var(--button-hover-bg, #444);
color: var(--text-primary, #fff);
}
.clayer-list {
overflow-y: auto;
max-height: 240px;
}
.clayer-item {
display: flex;
align-items: center;
padding: 3px 6px;
cursor: pointer;
gap: 6px;
font-size: 11px;
}
.clayer-item:hover {
background: var(--list-hover-bg, #2a2d2e);
}
.clayer-item.selected {
background: var(--list-active-bg, #094771);
}
.clayer-check {
width: 12px;
height: 12px;
accent-color: var(--accent-color, #007acc);
cursor: pointer;
}
.clayer-idx {
width: 14px;
font-size: 9px;
color: var(--text-tertiary, #666);
text-align: right;
}
.clayer-name {
flex: 1;
color: var(--text-primary, #ccc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`}</style>
</div>
);
};
export default CollisionLayerField;

View File

@@ -0,0 +1,348 @@
import { useState, useEffect, useRef } from 'react';
import { ChevronRight, Lock, Unlock, RotateCcw } from 'lucide-react';
interface TransformValue {
x: number;
y: number;
z?: number;
}
interface AxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
onChange: (value: number) => void;
suffix?: string;
}
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [inputValue, setInputValue] = useState(String(value ?? 0));
const dragStartRef = useRef({ x: 0, value: 0 });
useEffect(() => {
setInputValue(String(value ?? 0));
}, [value]);
const handleBarMouseDown = (e: React.MouseEvent) => {
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 : e.ctrlKey ? 1 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
const rounded = Math.round(newValue * 1000) / 1000;
onChange(rounded);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputBlur = () => {
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
onChange(parsed);
} else {
setInputValue(String(value ?? 0));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setInputValue(String(value ?? 0));
(e.target as HTMLInputElement).blur();
}
};
return (
<div className={`transform-axis-input ${isDragging ? 'dragging' : ''}`}>
<div
className={`transform-axis-bar ${axis}`}
onMouseDown={handleBarMouseDown}
/>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
onFocus={(e) => e.target.select()}
/>
{suffix && <span className="transform-axis-suffix">{suffix}</span>}
</div>
);
}
interface TransformRowProps {
label: string;
value: TransformValue;
showZ?: boolean;
showLock?: boolean;
isLocked?: boolean;
onLockChange?: (locked: boolean) => void;
onChange: (value: TransformValue) => void;
onReset?: () => void;
suffix?: string;
}
export function TransformRow({
label,
value,
showZ = false,
showLock = false,
isLocked = false,
onLockChange,
onChange,
onReset,
suffix
}: TransformRowProps) {
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
if (isLocked && showLock) {
const oldVal = axis === 'x' ? value.x : axis === 'y' ? value.y : (value.z ?? 0);
if (oldVal !== 0) {
const ratio = newValue / oldVal;
onChange({
x: axis === 'x' ? newValue : value.x * ratio,
y: axis === 'y' ? newValue : value.y * ratio,
z: showZ ? (axis === 'z' ? newValue : (value.z ?? 1) * ratio) : undefined
});
} else {
onChange({ ...value, [axis]: newValue });
}
} else {
onChange({ ...value, [axis]: newValue });
}
};
return (
<div className="transform-row">
<div className="transform-row-label">
<span className="transform-label-text">{label}</span>
</div>
<div className="transform-row-inputs">
<AxisInput
axis="x"
value={value?.x ?? 0}
onChange={(v) => handleAxisChange('x', v)}
suffix={suffix}
/>
<AxisInput
axis="y"
value={value?.y ?? 0}
onChange={(v) => handleAxisChange('y', v)}
suffix={suffix}
/>
{showZ && (
<AxisInput
axis="z"
value={value?.z ?? 0}
onChange={(v) => handleAxisChange('z', v)}
suffix={suffix}
/>
)}
</div>
{showLock && (
<button
className={`transform-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => onLockChange?.(!isLocked)}
title={isLocked ? 'Unlock' : 'Lock'}
>
{isLocked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
)}
<button
className="transform-reset-btn"
onClick={onReset}
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
);
}
interface RotationRowProps {
value: number | { x: number; y: number; z: number };
onChange: (value: number | { x: number; y: number; z: number }) => void;
onReset?: () => void;
is3D?: boolean;
}
export function RotationRow({ value, onChange, onReset, is3D = false }: RotationRowProps) {
if (is3D && typeof value === 'object') {
return (
<div className="transform-row">
<div className="transform-row-label">
<span className="transform-label-text">Rotation</span>
</div>
<div className="transform-row-inputs">
<AxisInput
axis="x"
value={value.x ?? 0}
onChange={(v) => onChange({ ...value, x: v })}
suffix="°"
/>
<AxisInput
axis="y"
value={value.y ?? 0}
onChange={(v) => onChange({ ...value, y: v })}
suffix="°"
/>
<AxisInput
axis="z"
value={value.z ?? 0}
onChange={(v) => onChange({ ...value, z: v })}
suffix="°"
/>
</div>
<button
className="transform-reset-btn"
onClick={() => onReset?.() || onChange({ x: 0, y: 0, z: 0 })}
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
);
}
const numericValue = typeof value === 'number' ? value : 0;
return (
<div className="transform-row">
<div className="transform-row-label">
<span className="transform-label-text">Rotation</span>
</div>
<div className="transform-row-inputs rotation-single">
<AxisInput
axis="z"
value={numericValue}
onChange={(v) => onChange(v)}
suffix="°"
/>
</div>
<button
className="transform-reset-btn"
onClick={() => onReset?.() || onChange(0)}
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
);
}
interface MobilityRowProps {
value: 'static' | 'stationary' | 'movable';
onChange: (value: 'static' | 'stationary' | 'movable') => void;
}
export function MobilityRow({ value, onChange }: MobilityRowProps) {
return (
<div className="transform-mobility-row">
<span className="transform-mobility-label">Mobility</span>
<div className="transform-mobility-buttons">
<button
className={`transform-mobility-btn ${value === 'static' ? 'active' : ''}`}
onClick={() => onChange('static')}
>
Static
</button>
<button
className={`transform-mobility-btn ${value === 'stationary' ? 'active' : ''}`}
onClick={() => onChange('stationary')}
>
Stationary
</button>
<button
className={`transform-mobility-btn ${value === 'movable' ? 'active' : ''}`}
onClick={() => onChange('movable')}
>
Movable
</button>
</div>
</div>
);
}
interface TransformSectionProps {
position: { x: number; y: number };
rotation: number;
scale: { x: number; y: number };
onPositionChange: (value: { x: number; y: number }) => void;
onRotationChange: (value: number) => void;
onScaleChange: (value: { x: number; y: number }) => void;
}
export function TransformSection({
position,
rotation,
scale,
onPositionChange,
onRotationChange,
onScaleChange
}: TransformSectionProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isScaleLocked, setIsScaleLocked] = useState(false);
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
return (
<div className="transform-section">
<div
className="transform-section-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className={`transform-section-expand ${isExpanded ? 'expanded' : ''}`}>
<ChevronRight size={14} />
</span>
<span className="transform-section-title">Transform</span>
</div>
{isExpanded && (
<div className="transform-section-content">
<TransformRow
label="Location"
value={position}
onChange={onPositionChange}
onReset={() => onPositionChange({ x: 0, y: 0 })}
/>
<RotationRow
value={rotation}
onChange={(v) => onRotationChange(typeof v === 'number' ? v : 0)}
onReset={() => onRotationChange(0)}
/>
<TransformRow
label="Scale"
value={scale}
showLock
isLocked={isScaleLocked}
onLockChange={setIsScaleLocked}
onChange={onScaleChange}
onReset={() => onScaleChange({ x: 1, y: 1 })}
/>
<MobilityRow value={mobility} onChange={setMobility} />
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search } from 'lucide-react';
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search, Lock, Unlock } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
@@ -8,6 +8,20 @@ import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } f
import '../../../styles/EntityInspector.css';
import * as LucideIcons from 'lucide-react';
type CategoryFilter = 'all' | 'general' | 'transform' | 'rendering' | 'physics' | 'audio' | 'other';
// 从 ComponentRegistry category 到 CategoryFilter 的映射
const categoryKeyMap: Record<string, CategoryFilter> = {
'components.category.core': 'general',
'components.category.rendering': 'rendering',
'components.category.physics': 'physics',
'components.category.audio': 'audio',
'components.category.ui': 'rendering',
'components.category.ui.core': 'rendering',
'components.category.ui.widgets': 'rendering',
'components.category.other': 'other',
};
interface ComponentInfo {
name: string;
type?: new () => Component;
@@ -24,12 +38,18 @@ interface EntityInspectorProps {
}
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
// 默认展开所有组件
return new Set(entity.components.map((_, index) => index));
});
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 [isLocked, setIsLocked] = useState(false);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [propertySearchQuery, setPropertySearchQuery] = useState('');
const addButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -38,6 +58,18 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
useEffect(() => {
setExpandedComponents((prev) => {
const newSet = new Set(prev);
// 添加所有当前组件的索引(保留已有的展开状态)
entity.components.forEach((_, index) => {
newSet.add(index);
});
return newSet;
});
}, [entity, entity.components.length, componentVersion]);
useEffect(() => {
if (showComponentMenu && addButtonRef.current) {
const rect = addButtonRef.current.getBoundingClientRect();
@@ -182,25 +214,89 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
}
};
const categoryTabs: { key: CategoryFilter; label: string }[] = [
{ key: 'general', label: 'General' },
{ key: 'transform', label: 'Transform' },
{ key: 'rendering', label: 'Rendering' },
{ key: 'physics', label: 'Physics' },
{ key: 'audio', label: 'Audio' },
{ key: 'other', label: 'Other' },
{ key: 'all', label: 'All' }
];
const getComponentCategory = useCallback((componentName: string): CategoryFilter => {
const componentInfo = componentRegistry?.getComponent(componentName);
if (componentInfo?.category) {
return categoryKeyMap[componentInfo.category] || 'general';
}
return 'general';
}, [componentRegistry]);
const filteredComponents = useMemo(() => {
return entity.components.filter((component: Component) => {
const componentName = getComponentInstanceTypeName(component);
if (categoryFilter !== 'all') {
const category = getComponentCategory(componentName);
if (category !== categoryFilter) {
return false;
}
}
if (propertySearchQuery.trim()) {
const query = propertySearchQuery.toLowerCase();
if (!componentName.toLowerCase().includes(query)) {
return false;
}
}
return true;
});
}, [entity.components, categoryFilter, propertySearchQuery, getComponentCategory]);
return (
<div className="entity-inspector">
{/* Header */}
<div className="inspector-header">
<Settings size={16} />
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
<div className="inspector-header-left">
<button
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
onClick={() => setIsLocked(!isLocked)}
title={isLocked ? '解锁检视器' : '锁定检视器'}
>
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<Settings size={14} color="#666" />
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
</div>
<span className="inspector-object-count">1 object</span>
</div>
{/* Search Box */}
<div className="inspector-search">
<Search size={14} />
<input
type="text"
placeholder="Search..."
value={propertySearchQuery}
onChange={(e) => setPropertySearchQuery(e.target.value)}
/>
</div>
{/* Category Tabs */}
<div className="inspector-category-tabs">
{categoryTabs.map((tab) => (
<button
key={tab.key}
className={`inspector-category-tab ${categoryFilter === tab.key ? 'active' : ''}`}
onClick={() => setCategoryFilter(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label">Entity ID</label>
<span className="property-value-text">{entity.id}</span>
</div>
<div className="property-field">
<label className="property-label">Enabled</label>
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
</div>
</div>
<div className="inspector-section">
<div className="section-title section-title-with-action">
@@ -273,11 +369,14 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
)}
</div>
</div>
{entity.components.length === 0 ? (
<div className="empty-state-small"></div>
{filteredComponents.length === 0 ? (
<div className="empty-state-small">
{entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'}
</div>
) : (
entity.components.map((component: Component, index: number) => {
const isExpanded = expandedComponents.has(index);
filteredComponents.map((component: Component) => {
const originalIndex = entity.components.indexOf(component);
const isExpanded = expandedComponents.has(originalIndex);
const componentName = getComponentInstanceTypeName(component);
const componentInfo = componentRegistry?.getComponent(componentName);
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
@@ -285,12 +384,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
return (
<div
key={`${componentName}-${index}`}
key={`${componentName}-${originalIndex}`}
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
>
<div
className="component-item-header"
onClick={() => toggleComponentExpanded(index)}
onClick={() => toggleComponentExpanded(originalIndex)}
>
<span className="component-expand-icon">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
@@ -311,7 +410,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
className="component-remove-btn"
onClick={(e) => {
e.stopPropagation();
handleRemoveComponent(index);
handleRemoveComponent(originalIndex);
}}
title="移除组件"
>