Feature/editor optimization (#251)

* refactor: 编辑器/运行时架构拆分与构建系统升级

* feat(core): 层级系统重构与UI变换矩阵修复

* refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题

* fix(physics): 修复跨包组件类引用问题

* feat: 统一运行时架构与浏览器运行支持

* feat(asset): 实现浏览器运行时资产加载系统

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题和CI类型检查错误

* fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误

* test: 补齐核心模块测试用例,修复CI构建配置

* fix: 修复测试用例中的类型错误和断言问题

* fix: 修复 turbo build:npm 任务的依赖顺序问题

* fix: 修复 CI 构建错误并优化构建性能
This commit is contained in:
YHH
2025-12-01 22:28:51 +08:00
committed by GitHub
parent 189714c727
commit b42a7b4e43
468 changed files with 18301 additions and 9075 deletions

View File

@@ -0,0 +1,369 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
/**
* 动画帧数据
* Animation frame data
*/
export interface AnimationFrame {
/** 纹理路径 | Texture path */
texture: string;
/** 帧持续时间(秒) | Frame duration in seconds */
duration: number;
/** UV坐标 [u0, v0, u1, v1] | UV coordinates */
uv?: [number, number, number, number];
}
/**
* 动画剪辑数据
* Animation clip data
*/
export interface AnimationClip {
/** 动画名称 | Animation name */
name: string;
/** 动画帧列表 | Animation frames */
frames: AnimationFrame[];
/** 是否循环 | Whether to loop */
loop: boolean;
/** 播放速度倍数 | Playback speed multiplier */
speed: number;
}
/**
* 精灵动画组件 - 管理精灵帧动画
* Sprite animator component - manages sprite frame animation
*/
@ECSComponent('SpriteAnimator', { requires: ['Sprite'] })
@Serializable({ version: 1, typeId: 'SpriteAnimator' })
export class SpriteAnimatorComponent extends Component {
/**
* 动画剪辑列表
* Animation clips
*/
@Serialize()
@Property({
type: 'animationClips',
label: 'Animation Clips',
controls: [{ component: 'Sprite', property: 'texture' }]
})
public clips: AnimationClip[] = [];
/**
* 当前播放的动画名称
* Currently playing animation name
*/
@Serialize()
@Property({ type: 'string', label: 'Default Animation' })
public defaultAnimation: string = '';
/**
* 是否自动播放
* Auto play on start
*/
@Serialize()
@Property({ type: 'boolean', label: 'Auto Play' })
public autoPlay: boolean = true;
/**
* 全局播放速度
* Global playback speed
*/
@Serialize()
@Property({ type: 'number', label: 'Speed', min: 0, max: 10, step: 0.1 })
public speed: number = 1;
// Runtime state (not serialized)
private _currentClip: AnimationClip | null = null;
private _currentFrameIndex: number = 0;
private _frameTimer: number = 0;
private _isPlaying: boolean = false;
private _isPaused: boolean = false;
// Callbacks
private _onAnimationComplete?: (clipName: string) => void;
private _onFrameChange?: (frameIndex: number, frame: AnimationFrame) => void;
constructor() {
super();
}
/**
* 添加动画剪辑
* Add animation clip
*/
addClip(clip: AnimationClip): void {
// Remove existing clip with same name
this.clips = this.clips.filter((c) => c.name !== clip.name);
this.clips.push(clip);
}
/**
* 从精灵图集创建动画剪辑
* Create animation clip from sprite atlas
*
* @param name - 动画名称 | Animation name
* @param texture - 纹理路径 | Texture path
* @param frameCount - 帧数 | Number of frames
* @param frameWidth - 每帧宽度 | Frame width
* @param frameHeight - 每帧高度 | Frame height
* @param atlasWidth - 图集宽度 | Atlas width
* @param atlasHeight - 图集高度 | Atlas height
* @param fps - 帧率 | Frames per second
* @param loop - 是否循环 | Whether to loop
*/
createClipFromAtlas(
name: string,
texture: string,
frameCount: number,
frameWidth: number,
frameHeight: number,
atlasWidth: number,
atlasHeight: number,
fps: number = 12,
loop: boolean = true
): AnimationClip {
const frames: AnimationFrame[] = [];
const duration = 1 / fps;
const cols = Math.floor(atlasWidth / frameWidth);
for (let i = 0; i < frameCount; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = col * frameWidth;
const y = row * frameHeight;
frames.push({
texture,
duration,
uv: [
x / atlasWidth,
y / atlasHeight,
(x + frameWidth) / atlasWidth,
(y + frameHeight) / atlasHeight
]
});
}
const clip: AnimationClip = {
name,
frames,
loop,
speed: 1
};
this.addClip(clip);
return clip;
}
/**
* 从帧序列创建动画剪辑
* Create animation clip from frame sequence
*
* @param name - 动画名称 | Animation name
* @param textures - 纹理路径数组 | Array of texture paths
* @param fps - 帧率 | Frames per second
* @param loop - 是否循环 | Whether to loop
*/
createClipFromSequence(
name: string,
textures: string[],
fps: number = 12,
loop: boolean = true
): AnimationClip {
const duration = 1 / fps;
const frames: AnimationFrame[] = textures.map((texture) => ({
texture,
duration
}));
const clip: AnimationClip = {
name,
frames,
loop,
speed: 1
};
this.addClip(clip);
return clip;
}
/**
* 播放动画
* Play animation
*/
play(clipName?: string): void {
const name = clipName || this.defaultAnimation;
if (!name) return;
const clip = this.clips.find((c) => c.name === name);
if (!clip || clip.frames.length === 0) {
console.warn(`Animation clip not found: ${name}`);
return;
}
this._currentClip = clip;
this._currentFrameIndex = 0;
this._frameTimer = 0;
this._isPlaying = true;
this._isPaused = false;
this._notifyFrameChange();
}
/**
* 停止动画
* Stop animation
*/
stop(): void {
this._isPlaying = false;
this._isPaused = false;
this._currentFrameIndex = 0;
this._frameTimer = 0;
}
/**
* 暂停动画
* Pause animation
*/
pause(): void {
if (this._isPlaying) {
this._isPaused = true;
}
}
/**
* 恢复动画
* Resume animation
*/
resume(): void {
if (this._isPlaying && this._isPaused) {
this._isPaused = false;
}
}
/**
* 更新动画(由系统调用)
* Update animation (called by system)
*/
update(deltaTime: number): void {
if (!this._isPlaying || this._isPaused || !this._currentClip) return;
const clip = this._currentClip;
const frame = clip.frames[this._currentFrameIndex];
if (!frame) return;
this._frameTimer += deltaTime * this.speed * clip.speed;
if (this._frameTimer >= frame.duration) {
this._frameTimer -= frame.duration;
this._currentFrameIndex++;
if (this._currentFrameIndex >= clip.frames.length) {
if (clip.loop) {
this._currentFrameIndex = 0;
} else {
this._currentFrameIndex = clip.frames.length - 1;
this._isPlaying = false;
this._onAnimationComplete?.(clip.name);
return;
}
}
this._notifyFrameChange();
}
}
/**
* 获取当前帧
* Get current frame
*/
getCurrentFrame(): AnimationFrame | null {
if (!this._currentClip) return null;
return this._currentClip.frames[this._currentFrameIndex] || null;
}
/**
* 获取当前帧索引
* Get current frame index
*/
getCurrentFrameIndex(): number {
return this._currentFrameIndex;
}
/**
* 设置当前帧
* Set current frame
*/
setFrame(index: number): void {
if (!this._currentClip) return;
this._currentFrameIndex = Math.max(0, Math.min(index, this._currentClip.frames.length - 1));
this._frameTimer = 0;
this._notifyFrameChange();
}
/**
* 是否正在播放
* Whether animation is playing
*/
isPlaying(): boolean {
return this._isPlaying && !this._isPaused;
}
/**
* 获取当前动画名称
* Get current animation name
*/
getCurrentClipName(): string | null {
return this._currentClip?.name || null;
}
/**
* 设置动画完成回调
* Set animation complete callback
*/
onAnimationComplete(callback: (clipName: string) => void): void {
this._onAnimationComplete = callback;
}
/**
* 设置帧变化回调
* Set frame change callback
*/
onFrameChange(callback: (frameIndex: number, frame: AnimationFrame) => void): void {
this._onFrameChange = callback;
}
private _notifyFrameChange(): void {
const frame = this.getCurrentFrame();
if (frame) {
this._onFrameChange?.(this._currentFrameIndex, frame);
}
}
/**
* 获取动画剪辑
* Get animation clip by name
*/
getClip(name: string): AnimationClip | undefined {
return this.clips.find((c) => c.name === name);
}
/**
* 移除动画剪辑
* Remove animation clip
*/
removeClip(name: string): void {
this.clips = this.clips.filter((c) => c.name !== name);
if (this._currentClip?.name === name) {
this.stop();
this._currentClip = null;
}
}
/**
* 获取所有动画名称
* Get all animation names
*/
getClipNames(): string[] {
return this.clips.map((c) => c.name);
}
}

View File

@@ -0,0 +1,243 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
import type { AssetReference } from '@esengine/asset-system';
/**
* 精灵组件 - 管理2D图像渲染
* Sprite component - manages 2D image rendering
*/
@ECSComponent('Sprite')
@Serializable({ version: 2, typeId: 'Sprite' })
export class SpriteComponent extends Component {
/** 纹理路径或资源ID | Texture path or asset ID */
@Serialize()
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public texture: string = '';
/**
* 资产GUID新的资产系统
* Asset GUID for new asset system
*/
@Serialize()
public assetGuid?: string;
/**
* 纹理ID运行时使用
* Texture ID for runtime rendering
*/
public textureId: number = 0;
/**
* 资产引用(运行时,不序列化)
* Asset reference (runtime only, not serialized)
*/
private _assetReference?: AssetReference<HTMLImageElement>;
/**
* 精灵宽度(像素)
* Sprite width in pixels
*/
@Serialize()
@Property({
type: 'number',
label: 'Width',
min: 0,
actions: [{
id: 'nativeSize',
label: 'Native',
tooltip: 'Set to texture native size',
icon: 'Maximize2'
}]
})
public width: number = 64;
/**
* 精灵高度(像素)
* Sprite height in pixels
*/
@Serialize()
@Property({
type: 'number',
label: 'Height',
min: 0,
actions: [{
id: 'nativeSize',
label: 'Native',
tooltip: 'Set to texture native size',
icon: 'Maximize2'
}]
})
public height: number = 64;
/**
* UV坐标 [u0, v0, u1, v1]
* UV coordinates [u0, v0, u1, v1]
* 默认为完整纹理 [0, 0, 1, 1]
* Default is full texture [0, 0, 1, 1]
*/
@Serialize()
public uv: [number, number, number, number] = [0, 0, 1, 1];
/** 颜色(十六进制)| Color (hex string) */
@Serialize()
@Property({ type: 'color', label: 'Color' })
public color: string = '#ffffff';
/** 透明度 (0-1) | Alpha (0-1) */
@Serialize()
@Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 })
public alpha: number = 1;
/**
* 原点X (0-1, 0.5为中心)
* Origin point X (0-1, where 0.5 is center)
*/
@Serialize()
@Property({ type: 'number', label: 'Origin X', min: 0, max: 1, step: 0.01 })
public originX: number = 0.5;
/**
* 原点Y (0-1, 0.5为中心)
* Origin point Y (0-1, where 0.5 is center)
*/
@Serialize()
@Property({ type: 'number', label: 'Origin Y', min: 0, max: 1, step: 0.01 })
public originY: number = 0.5;
/**
* 精灵是否可见
* Whether sprite is visible
*/
@Serialize()
@Property({ type: 'boolean', label: 'Visible' })
public visible: boolean = true;
/** 是否水平翻转 | Flip sprite horizontally */
@Serialize()
@Property({ type: 'boolean', label: 'Flip X' })
public flipX: boolean = false;
/** 是否垂直翻转 | Flip sprite vertically */
@Serialize()
@Property({ type: 'boolean', label: 'Flip Y' })
public flipY: boolean = false;
/**
* 渲染层级/顺序(越高越在上面)
* Render layer/order (higher = rendered on top)
*/
@Serialize()
@Property({ type: 'integer', label: 'Sorting Order' })
public sortingOrder: number = 0;
/** 锚点X (0-1) - 别名为originX | Anchor X (0-1) - alias for originX */
get anchorX(): number {
return this.originX;
}
set anchorX(value: number) {
this.originX = value;
}
/** 锚点Y (0-1) - 别名为originY | Anchor Y (0-1) - alias for originY */
get anchorY(): number {
return this.originY;
}
set anchorY(value: number) {
this.originY = value;
}
constructor(texture: string = '') {
super();
this.texture = texture;
}
/**
* 从精灵图集区域设置UV
* Set UV from a sprite atlas region
*
* @param x - 区域X像素| Region X in pixels
* @param y - 区域Y像素| Region Y in pixels
* @param w - 区域宽度(像素)| Region width in pixels
* @param h - 区域高度(像素)| Region height in pixels
* @param atlasWidth - 图集总宽度 | Atlas total width
* @param atlasHeight - 图集总高度 | Atlas total height
*/
setAtlasRegion(
x: number,
y: number,
w: number,
h: number,
atlasWidth: number,
atlasHeight: number
): void {
this.uv = [
x / atlasWidth,
y / atlasHeight,
(x + w) / atlasWidth,
(y + h) / atlasHeight
];
this.width = w;
this.height = h;
}
/**
* 设置资产引用
* Set asset reference
*/
setAssetReference(reference: AssetReference<HTMLImageElement>): void {
// 释放旧引用 / Release old reference
if (this._assetReference) {
this._assetReference.release();
}
this._assetReference = reference;
if (reference) {
this.assetGuid = reference.guid;
}
}
/**
* 获取资产引用
* Get asset reference
*/
getAssetReference(): AssetReference<HTMLImageElement> | undefined {
return this._assetReference;
}
/**
* 异步加载纹理
* Load texture asynchronously
*/
async loadTextureAsync(): Promise<void> {
if (this._assetReference) {
try {
const textureAsset = await this._assetReference.loadAsync();
// 如果纹理资产有 textureId 属性,使用它
// If texture asset has textureId property, use it
if (textureAsset && typeof textureAsset === 'object' && 'textureId' in textureAsset) {
this.textureId = (textureAsset as any).textureId;
}
} catch (error) {
console.error('Failed to load texture:', error);
}
}
}
/**
* 获取有效的纹理源
* Get effective texture source
*/
getTextureSource(): string {
return this.assetGuid || this.texture;
}
/**
* 组件销毁时调用
* Called when component is destroyed
*/
onDestroy(): void {
// 释放资产引用 / Release asset reference
if (this._assetReference) {
this._assetReference.release();
this._assetReference = undefined;
}
}
}

View File

@@ -0,0 +1,42 @@
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
import { SpriteComponent } from './SpriteComponent';
import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
import { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
export type { SystemContext, PluginDescriptor, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader };
class SpriteRuntimeModule implements IRuntimeModule {
registerComponents(registry: typeof ComponentRegistryType): void {
registry.register(SpriteComponent);
registry.register(SpriteAnimatorComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
const animatorSystem = new SpriteAnimatorSystem();
if (context.isEditor) {
animatorSystem.enabled = false;
}
scene.addSystem(animatorSystem);
(context as any).animatorSystem = animatorSystem;
}
}
const descriptor: PluginDescriptor = {
id: '@esengine/sprite',
name: 'Sprite Components',
version: '1.0.0',
description: 'Sprite and SpriteAnimator components for 2D rendering',
category: 'rendering',
enabledByDefault: true,
isEnginePlugin: true
};
export const SpritePlugin: IPlugin = {
descriptor,
runtimeModule: new SpriteRuntimeModule()
};
export { SpriteRuntimeModule };

View File

@@ -0,0 +1,5 @@
export { SpriteComponent } from './SpriteComponent';
export { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
export { SpriteRuntimeModule, SpritePlugin } from './SpriteRuntimeModule';

View File

@@ -0,0 +1,86 @@
import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework';
import { SpriteAnimatorComponent } from '../SpriteAnimatorComponent';
import { SpriteComponent } from '../SpriteComponent';
/**
* 精灵动画系统 - 更新所有精灵动画
* Sprite animator system - updates all sprite animations
*/
@ECSSystem('SpriteAnimator', { updateOrder: 50 })
export class SpriteAnimatorSystem extends EntitySystem {
constructor() {
super(Matcher.empty().all(SpriteAnimatorComponent));
}
/**
* 系统初始化时调用
* Called when system is initialized
*/
protected override onInitialize(): void {
// System initialized
}
/**
* 每帧开始时调用
* Called at the beginning of each frame
*/
protected override onBegin(): void {
// Frame begin
}
/**
* 处理匹配的实体
* Process matched entities
*/
protected override process(entities: readonly Entity[]): void {
const deltaTime = Time.deltaTime;
for (const entity of entities) {
if (!entity.enabled) continue;
const animator = entity.getComponent(SpriteAnimatorComponent) as SpriteAnimatorComponent | null;
if (!animator) continue;
// Only call update if playing
if (animator.isPlaying()) {
animator.update(deltaTime);
}
// Sync current frame to sprite component (always, even if not playing)
const sprite = entity.getComponent(SpriteComponent) as SpriteComponent | null;
if (sprite) {
const frame = animator.getCurrentFrame();
if (frame) {
sprite.texture = frame.texture;
// Update UV if specified
if (frame.uv) {
sprite.uv = frame.uv;
}
}
}
}
}
/**
* 实体添加到系统时调用
* Called when entity is added to system
*/
protected override onAdded(entity: Entity): void {
const animator = entity.getComponent(SpriteAnimatorComponent) as SpriteAnimatorComponent | null;
if (animator && animator.autoPlay && animator.defaultAnimation) {
animator.play();
}
}
/**
* 实体从系统移除时调用
* Called when entity is removed from system
*/
protected override onRemoved(entity: Entity): void {
const animator = entity.getComponent(SpriteAnimatorComponent) as SpriteAnimatorComponent | null;
if (animator) {
animator.stop();
}
}
}