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:
@@ -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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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="移除组件"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user