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,498 @@
/**
* Animation Preview Panel
* 动画预览面板
*
* Displays 3D model preview with animation playback controls.
* 显示 3D 模型预览和动画播放控制。
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import type { IAssetContent, IAssetParseContext, IGLTFAsset, IGLTFAnimationClip } from '@esengine/asset-system';
import { FBXLoader, GLTFLoader } from '@esengine/asset-system';
import {
Play, Pause, Square, SkipBack, SkipForward,
RefreshCw, Clock, Layers, Activity, ChevronDown, RotateCcw
} from 'lucide-react';
import { ModelPreview3D } from './ModelPreview3D';
import '../styles/AnimationPreviewPanel.css';
/**
* 格式化时间为 MM:SS.ms 格式
* Format time to MM:SS.ms format
*/
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 100);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
}
/**
* 读取二进制文件Tauri 环境)
* Read binary file (Tauri environment)
*/
async function readFileBinary(path: string): Promise<ArrayBuffer | null> {
try {
const { invoke } = await import('@tauri-apps/api/core');
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
} catch (error) {
console.error('[AnimationPreview] Failed to read file:', error);
return null;
}
}
interface AnimationPreviewState {
asset: IGLTFAsset | null;
assetPath: string | null;
selectedAnimationIndex: number;
isPlaying: boolean;
currentTime: number;
speed: number;
loop: boolean;
isLoading: boolean;
}
const initialState: AnimationPreviewState = {
asset: null,
assetPath: null,
selectedAnimationIndex: 0,
isPlaying: false,
currentTime: 0,
speed: 1.0,
loop: true,
isLoading: false,
};
export function AnimationPreviewPanel() {
const [state, setState] = useState<AnimationPreviewState>(initialState);
const animationFrameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const {
asset,
assetPath,
selectedAnimationIndex,
isPlaying,
currentTime,
speed,
loop,
isLoading,
} = state;
const currentClip = asset?.animations?.[selectedAnimationIndex] ?? null;
// Animation loop | 动画循环
useEffect(() => {
if (!isPlaying || !asset) return;
const clip = asset.animations?.[selectedAnimationIndex];
if (!clip || clip.duration <= 0) return;
const animate = (time: number) => {
if (lastTimeRef.current === 0) {
lastTimeRef.current = time;
}
const deltaTime = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
setState(prev => {
if (!prev.isPlaying) return prev;
const clip = prev.asset?.animations?.[prev.selectedAnimationIndex];
if (!clip || clip.duration <= 0) return prev;
let newTime = prev.currentTime + deltaTime * prev.speed;
if (newTime >= clip.duration) {
if (prev.loop) {
newTime = newTime % clip.duration;
} else {
return { ...prev, currentTime: clip.duration, isPlaying: false };
}
}
return { ...prev, currentTime: newTime };
});
animationFrameRef.current = requestAnimationFrame(animate);
};
lastTimeRef.current = 0;
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying, asset, selectedAnimationIndex, speed, loop]);
// Load asset | 加载资产
const loadAsset = useCallback(async (filePath: string) => {
setState(prev => ({ ...prev, isLoading: true }));
try {
const fileName = filePath.split(/[\\/]/).pop() || 'Model';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const binaryData = await readFileBinary(filePath);
if (!binaryData || binaryData.byteLength === 0) {
console.warn('[AnimationPreview] Cannot read file:', filePath);
setState(prev => ({ ...prev, isLoading: false }));
return;
}
const parseContext = {
metadata: {
path: filePath,
name: fileName,
type: ext === 'fbx' ? 'model/fbx' : 'model/gltf',
guid: '',
size: binaryData.byteLength,
hash: '',
dependencies: [],
lastModified: Date.now(),
importerVersion: '1.0.0',
labels: [],
tags: [],
version: 1
},
loadDependency: async () => null
} as unknown as IAssetParseContext;
const content: IAssetContent = {
type: 'binary',
binary: binaryData
};
let parsedAsset: IGLTFAsset;
if (ext === 'fbx') {
const loader = new FBXLoader();
parsedAsset = await loader.parse(content, parseContext);
} else if (ext === 'gltf' || ext === 'glb') {
const loader = new GLTFLoader();
parsedAsset = await loader.parse(content, parseContext);
} else {
console.warn('[AnimationPreview] Unsupported format:', ext);
setState(prev => ({ ...prev, isLoading: false }));
return;
}
console.log(`[AnimationPreview] Loaded: ${parsedAsset.meshes?.length ?? 0} meshes, ${parsedAsset.animations?.length ?? 0} animations`);
setState({
asset: parsedAsset,
assetPath: filePath,
selectedAnimationIndex: 0,
currentTime: 0,
isPlaying: false,
speed: 1.0,
loop: true,
isLoading: false,
});
} catch (error) {
console.error('[AnimationPreview] Failed to load asset:', error);
setState(prev => ({ ...prev, isLoading: false }));
}
}, []);
// Listen for animation preview requests | 监听动画预览请求
useEffect(() => {
const messageHub = Core.services.tryResolve(MessageHub);
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('animation:preview', (data: { filePath: string; animationIndex?: number }) => {
loadAsset(data.filePath);
if (data.animationIndex !== undefined) {
setState(prev => ({
...prev,
selectedAnimationIndex: data.animationIndex!,
currentTime: 0,
isPlaying: false,
}));
}
});
return () => unsubscribe?.();
}, [loadAsset]);
// Action handlers | 操作处理器
const selectAnimation = useCallback((index: number) => {
setState(prev => ({
...prev,
selectedAnimationIndex: index,
currentTime: 0,
isPlaying: false,
}));
}, []);
const play = useCallback(() => {
setState(prev => ({ ...prev, isPlaying: true }));
}, []);
const pause = useCallback(() => {
setState(prev => ({ ...prev, isPlaying: false }));
}, []);
const stop = useCallback(() => {
setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }));
}, []);
const setTime = useCallback((time: number) => {
setState(prev => {
const clip = prev.asset?.animations?.[prev.selectedAnimationIndex];
if (clip) {
return { ...prev, currentTime: Math.max(0, Math.min(time, clip.duration)) };
}
return prev;
});
}, []);
const setSpeed = useCallback((newSpeed: number) => {
setState(prev => ({ ...prev, speed: Math.max(0.1, Math.min(newSpeed, 5)) }));
}, []);
const setLoop = useCallback((newLoop: boolean) => {
setState(prev => ({ ...prev, loop: newLoop }));
}, []);
const handleTimelineChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
setTime(value);
}, [setTime]);
const handleSpeedChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setSpeed(parseFloat(e.target.value));
}, [setSpeed]);
// Render loading state | 渲染加载状态
if (isLoading) {
return (
<div className="animation-preview-panel loading">
<RefreshCw className="spin" size={24} />
<span>Loading...</span>
</div>
);
}
// Render empty state | 渲染空状态
if (!asset) {
return (
<div className="animation-preview-panel empty">
<Activity size={48} strokeWidth={1} />
<p>No model loaded</p>
<p className="hint">Double-click a model or animation in Content Browser</p>
</div>
);
}
const animations = asset.animations ?? [];
const hasAnimations = animations.length > 0;
const hasMeshes = (asset.meshes?.length ?? 0) > 0;
const duration = currentClip?.duration ?? 0;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="animation-preview-panel">
{/* Header | 头部 */}
<div className="panel-header">
<span className="asset-name" title={assetPath ?? ''}>
{assetPath?.split(/[\\/]/).pop() ?? 'Unknown'}
</span>
<button
className="icon-button"
onClick={() => setState(initialState)}
title="Clear"
>
<RotateCcw size={14} />
</button>
</div>
{/* 3D Preview | 3D 预览 */}
{hasMeshes && (
<div className="preview-viewport">
<ModelPreview3D
asset={asset}
animationClip={currentClip}
currentTime={currentTime}
width={280}
height={180}
/>
</div>
)}
{/* No mesh message | 无网格消息 */}
{!hasMeshes && (
<div className="no-mesh-message">
<p>No mesh data in this file</p>
</div>
)}
{/* Animation selector | 动画选择器 */}
{hasAnimations && (
<div className="animation-selector">
<label>Animation:</label>
<div className="select-wrapper">
<select
value={selectedAnimationIndex}
onChange={(e) => selectAnimation(parseInt(e.target.value))}
>
{animations.map((anim: IGLTFAnimationClip, index: number) => (
<option key={index} value={index}>
{anim.name || `Animation ${index}`}
</option>
))}
</select>
<ChevronDown size={14} />
</div>
</div>
)}
{/* Animation info | 动画信息 */}
{currentClip && (
<div className="animation-info">
<div className="info-row">
<Clock size={14} />
<span>Duration: {formatTime(currentClip.duration)}</span>
</div>
<div className="info-row">
<Layers size={14} />
<span>Channels: {currentClip.channels?.length ?? 0}</span>
</div>
</div>
)}
{/* Timeline | 时间轴 */}
{hasAnimations && (
<div className="timeline-section">
<div className="time-display">
<span className="current-time">{formatTime(currentTime)}</span>
<span className="separator">/</span>
<span className="total-time">{formatTime(duration)}</span>
</div>
<div className="timeline-track">
<input
type="range"
min={0}
max={duration}
step={0.01}
value={currentTime}
onChange={handleTimelineChange}
className="timeline-slider"
/>
<div
className="timeline-progress"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Playback controls | 播放控制 */}
{hasAnimations && (
<div className="playback-controls">
<button
className="control-button"
onClick={() => setTime(0)}
title="Go to start"
>
<SkipBack size={16} />
</button>
{isPlaying ? (
<button
className="control-button primary"
onClick={pause}
title="Pause"
>
<Pause size={20} />
</button>
) : (
<button
className="control-button primary"
onClick={play}
title="Play"
>
<Play size={20} />
</button>
)}
<button
className="control-button"
onClick={stop}
title="Stop"
>
<Square size={16} />
</button>
<button
className="control-button"
onClick={() => setTime(duration)}
title="Go to end"
>
<SkipForward size={16} />
</button>
</div>
)}
{/* Options | 选项 */}
{hasAnimations && (
<div className="playback-options">
<div className="option-row">
<label>Speed:</label>
<select value={speed} onChange={handleSpeedChange}>
<option value={0.25}>0.25x</option>
<option value={0.5}>0.5x</option>
<option value={1}>1x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</div>
<div className="option-row">
<label>
<input
type="checkbox"
checked={loop}
onChange={(e) => setLoop(e.target.checked)}
/>
Loop
</label>
</div>
</div>
)}
{/* No animations message | 无动画消息 */}
{hasMeshes && !hasAnimations && (
<div className="no-animations">
<p>This model has no animations</p>
</div>
)}
{/* Model info | 模型信息 */}
<div className="model-info">
<div className="section-title">Model Info</div>
<div className="info-row">
<span>Meshes: {asset.meshes?.length ?? 0}</span>
</div>
<div className="info-row">
<span>Materials: {asset.materials?.length ?? 0}</span>
</div>
{asset.skeleton && (
<div className="info-row">
<span>Joints: {asset.skeleton.joints?.length ?? 0}</span>
</div>
)}
</div>
</div>
);
}
export default AnimationPreviewPanel;

File diff suppressed because it is too large Load Diff