refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,493 @@
import React, { useState, useCallback, useEffect } from 'react';
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/sprite';
import { AssetField } from '../../components/inspectors/fields/AssetField';
import { EngineService } from '../../services/EngineService';
interface DraggableNumberProps {
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
disabled?: boolean;
label: string;
}
function DraggableNumber({ value, min = 0, max = 10, step = 0.1, onChange, disabled, label }: DraggableNumberProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
if (disabled) return;
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartValue(value);
e.preventDefault();
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartX;
const sensitivity = e.shiftKey ? 0.1 : 1;
let newValue = dragStartValue + delta * step * sensitivity;
newValue = Math.max(min, Math.min(max, newValue));
newValue = parseFloat(newValue.toFixed(2));
onChange(newValue);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
return (
<label className="clip-draggable-number">
<span
className="clip-draggable-label"
onMouseDown={handleMouseDown}
style={{ cursor: disabled ? 'default' : 'ew-resize' }}
>
{label}
</span>
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(parseFloat(e.target.value) || 1)}
disabled={disabled}
/>
</label>
);
}
export class AnimationClipsFieldEditor implements IFieldEditor<AnimationClip[]> {
readonly type = 'animationClips';
readonly name = 'Animation Clips Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'animationClips';
}
render({ label, value, onChange, context }: FieldEditorProps<AnimationClip[]>): React.ReactElement {
return (
<AnimationClipsEditor
label={label}
clips={value || []}
onChange={onChange}
readonly={context.readonly}
component={context.metadata?.component as SpriteAnimatorComponent}
onDefaultAnimationChange={context.metadata?.onDefaultAnimationChange}
/>
);
}
}
interface AnimationClipsEditorProps {
label: string;
clips: AnimationClip[];
onChange: (clips: AnimationClip[]) => void;
readonly?: boolean;
component?: SpriteAnimatorComponent;
onDefaultAnimationChange?: (value: string) => void;
}
function AnimationClipsEditor({ label, clips, onChange, readonly, component, onDefaultAnimationChange }: AnimationClipsEditorProps) {
const [expandedClips, setExpandedClips] = useState<Set<number>>(new Set());
const [playingClip, setPlayingClip] = useState<string | null>(null);
const handleNavigate = useCallback((path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
}, []);
const toggleClip = (index: number) => {
const newExpanded = new Set(expandedClips);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedClips(newExpanded);
};
const addClip = () => {
const newName = `Animation ${clips.length + 1}`;
const newClip: AnimationClip = {
name: newName,
frames: [],
loop: true,
speed: 1
};
onChange([...clips, newClip]);
setExpandedClips(new Set([...expandedClips, clips.length]));
// Auto-set first clip as default animation
if (clips.length === 0 && component && !component.defaultAnimation) {
component.defaultAnimation = newName;
setDefaultAnimation(newName);
if (onDefaultAnimationChange) {
onDefaultAnimationChange(newName);
}
}
};
const removeClip = (index: number) => {
const newClips = clips.filter((_, i) => i !== index);
onChange(newClips);
};
const updateClip = (index: number, updates: Partial<AnimationClip>) => {
const newClips = [...clips];
const existingClip = newClips[index];
if (!existingClip) return;
newClips[index] = { ...existingClip, ...updates } as AnimationClip;
onChange(newClips);
};
const addFrame = (clipIndex: number) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = [...clip.frames, { textureGuid: '', duration: 0.1 }];
onChange(newClips);
};
const removeFrame = (clipIndex: number, frameIndex: number) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = clip.frames.filter((_, i) => i !== frameIndex);
onChange(newClips);
};
const updateFrame = (clipIndex: number, frameIndex: number, updates: Partial<AnimationFrame>) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = [...clip.frames];
const existingFrame = clip.frames[frameIndex];
if (!existingFrame) return;
clip.frames[frameIndex] = { ...existingFrame, ...updates } as AnimationFrame;
onChange(newClips);
};
const addFramesBatch = (clipIndex: number, texturePaths: string[]) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
const newFrames = texturePaths.map((textureGuid) => ({
textureGuid,
duration: 0.1
}));
clip.frames = [...clip.frames, ...newFrames];
onChange(newClips);
};
const handleFramesDrop = useCallback((clipIndex: number, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const data = e.dataTransfer.getData('application/json');
if (data) {
try {
const items = JSON.parse(data);
if (Array.isArray(items)) {
const textures = items
.filter((item: { type: string; path: string }) =>
item.type === 'file' && /\.(png|jpg|jpeg|webp|gif)$/i.test(item.path))
.map((item: { path: string }) => item.path)
.sort();
if (textures.length > 0) {
addFramesBatch(clipIndex, textures);
}
}
} catch {
// Try text data for single file
const text = e.dataTransfer.getData('text/plain');
if (text && /\.(png|jpg|jpeg|webp|gif)$/i.test(text)) {
addFramesBatch(clipIndex, [text]);
}
}
}
}, [clips, onChange]);
const handleFramesDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const [defaultAnimation, setDefaultAnimation] = useState(component?.defaultAnimation || '');
// Sync with component changes
useEffect(() => {
if (component) {
setDefaultAnimation(component.defaultAnimation || '');
}
}, [component?.defaultAnimation]);
const setAsDefaultAnimationHandler = (clipName: string) => {
if (component) {
component.defaultAnimation = clipName;
setDefaultAnimation(clipName);
// Notify parent to update the defaultAnimation field
if (onDefaultAnimationChange) {
onDefaultAnimationChange(clipName);
}
}
};
const isDefaultAnimation = (clipName: string) => {
return defaultAnimation === clipName;
};
const handlePlayPreview = (clipName: string) => {
if (component) {
const engineService = EngineService.getInstance();
// Get the actual component from scene entity (not the one passed as prop)
const scene = engineService.getScene();
const entityId = component.entityId;
let actualComponent = component;
if (scene && entityId !== undefined && entityId !== null) {
const sceneEntity = scene.findEntityById(entityId);
if (sceneEntity) {
const sceneAnimator = sceneEntity.getComponent(component.constructor as any);
if (sceneAnimator) {
actualComponent = sceneAnimator as SpriteAnimatorComponent;
}
}
}
if (playingClip === clipName) {
// Stop playing
actualComponent.stop();
setPlayingClip(null);
engineService.disableAnimationPreview();
} else {
// Stop previous animation if any
actualComponent.stop();
// Sync clips data to component before playing
actualComponent.clips = clips;
// Enable animation preview if not already enabled
if (!engineService.isAnimationPreviewEnabled()) {
engineService.enableAnimationPreview();
}
// Play this clip
actualComponent.play(clipName);
setPlayingClip(clipName);
}
}
};
// Sync playingClip state with actual component state
useEffect(() => {
if (component && playingClip) {
// Check if component is still playing
if (!component.isPlaying()) {
setPlayingClip(null);
}
}
});
// Stop preview when component unmounts
useEffect(() => {
return () => {
if (component) {
component.stop();
const engineService = EngineService.getInstance();
engineService.disableAnimationPreview();
}
};
}, [component]);
return (
<div className="animation-clips-editor">
<div className="clips-header">
<span className="clips-label">{label}</span>
{!readonly && (
<button className="add-clip-btn" onClick={addClip} title="Add Animation Clip">
<Plus size={12} />
</button>
)}
</div>
{clips.length === 0 ? (
<div className="clips-empty">
<Film size={24} strokeWidth={1} />
<span>No animation clips</span>
</div>
) : (
<div className="clips-list">
{clips.map((clip, clipIndex) => (
<div key={clipIndex} className="clip-item">
<div className="clip-header" onClick={() => toggleClip(clipIndex)}>
{expandedClips.has(clipIndex) ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<Film size={14} />
<input
className="clip-name-input"
value={clip.name}
onChange={(e) => {
e.stopPropagation();
updateClip(clipIndex, { name: e.target.value });
}}
onClick={(e) => e.stopPropagation()}
disabled={readonly}
/>
<span className="frame-count">{clip.frames.length} frames</span>
{component && clip.frames.length > 0 && (
<button
className={`preview-clip-btn ${playingClip === clip.name ? 'is-playing' : ''}`}
onClick={(e) => {
e.stopPropagation();
handlePlayPreview(clip.name);
}}
title={playingClip === clip.name ? 'Stop Preview' : 'Preview Animation'}
>
{playingClip === clip.name ? <Square size={10} /> : <Play size={10} />}
</button>
)}
{component && !readonly && (
<button
className={`set-default-btn ${isDefaultAnimation(clip.name) ? 'is-default' : ''}`}
onClick={(e) => {
e.stopPropagation();
setAsDefaultAnimationHandler(clip.name);
}}
title={isDefaultAnimation(clip.name) ? 'Current Default Animation' : 'Set as Default Animation'}
>
<Star size={12} fill={isDefaultAnimation(clip.name) ? 'currentColor' : 'none'} />
</button>
)}
{!readonly && (
<button
className="remove-clip-btn"
onClick={(e) => {
e.stopPropagation();
removeClip(clipIndex);
}}
title="Remove Clip"
>
<Trash2 size={12} />
</button>
)}
</div>
{expandedClips.has(clipIndex) && (
<div className="clip-content">
<div className="clip-settings">
<label>
<input
type="checkbox"
checked={clip.loop}
onChange={(e) => updateClip(clipIndex, { loop: e.target.checked })}
disabled={readonly}
/>
Loop
</label>
<DraggableNumber
label="Speed:"
value={clip.speed}
min={0}
max={10}
step={0.1}
onChange={(val) => updateClip(clipIndex, { speed: val })}
disabled={readonly}
/>
</div>
<div
className="frames-section"
onDrop={(e) => handleFramesDrop(clipIndex, e)}
onDragOver={handleFramesDragOver}
>
<div className="frames-header">
<span>Frames</span>
{!readonly && (
<button onClick={() => addFrame(clipIndex)} title="Add Frame">
<Plus size={10} />
</button>
)}
</div>
{clip.frames.length === 0 ? (
<div className="frames-empty frames-drop-zone">
<Upload size={16} />
<span>Drop images here or click + to add</span>
</div>
) : (
<div className="frames-list">
{clip.frames.map((frame, frameIndex) => (
<div key={frameIndex} className="frame-item">
<span className="frame-index">{frameIndex + 1}</span>
<div className="frame-texture-field">
<AssetField
value={frame.textureGuid}
onChange={(val) => updateFrame(clipIndex, frameIndex, { textureGuid: val || '' })}
fileExtension=".png"
placeholder="Texture..."
readonly={readonly}
onNavigate={handleNavigate}
/>
</div>
<input
className="frame-duration"
type="number"
min={0.01}
step={0.01}
value={frame.duration}
onChange={(e) => updateFrame(clipIndex, frameIndex, { duration: parseFloat(e.target.value) || 0.1 })}
disabled={readonly}
title="Duration (seconds)"
/>
{!readonly && (
<button
onClick={() => removeFrame(clipIndex, frameIndex)}
title="Remove Frame"
>
<Trash2 size={10} />
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { IFieldEditor, FieldEditorProps, MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { AssetField } from '../../components/inspectors/fields/AssetField';
export class AssetFieldEditor implements IFieldEditor<string | null> {
readonly type = 'asset';
readonly name = 'Asset Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'asset' || fieldType === 'assetReference' || fieldType === 'resourcePath';
}
render({ label, value, onChange, context }: FieldEditorProps<string | null>): React.ReactElement {
const fileExtension = context.metadata?.fileExtension || '';
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
const handleNavigate = (path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
};
// 从 FileActionRegistry 获取资产创建消息映射
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
const creationMapping = fileActionRegistry?.getAssetCreationMapping(fileExtension);
const handleCreate = () => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub && creationMapping) {
messageHub.publish(creationMapping.createMessage, {
entityId: context.metadata?.entityId,
onChange
});
}
};
const canCreate = creationMapping !== null && creationMapping !== undefined;
return (
<AssetField
label={label}
value={value}
onChange={onChange}
fileExtension={fileExtension}
placeholder={placeholder}
readonly={context.readonly}
onNavigate={handleNavigate}
onCreate={canCreate ? handleCreate : undefined}
/>
);
}
}

View File

@@ -0,0 +1,199 @@
import React, { useState, useRef, useEffect } from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
interface Color {
r: number;
g: number;
b: number;
a: number;
}
function rgbaToHex(color: Color): string {
const toHex = (c: number) => Math.round(c * 255).toString(16).padStart(2, '0');
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
}
function hexToRgba(hex: string): Color {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result && result[1] && result[2] && result[3]) {
return {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
a: 1
};
}
return { r: 0, g: 0, b: 0, a: 1 };
}
export class ColorFieldEditor implements IFieldEditor<Color> {
readonly type = 'color';
readonly name = 'Color Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'color' || fieldType === 'rgba' || fieldType === 'rgb';
}
render({ label, value, onChange, context }: FieldEditorProps<Color>): React.ReactElement {
const color = value || { r: 1, g: 1, b: 1, a: 1 };
const [showPicker, setShowPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setShowPicker(false);
}
};
if (showPicker) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showPicker]);
const hexColor = rgbaToHex(color);
const rgbDisplay = `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${color.a.toFixed(2)})`;
return (
<div className="property-field" style={{ position: 'relative' }}>
<label className="property-label">{label}</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={() => !context.readonly && setShowPicker(!showPicker)}
disabled={context.readonly}
style={{
width: '32px',
height: '24px',
backgroundColor: hexColor,
border: '2px solid #444',
borderRadius: '3px',
cursor: context.readonly ? 'default' : 'pointer',
position: 'relative',
overflow: 'hidden'
}}
>
{color.a < 1 && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%)',
backgroundPosition: '0 0, 8px 8px',
backgroundSize: '16px 16px',
opacity: 1 - color.a
}}
/>
)}
</button>
<span style={{ fontSize: '11px', color: '#888', fontFamily: 'monospace' }}>
{rgbDisplay}
</span>
{showPicker && (
<div
ref={pickerRef}
style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
zIndex: 1000,
backgroundColor: '#2a2a2a',
border: '1px solid #444',
borderRadius: '4px',
padding: '8px',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
}}
>
<div style={{ marginBottom: '8px' }}>
<label style={{ fontSize: '10px', color: '#888' }}>Hex: </label>
<input
type="color"
value={hexColor}
onChange={(e) => {
const newColor = hexToRgba(e.target.value);
onChange({ ...newColor, a: color.a });
}}
style={{ marginLeft: '4px' }}
/>
</div>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<input
type="number"
value={Math.round(color.r * 255)}
onChange={(e) => onChange({ ...color, r: (parseInt(e.target.value) || 0) / 255 })}
min={0}
max={255}
style={{
width: '50px',
padding: '2px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '2px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
<input
type="number"
value={Math.round(color.g * 255)}
onChange={(e) => onChange({ ...color, g: (parseInt(e.target.value) || 0) / 255 })}
min={0}
max={255}
style={{
width: '50px',
padding: '2px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '2px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
<input
type="number"
value={Math.round(color.b * 255)}
onChange={(e) => onChange({ ...color, b: (parseInt(e.target.value) || 0) / 255 })}
min={0}
max={255}
style={{
width: '50px',
padding: '2px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '2px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<label style={{ fontSize: '10px', color: '#888' }}>Alpha:</label>
<input
type="range"
value={color.a}
onChange={(e) => onChange({ ...color, a: parseFloat(e.target.value) })}
min={0}
max={1}
step={0.01}
style={{ flex: 1 }}
/>
<span style={{ fontSize: '10px', color: '#888', minWidth: '30px' }}>
{(color.a * 100).toFixed(0)}%
</span>
</div>
</div>
)}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,60 @@
/**
* Entity Reference Field Editor
* 实体引用字段编辑器
*
* Handles editing of entity reference fields with drag-and-drop support.
* 处理实体引用字段的编辑,支持拖放操作。
*/
import React from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
import { EntityRefField } from '../../components/inspectors/fields/EntityRefField';
/**
* Field editor for entity references (entity IDs)
* 实体引用(实体 ID的字段编辑器
*
* Supports:
* - Drag-and-drop entities from SceneHierarchy
* - Click to navigate to referenced entity
* - Clear button to remove reference
*
* 支持:
* - 从场景层级面板拖放实体
* - 点击导航到引用的实体
* - 清除按钮移除引用
*/
export class EntityRefFieldEditor implements IFieldEditor<number> {
readonly type = 'entityRef';
readonly name = 'Entity Reference Field Editor';
readonly priority = 100;
/**
* Check if this editor can handle the given field type
* 检查此编辑器是否可以处理给定的字段类型
*/
canHandle(fieldType: string): boolean {
return fieldType === 'entityRef' ||
fieldType === 'entityReference' ||
fieldType === 'EntityRef' ||
fieldType.endsWith('EntityId');
}
/**
* Render the entity reference field
* 渲染实体引用字段
*/
render({ label, value, onChange, context }: FieldEditorProps<number>): React.ReactElement {
const placeholder = context.metadata?.placeholder || '拖拽实体到此处 / Drop entity here';
return (
<EntityRefField
label={label}
value={value ?? 0}
onChange={onChange}
placeholder={placeholder}
readonly={context.readonly}
/>
);
}
}

View File

@@ -0,0 +1,202 @@
import React from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
interface Vector2 { x: number; y: number; }
interface Vector3 extends Vector2 { z: number; }
interface Vector4 extends Vector3 { w: number; }
const VectorInput: React.FC<{
label: string;
value: number;
onChange: (value: number) => void;
readonly?: boolean;
axis: 'x' | 'y' | 'z' | 'w';
step?: number;
}> = ({ label, value, onChange, readonly, axis, step = 0.01 }) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// 允许空字符串、负号、小数点等中间输入状态
// Allow empty string, minus sign, decimal point as intermediate states
if (inputValue === '' || inputValue === '-' || inputValue === '.' || inputValue === '-.') {
return; // 不触发 onChange等待用户完成输入
}
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
onChange(parsed);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
// 失去焦点时,如果是无效值则重置为当前值
// On blur, if value is invalid, reset to current value
const parsed = parseFloat(e.target.value);
if (isNaN(parsed)) {
e.target.value = String(value);
}
};
return (
<div className="property-vector-axis-compact">
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
<input
type="number"
defaultValue={value}
key={value} // 强制在外部值变化时重新渲染
onChange={handleChange}
onBlur={handleBlur}
disabled={readonly}
step={step}
className="property-input property-input-number property-input-number-compact"
/>
</div>
);
};
export class Vector2FieldEditor implements IFieldEditor<Vector2> {
readonly type = 'vector2';
readonly name = 'Vector2 Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'vector2' || fieldType === 'vec2';
}
render({ label, value, onChange, context }: FieldEditorProps<Vector2>): React.ReactElement {
const v = value || { x: 0, y: 0 };
return (
<div className="property-field">
<label className="property-label">{label}</label>
<div className="property-vector-compact">
<VectorInput
label="X"
value={v.x}
onChange={(x) => onChange({ ...v, x })}
readonly={context.readonly}
axis="x"
/>
<VectorInput
label="Y"
value={v.y}
onChange={(y) => onChange({ ...v, y })}
readonly={context.readonly}
axis="y"
/>
</div>
</div>
);
}
}
export class Vector3FieldEditor implements IFieldEditor<Vector3> {
readonly type = 'vector3';
readonly name = 'Vector3 Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'vector3' || fieldType === 'vec3';
}
render({ label, value, onChange, context }: FieldEditorProps<Vector3>): React.ReactElement {
const v = value || { x: 0, y: 0, z: 0 };
return (
<div className="property-field">
<label className="property-label">{label}</label>
<div className="property-vector-compact">
<VectorInput
label="X"
value={v.x}
onChange={(x) => onChange({ ...v, x })}
readonly={context.readonly}
axis="x"
/>
<VectorInput
label="Y"
value={v.y}
onChange={(y) => onChange({ ...v, y })}
readonly={context.readonly}
axis="y"
/>
<VectorInput
label="Z"
value={v.z}
onChange={(z) => onChange({ ...v, z })}
readonly={context.readonly}
axis="z"
/>
</div>
</div>
);
}
}
export class Vector4FieldEditor implements IFieldEditor<Vector4> {
readonly type = 'vector4';
readonly name = 'Vector4 Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'vector4' || fieldType === 'vec4' || fieldType === 'quaternion';
}
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
// Support both object {x,y,z,w} and array [0,1,2,3] formats
// 支持对象 {x,y,z,w} 和数组 [0,1,2,3] 两种格式
let v: Vector4;
const isArray = Array.isArray(value);
if (isArray) {
const arr = value as unknown as number[];
v = { x: arr[0] ?? 0, y: arr[1] ?? 0, z: arr[2] ?? 0, w: arr[3] ?? 0 };
} else {
v = value || { x: 0, y: 0, z: 0, w: 0 };
}
const handleChange = (newV: Vector4) => {
if (isArray) {
// Return as array if input was array
// 如果输入是数组,则返回数组
onChange([newV.x, newV.y, newV.z, newV.w] as unknown as Vector4);
} else {
onChange(newV);
}
};
return (
<div className="property-field">
<label className="property-label">{label}</label>
<div className="property-vector-compact">
<VectorInput
label="X"
value={v.x}
onChange={(x) => handleChange({ ...v, x })}
readonly={context.readonly}
axis="x"
/>
<VectorInput
label="Y"
value={v.y}
onChange={(y) => handleChange({ ...v, y })}
readonly={context.readonly}
axis="y"
/>
<VectorInput
label="Z"
value={v.z}
onChange={(z) => handleChange({ ...v, z })}
readonly={context.readonly}
axis="z"
/>
<VectorInput
label="W"
value={v.w}
onChange={(w) => handleChange({ ...v, w })}
readonly={context.readonly}
axis="w"
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,5 @@
export * from './AssetFieldEditor';
export * from './VectorFieldEditors';
export * from './ColorFieldEditor';
export * from './AnimationClipsFieldEditor';
export * from './EntityRefFieldEditor';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,5 @@
export * from './tauri';
export * from './github';
export * from './plugins';
export * from './serialization';
export * from './events';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
import { IPropertyRenderer, PropertyContext, PropertyRendererRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
interface ComponentData {
typeName: string;
properties: Record<string, any>;
}
export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
readonly id = 'app.component';
readonly name = 'Component Renderer';
readonly priority = 75;
canHandle(value: any, _context: PropertyContext): value is ComponentData {
return (
typeof value === 'object' &&
value !== null &&
typeof value.typeName === 'string' &&
typeof value.properties === 'object' &&
value.properties !== null
);
}
render(value: ComponentData, context: PropertyContext): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(context.expandByDefault ?? false);
const depth = context.depth ?? 0;
return (
<div style={{ marginLeft: depth > 0 ? '12px' : 0 }}>
<div
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
backgroundColor: '#3a3a3a',
cursor: 'pointer',
userSelect: 'none',
borderRadius: '4px',
marginBottom: '2px'
}}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
<span
style={{
marginLeft: '6px',
fontSize: '12px',
fontWeight: 500,
color: '#e0e0e0'
}}
>
{value.typeName}
</span>
</div>
{isExpanded && (
<div style={{ marginLeft: '8px', borderLeft: '1px solid #444', paddingLeft: '8px' }}>
{Object.entries(value.properties).map(([key, propValue]) => {
const registry = Core.services.resolve(PropertyRendererRegistry);
const propContext: PropertyContext = {
...context,
name: key,
depth: depth + 1,
path: [...(context.path || []), key]
};
const rendered = registry.render(propValue, propContext);
if (rendered) {
return <div key={key}>{rendered}</div>;
}
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<span className="property-value-text" style={{ color: '#666', fontStyle: 'italic' }}>
[No Renderer]
</span>
</div>
);
})}
</div>
)}
</div>
);
}
}

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { IPropertyRenderer, PropertyContext } from '@esengine/editor-core';
export class FallbackRenderer implements IPropertyRenderer<any> {
readonly id = 'app.fallback';
readonly name = 'Fallback Renderer';
readonly priority = -1000;
canHandle(_value: any, _context: PropertyContext): _value is any {
return true;
}
render(value: any, context: PropertyContext): React.ReactElement {
const typeInfo = this.getTypeInfo(value);
return (
<div className="property-field" style={{ opacity: 0.6 }}>
<label className="property-label">{context.name}</label>
<span
className="property-value-text"
style={{ color: '#888', fontStyle: 'italic', fontSize: '0.9em' }}
title="No renderer registered for this type"
>
{typeInfo}
</span>
</div>
);
}
private getTypeInfo(value: any): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
const type = typeof value;
if (type === 'object') {
if (Array.isArray(value)) {
return `Array(${value.length})`;
}
const constructor = value.constructor?.name;
if (constructor && constructor !== 'Object') {
return `[${constructor}]`;
}
const keys = Object.keys(value);
if (keys.length === 0) return '{}';
if (keys.length <= 3) {
return `{${keys.join(', ')}}`;
}
return `{${keys.slice(0, 3).join(', ')}...}`;
}
return `[${type}]`;
}
}
export class ArrayRenderer implements IPropertyRenderer<any[]> {
readonly id = 'app.array';
readonly name = 'Array Renderer';
readonly priority = 50;
canHandle(value: any, _context: PropertyContext): value is any[] {
return Array.isArray(value);
}
render(value: any[], context: PropertyContext): React.ReactElement {
const [isExpanded, setIsExpanded] = React.useState(false);
const depth = context.depth ?? 0;
if (value.length === 0) {
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<span className="property-value-text" style={{ color: '#666' }}>
[]
</span>
</div>
);
}
const isStringArray = value.every((item) => typeof item === 'string');
if (isStringArray && value.length <= 5) {
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }}>
{(value as string[]).map((item, index) => (
<span
key={index}
style={{
padding: '2px 8px',
backgroundColor: '#2d4a3e',
color: '#8fbc8f',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'monospace'
}}
>
{item}
</span>
))}
</div>
</div>
);
}
return (
<div style={{ marginLeft: depth > 0 ? '12px' : 0 }}>
<div
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
padding: '3px 0',
fontSize: '11px',
borderBottom: '1px solid #333',
cursor: 'pointer',
userSelect: 'none'
}}
>
{isExpanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
<span style={{ color: '#9cdcfe', marginLeft: '4px' }}>{context.name}</span>
<span
style={{
color: '#666',
fontFamily: 'monospace',
marginLeft: '8px',
fontSize: '10px'
}}
>
Array({value.length})
</span>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { IPropertyRenderer, PropertyContext } from '@esengine/editor-core';
import { formatNumber } from '../../components/inspectors/utils';
export class StringRenderer implements IPropertyRenderer<string> {
readonly id = 'app.string';
readonly name = 'String Renderer';
readonly priority = 100;
canHandle(value: any, _context: PropertyContext): value is string {
return typeof value === 'string';
}
render(value: string, context: PropertyContext): React.ReactElement {
const displayValue = value.length > 50 ? `${value.substring(0, 50)}...` : value;
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<span className="property-value-text" title={value}>
{displayValue}
</span>
</div>
);
}
}
export class NumberRenderer implements IPropertyRenderer<number> {
readonly id = 'app.number';
readonly name = 'Number Renderer';
readonly priority = 100;
canHandle(value: any, _context: PropertyContext): value is number {
return typeof value === 'number';
}
render(value: number, context: PropertyContext): React.ReactElement {
const decimalPlaces = context.decimalPlaces ?? 4;
const displayValue = formatNumber(value, decimalPlaces);
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<span className="property-value-text" style={{ color: '#b5cea8' }}>
{displayValue}
</span>
</div>
);
}
}
export class BooleanRenderer implements IPropertyRenderer<boolean> {
readonly id = 'app.boolean';
readonly name = 'Boolean Renderer';
readonly priority = 100;
canHandle(value: any, _context: PropertyContext): value is boolean {
return typeof value === 'boolean';
}
render(value: boolean, context: PropertyContext): React.ReactElement {
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<span
className="property-value-text"
style={{ color: value ? '#4ade80' : '#f87171' }}
>
{value ? 'true' : 'false'}
</span>
</div>
);
}
}
export class NullRenderer implements IPropertyRenderer<null> {
readonly id = 'app.null';
readonly name = 'Null Renderer';
readonly priority = 100;
canHandle(value: any, _context: PropertyContext): value is null {
return value === null || value === undefined;
}
render(_value: null, context: PropertyContext): React.ReactElement {
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<span className="property-value-text" style={{ color: '#666' }}>
null
</span>
</div>
);
}
}

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { IPropertyRenderer, PropertyContext } from '@esengine/editor-core';
import { formatNumber } from '../../components/inspectors/utils';
interface Vector2 {
x: number;
y: number;
}
interface Vector3 extends Vector2 {
z: number;
}
interface Vector4 extends Vector3 {
w: number;
}
interface Color {
r: number;
g: number;
b: number;
a: number;
}
const VectorValue: React.FC<{
label: string;
value: number;
axis: 'x' | 'y' | 'z' | 'w';
decimals: number;
}> = ({ label, value, axis, decimals }) => (
<div className="property-vector-axis-compact">
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
<span className="property-input property-input-number property-input-number-compact" style={{ cursor: 'default' }}>
{formatNumber(value, decimals)}
</span>
</div>
);
export class Vector2Renderer implements IPropertyRenderer<Vector2> {
readonly id = 'app.vector2';
readonly name = 'Vector2 Renderer';
readonly priority = 80;
canHandle(value: any, _context: PropertyContext): value is Vector2 {
return (
typeof value === 'object' &&
value !== null &&
typeof value.x === 'number' &&
typeof value.y === 'number' &&
!('z' in value) &&
Object.keys(value).length === 2
);
}
render(value: Vector2, context: PropertyContext): React.ReactElement {
const decimals = context.decimalPlaces ?? 2;
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<div className="property-vector-compact">
<VectorValue label="X" value={value.x} axis="x" decimals={decimals} />
<VectorValue label="Y" value={value.y} axis="y" decimals={decimals} />
</div>
</div>
);
}
}
export class Vector3Renderer implements IPropertyRenderer<Vector3> {
readonly id = 'app.vector3';
readonly name = 'Vector3 Renderer';
readonly priority = 80;
canHandle(value: any, _context: PropertyContext): value is Vector3 {
return (
typeof value === 'object' &&
value !== null &&
typeof value.x === 'number' &&
typeof value.y === 'number' &&
typeof value.z === 'number' &&
!('w' in value) &&
Object.keys(value).length === 3
);
}
render(value: Vector3, context: PropertyContext): React.ReactElement {
const decimals = context.decimalPlaces ?? 2;
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<div className="property-vector-compact">
<VectorValue label="X" value={value.x} axis="x" decimals={decimals} />
<VectorValue label="Y" value={value.y} axis="y" decimals={decimals} />
<VectorValue label="Z" value={value.z} axis="z" decimals={decimals} />
</div>
</div>
);
}
}
export class ColorRenderer implements IPropertyRenderer<Color> {
readonly id = 'app.color';
readonly name = 'Color Renderer';
readonly priority = 85;
canHandle(value: any, _context: PropertyContext): value is Color {
return (
typeof value === 'object' &&
value !== null &&
typeof value.r === 'number' &&
typeof value.g === 'number' &&
typeof value.b === 'number' &&
typeof value.a === 'number' &&
Object.keys(value).length === 4
);
}
render(value: Color, context: PropertyContext): React.ReactElement {
const r = Math.round(value.r * 255);
const g = Math.round(value.g * 255);
const b = Math.round(value.b * 255);
const colorHex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
return (
<div className="property-field">
<label className="property-label">{context.name}</label>
<div className="property-color-wrapper">
<div
className="property-color-preview"
style={{ backgroundColor: colorHex }}
/>
<span className="property-input property-input-color-text" style={{ cursor: 'default' }}>
{colorHex.toUpperCase()}
</span>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,4 @@
export * from './PrimitiveRenderers';
export * from './VectorRenderers';
export * from './ComponentRenderer';
export * from './FallbackRenderer';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};