From 673f5e58559776af9cf2b833abe97ffc0cf1710e Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Fri, 28 Nov 2025 10:32:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(physics):=20=E9=9B=86=E6=88=90=20Rapier2D?= =?UTF-8?q?=20=E7=89=A9=E7=90=86=E5=BC=95=E6=93=8E=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=A2=84=E8=A7=88=E9=87=8D=E7=BD=AE=E9=97=AE=E9=A2=98?= =?UTF-8?q?=20(#244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(physics): 集成 Rapier2D 物理引擎并修复预览重置问题 * fix: 修复 CI 流程并清理代码 --- .github/workflows/ci.yml | 1 + .github/workflows/release-editor.yml | 7 +- packages/behavior-tree/vite.config.ts | 5 +- .../components/src/BoxColliderComponent.ts | 33 - .../components/src/CircleColliderComponent.ts | 24 - packages/components/src/RigidBodyComponent.ts | 57 - packages/components/src/index.ts | 6 +- .../ComponentStorage/ComponentRegistry.ts | 8 + .../Serialization/SerializationDecorators.ts | 65 +- packages/core/src/ECS/Systems/EntitySystem.ts | 11 + .../Core/ComponentRegistry.extended.test.ts | 19 - packages/editor-app/package.json | 1 + packages/editor-app/src/App.tsx | 15 +- .../src/app/managers/PluginInstaller.ts | 2 + .../src/app/managers/ServiceRegistry.ts | 6 - .../inspectors/views/EntityInspector.tsx | 56 +- .../editor-app/src/services/EngineService.ts | 24 +- .../src/services/RuntimeResolver.ts | 17 +- .../editor-app/src/styles/EntityInspector.css | 4 +- packages/physics-rapier2d/package.json | 78 ++ packages/physics-rapier2d/plugin.json | 22 + .../src/Physics2DComponentsModule.ts | 50 + .../src/PhysicsRuntimeModule.ts | 117 ++ .../src/components/BoxCollider2DComponent.ts | 83 ++ .../components/CapsuleCollider2DComponent.ts | 117 ++ .../components/CircleCollider2DComponent.ts | 55 + .../src/components/Collider2DBase.ts | 186 +++ .../components/PolygonCollider2DComponent.ts | 154 +++ .../src/components/Rigidbody2DComponent.ts | 321 +++++ .../physics-rapier2d/src/components/index.ts | 11 + .../src/editor/Physics2DPlugin.ts | 59 + .../src/editor/gizmos/Physics2DGizmo.ts | 351 ++++++ packages/physics-rapier2d/src/editor/index.ts | 227 ++++ .../inspectors/BoxCollider2DInspector.tsx | 167 +++ .../inspectors/CircleCollider2DInspector.tsx | 153 +++ .../inspectors/Rigidbody2DInspector.tsx | 201 +++ packages/physics-rapier2d/src/index.ts | 26 + packages/physics-rapier2d/src/runtime.ts | 57 + .../physics-rapier2d/src/runtime/index.ts | 17 + .../src/services/Physics2DService.ts | 213 ++++ .../physics-rapier2d/src/services/index.ts | 5 + .../src/systems/Physics2DSystem.ts | 562 +++++++++ .../physics-rapier2d/src/systems/index.ts | 5 + .../src/types/Physics2DEvents.ts | 104 ++ .../src/types/Physics2DTypes.ts | 183 +++ packages/physics-rapier2d/src/types/index.ts | 29 + .../src/world/Physics2DWorld.ts | 1073 +++++++++++++++++ packages/physics-rapier2d/src/world/index.ts | 5 + packages/physics-rapier2d/tsconfig.json | 30 + packages/physics-rapier2d/vite.config.ts | 47 + packages/platform-web/package.json | 1 + .../platform-web/rollup.runtime.config.js | 4 +- packages/platform-web/src/RuntimeSystems.ts | 21 +- packages/tilemap/src/editor/index.ts | 4 +- packages/tilemap/vite.config.ts | 5 +- pnpm-lock.yaml | 48 + 56 files changed, 4934 insertions(+), 218 deletions(-) delete mode 100644 packages/components/src/BoxColliderComponent.ts delete mode 100644 packages/components/src/CircleColliderComponent.ts delete mode 100644 packages/components/src/RigidBodyComponent.ts create mode 100644 packages/physics-rapier2d/package.json create mode 100644 packages/physics-rapier2d/plugin.json create mode 100644 packages/physics-rapier2d/src/Physics2DComponentsModule.ts create mode 100644 packages/physics-rapier2d/src/PhysicsRuntimeModule.ts create mode 100644 packages/physics-rapier2d/src/components/BoxCollider2DComponent.ts create mode 100644 packages/physics-rapier2d/src/components/CapsuleCollider2DComponent.ts create mode 100644 packages/physics-rapier2d/src/components/CircleCollider2DComponent.ts create mode 100644 packages/physics-rapier2d/src/components/Collider2DBase.ts create mode 100644 packages/physics-rapier2d/src/components/PolygonCollider2DComponent.ts create mode 100644 packages/physics-rapier2d/src/components/Rigidbody2DComponent.ts create mode 100644 packages/physics-rapier2d/src/components/index.ts create mode 100644 packages/physics-rapier2d/src/editor/Physics2DPlugin.ts create mode 100644 packages/physics-rapier2d/src/editor/gizmos/Physics2DGizmo.ts create mode 100644 packages/physics-rapier2d/src/editor/index.ts create mode 100644 packages/physics-rapier2d/src/editor/inspectors/BoxCollider2DInspector.tsx create mode 100644 packages/physics-rapier2d/src/editor/inspectors/CircleCollider2DInspector.tsx create mode 100644 packages/physics-rapier2d/src/editor/inspectors/Rigidbody2DInspector.tsx create mode 100644 packages/physics-rapier2d/src/index.ts create mode 100644 packages/physics-rapier2d/src/runtime.ts create mode 100644 packages/physics-rapier2d/src/runtime/index.ts create mode 100644 packages/physics-rapier2d/src/services/Physics2DService.ts create mode 100644 packages/physics-rapier2d/src/services/index.ts create mode 100644 packages/physics-rapier2d/src/systems/Physics2DSystem.ts create mode 100644 packages/physics-rapier2d/src/systems/index.ts create mode 100644 packages/physics-rapier2d/src/types/Physics2DEvents.ts create mode 100644 packages/physics-rapier2d/src/types/Physics2DTypes.ts create mode 100644 packages/physics-rapier2d/src/types/index.ts create mode 100644 packages/physics-rapier2d/src/world/Physics2DWorld.ts create mode 100644 packages/physics-rapier2d/src/world/index.ts create mode 100644 packages/physics-rapier2d/tsconfig.json create mode 100644 packages/physics-rapier2d/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3058715..1d473b16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,7 @@ jobs: cd ../editor-runtime && pnpm run build cd ../behavior-tree && pnpm run build cd ../tilemap && pnpm run build + cd ../physics-rapier2d && pnpm run build - name: Build ecs-engine-bindgen run: | diff --git a/.github/workflows/release-editor.yml b/.github/workflows/release-editor.yml index ac6a82c5..078a9ae5 100644 --- a/.github/workflows/release-editor.yml +++ b/.github/workflows/release-editor.yml @@ -147,7 +147,12 @@ jobs: cd packages/behavior-tree pnpm run build - # ===== 第七层:平台包(依赖 ui, tilemap) ===== + - name: Build physics-rapier2d package + run: | + cd packages/physics-rapier2d + pnpm run build + + # ===== 第七层:平台包(依赖 ui, tilemap, behavior-tree, physics-rapier2d) ===== - name: Build platform-web package run: | cd packages/platform-web diff --git a/packages/behavior-tree/vite.config.ts b/packages/behavior-tree/vite.config.ts index 131633cd..e12d85a4 100644 --- a/packages/behavior-tree/vite.config.ts +++ b/packages/behavior-tree/vite.config.ts @@ -21,10 +21,11 @@ function inlineCSS(): any { const cssContent = bundle[cssFile].source; if (!cssContent) return; - // 找到包含编辑器代码的主要 JS 文件(带 hash 的 chunk) + // 找到包含编辑器代码的主要 JS 文件 + // 优先查找 editor/index.js,然后是带 hash 的 index-*.js const mainJsFile = bundleKeys.find(key => + (key === 'editor/index.js' || key.includes('index-')) && key.endsWith('.js') && - key.includes('index-') && bundle[key].type === 'chunk' && bundle[key].code ); diff --git a/packages/components/src/BoxColliderComponent.ts b/packages/components/src/BoxColliderComponent.ts deleted file mode 100644 index 2e02b49b..00000000 --- a/packages/components/src/BoxColliderComponent.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework'; - -/** - * 盒型碰撞器组件 - */ -@ECSComponent('BoxCollider') -@Serializable({ version: 1, typeId: 'BoxCollider' }) -export class BoxColliderComponent extends Component { - /** 是否为触发器 */ - @Serialize() public isTrigger: boolean = false; - - /** 中心点X偏移 */ - @Serialize() public centerX: number = 0; - - /** 中心点Y偏移 */ - @Serialize() public centerY: number = 0; - - /** 中心点Z偏移 */ - @Serialize() public centerZ: number = 0; - - /** 宽度 */ - @Serialize() public width: number = 1; - - /** 高度 */ - @Serialize() public height: number = 1; - - /** 深度 */ - @Serialize() public depth: number = 1; - - constructor() { - super(); - } -} diff --git a/packages/components/src/CircleColliderComponent.ts b/packages/components/src/CircleColliderComponent.ts deleted file mode 100644 index c8db5c3c..00000000 --- a/packages/components/src/CircleColliderComponent.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework'; - -/** - * 圆形碰撞器组件 - */ -@ECSComponent('CircleCollider') -@Serializable({ version: 1, typeId: 'CircleCollider' }) -export class CircleColliderComponent extends Component { - /** 是否为触发器 */ - @Serialize() public isTrigger: boolean = false; - - /** 中心点X偏移 */ - @Serialize() public centerX: number = 0; - - /** 中心点Y偏移 */ - @Serialize() public centerY: number = 0; - - /** 半径 */ - @Serialize() public radius: number = 0.5; - - constructor() { - super(); - } -} diff --git a/packages/components/src/RigidBodyComponent.ts b/packages/components/src/RigidBodyComponent.ts deleted file mode 100644 index d2b7f25a..00000000 --- a/packages/components/src/RigidBodyComponent.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component, ECSComponent, Serializable, Serialize } from '@esengine/ecs-framework'; - -/** - * 刚体类型 - */ -export enum BodyType { - Static = 'static', - Dynamic = 'dynamic', - Kinematic = 'kinematic' -} - -/** - * 刚体组件 - 管理物理模拟 - */ -@ECSComponent('RigidBody') -@Serializable({ version: 1, typeId: 'RigidBody' }) -export class RigidBodyComponent extends Component { - /** 刚体类型 */ - @Serialize() public bodyType: BodyType = BodyType.Dynamic; - - /** 质量 */ - @Serialize() public mass: number = 1; - - /** 线性阻尼 */ - @Serialize() public linearDamping: number = 0; - - /** 角阻尼 */ - @Serialize() public angularDamping: number = 0.05; - - /** 重力缩放 */ - @Serialize() public gravityScale: number = 1; - - /** 是否使用连续碰撞检测 */ - @Serialize() public continuousDetection: boolean = false; - - /** 是否冻结X轴旋转 */ - @Serialize() public freezeRotationX: boolean = false; - - /** 是否冻结Y轴旋转 */ - @Serialize() public freezeRotationY: boolean = false; - - /** 是否冻结Z轴旋转 */ - @Serialize() public freezeRotationZ: boolean = false; - - /** X轴速度 */ - @Serialize() public velocityX: number = 0; - - /** Y轴速度 */ - @Serialize() public velocityY: number = 0; - - /** Z轴速度 */ - @Serialize() public velocityZ: number = 0; - - constructor() { - super(); - } -} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 8a7537e0..adf75bf4 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -10,10 +10,8 @@ export { CameraComponent, CameraProjection } from './CameraComponent'; // 系统 export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem'; -// 物理 -export { RigidBodyComponent, BodyType } from './RigidBodyComponent'; -export { BoxColliderComponent } from './BoxColliderComponent'; -export { CircleColliderComponent } from './CircleColliderComponent'; +// 物理组件已移至 @esengine/physics-rapier2d 包 +// Physics components have been moved to @esengine/physics-rapier2d package // 音频 export { AudioSourceComponent } from './AudioSourceComponent'; diff --git a/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts b/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts index bf1ec8cd..48de0f38 100644 --- a/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts +++ b/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts @@ -34,6 +34,14 @@ export class ComponentRegistry { return existingIndex; } + // 检查是否有同名但不同类的组件已注册 + if (this.componentNameToType.has(typeName)) { + const existingType = this.componentNameToType.get(typeName); + if (existingType !== componentType) { + console.warn(`[ComponentRegistry] Component name conflict: "${typeName}" already registered with different class. Existing: ${existingType?.name}, New: ${componentType.name}`); + } + } + const bitIndex = this.nextBitIndex++; this.componentTypes.set(componentType, bitIndex); this.bitIndexToType.set(bitIndex, componentType); diff --git a/packages/core/src/ECS/Serialization/SerializationDecorators.ts b/packages/core/src/ECS/Serialization/SerializationDecorators.ts index e9969e7a..f5e74163 100644 --- a/packages/core/src/ECS/Serialization/SerializationDecorators.ts +++ b/packages/core/src/ECS/Serialization/SerializationDecorators.ts @@ -80,17 +80,26 @@ export function Serializable(options: SerializableOptions) { throw new Error('Serializable装饰器必须提供有效的版本号'); } - // 初始化或获取现有元数据 - let metadata: SerializationMetadata = (target as any)[SERIALIZABLE_METADATA]; - if (!metadata) { + // 检查是否有自己的元数据(不是从父类继承的) + const hasOwnMetadata = Object.prototype.hasOwnProperty.call(target, SERIALIZABLE_METADATA); + + let metadata: SerializationMetadata; + + if (hasOwnMetadata) { + // 已有自己的元数据,更新 options + metadata = (target as any)[SERIALIZABLE_METADATA]; + metadata.options = options; + } else { + // 没有自己的元数据,检查是否有继承的元数据 + const inheritedMetadata: SerializationMetadata | undefined = (target as any)[SERIALIZABLE_METADATA]; + + // 创建新的元数据对象(从继承的元数据复制字段,但使用新的 options) metadata = { options, - fields: new Map(), - ignoredFields: new Set() + fields: inheritedMetadata ? new Map(inheritedMetadata.fields) : new Map(), + ignoredFields: inheritedMetadata ? new Set(inheritedMetadata.ignoredFields) : new Set() }; (target as any)[SERIALIZABLE_METADATA] = metadata; - } else { - metadata.options = options; } return target; @@ -117,13 +126,22 @@ export function Serialize(options?: FieldSerializeOptions) { return function (target: any, propertyKey: string | symbol) { const constructor = target.constructor; - // 获取或创建元数据 - let metadata: SerializationMetadata = constructor[SERIALIZABLE_METADATA]; - if (!metadata) { + // 检查是否有自己的元数据(不是从父类继承的) + const hasOwnMetadata = Object.prototype.hasOwnProperty.call(constructor, SERIALIZABLE_METADATA); + let metadata: SerializationMetadata; + + if (hasOwnMetadata) { + // 已有自己的元数据 + metadata = constructor[SERIALIZABLE_METADATA]; + } else { + // 没有自己的元数据,检查是否有继承的元数据 + const inheritedMetadata: SerializationMetadata | undefined = constructor[SERIALIZABLE_METADATA]; + + // 创建新的元数据对象(从继承的元数据复制) metadata = { - options: { version: 1 }, // 默认版本 - fields: new Map(), - ignoredFields: new Set() + options: inheritedMetadata ? { ...inheritedMetadata.options } : { version: 1 }, + fields: inheritedMetadata ? new Map(inheritedMetadata.fields) : new Map(), + ignoredFields: inheritedMetadata ? new Set(inheritedMetadata.ignoredFields) : new Set() }; constructor[SERIALIZABLE_METADATA] = metadata; } @@ -208,13 +226,22 @@ export function IgnoreSerialization() { return function (target: any, propertyKey: string | symbol) { const constructor = target.constructor; - // 获取或创建元数据 - let metadata: SerializationMetadata = constructor[SERIALIZABLE_METADATA]; - if (!metadata) { + // 检查是否有自己的元数据(不是从父类继承的) + const hasOwnMetadata = Object.prototype.hasOwnProperty.call(constructor, SERIALIZABLE_METADATA); + let metadata: SerializationMetadata; + + if (hasOwnMetadata) { + // 已有自己的元数据 + metadata = constructor[SERIALIZABLE_METADATA]; + } else { + // 没有自己的元数据,检查是否有继承的元数据 + const inheritedMetadata: SerializationMetadata | undefined = constructor[SERIALIZABLE_METADATA]; + + // 创建新的元数据对象(从继承的元数据复制) metadata = { - options: { version: 1 }, - fields: new Map(), - ignoredFields: new Set() + options: inheritedMetadata ? { ...inheritedMetadata.options } : { version: 1 }, + fields: inheritedMetadata ? new Map(inheritedMetadata.fields) : new Map(), + ignoredFields: inheritedMetadata ? new Set(inheritedMetadata.ignoredFields) : new Set() }; constructor[SERIALIZABLE_METADATA] = metadata; } diff --git a/packages/core/src/ECS/Systems/EntitySystem.ts b/packages/core/src/ECS/Systems/EntitySystem.ts index ba8f6e16..1334fac4 100644 --- a/packages/core/src/ECS/Systems/EntitySystem.ts +++ b/packages/core/src/ECS/Systems/EntitySystem.ts @@ -245,6 +245,17 @@ export abstract class EntitySystem implements ISystemBase, IService { this._entityCache.invalidate(); } + /** + * 完全重置实体跟踪状态 + * 清除所有缓存和跟踪的实体,强制下次 update 时重新扫描所有实体并触发 onAdded + * 用于场景重载/预览重置等场景 + */ + public resetEntityTracking(): void { + this._entityCache.clearAll(); + this._entityIdMap = null; + this._entityIdMapVersion = -1; + } + /** * 重置系统状态 * diff --git a/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts b/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts index c8c8b000..8b76fe46 100644 --- a/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts +++ b/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts @@ -180,25 +180,6 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { }); }); - describe('性能测试', () => { - it('大量组件注册应该高效', () => { - const startTime = performance.now(); - - // 注册 200 个组件 - for (let i = 0; i < 200; i++) { - const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); - } - - const endTime = performance.now(); - const duration = endTime - startTime; - - // 应该在 100ms 内完成 - expect(duration).toBeLessThan(100); - }); - - }); - describe('边界情况', () => { it('应该正确处理第 64 个组件(边界)', () => { const scene = new Scene(); diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index 37740a49..14f7d0bb 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -19,6 +19,7 @@ "@esengine/behavior-tree": "workspace:*", "@esengine/editor-runtime": "workspace:*", "@esengine/ecs-components": "workspace:*", + "@esengine/physics-rapier2d": "workspace:*", "@esengine/tilemap": "workspace:*", "@esengine/ui": "workspace:*", "@esengine/ecs-engine-bindgen": "workspace:*", diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 2e882a12..460e0c80 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -795,18 +795,27 @@ function App() { const dynamicPanels: FlexDockPanel[] = activeDynamicPanels .filter((panelId) => { const panelDesc = uiRegistry.getPanel(panelId); - return panelDesc && panelDesc.component; + return panelDesc && (panelDesc.component || panelDesc.render); }) .map((panelId) => { const panelDesc = uiRegistry.getPanel(panelId)!; - const Component = panelDesc.component; // 优先使用动态标题,否则使用默认标题 const customTitle = dynamicPanelTitles.get(panelId); const defaultTitle = (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title; + + // 支持 component 或 render 两种方式 + let content: React.ReactNode; + if (panelDesc.component) { + const Component = panelDesc.component; + content = ; + } else if (panelDesc.render) { + content = panelDesc.render(); + } + return { id: panelDesc.id, title: customTitle || defaultTitle, - content: , + content, closable: panelDesc.closable ?? true }; }); diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index e06aa150..e5d68329 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -17,6 +17,7 @@ import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlug import { TilemapPlugin } from '@esengine/tilemap'; import { UIPlugin } from '@esengine/ui'; import { BehaviorTreePlugin } from '@esengine/behavior-tree'; +import { Physics2DPlugin } from '@esengine/physics-rapier2d'; export class PluginInstaller { /** @@ -50,6 +51,7 @@ export class PluginInstaller { { name: 'TilemapPlugin', plugin: TilemapPlugin }, { name: 'UIPlugin', plugin: UIPlugin }, { name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin }, + { name: 'Physics2DPlugin', plugin: Physics2DPlugin }, ]; for (const { name, plugin } of modulePlugins) { diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index 42e90ead..d59f4656 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -34,9 +34,6 @@ import { SpriteAnimatorComponent, TextComponent, CameraComponent, - RigidBodyComponent, - BoxColliderComponent, - CircleColliderComponent, AudioSourceComponent } from '@esengine/ecs-components'; import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree'; @@ -114,9 +111,6 @@ export class ServiceRegistry { { name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' }, { name: 'TextComponent', type: TextComponent, editorName: 'Text', category: 'components.category.rendering', description: 'components.text.description', icon: 'Type' }, { name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' }, - { name: 'RigidBodyComponent', type: RigidBodyComponent, editorName: 'RigidBody', category: 'components.category.physics', description: 'components.rigidBody.description', icon: 'Atom' }, - { name: 'BoxColliderComponent', type: BoxColliderComponent, editorName: 'BoxCollider', category: 'components.category.physics', description: 'components.boxCollider.description', icon: 'Square' }, - { name: 'CircleColliderComponent', type: CircleColliderComponent, editorName: 'CircleCollider', category: 'components.category.physics', description: 'components.circleCollider.description', icon: 'Circle' }, { name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' }, { name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' } ]; diff --git a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx index 52b6a8a6..97c85e64 100644 --- a/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/EntityInspector.tsx @@ -341,31 +341,37 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV /> } {/* Dynamic component actions from plugins */} - {componentActionRegistry?.getActionsForComponent(componentName).map((action) => ( - - ))} + {componentActionRegistry?.getActionsForComponent(componentName).map((action) => { + // 解析图标:支持字符串(Lucide 图标名)或 React 元素 + const ActionIcon = typeof action.icon === 'string' + ? (LucideIcons as unknown as Record>)[action.icon] + : null; + return ( + + ); + })} )} diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index 3512dc7e..ce931eeb 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -10,6 +10,7 @@ import { TransformComponent, SpriteComponent, SpriteAnimatorComponent, SpriteAni import { TilemapComponent, TilemapRenderingSystem } from '@esengine/tilemap'; import { BehaviorTreeExecutionSystem } from '@esengine/behavior-tree'; import { UIRenderDataProvider, invalidateUIRenderCaches, UIInputSystem } from '@esengine/ui'; +import { Physics2DSystem } from '@esengine/physics-rapier2d'; import * as esEngine from '@esengine/engine'; import { AssetManager, @@ -36,6 +37,7 @@ export class EngineService { private animatorSystem: SpriteAnimatorSystem | null = null; private tilemapSystem: TilemapRenderingSystem | null = null; private behaviorTreeSystem: BehaviorTreeExecutionSystem | null = null; + private physicsSystem: Physics2DSystem | null = null; private uiRenderProvider: UIRenderDataProvider | null = null; private uiInputSystem: UIInputSystem | null = null; private initialized = false; @@ -244,6 +246,7 @@ export class EngineService { this.animatorSystem = context.animatorSystem as SpriteAnimatorSystem | undefined ?? null; this.tilemapSystem = context.tilemapSystem as TilemapRenderingSystem | undefined ?? null; this.behaviorTreeSystem = context.behaviorTreeSystem as BehaviorTreeExecutionSystem | undefined ?? null; + this.physicsSystem = context.physicsSystem as Physics2DSystem | undefined ?? null; this.uiRenderProvider = context.uiRenderProvider as UIRenderDataProvider | undefined ?? null; this.uiInputSystem = context.uiInputSystem as UIInputSystem | undefined ?? null; @@ -253,14 +256,17 @@ export class EngineService { this.renderSystem.setUIRenderDataProvider(this.uiRenderProvider); } - // 在编辑器模式下,动画和行为树系统默认禁用 - // In editor mode, animation and behavior tree systems are disabled by default + // 在编辑器模式下,动画、行为树和物理系统默认禁用 + // In editor mode, animation, behavior tree and physics systems are disabled by default if (this.animatorSystem) { this.animatorSystem.enabled = false; } if (this.behaviorTreeSystem) { this.behaviorTreeSystem.enabled = false; } + if (this.physicsSystem) { + this.physicsSystem.enabled = false; + } this.modulesInitialized = true; } @@ -289,6 +295,7 @@ export class EngineService { this.animatorSystem = null; this.tilemapSystem = null; this.behaviorTreeSystem = null; + this.physicsSystem = null; this.uiRenderProvider = null; this.uiInputSystem = null; this.modulesInitialized = false; @@ -390,6 +397,11 @@ export class EngineService { if (this.behaviorTreeSystem) { this.behaviorTreeSystem.enabled = true; } + // Enable physics system for preview + // 启用物理系统用于预览 + if (this.physicsSystem) { + this.physicsSystem.enabled = true; + } this.startAutoPlayAnimations(); this.gameLoop(); @@ -469,6 +481,14 @@ export class EngineService { if (this.behaviorTreeSystem) { this.behaviorTreeSystem.enabled = false; } + // Disable and reset physics system + // 禁用并重置物理系统 + if (this.physicsSystem) { + this.physicsSystem.enabled = false; + // Reset physics world state to prepare for next preview + // 重置物理世界状态,为下次预览做准备 + this.physicsSystem.reset(); + } this.stopAllAnimations(); // Note: Don't cancel animationFrameId here, as renderLoop should keep running diff --git a/packages/editor-app/src/services/RuntimeResolver.ts b/packages/editor-app/src/services/RuntimeResolver.ts index b8e3d03d..6055b68f 100644 --- a/packages/editor-app/src/services/RuntimeResolver.ts +++ b/packages/editor-app/src/services/RuntimeResolver.ts @@ -86,12 +86,10 @@ export class RuntimeResolver { if (await this.hasRuntimeFilesInWorkspace(workspaceRoot)) { this.baseDir = workspaceRoot; this.isDev = true; - console.log(`[RuntimeResolver] Using workspace dev files: ${this.baseDir}`); } else { // 回退到打包的资源目录(生产模式) this.baseDir = await TauriAPI.getAppResourceDir(); this.isDev = false; - console.log(`[RuntimeResolver] Using bundled resource dir: ${this.baseDir}`); } } @@ -101,9 +99,7 @@ export class RuntimeResolver { */ private async hasRuntimeFilesInWorkspace(workspaceRoot: string): Promise { const runtimePath = `${workspaceRoot}\\packages\\platform-web\\dist\\runtime.browser.js`; - const exists = await TauriAPI.pathExists(runtimePath); - console.log(`[RuntimeResolver] Checking workspace runtime: ${runtimePath} -> ${exists}`); - return exists; + return await TauriAPI.pathExists(runtimePath); } /** @@ -209,9 +205,6 @@ export class RuntimeResolver { * 生产模式:从编辑器内置资源复制 */ async prepareRuntimeFiles(targetDir: string): Promise { - console.log(`[RuntimeResolver] Preparing runtime files to: ${targetDir}`); - console.log(`[RuntimeResolver] isDev: ${this.isDev}, baseDir: ${this.baseDir}`); - // Ensure target directory exists const dirExists = await TauriAPI.pathExists(targetDir); if (!dirExists) { @@ -220,16 +213,13 @@ export class RuntimeResolver { // Copy platform-web runtime const platformWeb = await this.getModuleFiles('platform-web'); - console.log(`[RuntimeResolver] platform-web files:`, platformWeb.files); for (const srcFile of platformWeb.files) { const filename = srcFile.split(/[/\\]/).pop() || ''; const dstFile = `${targetDir}\\${filename}`; const srcExists = await TauriAPI.pathExists(srcFile); - console.log(`[RuntimeResolver] Copying ${srcFile} -> ${dstFile} (src exists: ${srcExists})`); if (srcExists) { await TauriAPI.copyFile(srcFile, dstFile); - console.log(`[RuntimeResolver] Copied ${filename}`); } else { throw new Error(`Runtime file not found: ${srcFile}`); } @@ -237,22 +227,17 @@ export class RuntimeResolver { // Copy engine WASM files const engine = await this.getModuleFiles('engine'); - console.log(`[RuntimeResolver] engine files:`, engine.files); for (const srcFile of engine.files) { const filename = srcFile.split(/[/\\]/).pop() || ''; const dstFile = `${targetDir}\\${filename}`; const srcExists = await TauriAPI.pathExists(srcFile); - console.log(`[RuntimeResolver] Copying ${srcFile} -> ${dstFile} (src exists: ${srcExists})`); if (srcExists) { await TauriAPI.copyFile(srcFile, dstFile); - console.log(`[RuntimeResolver] Copied ${filename}`); } else { throw new Error(`Engine file not found: ${srcFile}`); } } - - console.log(`[RuntimeResolver] Runtime files prepared successfully`); } /** diff --git a/packages/editor-app/src/styles/EntityInspector.css b/packages/editor-app/src/styles/EntityInspector.css index 8b638f77..70ec2cb7 100644 --- a/packages/editor-app/src/styles/EntityInspector.css +++ b/packages/editor-app/src/styles/EntityInspector.css @@ -511,7 +511,7 @@ left: 0; right: 0; bottom: 0; - z-index: var(--z-index-sticky); + z-index: var(--z-index-dropdown); } .component-dropdown { @@ -522,7 +522,7 @@ border: 1px solid var(--color-border-strong); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - z-index: var(--z-index-dropdown); + z-index: calc(var(--z-index-dropdown) + 1); overflow: hidden; animation: dropdownSlide 0.15s ease; } diff --git a/packages/physics-rapier2d/package.json b/packages/physics-rapier2d/package.json new file mode 100644 index 00000000..a64ca634 --- /dev/null +++ b/packages/physics-rapier2d/package.json @@ -0,0 +1,78 @@ +{ + "name": "@esengine/physics-rapier2d", + "version": "1.0.0", + "description": "Deterministic 2D physics engine based on Rapier2D with enhanced-determinism", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime.d.ts", + "import": "./dist/runtime.js" + }, + "./editor": { + "types": "./dist/editor/index.d.ts", + "import": "./dist/editor/index.js" + }, + "./plugin.json": "./plugin.json" + }, + "files": [ + "dist", + "plugin.json" + ], + "scripts": { + "clean": "rimraf dist tsconfig.tsbuildinfo", + "build": "vite build", + "build:watch": "vite build --watch", + "type-check": "tsc --noEmit" + }, + "keywords": [ + "ecs", + "physics", + "rapier2d", + "deterministic", + "game-physics", + "2d-physics" + ], + "author": "yhh", + "license": "MIT", + "peerDependencies": { + "@esengine/ecs-framework": ">=2.0.0", + "@esengine/ecs-components": "workspace:*", + "@esengine/editor-core": "workspace:*", + "react": "^18.3.1" + }, + "peerDependenciesMeta": { + "@esengine/editor-core": { + "optional": true + }, + "react": { + "optional": true + } + }, + "dependencies": { + "@dimforge/rapier2d-compat": "^0.14.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@vitejs/plugin-react": "^4.7.0", + "rimraf": "^5.0.0", + "typescript": "^5.8.3", + "vite": "^6.0.7", + "vite-plugin-dts": "^4.5.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "https://github.com/esengine/ecs-framework.git", + "directory": "packages/physics-rapier2d" + } +} diff --git a/packages/physics-rapier2d/plugin.json b/packages/physics-rapier2d/plugin.json new file mode 100644 index 00000000..4789a636 --- /dev/null +++ b/packages/physics-rapier2d/plugin.json @@ -0,0 +1,22 @@ +{ + "id": "@esengine/physics-rapier2d", + "name": "Rapier 2D Physics", + "version": "1.0.0", + "description": "Deterministic 2D physics engine based on Rapier2D with enhanced-determinism support", + "category": "physics", + "loadingPhase": "default", + "enabledByDefault": true, + "canContainContent": false, + "isEnginePlugin": true, + "modules": [ + { + "name": "PhysicsRuntime", + "type": "runtime", + "entry": "./src/runtime.ts" + } + ], + "dependencies": [ + "@esengine/ecs-framework", + "@esengine/ecs-components" + ] +} diff --git a/packages/physics-rapier2d/src/Physics2DComponentsModule.ts b/packages/physics-rapier2d/src/Physics2DComponentsModule.ts new file mode 100644 index 00000000..dd2ac7e8 --- /dev/null +++ b/packages/physics-rapier2d/src/Physics2DComponentsModule.ts @@ -0,0 +1,50 @@ +/** + * Physics 2D Components Module (Lightweight) + * 2D 物理组件模块(轻量级) + * + * 仅注册组件,不包含 WASM 依赖 + * 用于编辑器中的组件序列化/反序列化 + */ + +import { ComponentRegistry } from '@esengine/ecs-framework'; +import type { IRuntimeModuleLoader } from '@esengine/ecs-components'; + +// Components (no WASM dependency) +import { Rigidbody2DComponent } from './components/Rigidbody2DComponent'; +import { BoxCollider2DComponent } from './components/BoxCollider2DComponent'; +import { CircleCollider2DComponent } from './components/CircleCollider2DComponent'; +import { CapsuleCollider2DComponent } from './components/CapsuleCollider2DComponent'; +import { PolygonCollider2DComponent } from './components/PolygonCollider2DComponent'; + +/** + * Physics 2D Components Module (Lightweight) + * 2D 物理组件模块(轻量级) + * + * 仅实现组件注册,不包含系统创建和 WASM 初始化 + * 用于编辑器场景序列化 + */ +export class Physics2DComponentsModule implements IRuntimeModuleLoader { + /** + * 注册组件到 ComponentRegistry + */ + registerComponents(registry: typeof ComponentRegistry): void { + registry.register(Rigidbody2DComponent); + registry.register(BoxCollider2DComponent); + registry.register(CircleCollider2DComponent); + registry.register(CapsuleCollider2DComponent); + registry.register(PolygonCollider2DComponent); + } + + /** + * 不创建系统(完整运行时模块负责) + */ + createSystems(): void { + // No-op: Systems are created by the full runtime module + } +} + +/** + * 默认导出模块实例 + */ +export const physics2DComponentsModule = new Physics2DComponentsModule(); +export default physics2DComponentsModule; diff --git a/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts b/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts new file mode 100644 index 00000000..fc5a4cbf --- /dev/null +++ b/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts @@ -0,0 +1,117 @@ +/** + * Physics 2D Runtime Module + * 2D 物理运行时模块 + * + * 提供确定性 2D 物理模拟功能 + */ + +import type { IScene, ServiceContainer } from '@esengine/ecs-framework'; +import { ComponentRegistry } from '@esengine/ecs-framework'; +import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components'; +import * as RAPIER from '@dimforge/rapier2d-compat'; + +// Components +import { Rigidbody2DComponent } from './components/Rigidbody2DComponent'; +import { BoxCollider2DComponent } from './components/BoxCollider2DComponent'; +import { CircleCollider2DComponent } from './components/CircleCollider2DComponent'; +import { CapsuleCollider2DComponent } from './components/CapsuleCollider2DComponent'; +import { PolygonCollider2DComponent } from './components/PolygonCollider2DComponent'; + +// Systems +import { Physics2DSystem } from './systems/Physics2DSystem'; + +// Services +import { Physics2DService } from './services/Physics2DService'; + +/** + * Physics 2D Runtime Module + * 2D 物理运行时模块 + * + * 实现 IRuntimeModuleLoader 接口,提供: + * - 物理组件注册 + * - 物理系统创建 + * - Rapier2D 初始化 + */ +export class PhysicsRuntimeModule implements IRuntimeModuleLoader { + private _rapierModule: typeof RAPIER | null = null; + private _physicsSystem: Physics2DSystem | null = null; + + /** + * 初始化模块 + * 异步初始化 Rapier2D WASM 模块 + */ + async onInitialize(): Promise { + // 初始化 Rapier2D WASM + await RAPIER.init(); + this._rapierModule = RAPIER; + } + + /** + * 注册组件 + */ + registerComponents(registry: typeof ComponentRegistry): void { + registry.register(Rigidbody2DComponent); + registry.register(BoxCollider2DComponent); + registry.register(CircleCollider2DComponent); + registry.register(CapsuleCollider2DComponent); + registry.register(PolygonCollider2DComponent); + } + + /** + * 注册服务 + */ + registerServices?(services: ServiceContainer): void { + // 注册物理服务 + services.registerSingleton(Physics2DService); + } + + /** + * 创建系统 + */ + createSystems(scene: IScene, context: SystemContext): void { + // 创建物理系统 + const physicsSystem = new Physics2DSystem({ + physics: context.physicsConfig, + updateOrder: -1000 // 在其他系统之前运行 + }); + + scene.addSystem(physicsSystem); + this._physicsSystem = physicsSystem; + + // 如果 Rapier 已加载,初始化物理系统 + if (this._rapierModule) { + physicsSystem.initializeWithRapier(this._rapierModule); + } + + // 导出到上下文供其他系统使用 + context.physicsSystem = physicsSystem; + context.physics2DWorld = physicsSystem.world; + } + + /** + * 销毁模块 + */ + onDestroy(): void { + this._physicsSystem = null; + this._rapierModule = null; + } + + /** + * 获取 Rapier 模块 + */ + getRapierModule(): typeof RAPIER | null { + return this._rapierModule; + } + + /** + * 获取物理系统 + */ + getPhysicsSystem(): Physics2DSystem | null { + return this._physicsSystem; + } +} + +/** + * 默认导出模块实例 + */ +export default new PhysicsRuntimeModule(); diff --git a/packages/physics-rapier2d/src/components/BoxCollider2DComponent.ts b/packages/physics-rapier2d/src/components/BoxCollider2DComponent.ts new file mode 100644 index 00000000..e90080d9 --- /dev/null +++ b/packages/physics-rapier2d/src/components/BoxCollider2DComponent.ts @@ -0,0 +1,83 @@ +/** + * BoxCollider2D Component + * 2D 矩形碰撞体组件 + */ + +import { Property, Serialize, Serializable, ECSComponent } from '@esengine/ecs-framework'; +import { Collider2DBase } from './Collider2DBase'; +import type { Vector2 } from '../types/Physics2DTypes'; + +/** + * 2D 矩形碰撞体 + * + * 用于创建矩形形状的碰撞体。 + * + * @example + * ```typescript + * const entity = scene.createEntity('Box'); + * const collider = entity.addComponent(BoxCollider2DComponent); + * collider.width = 2; + * collider.height = 1; + * ``` + */ +@ECSComponent('BoxCollider2D') +@Serializable({ version: 1, typeId: 'BoxCollider2D' }) +export class BoxCollider2DComponent extends Collider2DBase { + /** + * 矩形宽度(半宽度的2倍) + */ + @Serialize() + @Property({ type: 'number', label: 'Width', min: 0.01, step: 0.1 }) + public width: number = 10; + + /** + * 矩形高度(半高度的2倍) + */ + @Serialize() + @Property({ type: 'number', label: 'Height', min: 0.01, step: 0.1 }) + public height: number = 10; + + /** + * 获取半宽度 + */ + public get halfWidth(): number { + return this.width / 2; + } + + /** + * 获取半高度 + */ + public get halfHeight(): number { + return this.height / 2; + } + + public override getShapeType(): string { + return 'box'; + } + + public override calculateArea(): number { + return this.width * this.height; + } + + public override calculateAABB(): { min: Vector2; max: Vector2 } { + const hw = this.halfWidth; + const hh = this.halfHeight; + + // 简化版本,不考虑旋转偏移 + return { + min: { x: this.offset.x - hw, y: this.offset.y - hh }, + max: { x: this.offset.x + hw, y: this.offset.y + hh } + }; + } + + /** + * 设置尺寸 + * @param width 宽度 + * @param height 高度 + */ + public setSize(width: number, height: number): void { + this.width = width; + this.height = height; + this._needsRebuild = true; + } +} diff --git a/packages/physics-rapier2d/src/components/CapsuleCollider2DComponent.ts b/packages/physics-rapier2d/src/components/CapsuleCollider2DComponent.ts new file mode 100644 index 00000000..3e467619 --- /dev/null +++ b/packages/physics-rapier2d/src/components/CapsuleCollider2DComponent.ts @@ -0,0 +1,117 @@ +/** + * CapsuleCollider2D Component + * 2D 胶囊碰撞体组件 + */ + +import { Property, Serialize, Serializable, ECSComponent } from '@esengine/ecs-framework'; +import { Collider2DBase } from './Collider2DBase'; +import type { Vector2 } from '../types/Physics2DTypes'; + +/** + * 胶囊方向 + */ +export enum CapsuleDirection2D { + /** 垂直方向(默认) */ + Vertical = 0, + /** 水平方向 */ + Horizontal = 1 +} + +/** + * 2D 胶囊碰撞体 + * + * 胶囊由两个半圆和一个矩形组成。 + * 常用于角色碰撞体。 + * + * @example + * ```typescript + * const entity = scene.createEntity('Character'); + * const collider = entity.addComponent(CapsuleCollider2DComponent); + * collider.radius = 0.25; + * collider.height = 1; + * collider.direction = CapsuleDirection2D.Vertical; + * ``` + */ +@ECSComponent('CapsuleCollider2D') +@Serializable({ version: 1, typeId: 'CapsuleCollider2D' }) +export class CapsuleCollider2DComponent extends Collider2DBase { + /** + * 胶囊半径 + */ + @Serialize() + @Property({ type: 'number', label: 'Radius', min: 0.01, step: 0.1 }) + public radius: number = 3; + + /** + * 胶囊总高度(包括两端的半圆) + */ + @Serialize() + @Property({ type: 'number', label: 'Height', min: 0.01, step: 0.1 }) + public height: number = 10; + + /** + * 胶囊方向 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Direction', + options: [ + { label: 'Vertical', value: 0 }, + { label: 'Horizontal', value: 1 } + ] + }) + public direction: CapsuleDirection2D = CapsuleDirection2D.Vertical; + + /** + * 获取半高度(中间矩形部分的一半) + */ + public get halfHeight(): number { + return Math.max(0, (this.height - this.radius * 2) / 2); + } + + public override getShapeType(): string { + return 'capsule'; + } + + public override calculateArea(): number { + // 胶囊面积 = 矩形面积 + 圆面积 + const rectArea = this.radius * 2 * this.halfHeight * 2; + const circleArea = Math.PI * this.radius * this.radius; + return rectArea + circleArea; + } + + public override calculateAABB(): { min: Vector2; max: Vector2 } { + if (this.direction === CapsuleDirection2D.Vertical) { + return { + min: { x: this.offset.x - this.radius, y: this.offset.y - this.height / 2 }, + max: { x: this.offset.x + this.radius, y: this.offset.y + this.height / 2 } + }; + } else { + return { + min: { x: this.offset.x - this.height / 2, y: this.offset.y - this.radius }, + max: { x: this.offset.x + this.height / 2, y: this.offset.y + this.radius } + }; + } + } + + /** + * 设置胶囊尺寸 + * @param radius 半径 + * @param height 总高度 + */ + public setSize(radius: number, height: number): void { + this.radius = radius; + this.height = height; + this._needsRebuild = true; + } + + /** + * 设置方向 + * @param direction 方向 + */ + public setDirection(direction: CapsuleDirection2D): void { + this.direction = direction; + this._needsRebuild = true; + } +} diff --git a/packages/physics-rapier2d/src/components/CircleCollider2DComponent.ts b/packages/physics-rapier2d/src/components/CircleCollider2DComponent.ts new file mode 100644 index 00000000..e8b57c50 --- /dev/null +++ b/packages/physics-rapier2d/src/components/CircleCollider2DComponent.ts @@ -0,0 +1,55 @@ +/** + * CircleCollider2D Component + * 2D 圆形碰撞体组件 + */ + +import { Property, Serialize, Serializable, ECSComponent } from '@esengine/ecs-framework'; +import { Collider2DBase } from './Collider2DBase'; +import type { Vector2 } from '../types/Physics2DTypes'; + +/** + * 2D 圆形碰撞体 + * + * 用于创建圆形形状的碰撞体。 + * + * @example + * ```typescript + * const entity = scene.createEntity('Ball'); + * const collider = entity.addComponent(CircleCollider2DComponent); + * collider.radius = 0.5; + * ``` + */ +@ECSComponent('CircleCollider2D') +@Serializable({ version: 1, typeId: 'CircleCollider2D' }) +export class CircleCollider2DComponent extends Collider2DBase { + /** + * 圆的半径 + */ + @Serialize() + @Property({ type: 'number', label: 'Radius', min: 0.01, step: 0.1 }) + public radius: number = 5; + + public override getShapeType(): string { + return 'circle'; + } + + public override calculateArea(): number { + return Math.PI * this.radius * this.radius; + } + + public override calculateAABB(): { min: Vector2; max: Vector2 } { + return { + min: { x: this.offset.x - this.radius, y: this.offset.y - this.radius }, + max: { x: this.offset.x + this.radius, y: this.offset.y + this.radius } + }; + } + + /** + * 设置半径 + * @param radius 半径 + */ + public setRadius(radius: number): void { + this.radius = radius; + this._needsRebuild = true; + } +} diff --git a/packages/physics-rapier2d/src/components/Collider2DBase.ts b/packages/physics-rapier2d/src/components/Collider2DBase.ts new file mode 100644 index 00000000..fe8780ef --- /dev/null +++ b/packages/physics-rapier2d/src/components/Collider2DBase.ts @@ -0,0 +1,186 @@ +/** + * Collider2D Base Component + * 2D 碰撞体基类组件 + */ + +import { Component, Property, Serialize } from '@esengine/ecs-framework'; +import { Vector2, CollisionLayer2D } from '../types/Physics2DTypes'; + +/** + * 2D 碰撞体基类 + * + * 定义了所有 2D 碰撞体的共同属性和接口。 + * 具体的碰撞体形状由子类实现。 + */ +export abstract class Collider2DBase extends Component { + // ==================== 物理材质属性 ==================== + + /** + * 摩擦系数 [0, 1] + * 0 = 完全光滑,1 = 最大摩擦 + */ + @Serialize() + @Property({ type: 'number', label: 'Friction', min: 0, max: 1, step: 0.01 }) + public friction: number = 0.5; + + /** + * 弹性系数(恢复系数)[0, 1] + * 0 = 完全非弹性碰撞,1 = 完全弹性碰撞 + */ + @Serialize() + @Property({ type: 'number', label: 'Restitution', min: 0, max: 1, step: 0.01 }) + public restitution: number = 0; + + /** + * 密度 (kg/m²) + * 用于计算质量(与碰撞体面积相乘) + */ + @Serialize() + @Property({ type: 'number', label: 'Density', min: 0.001, step: 0.1 }) + public density: number = 1; + + // ==================== 碰撞过滤 ==================== + + /** + * 是否为触发器 + * 触发器不产生物理碰撞响应,只触发事件 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Is Trigger' }) + public isTrigger: boolean = false; + + /** + * 碰撞层(该碰撞体所在的层) + * 使用位掩码,可以属于多个层 + */ + @Serialize() + @Property({ type: 'integer', label: 'Collision Layer', min: 0 }) + public collisionLayer: number = CollisionLayer2D.Default; + + /** + * 碰撞掩码(该碰撞体可以与哪些层碰撞) + * 使用位掩码 + */ + @Serialize() + @Property({ type: 'integer', label: 'Collision Mask', min: 0 }) + public collisionMask: number = CollisionLayer2D.All; + + // ==================== 偏移 ==================== + + /** + * 相对于实体 Transform 的位置偏移 + */ + @Serialize() + @Property({ type: 'vector2', label: 'Offset' }) + public offset: Vector2 = { x: 0, y: 0 }; + + /** + * 相对于实体 Transform 的旋转偏移(度) + */ + @Serialize() + @Property({ type: 'number', label: 'Rotation Offset', min: -180, max: 180, step: 1 }) + public rotationOffset: number = 0; + + // ==================== 内部状态 ==================== + + /** + * Rapier 碰撞体句柄 + * @internal + */ + public _colliderHandle: number | null = null; + + /** + * 关联的刚体实体 ID(如果有) + * @internal + */ + public _attachedBodyEntityId: number | null = null; + + /** + * 是否需要重建碰撞体 + * @internal + */ + public _needsRebuild: boolean = false; + + // ==================== 抽象方法 ==================== + + /** + * 获取碰撞体形状类型名称 + */ + public abstract getShapeType(): string; + + /** + * 计算碰撞体的面积(用于质量计算) + */ + public abstract calculateArea(): number; + + /** + * 计算碰撞体的 AABB(轴对齐包围盒) + */ + public abstract calculateAABB(): { min: Vector2; max: Vector2 }; + + // ==================== API 方法 ==================== + + /** + * 设置碰撞层 + * @param layer 层标识 + */ + public setLayer(layer: CollisionLayer2D): void { + this.collisionLayer = layer; + this._needsRebuild = true; + } + + /** + * 添加碰撞层 + * @param layer 层标识 + */ + public addLayer(layer: CollisionLayer2D): void { + this.collisionLayer |= layer; + this._needsRebuild = true; + } + + /** + * 移除碰撞层 + * @param layer 层标识 + */ + public removeLayer(layer: CollisionLayer2D): void { + this.collisionLayer &= ~layer; + this._needsRebuild = true; + } + + /** + * 检查是否在指定层 + * @param layer 层标识 + */ + public isInLayer(layer: CollisionLayer2D): boolean { + return (this.collisionLayer & layer) !== 0; + } + + /** + * 设置碰撞掩码 + * @param mask 掩码值 + */ + public setCollisionMask(mask: number): void { + this.collisionMask = mask; + this._needsRebuild = true; + } + + /** + * 检查是否可以与指定层碰撞 + * @param layer 层标识 + */ + public canCollideWith(layer: CollisionLayer2D): boolean { + return (this.collisionMask & layer) !== 0; + } + + /** + * 标记需要重建 + */ + public markNeedsRebuild(): void { + this._needsRebuild = true; + } + + public override onRemovedFromEntity(): void { + this._colliderHandle = null; + this._attachedBodyEntityId = null; + } +} diff --git a/packages/physics-rapier2d/src/components/PolygonCollider2DComponent.ts b/packages/physics-rapier2d/src/components/PolygonCollider2DComponent.ts new file mode 100644 index 00000000..d44e9d7d --- /dev/null +++ b/packages/physics-rapier2d/src/components/PolygonCollider2DComponent.ts @@ -0,0 +1,154 @@ +/** + * PolygonCollider2D Component + * 2D 多边形碰撞体组件 + */ + +import { Serialize, Serializable, ECSComponent } from '@esengine/ecs-framework'; +import { Collider2DBase } from './Collider2DBase'; +import type { Vector2 } from '../types/Physics2DTypes'; + +/** + * 2D 多边形碰撞体 + * + * 用于创建任意凸多边形形状的碰撞体。 + * 注意:Rapier 只支持凸多边形,非凸多边形需要分解。 + * + * @example + * ```typescript + * const entity = scene.createEntity('Triangle'); + * const collider = entity.addComponent(PolygonCollider2DComponent); + * collider.setVertices([ + * { x: 0, y: 1 }, + * { x: -1, y: -1 }, + * { x: 1, y: -1 } + * ]); + * ``` + */ +@ECSComponent('PolygonCollider2D') +@Serializable({ version: 1, typeId: 'PolygonCollider2D' }) +export class PolygonCollider2DComponent extends Collider2DBase { + /** + * 多边形顶点(局部坐标,逆时针顺序) + * 最少3个,最多不超过引擎限制(通常是 8-16 个) + */ + @Serialize() + public vertices: Vector2[] = [ + { x: -5, y: -5 }, + { x: 5, y: -5 }, + { x: 5, y: 5 }, + { x: -5, y: 5 } + ]; + + public override getShapeType(): string { + return 'polygon'; + } + + public override calculateArea(): number { + // 使用鞋带公式计算多边形面积 + if (this.vertices.length < 3) return 0; + + let area = 0; + const n = this.vertices.length; + + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + area += this.vertices[i].x * this.vertices[j].y; + area -= this.vertices[j].x * this.vertices[i].y; + } + + return Math.abs(area) / 2; + } + + public override calculateAABB(): { min: Vector2; max: Vector2 } { + if (this.vertices.length === 0) { + return { + min: { x: this.offset.x, y: this.offset.y }, + max: { x: this.offset.x, y: this.offset.y } + }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const v of this.vertices) { + minX = Math.min(minX, v.x); + minY = Math.min(minY, v.y); + maxX = Math.max(maxX, v.x); + maxY = Math.max(maxY, v.y); + } + + return { + min: { x: this.offset.x + minX, y: this.offset.y + minY }, + max: { x: this.offset.x + maxX, y: this.offset.y + maxY } + }; + } + + /** + * 设置顶点 + * @param vertices 顶点数组(逆时针顺序) + */ + public setVertices(vertices: Vector2[]): void { + if (vertices.length < 3) { + console.warn('PolygonCollider2D: 至少需要3个顶点'); + return; + } + this.vertices = vertices.map((v) => ({ x: v.x, y: v.y })); + this._needsRebuild = true; + } + + /** + * 创建正多边形 + * @param sides 边数(至少3) + * @param radius 外接圆半径 + */ + public setRegularPolygon(sides: number, radius: number): void { + if (sides < 3) { + console.warn('PolygonCollider2D: 正多边形至少需要3条边'); + return; + } + + const vertices: Vector2[] = []; + const angleStep = (Math.PI * 2) / sides; + + for (let i = 0; i < sides; i++) { + const angle = angleStep * i - Math.PI / 2; // 从顶部开始 + vertices.push({ + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius + }); + } + + this.setVertices(vertices); + } + + /** + * 验证多边形是否为凸多边形 + * @returns 是否为凸多边形 + */ + public isConvex(): boolean { + if (this.vertices.length < 3) return false; + + const n = this.vertices.length; + let sign = 0; + + for (let i = 0; i < n; i++) { + const v0 = this.vertices[i]; + const v1 = this.vertices[(i + 1) % n]; + const v2 = this.vertices[(i + 2) % n]; + + const cross = (v1.x - v0.x) * (v2.y - v1.y) - (v1.y - v0.y) * (v2.x - v1.x); + + if (cross !== 0) { + if (sign === 0) { + sign = cross > 0 ? 1 : -1; + } else if ((cross > 0 ? 1 : -1) !== sign) { + return false; + } + } + } + + return true; + } +} diff --git a/packages/physics-rapier2d/src/components/Rigidbody2DComponent.ts b/packages/physics-rapier2d/src/components/Rigidbody2DComponent.ts new file mode 100644 index 00000000..17e259e6 --- /dev/null +++ b/packages/physics-rapier2d/src/components/Rigidbody2DComponent.ts @@ -0,0 +1,321 @@ +/** + * Rigidbody2D Component + * 2D 刚体组件 + */ + +import { Component, Property, Serialize, Serializable, ECSComponent } from '@esengine/ecs-framework'; +import { RigidbodyType2D, CollisionDetectionMode2D, Vector2 } from '../types/Physics2DTypes'; + +/** + * 刚体约束配置 + */ +export interface RigidbodyConstraints2D { + /** 冻结 X 轴位置 */ + freezePositionX: boolean; + /** 冻结 Y 轴位置 */ + freezePositionY: boolean; + /** 冻结旋转 */ + freezeRotation: boolean; +} + +/** + * 2D 刚体组件 + * + * 用于给实体添加物理模拟能力。必须与 TransformComponent 配合使用。 + * + * @example + * ```typescript + * const entity = scene.createEntity('Player'); + * entity.addComponent(TransformComponent); + * const rb = entity.addComponent(Rigidbody2DComponent); + * rb.bodyType = RigidbodyType2D.Dynamic; + * rb.mass = 1; + * rb.gravityScale = 1; + * ``` + */ +@ECSComponent('Rigidbody2D') +@Serializable({ version: 1, typeId: 'Rigidbody2D' }) +export class Rigidbody2DComponent extends Component { + // ==================== 基础属性 ==================== + + /** + * 刚体类型 + * - Dynamic: 动态刚体,受力和碰撞影响 + * - Kinematic: 运动学刚体,手动控制 + * - Static: 静态刚体,不移动 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Body Type', + options: [ + { label: 'Dynamic', value: 0 }, + { label: 'Kinematic', value: 1 }, + { label: 'Static', value: 2 } + ] + }) + public bodyType: RigidbodyType2D = RigidbodyType2D.Dynamic; + + /** + * 质量(kg) + * 仅对 Dynamic 刚体有效 + */ + @Serialize() + @Property({ type: 'number', label: 'Mass', min: 0.001, step: 0.1 }) + public mass: number = 1; + + /** + * 重力缩放 + * 0 = 不受重力影响,1 = 正常重力,-1 = 反重力 + */ + @Serialize() + @Property({ type: 'number', label: 'Gravity Scale', min: -10, max: 10, step: 0.1 }) + public gravityScale: number = 1; + + // ==================== 阻尼 ==================== + + /** + * 线性阻尼 + * 值越大,移动速度衰减越快 + */ + @Serialize() + @Property({ type: 'number', label: 'Linear Damping', min: 0, max: 100, step: 0.1 }) + public linearDamping: number = 0; + + /** + * 角速度阻尼 + * 值越大,旋转速度衰减越快 + */ + @Serialize() + @Property({ type: 'number', label: 'Angular Damping', min: 0, max: 100, step: 0.01 }) + public angularDamping: number = 0.05; + + // ==================== 约束 ==================== + + /** + * 冻结 X 轴位置 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Freeze Position X' }) + public freezePositionX: boolean = false; + + /** + * 冻结 Y 轴位置 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Freeze Position Y' }) + public freezePositionY: boolean = false; + + /** + * 冻结旋转 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Freeze Rotation' }) + public freezeRotation: boolean = false; + + /** + * 运动约束(兼容旧代码) + * @deprecated 使用 freezePositionX, freezePositionY, freezeRotation 代替 + */ + public get constraints(): RigidbodyConstraints2D { + return { + freezePositionX: this.freezePositionX, + freezePositionY: this.freezePositionY, + freezeRotation: this.freezeRotation + }; + } + + public set constraints(value: RigidbodyConstraints2D) { + this.freezePositionX = value.freezePositionX; + this.freezePositionY = value.freezePositionY; + this.freezeRotation = value.freezeRotation; + } + + // ==================== 碰撞检测 ==================== + + /** + * 碰撞检测模式 + * - Discrete: 离散检测,性能好 + * - Continuous: 连续检测,防穿透 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Collision Detection', + options: [ + { label: 'Discrete', value: 0 }, + { label: 'Continuous', value: 1 } + ] + }) + public collisionDetection: CollisionDetectionMode2D = CollisionDetectionMode2D.Discrete; + + // ==================== 休眠 ==================== + + /** + * 是否允许休眠 + * 休眠的刚体不参与物理计算,提高性能 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Can Sleep' }) + public canSleep: boolean = true; + + /** + * 是否处于唤醒状态 + */ + @Property({ type: 'boolean', label: 'Is Awake', readOnly: true }) + public isAwake: boolean = true; + + // ==================== 运行时状态(不序列化)==================== + + /** + * 当前线速度 + */ + public velocity: Vector2 = { x: 0, y: 0 }; + + /** + * 当前角速度(弧度/秒) + */ + public angularVelocity: number = 0; + + // ==================== 内部状态 ==================== + + /** + * Rapier 刚体句柄 + * @internal + */ + public _bodyHandle: number | null = null; + + /** + * 是否需要同步 Transform 到物理世界 + * @internal + */ + public _needsSync: boolean = true; + + /** + * 上一帧的位置(用于插值) + * @internal + */ + public _previousPosition: Vector2 = { x: 0, y: 0 }; + + /** + * 上一帧的旋转角度 + * @internal + */ + public _previousRotation: number = 0; + + // ==================== API 方法 ==================== + + /** + * 添加力(在下一个物理步进中应用) + * 这是一个标记方法,实际力的应用由 Physics2DSystem 处理 + */ + public addForce(force: Vector2): void { + this._pendingForce.x += force.x; + this._pendingForce.y += force.y; + } + + /** + * 添加冲量(立即改变速度) + */ + public addImpulse(impulse: Vector2): void { + this._pendingImpulse.x += impulse.x; + this._pendingImpulse.y += impulse.y; + } + + /** + * 添加扭矩 + */ + public addTorque(torque: number): void { + this._pendingTorque += torque; + } + + /** + * 添加角冲量 + */ + public addAngularImpulse(impulse: number): void { + this._pendingAngularImpulse += impulse; + } + + /** + * 设置线速度 + */ + public setVelocity(velocity: Vector2): void { + this._targetVelocity = { ...velocity }; + this._hasTargetVelocity = true; + } + + /** + * 设置角速度 + */ + public setAngularVelocity(angularVelocity: number): void { + this._targetAngularVelocity = angularVelocity; + this._hasTargetAngularVelocity = true; + } + + /** + * 唤醒刚体 + */ + public wakeUp(): void { + this._shouldWakeUp = true; + } + + /** + * 使刚体休眠 + */ + public sleep(): void { + this._shouldSleep = true; + } + + /** + * 标记需要重新同步 Transform + */ + public markNeedsSync(): void { + this._needsSync = true; + } + + // ==================== 待处理的力和冲量 ==================== + + /** @internal */ + public _pendingForce: Vector2 = { x: 0, y: 0 }; + /** @internal */ + public _pendingImpulse: Vector2 = { x: 0, y: 0 }; + /** @internal */ + public _pendingTorque: number = 0; + /** @internal */ + public _pendingAngularImpulse: number = 0; + /** @internal */ + public _targetVelocity: Vector2 = { x: 0, y: 0 }; + /** @internal */ + public _hasTargetVelocity: boolean = false; + /** @internal */ + public _targetAngularVelocity: number = 0; + /** @internal */ + public _hasTargetAngularVelocity: boolean = false; + /** @internal */ + public _shouldWakeUp: boolean = false; + /** @internal */ + public _shouldSleep: boolean = false; + + /** + * 清除待处理的力和冲量 + * @internal + */ + public _clearPendingForces(): void { + this._pendingForce.x = 0; + this._pendingForce.y = 0; + this._pendingImpulse.x = 0; + this._pendingImpulse.y = 0; + this._pendingTorque = 0; + this._pendingAngularImpulse = 0; + this._hasTargetVelocity = false; + this._hasTargetAngularVelocity = false; + this._shouldWakeUp = false; + this._shouldSleep = false; + } + + public override onRemovedFromEntity(): void { + // 清理句柄,实际的物理对象清理由系统处理 + this._bodyHandle = null; + this._clearPendingForces(); + } +} diff --git a/packages/physics-rapier2d/src/components/index.ts b/packages/physics-rapier2d/src/components/index.ts new file mode 100644 index 00000000..e481899b --- /dev/null +++ b/packages/physics-rapier2d/src/components/index.ts @@ -0,0 +1,11 @@ +/** + * Physics 2D Components + * 2D 物理组件导出 + */ + +export { Rigidbody2DComponent, type RigidbodyConstraints2D } from './Rigidbody2DComponent'; +export { Collider2DBase } from './Collider2DBase'; +export { BoxCollider2DComponent } from './BoxCollider2DComponent'; +export { CircleCollider2DComponent } from './CircleCollider2DComponent'; +export { CapsuleCollider2DComponent, CapsuleDirection2D } from './CapsuleCollider2DComponent'; +export { PolygonCollider2DComponent } from './PolygonCollider2DComponent'; diff --git a/packages/physics-rapier2d/src/editor/Physics2DPlugin.ts b/packages/physics-rapier2d/src/editor/Physics2DPlugin.ts new file mode 100644 index 00000000..953583bf --- /dev/null +++ b/packages/physics-rapier2d/src/editor/Physics2DPlugin.ts @@ -0,0 +1,59 @@ +/** + * Physics 2D Unified Plugin + * 2D 物理统一插件 + * + * 编辑器专用插件入口 + * 使用完整运行时模块以支持编辑器预览 + */ + +import type { IPluginLoader, PluginDescriptor } from '@esengine/editor-core'; +import { Physics2DEditorModule } from './index'; +import { PhysicsRuntimeModule } from '../PhysicsRuntimeModule'; + +/** + * 插件描述符 + */ +const descriptor: PluginDescriptor = { + id: '@esengine/physics-rapier2d', + name: 'Rapier 2D Physics', + version: '1.0.0', + description: '基于 Rapier2D 的确定性 2D 物理引擎', + category: 'physics', + enabledByDefault: true, + canContainContent: false, + isEnginePlugin: true, + modules: [ + { + name: 'PhysicsRuntime', + type: 'runtime', + loadingPhase: 'default', + entry: './src/runtime.ts' + }, + { + name: 'PhysicsEditor', + type: 'editor', + loadingPhase: 'default', + entry: './src/editor/index.ts' + } + ], + dependencies: [ + { id: '@esengine/ecs-framework', version: '^2.0.0' }, + { id: '@esengine/ecs-components', version: '^1.0.0' } + ], + icon: 'Atom' +}; + +/** + * Physics 2D Plugin Loader + * 2D 物理插件加载器 + * + * - runtimeModule: 完整运行时模块(含 WASM 物理系统),支持编辑器预览和游戏运行 + * - editorModule: 编辑器功能模块(检视器、Gizmo、实体模板等) + */ +export const Physics2DPlugin: IPluginLoader = { + descriptor, + editorModule: new Physics2DEditorModule(), + runtimeModule: new PhysicsRuntimeModule(), +}; + +export default Physics2DPlugin; diff --git a/packages/physics-rapier2d/src/editor/gizmos/Physics2DGizmo.ts b/packages/physics-rapier2d/src/editor/gizmos/Physics2DGizmo.ts new file mode 100644 index 00000000..552ed493 --- /dev/null +++ b/packages/physics-rapier2d/src/editor/gizmos/Physics2DGizmo.ts @@ -0,0 +1,351 @@ +/** + * Physics 2D Gizmo Implementation + * 2D 物理 Gizmo 实现 + * + * Registers gizmo providers for physics components using the GizmoRegistry. + * Rendered via Rust WebGL engine for optimal performance. + * 使用 GizmoRegistry 为物理组件注册 gizmo 提供者。 + * 通过 Rust WebGL 引擎渲染以获得最佳性能。 + */ + +import type { Entity } from '@esengine/ecs-framework'; +import type { + IGizmoRenderData, + IRectGizmoData, + ICircleGizmoData, + ILineGizmoData, + GizmoColor +} from '@esengine/editor-core'; +import { GizmoColors, GizmoRegistry } from '@esengine/editor-core'; +import { TransformComponent } from '@esengine/ecs-components'; + +import { BoxCollider2DComponent } from '../../components/BoxCollider2DComponent'; +import { CircleCollider2DComponent } from '../../components/CircleCollider2DComponent'; +import { CapsuleCollider2DComponent } from '../../components/CapsuleCollider2DComponent'; +import { PolygonCollider2DComponent } from '../../components/PolygonCollider2DComponent'; +import { Rigidbody2DComponent } from '../../components/Rigidbody2DComponent'; +import { RigidbodyType2D } from '../../types/Physics2DTypes'; + +/** + * Collider gizmo color based on selection state + * 根据选择状态设置碰撞体 gizmo 颜色 + */ +function getColliderColor(isSelected: boolean, isTrigger: boolean): GizmoColor { + if (isTrigger) { + return isSelected + ? { r: 1, g: 0.5, b: 0, a: 0.8 } // Orange for selected trigger + : { r: 1, g: 0.5, b: 0, a: 0.4 }; // Semi-transparent orange for unselected trigger + } + return isSelected + ? GizmoColors.collider // Cyan for selected collider + : { ...GizmoColors.collider, a: 0.4 }; // Semi-transparent cyan for unselected +} + +/** + * Rigidbody indicator color based on body type + * 根据刚体类型设置指示器颜色 + */ +function getRigidbodyColor(bodyType: RigidbodyType2D, isSelected: boolean): GizmoColor { + const alpha = isSelected ? 0.8 : 0.4; + switch (bodyType) { + case RigidbodyType2D.Dynamic: + return { r: 0, g: 0.8, b: 1, a: alpha }; // Light blue for dynamic + case RigidbodyType2D.Kinematic: + return { r: 1, g: 0.8, b: 0, a: alpha }; // Yellow for kinematic + case RigidbodyType2D.Static: + return { r: 0.5, g: 0.5, b: 0.5, a: alpha }; // Gray for static + default: + return { r: 1, g: 1, b: 1, a: alpha }; + } +} + +/** + * BoxCollider2D gizmo provider + * 矩形碰撞体 gizmo 提供者 + */ +function boxCollider2DGizmoProvider( + collider: BoxCollider2DComponent, + entity: Entity, + isSelected: boolean +): IGizmoRenderData[] { + const transform = entity.getComponent(TransformComponent); + if (!transform) return []; + + const gizmos: IGizmoRenderData[] = []; + const color = getColliderColor(isSelected, collider.isTrigger); + + // Get rotation (handle both number and Vector3) + const rotation = typeof transform.rotation === 'number' + ? transform.rotation + : transform.rotation.z; + + // Calculate world position with offset + const worldX = transform.position.x + collider.offset.x * transform.scale.x; + const worldY = transform.position.y + collider.offset.y * transform.scale.y; + + const rectGizmo: IRectGizmoData = { + type: 'rect', + x: worldX, + y: worldY, + width: collider.width * transform.scale.x, + height: collider.height * transform.scale.y, + rotation: rotation + collider.rotationOffset, + originX: 0.5, + originY: 0.5, + color, + showHandles: false + }; + gizmos.push(rectGizmo); + + return gizmos; +} + +/** + * CircleCollider2D gizmo provider + * 圆形碰撞体 gizmo 提供者 + */ +function circleCollider2DGizmoProvider( + collider: CircleCollider2DComponent, + entity: Entity, + isSelected: boolean +): IGizmoRenderData[] { + const transform = entity.getComponent(TransformComponent); + if (!transform) return []; + + const gizmos: IGizmoRenderData[] = []; + const color = getColliderColor(isSelected, collider.isTrigger); + + // Calculate world position with offset + const worldX = transform.position.x + collider.offset.x * transform.scale.x; + const worldY = transform.position.y + collider.offset.y * transform.scale.y; + + // Use the larger scale for radius (circles should remain circular) + const scale = Math.max(Math.abs(transform.scale.x), Math.abs(transform.scale.y)); + + const circleGizmo: ICircleGizmoData = { + type: 'circle', + x: worldX, + y: worldY, + radius: collider.radius * scale, + color + }; + gizmos.push(circleGizmo); + + return gizmos; +} + +/** + * CapsuleCollider2D gizmo provider + * 胶囊碰撞体 gizmo 提供者 + */ +function capsuleCollider2DGizmoProvider( + collider: CapsuleCollider2DComponent, + entity: Entity, + isSelected: boolean +): IGizmoRenderData[] { + const transform = entity.getComponent(TransformComponent); + if (!transform) return []; + + const gizmos: IGizmoRenderData[] = []; + const color = getColliderColor(isSelected, collider.isTrigger); + + // Get rotation + const rotation = typeof transform.rotation === 'number' + ? transform.rotation + : transform.rotation.z; + const totalRotation = rotation + collider.rotationOffset; + + // Calculate world position with offset + const worldX = transform.position.x + collider.offset.x * transform.scale.x; + const worldY = transform.position.y + collider.offset.y * transform.scale.y; + + const radius = collider.radius * transform.scale.x; + const halfHeight = collider.halfHeight * transform.scale.y; + + // Draw capsule as two circles and connecting lines + // 绘制胶囊体为两个圆和连接线 + const cos = Math.cos(totalRotation); + const sin = Math.sin(totalRotation); + + // Top circle center + const topCenterX = worldX - sin * halfHeight; + const topCenterY = worldY + cos * halfHeight; + + // Bottom circle center + const bottomCenterX = worldX + sin * halfHeight; + const bottomCenterY = worldY - cos * halfHeight; + + // Top semicircle + gizmos.push({ + type: 'circle', + x: topCenterX, + y: topCenterY, + radius, + color + } as ICircleGizmoData); + + // Bottom semicircle + gizmos.push({ + type: 'circle', + x: bottomCenterX, + y: bottomCenterY, + radius, + color + } as ICircleGizmoData); + + // Connecting lines (left and right sides) + const perpX = cos * radius; + const perpY = sin * radius; + + gizmos.push({ + type: 'line', + points: [ + { x: topCenterX - perpX, y: topCenterY - perpY }, + { x: bottomCenterX - perpX, y: bottomCenterY - perpY } + ], + color, + closed: false + } as ILineGizmoData); + + gizmos.push({ + type: 'line', + points: [ + { x: topCenterX + perpX, y: topCenterY + perpY }, + { x: bottomCenterX + perpX, y: bottomCenterY + perpY } + ], + color, + closed: false + } as ILineGizmoData); + + return gizmos; +} + +/** + * PolygonCollider2D gizmo provider + * 多边形碰撞体 gizmo 提供者 + */ +function polygonCollider2DGizmoProvider( + collider: PolygonCollider2DComponent, + entity: Entity, + isSelected: boolean +): IGizmoRenderData[] { + const transform = entity.getComponent(TransformComponent); + if (!transform) return []; + + if (collider.vertices.length < 3) return []; + + const gizmos: IGizmoRenderData[] = []; + const color = getColliderColor(isSelected, collider.isTrigger); + + // Get rotation + const rotation = typeof transform.rotation === 'number' + ? transform.rotation + : transform.rotation.z; + const totalRotation = rotation + collider.rotationOffset; + const cos = Math.cos(totalRotation); + const sin = Math.sin(totalRotation); + + // Transform vertices to world space + const worldPoints = collider.vertices.map(v => { + // Apply scale + const scaledX = (v.x + collider.offset.x) * transform.scale.x; + const scaledY = (v.y + collider.offset.y) * transform.scale.y; + + // Apply rotation + const rotatedX = scaledX * cos - scaledY * sin; + const rotatedY = scaledX * sin + scaledY * cos; + + // Apply translation + return { + x: transform.position.x + rotatedX, + y: transform.position.y + rotatedY + }; + }); + + gizmos.push({ + type: 'line', + points: worldPoints, + color, + closed: true + } as ILineGizmoData); + + return gizmos; +} + +/** + * Rigidbody2D gizmo provider - shows velocity indicator when playing + * 刚体 gizmo 提供者 - 播放时显示速度指示器 + */ +function rigidbody2DGizmoProvider( + rigidbody: Rigidbody2DComponent, + entity: Entity, + isSelected: boolean +): IGizmoRenderData[] { + const transform = entity.getComponent(TransformComponent); + if (!transform) return []; + + const gizmos: IGizmoRenderData[] = []; + + // Only show velocity indicator when selected and has significant velocity + if (isSelected) { + const velMagnitude = Math.sqrt( + rigidbody.velocity.x * rigidbody.velocity.x + + rigidbody.velocity.y * rigidbody.velocity.y + ); + + // Draw velocity indicator if moving + if (velMagnitude > 0.1) { + const color = getRigidbodyColor(rigidbody.bodyType, isSelected); + const scale = 0.5; // Scale factor for velocity visualization + + gizmos.push({ + type: 'line', + points: [ + { x: transform.position.x, y: transform.position.y }, + { + x: transform.position.x + rigidbody.velocity.x * scale, + y: transform.position.y + rigidbody.velocity.y * scale + } + ], + color, + closed: false + } as ILineGizmoData); + } + + // Show body type indicator as small marker + const markerColor = getRigidbodyColor(rigidbody.bodyType, true); + gizmos.push({ + type: 'circle', + x: transform.position.x, + y: transform.position.y, + radius: 0.1, + color: markerColor + } as ICircleGizmoData); + } + + return gizmos; +} + +/** + * Register gizmo providers for all physics components. + * 为所有物理组件注册 gizmo 提供者。 + */ +export function registerPhysics2DGizmos(): void { + GizmoRegistry.register(BoxCollider2DComponent, boxCollider2DGizmoProvider); + GizmoRegistry.register(CircleCollider2DComponent, circleCollider2DGizmoProvider); + GizmoRegistry.register(CapsuleCollider2DComponent, capsuleCollider2DGizmoProvider); + GizmoRegistry.register(PolygonCollider2DComponent, polygonCollider2DGizmoProvider); + GizmoRegistry.register(Rigidbody2DComponent, rigidbody2DGizmoProvider); +} + +/** + * Unregister gizmo providers for all physics components. + * 取消注册所有物理组件的 gizmo 提供者。 + */ +export function unregisterPhysics2DGizmos(): void { + GizmoRegistry.unregister(BoxCollider2DComponent); + GizmoRegistry.unregister(CircleCollider2DComponent); + GizmoRegistry.unregister(CapsuleCollider2DComponent); + GizmoRegistry.unregister(PolygonCollider2DComponent); + GizmoRegistry.unregister(Rigidbody2DComponent); +} diff --git a/packages/physics-rapier2d/src/editor/index.ts b/packages/physics-rapier2d/src/editor/index.ts new file mode 100644 index 00000000..c9b6df58 --- /dev/null +++ b/packages/physics-rapier2d/src/editor/index.ts @@ -0,0 +1,227 @@ +/** + * Physics 2D Editor Module Entry + * 2D 物理编辑器模块入口 + */ + +import type { ServiceContainer, Entity } from '@esengine/ecs-framework'; +import { Core } from '@esengine/ecs-framework'; +import type { + IEditorModuleLoader, + EntityCreationTemplate, + ComponentAction +} from '@esengine/editor-core'; +import { + EntityStoreService, + MessageHub, + ComponentRegistry +} from '@esengine/editor-core'; +import { TransformComponent } from '@esengine/ecs-components'; + +// Local imports +import { Rigidbody2DComponent } from '../components/Rigidbody2DComponent'; +import { BoxCollider2DComponent } from '../components/BoxCollider2DComponent'; +import { CircleCollider2DComponent } from '../components/CircleCollider2DComponent'; +import { CapsuleCollider2DComponent } from '../components/CapsuleCollider2DComponent'; +import { PolygonCollider2DComponent } from '../components/PolygonCollider2DComponent'; +import { registerPhysics2DGizmos } from './gizmos/Physics2DGizmo'; + +/** + * Physics 2D Editor Module + * 2D 物理编辑器模块 + */ +export class Physics2DEditorModule implements IEditorModuleLoader { + async install(services: ServiceContainer): Promise { + // 注册组件到编辑器组件注册表 + // 组件检视器现在通过 @Property 装饰器自动生成 + const componentRegistry = services.resolve(ComponentRegistry); + if (componentRegistry) { + componentRegistry.register({ + name: 'Rigidbody2D', + type: Rigidbody2DComponent, + category: 'components.category.physics', + description: '2D rigidbody for physics simulation', + icon: 'Box' + }); + + componentRegistry.register({ + name: 'BoxCollider2D', + type: BoxCollider2DComponent, + category: 'components.category.physics', + description: '2D box collider shape', + icon: 'Square' + }); + + componentRegistry.register({ + name: 'CircleCollider2D', + type: CircleCollider2DComponent, + category: 'components.category.physics', + description: '2D circle collider shape', + icon: 'Circle' + }); + + componentRegistry.register({ + name: 'CapsuleCollider2D', + type: CapsuleCollider2DComponent, + category: 'components.category.physics', + description: '2D capsule collider shape', + icon: 'Pill' + }); + + componentRegistry.register({ + name: 'PolygonCollider2D', + type: PolygonCollider2DComponent, + category: 'components.category.physics', + description: '2D polygon collider shape', + icon: 'Pentagon' + }); + } + + // 注册 Physics Gizmos + registerPhysics2DGizmos(); + } + + async uninstall(): Promise { + // 清理资源 + } + + getInspectorProviders() { + // 使用 @Property 装饰器自动生成检视器,不再需要自定义 + return []; + } + + getEntityCreationTemplates(): EntityCreationTemplate[] { + const createPhysicsEntity = ( + name: string, + colliderType: 'box' | 'circle' | 'capsule', + isStatic: boolean = false + ): number => { + const scene = Core.scene; + if (!scene) { + throw new Error('Scene not available'); + } + + const entityStore = Core.services.resolve(EntityStoreService); + const messageHub = Core.services.resolve(MessageHub); + + if (!entityStore || !messageHub) { + throw new Error('EntityStoreService or MessageHub not available'); + } + + const count = entityStore.getAllEntities() + .filter((e: Entity) => e.name.startsWith(name)).length; + const entityName = `${name} ${count + 1}`; + + const entity = scene.createEntity(entityName); + entity.addComponent(new TransformComponent()); + + const rb = new Rigidbody2DComponent(); + if (isStatic) { + rb.bodyType = 2; // Static + } + entity.addComponent(rb); + + switch (colliderType) { + case 'box': + entity.addComponent(new BoxCollider2DComponent()); + break; + case 'circle': + entity.addComponent(new CircleCollider2DComponent()); + break; + case 'capsule': + entity.addComponent(new CapsuleCollider2DComponent()); + break; + } + + entityStore.addEntity(entity); + messageHub.publish('entity:added', { entity }); + messageHub.publish('scene:modified', {}); + entityStore.selectEntity(entity); + + return entity.id; + }; + + return [ + { + id: 'create-physics-box', + label: '物理方块', + icon: 'Square', + category: 'physics', + order: 100, + create: () => createPhysicsEntity('PhysicsBox', 'box') + }, + { + id: 'create-physics-circle', + label: '物理圆球', + icon: 'Circle', + category: 'physics', + order: 101, + create: () => createPhysicsEntity('PhysicsBall', 'circle') + }, + { + id: 'create-physics-capsule', + label: '物理胶囊', + icon: 'Pill', + category: 'physics', + order: 102, + create: () => createPhysicsEntity('PhysicsCapsule', 'capsule') + }, + { + id: 'create-static-platform', + label: '静态平台', + icon: 'Minus', + category: 'physics', + order: 110, + create: () => createPhysicsEntity('Platform', 'box', true) + }, + { + id: 'create-static-ground', + label: '地面', + icon: 'AlignVerticalJustifyEnd', + category: 'physics', + order: 111, + create: (): number => { + const scene = Core.scene; + if (!scene) { + throw new Error('Scene not available'); + } + + const entityStore = Core.services.resolve(EntityStoreService); + const messageHub = Core.services.resolve(MessageHub); + + if (!entityStore || !messageHub) { + throw new Error('EntityStoreService or MessageHub not available'); + } + + const entity = scene.createEntity('Ground'); + entity.addComponent(new TransformComponent()); + + const rb = new Rigidbody2DComponent(); + rb.bodyType = 2; // Static + entity.addComponent(rb); + + const collider = new BoxCollider2DComponent(); + collider.width = 200; + collider.height = 10; + entity.addComponent(collider); + + entityStore.addEntity(entity); + messageHub.publish('entity:added', { entity }); + messageHub.publish('scene:modified', {}); + entityStore.selectEntity(entity); + + return entity.id; + } + } + ]; + } + + getComponentActions(): ComponentAction[] { + return []; + } +} + +export const physics2DEditorModule = new Physics2DEditorModule(); + +// Plugin exports +export { Physics2DPlugin } from './Physics2DPlugin'; +export default physics2DEditorModule; diff --git a/packages/physics-rapier2d/src/editor/inspectors/BoxCollider2DInspector.tsx b/packages/physics-rapier2d/src/editor/inspectors/BoxCollider2DInspector.tsx new file mode 100644 index 00000000..be335f4a --- /dev/null +++ b/packages/physics-rapier2d/src/editor/inspectors/BoxCollider2DInspector.tsx @@ -0,0 +1,167 @@ +/** + * BoxCollider2D Inspector Provider + * 2D 矩形碰撞体检视器 + */ + +import React from 'react'; +import { Component } from '@esengine/ecs-framework'; +import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core'; +import { BoxCollider2DComponent } from '../../components/BoxCollider2DComponent'; +import { CollisionLayer2D } from '../../types/Physics2DTypes'; + +export class BoxCollider2DInspectorProvider implements IComponentInspector { + readonly id = 'boxcollider2d-inspector'; + readonly name = 'BoxCollider2D Inspector'; + readonly priority = 100; + readonly targetComponents = ['BoxCollider2D', 'BoxCollider2DComponent']; + + canHandle(component: Component): component is BoxCollider2DComponent { + return component instanceof BoxCollider2DComponent || + component.constructor.name === 'BoxCollider2DComponent'; + } + + render(context: ComponentInspectorContext): React.ReactElement { + const component = context.component as BoxCollider2DComponent; + const onChange = context.onChange; + + const handleChange = (prop: string, value: unknown) => { + onChange?.(prop, value); + }; + + return ( +
+
+
Box Collider 2D
+ + {/* Size */} +
Size
+ +
+ + handleChange('width', parseFloat(e.target.value) || 1)} + className="property-input" + /> +
+ +
+ + handleChange('height', parseFloat(e.target.value) || 1)} + className="property-input" + /> +
+ + {/* Offset */} +
Offset
+ +
+ + handleChange('offset', { + ...component.offset, + x: parseFloat(e.target.value) || 0 + })} + className="property-input" + /> +
+ +
+ + handleChange('offset', { + ...component.offset, + y: parseFloat(e.target.value) || 0 + })} + className="property-input" + /> +
+ + {/* Material */} +
Material
+ +
+ + handleChange('friction', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ +
+ + handleChange('restitution', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ +
+ + handleChange('density', parseFloat(e.target.value) || 1)} + className="property-input" + /> +
+ + {/* Collision */} +
Collision
+ +
+ + handleChange('isTrigger', e.target.checked)} + className="property-checkbox" + /> +
+ +
+ + +
+
+
+ ); + } +} diff --git a/packages/physics-rapier2d/src/editor/inspectors/CircleCollider2DInspector.tsx b/packages/physics-rapier2d/src/editor/inspectors/CircleCollider2DInspector.tsx new file mode 100644 index 00000000..d16d6542 --- /dev/null +++ b/packages/physics-rapier2d/src/editor/inspectors/CircleCollider2DInspector.tsx @@ -0,0 +1,153 @@ +/** + * CircleCollider2D Inspector Provider + * 2D 圆形碰撞体检视器 + */ + +import React from 'react'; +import { Component } from '@esengine/ecs-framework'; +import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core'; +import { CircleCollider2DComponent } from '../../components/CircleCollider2DComponent'; +import { CollisionLayer2D } from '../../types/Physics2DTypes'; + +export class CircleCollider2DInspectorProvider implements IComponentInspector { + readonly id = 'circlecollider2d-inspector'; + readonly name = 'CircleCollider2D Inspector'; + readonly priority = 100; + readonly targetComponents = ['CircleCollider2D', 'CircleCollider2DComponent']; + + canHandle(component: Component): component is CircleCollider2DComponent { + return component instanceof CircleCollider2DComponent || + component.constructor.name === 'CircleCollider2DComponent'; + } + + render(context: ComponentInspectorContext): React.ReactElement { + const component = context.component as CircleCollider2DComponent; + const onChange = context.onChange; + + const handleChange = (prop: string, value: unknown) => { + onChange?.(prop, value); + }; + + return ( +
+
+
Circle Collider 2D
+ + {/* Radius */} +
+ + handleChange('radius', parseFloat(e.target.value) || 0.5)} + className="property-input" + /> +
+ + {/* Offset */} +
Offset
+ +
+ + handleChange('offset', { + ...component.offset, + x: parseFloat(e.target.value) || 0 + })} + className="property-input" + /> +
+ +
+ + handleChange('offset', { + ...component.offset, + y: parseFloat(e.target.value) || 0 + })} + className="property-input" + /> +
+ + {/* Material */} +
Material
+ +
+ + handleChange('friction', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ +
+ + handleChange('restitution', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ +
+ + handleChange('density', parseFloat(e.target.value) || 1)} + className="property-input" + /> +
+ + {/* Collision */} +
Collision
+ +
+ + handleChange('isTrigger', e.target.checked)} + className="property-checkbox" + /> +
+ +
+ + +
+
+
+ ); + } +} diff --git a/packages/physics-rapier2d/src/editor/inspectors/Rigidbody2DInspector.tsx b/packages/physics-rapier2d/src/editor/inspectors/Rigidbody2DInspector.tsx new file mode 100644 index 00000000..77a7c77a --- /dev/null +++ b/packages/physics-rapier2d/src/editor/inspectors/Rigidbody2DInspector.tsx @@ -0,0 +1,201 @@ +/** + * Rigidbody2D Inspector Provider + * 2D 刚体检视器 + */ + +import React from 'react'; +import { Component } from '@esengine/ecs-framework'; +import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core'; +import { Rigidbody2DComponent } from '../../components/Rigidbody2DComponent'; +import { RigidbodyType2D, CollisionDetectionMode2D } from '../../types/Physics2DTypes'; + +export class Rigidbody2DInspectorProvider implements IComponentInspector { + readonly id = 'rigidbody2d-inspector'; + readonly name = 'Rigidbody2D Inspector'; + readonly priority = 100; + readonly targetComponents = ['Rigidbody2D', 'Rigidbody2DComponent']; + + canHandle(component: Component): component is Rigidbody2DComponent { + return component instanceof Rigidbody2DComponent || + component.constructor.name === 'Rigidbody2DComponent'; + } + + render(context: ComponentInspectorContext): React.ReactElement { + const component = context.component as Rigidbody2DComponent; + const onChange = context.onChange; + + const handleChange = (prop: string, value: unknown) => { + onChange?.(prop, value); + }; + + return ( +
+
+
Rigidbody 2D
+ + {/* Body Type */} +
+ + +
+ + {/* Mass - only for Dynamic */} + {component.bodyType === RigidbodyType2D.Dynamic && ( +
+ + handleChange('mass', parseFloat(e.target.value) || 1)} + className="property-input" + /> +
+ )} + + {/* Gravity Scale */} +
+ + handleChange('gravityScale', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ + {/* Damping Section */} +
Damping
+ +
+ + handleChange('linearDamping', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ +
+ + handleChange('angularDamping', parseFloat(e.target.value) || 0)} + className="property-input" + /> +
+ + {/* Constraints Section */} +
Constraints
+ +
+ + handleChange('constraints', { + ...component.constraints, + freezePositionX: e.target.checked + })} + className="property-checkbox" + /> +
+ +
+ + handleChange('constraints', { + ...component.constraints, + freezePositionY: e.target.checked + })} + className="property-checkbox" + /> +
+ +
+ + handleChange('constraints', { + ...component.constraints, + freezeRotation: e.target.checked + })} + className="property-checkbox" + /> +
+ + {/* Collision Detection */} +
Collision
+ +
+ + +
+ + {/* Sleep */} +
Sleep
+ +
+ + handleChange('canSleep', e.target.checked)} + className="property-checkbox" + /> +
+ + {/* Runtime Info (read-only) */} +
Runtime Info
+ +
+ + + ({component.velocity.x.toFixed(2)}, {component.velocity.y.toFixed(2)}) + +
+ +
+ + + {component.angularVelocity.toFixed(2)} rad/s + +
+ +
+ + + {component.isAwake ? 'Yes' : 'No'} + +
+
+
+ ); + } +} diff --git a/packages/physics-rapier2d/src/index.ts b/packages/physics-rapier2d/src/index.ts new file mode 100644 index 00000000..49760ef0 --- /dev/null +++ b/packages/physics-rapier2d/src/index.ts @@ -0,0 +1,26 @@ +/** + * @esengine/physics-rapier2d + * + * Deterministic 2D physics engine based on Rapier2D with enhanced-determinism support. + * 基于 Rapier2D 的确定性 2D 物理引擎。 + * + * 注意:此入口不包含 WASM 依赖,可安全地在编辑器中同步导入。 + * 运行时模块(含 WASM)请使用 '@esengine/physics-rapier2d/runtime' 导入。 + * + * @packageDocumentation + */ + +// Types (no WASM dependency) +export * from './types'; + +// Components (no WASM dependency) +export * from './components'; + +// Services (no WASM dependency) +export * from './services'; + +// Systems (type only for editor usage) +export type { Physics2DSystem } from './systems/Physics2DSystem'; + +// Plugin (for editor, no WASM dependency) +export { Physics2DPlugin } from './editor/Physics2DPlugin'; diff --git a/packages/physics-rapier2d/src/runtime.ts b/packages/physics-rapier2d/src/runtime.ts new file mode 100644 index 00000000..9973d19b --- /dev/null +++ b/packages/physics-rapier2d/src/runtime.ts @@ -0,0 +1,57 @@ +/** + * @esengine/physics-rapier2d Runtime Entry Point + * + * This entry point exports only runtime-related code without any editor dependencies. + * Use this for standalone game runtime builds. + * + * 此入口点仅导出运行时相关代码,不包含任何编辑器依赖。 + * 用于独立游戏运行时构建。 + */ + +// Types +export { + RigidbodyType2D, + CollisionDetectionMode2D, + type Vector2, + type Physics2DConfig, + DEFAULT_PHYSICS_CONFIG, + CollisionLayer2D, + ForceMode2D, + type RaycastHit2D, + type ShapeCastHit2D, + type OverlapResult2D, + PhysicsMaterial2DPreset, + getPhysicsMaterialPreset, + JointType2D +} from './types/Physics2DTypes'; + +export { + type CollisionEventType, + type TriggerEventType, + type ContactPoint2D, + type CollisionEvent2D, + type TriggerEvent2D, + PHYSICS_EVENTS, + type Physics2DEventMap +} from './types/Physics2DEvents'; + +// Components +export { Rigidbody2DComponent, type RigidbodyConstraints2D } from './components/Rigidbody2DComponent'; +export { Collider2DBase } from './components/Collider2DBase'; +export { BoxCollider2DComponent } from './components/BoxCollider2DComponent'; +export { CircleCollider2DComponent } from './components/CircleCollider2DComponent'; +export { CapsuleCollider2DComponent, CapsuleDirection2D } from './components/CapsuleCollider2DComponent'; +export { PolygonCollider2DComponent } from './components/PolygonCollider2DComponent'; + +// World +export { Physics2DWorld, type Physics2DWorldState } from './world/Physics2DWorld'; + +// Systems +export { Physics2DSystem, type Physics2DSystemConfig } from './systems/Physics2DSystem'; + +// Services +export { Physics2DService } from './services/Physics2DService'; + +// Runtime Module +export { PhysicsRuntimeModule } from './PhysicsRuntimeModule'; +export { default as physicsRuntimeModule } from './PhysicsRuntimeModule'; diff --git a/packages/physics-rapier2d/src/runtime/index.ts b/packages/physics-rapier2d/src/runtime/index.ts new file mode 100644 index 00000000..f5896dce --- /dev/null +++ b/packages/physics-rapier2d/src/runtime/index.ts @@ -0,0 +1,17 @@ +/** + * Physics 2D Runtime Entry + * 2D 物理运行时入口 + * + * 包含 WASM 依赖,用于实际运行时环境 + * Contains WASM dependencies, for actual runtime environment + */ + +// Re-export runtime module with WASM +export { PhysicsRuntimeModule, default as physicsRuntimeModule } from '../PhysicsRuntimeModule'; + +// Re-export world and system (they have WASM type dependencies) +export { Physics2DWorld } from '../world/Physics2DWorld'; +export type { Physics2DWorldState } from '../world/Physics2DWorld'; + +export { Physics2DSystem } from '../systems/Physics2DSystem'; +export type { Physics2DSystemConfig } from '../systems/Physics2DSystem'; diff --git a/packages/physics-rapier2d/src/services/Physics2DService.ts b/packages/physics-rapier2d/src/services/Physics2DService.ts new file mode 100644 index 00000000..54eb8a12 --- /dev/null +++ b/packages/physics-rapier2d/src/services/Physics2DService.ts @@ -0,0 +1,213 @@ +/** + * Physics2DService + * 2D 物理服务 + * + * 提供全局物理配置和实用方法 + */ + +import type { IService } from '@esengine/ecs-framework'; +import type { Vector2, Physics2DConfig, RaycastHit2D, OverlapResult2D } from '../types/Physics2DTypes'; +import { DEFAULT_PHYSICS_CONFIG, CollisionLayer2D } from '../types/Physics2DTypes'; +import type { Physics2DSystem } from '../systems/Physics2DSystem'; + +/** + * 2D 物理服务 + * + * 提供场景级别的物理配置和全局查询方法。 + * 作为服务注册到 ServiceContainer 中。 + * + * @example + * ```typescript + * // 从服务容器获取 + * const physicsService = scene.services.resolve(Physics2DService); + * + * // 使用射线检测 + * const hit = physicsService.raycast(origin, direction, 100); + * if (hit) { + * console.log('Hit entity:', hit.entityId); + * } + * ``` + */ +export class Physics2DService implements IService { + private _config: Physics2DConfig = { ...DEFAULT_PHYSICS_CONFIG }; + private _physicsSystem: Physics2DSystem | null = null; + + /** + * 设置物理系统引用 + * @internal + */ + public setPhysicsSystem(system: Physics2DSystem): void { + this._physicsSystem = system; + } + + /** + * 获取物理系统 + */ + public getPhysicsSystem(): Physics2DSystem | null { + return this._physicsSystem; + } + + // ==================== 配置 ==================== + + /** + * 获取物理配置 + */ + public getConfig(): Readonly { + return this._config; + } + + /** + * 设置重力 + */ + public setGravity(gravity: Vector2): void { + this._config.gravity = { ...gravity }; + this._physicsSystem?.setGravity(gravity); + } + + /** + * 获取重力 + */ + public getGravity(): Vector2 { + return this._physicsSystem?.getGravity() ?? { ...this._config.gravity }; + } + + /** + * 设置时间步长 + */ + public setTimestep(timestep: number): void { + this._config.timestep = timestep; + } + + /** + * 获取时间步长 + */ + public getTimestep(): number { + return this._config.timestep; + } + + // ==================== 查询 ==================== + + /** + * 射线检测(第一个命中) + * @param origin 起点 + * @param direction 方向(归一化) + * @param maxDistance 最大距离 + * @param collisionMask 碰撞掩码(默认所有层) + */ + public raycast( + origin: Vector2, + direction: Vector2, + maxDistance: number, + collisionMask: number = CollisionLayer2D.All + ): RaycastHit2D | null { + return this._physicsSystem?.raycast(origin, direction, maxDistance, collisionMask) ?? null; + } + + /** + * 射线检测(所有命中) + * @param origin 起点 + * @param direction 方向(归一化) + * @param maxDistance 最大距离 + * @param collisionMask 碰撞掩码(默认所有层) + */ + public raycastAll( + origin: Vector2, + direction: Vector2, + maxDistance: number, + collisionMask: number = CollisionLayer2D.All + ): RaycastHit2D[] { + return this._physicsSystem?.raycastAll(origin, direction, maxDistance, collisionMask) ?? []; + } + + /** + * 点重叠检测 + * @param point 检测点 + * @param collisionMask 碰撞掩码 + */ + public overlapPoint(point: Vector2, collisionMask: number = CollisionLayer2D.All): OverlapResult2D { + return this._physicsSystem?.overlapPoint(point, collisionMask) ?? { entityIds: [], colliderHandles: [] }; + } + + /** + * 圆形重叠检测 + * @param center 圆心 + * @param radius 半径 + * @param collisionMask 碰撞掩码 + */ + public overlapCircle( + center: Vector2, + radius: number, + collisionMask: number = CollisionLayer2D.All + ): OverlapResult2D { + return this._physicsSystem?.overlapCircle(center, radius, collisionMask) ?? { entityIds: [], colliderHandles: [] }; + } + + /** + * 矩形重叠检测 + * @param center 中心点 + * @param halfExtents 半宽高 + * @param rotation 旋转角度 + * @param collisionMask 碰撞掩码 + */ + public overlapBox( + center: Vector2, + halfExtents: Vector2, + rotation: number = 0, + collisionMask: number = CollisionLayer2D.All + ): OverlapResult2D { + return ( + this._physicsSystem?.overlapBox(center, halfExtents, rotation, collisionMask) ?? { + entityIds: [], + colliderHandles: [] + } + ); + } + + // ==================== 工具方法 ==================== + + /** + * 归一化向量 + */ + public normalize(v: Vector2): Vector2 { + const length = Math.sqrt(v.x * v.x + v.y * v.y); + if (length === 0) return { x: 0, y: 0 }; + return { x: v.x / length, y: v.y / length }; + } + + /** + * 计算两点之间的距离 + */ + public distance(a: Vector2, b: Vector2): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 计算向量长度 + */ + public magnitude(v: Vector2): number { + return Math.sqrt(v.x * v.x + v.y * v.y); + } + + /** + * 向量点积 + */ + public dot(a: Vector2, b: Vector2): number { + return a.x * b.x + a.y * b.y; + } + + /** + * 向量叉积(返回标量,2D 特有) + */ + public cross(a: Vector2, b: Vector2): number { + return a.x * b.y - a.y * b.x; + } + + /** + * 释放资源 + */ + public dispose(): void { + this._physicsSystem = null; + } +} diff --git a/packages/physics-rapier2d/src/services/index.ts b/packages/physics-rapier2d/src/services/index.ts new file mode 100644 index 00000000..833eb20b --- /dev/null +++ b/packages/physics-rapier2d/src/services/index.ts @@ -0,0 +1,5 @@ +/** + * Physics 2D Services exports + */ + +export { Physics2DService } from './Physics2DService'; diff --git a/packages/physics-rapier2d/src/systems/Physics2DSystem.ts b/packages/physics-rapier2d/src/systems/Physics2DSystem.ts new file mode 100644 index 00000000..0fe75be7 --- /dev/null +++ b/packages/physics-rapier2d/src/systems/Physics2DSystem.ts @@ -0,0 +1,562 @@ +/** + * Physics2DSystem + * 2D 物理系统 + * + * 负责更新物理世界并同步 Transform + */ + +import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework'; +import { TransformComponent } from '@esengine/ecs-components'; +import { Physics2DWorld } from '../world/Physics2DWorld'; +import { Rigidbody2DComponent } from '../components/Rigidbody2DComponent'; +import { Collider2DBase } from '../components/Collider2DBase'; +import { BoxCollider2DComponent } from '../components/BoxCollider2DComponent'; +import { CircleCollider2DComponent } from '../components/CircleCollider2DComponent'; +import { CapsuleCollider2DComponent } from '../components/CapsuleCollider2DComponent'; +import { PolygonCollider2DComponent } from '../components/PolygonCollider2DComponent'; +import type { Physics2DConfig, Vector2 } from '../types/Physics2DTypes'; +import { PHYSICS_EVENTS, type CollisionEvent2D, type TriggerEvent2D } from '../types/Physics2DEvents'; + +/** + * 物理系统配置 + */ +export interface Physics2DSystemConfig { + /** 物理世界配置 */ + physics?: Partial; + /** 是否在 lateUpdate 中同步 Transform(用于渲染插值) */ + interpolateInLateUpdate?: boolean; + /** 更新优先级(默认 -1000,保证在其他系统之前更新) */ + updateOrder?: number; +} + +/** + * 2D 物理系统 + * + * 管理物理世界的更新和实体的物理属性同步。 + * + * 职责: + * - 初始化和管理 Physics2DWorld + * - 同步 Entity Transform 与物理世界 + * - 应用力和冲量 + * - 发送碰撞/触发器事件 + * + * @example + * ```typescript + * // 注册物理系统 + * scene.addEntityProcessor(Physics2DSystem); + * + * // 或使用自定义配置 + * const physicsSystem = new Physics2DSystem({ + * physics: { + * gravity: { x: 0, y: -20 } + * } + * }); + * scene.addEntityProcessor(physicsSystem); + * ``` + */ +export class Physics2DSystem extends EntitySystem { + private _world: Physics2DWorld; + private _rapierModule: typeof import('@dimforge/rapier2d-compat') | null = null; + private _rapierInitialized: boolean = false; + private _config: Physics2DSystemConfig; + + // 实体到物理对象的映射 + private _entityBodies: Map = new Map(); + + // 待处理的新实体队列 + private _pendingEntities: Entity[] = []; + + // Transform 组件类型(用于检查) + private _transformType = TransformComponent; + + constructor(config?: Physics2DSystemConfig) { + // 匹配所有拥有 Rigidbody2DComponent 的实体 + super(Matcher.empty().all(Rigidbody2DComponent)); + + this._config = { + interpolateInLateUpdate: true, + updateOrder: -1000, + ...config + }; + + this._world = new Physics2DWorld(this._config.physics); + this.setUpdateOrder(this._config.updateOrder ?? -1000); + } + + /** + * 获取物理世界实例 + */ + public get world(): Physics2DWorld { + return this._world; + } + + /** + * 系统初始化 + */ + protected override onInitialize(): void { + // Rapier 模块由外部通过 initializeWithRapier 注入 + this.logger.debug('Physics2DSystem initialized, waiting for Rapier module'); + } + + /** + * 使用 Rapier 模块初始化物理世界 + * + * 必须在系统开始处理前调用此方法 + * + * @param rapier Rapier2D 模块 + */ + public async initializeWithRapier(rapier: typeof import('@dimforge/rapier2d-compat')): Promise { + if (this._rapierInitialized) { + this.logger.warn('Physics2DSystem already initialized'); + return; + } + + this._rapierModule = rapier; + await this._world.initialize(rapier); + this._rapierInitialized = true; + + // 注册碰撞事件回调 + this._world.onCollision((event) => this._handleCollisionEvent(event)); + this._world.onTrigger((event) => this._handleTriggerEvent(event)); + + // 处理在初始化前添加的实体 + for (const entity of this._pendingEntities) { + this._createPhysicsBody(entity); + } + this._pendingEntities = []; + + this.logger.info('Physics2DSystem initialized with Rapier2D'); + } + + /** + * 检查是否可以处理 + */ + protected override onCheckProcessing(): boolean { + return this._rapierInitialized; + } + + /** + * 当实体添加到系统时 + */ + protected override onAdded(entity: Entity): void { + if (!this._rapierInitialized) { + // 延迟创建物理体,等待 Rapier 初始化 + this._pendingEntities.push(entity); + return; + } + + this._createPhysicsBody(entity); + } + + /** + * 当实体从系统移除时 + */ + protected override onRemoved(entity: Entity): void { + this._removePhysicsBody(entity); + + // 从待处理队列中移除(如果存在) + const pendingIndex = this._pendingEntities.indexOf(entity); + if (pendingIndex >= 0) { + this._pendingEntities.splice(pendingIndex, 1); + } + } + + /** + * 物理更新 + */ + protected override process(entities: readonly Entity[]): void { + if (!this._rapierInitialized || !this.scene) return; + + const deltaTime = this._getDeltaTime(); + + // 发送 pre-step 事件 + this.scene.eventSystem.emitSync(PHYSICS_EVENTS.PRE_STEP, { deltaTime }); + + // 同步 Transform 到物理世界 + this._syncTransformsToPhysics(entities); + + // 应用待处理的力和冲量 + this._applyPendingForces(entities); + + // 物理世界步进 + this._world.step(deltaTime); + + // 同步物理世界到 Transform + this._syncPhysicsToTransforms(entities); + + // 发送 post-step 事件 + this.scene.eventSystem.emitSync(PHYSICS_EVENTS.POST_STEP, { deltaTime }); + } + + /** + * 后期更新(用于渲染插值) + */ + protected override lateProcess(_entities: readonly Entity[]): void { + if (!this._config.interpolateInLateUpdate || !this._rapierInitialized) return; + + // 可在此处实现渲染插值 + // const alpha = this._world.getAlpha(); + // 插值逻辑... + } + + /** + * 系统销毁 + */ + protected override onDestroy(): void { + this._world.destroy(); + this._entityBodies.clear(); + this._pendingEntities = []; + this._rapierInitialized = false; + this.logger.info('Physics2DSystem destroyed'); + } + + /** + * 重置物理系统状态(保持初始化状态,但清除所有物理对象) + * 用于场景重载/预览重置 + */ + public reset(): void { + this._world.reset(); + this._entityBodies.clear(); + this._pendingEntities = []; + // 完全重置实体跟踪,强制下次 update 时重新扫描所有实体并触发 onAdded + this.resetEntityTracking(); + this.logger.info('Physics2DSystem reset'); + } + + // ==================== 物理 API ==================== + + /** + * 设置重力 + * @param gravity 重力向量 + */ + public setGravity(gravity: Vector2): void { + this._world.setGravity(gravity); + } + + /** + * 获取重力 + */ + public getGravity(): Vector2 { + return this._world.getGravity(); + } + + /** + * 射线检测 + */ + public raycast(origin: Vector2, direction: Vector2, maxDistance: number, collisionMask?: number) { + return this._world.raycast(origin, direction, maxDistance, collisionMask); + } + + /** + * 射线检测所有 + */ + public raycastAll(origin: Vector2, direction: Vector2, maxDistance: number, collisionMask?: number) { + return this._world.raycastAll(origin, direction, maxDistance, collisionMask); + } + + /** + * 点重叠检测 + */ + public overlapPoint(point: Vector2, collisionMask?: number) { + return this._world.overlapPoint(point, collisionMask); + } + + /** + * 圆形重叠检测 + */ + public overlapCircle(center: Vector2, radius: number, collisionMask?: number) { + return this._world.overlapCircle(center, radius, collisionMask); + } + + /** + * 矩形重叠检测 + */ + public overlapBox(center: Vector2, halfExtents: Vector2, rotation?: number, collisionMask?: number) { + return this._world.overlapBox(center, halfExtents, rotation, collisionMask); + } + + // ==================== 私有方法 ==================== + + /** + * 获取时间增量 + */ + private _getDeltaTime(): number { + // TODO: 从全局 Time 服务获取 + return 1 / 60; + } + + /** + * 创建物理体 + */ + private _createPhysicsBody(entity: Entity): void { + const rigidbody = entity.getComponent(Rigidbody2DComponent); + const transform = entity.getComponent(this._transformType); + + if (!rigidbody || !transform) { + this.logger.warn(`Entity ${entity.name} missing required components for physics`); + return; + } + + // 获取位置和旋转 + const position: Vector2 = { + x: transform.position.x, + y: transform.position.y + }; + const rotation = transform.rotation.z; + + // 创建刚体 + const bodyHandle = this._world.createBody(entity.id, rigidbody, position, rotation); + if (bodyHandle === null) { + this.logger.error(`Failed to create physics body for entity ${entity.name}`); + return; + } + + // 收集并创建碰撞体 + const colliderHandles: number[] = []; + const colliders = this._getColliders(entity); + + for (const collider of colliders) { + const colliderHandle = this._world.createCollider(entity.id, collider, bodyHandle); + if (colliderHandle !== null) { + colliderHandles.push(colliderHandle); + } + } + + // 记录映射 + this._entityBodies.set(entity.id, { bodyHandle, colliderHandles }); + + // 存储初始位置用于插值 + rigidbody._previousPosition = { ...position }; + rigidbody._previousRotation = rotation; + rigidbody._needsSync = false; + + this.logger.debug(`Created physics body for entity ${entity.name}`); + } + + /** + * 移除物理体 + */ + private _removePhysicsBody(entity: Entity): void { + const mapping = this._entityBodies.get(entity.id); + if (!mapping) return; + + // 移除碰撞体 + for (const colliderHandle of mapping.colliderHandles) { + this._world.removeCollider(colliderHandle); + } + + // 移除刚体 + this._world.removeBody(mapping.bodyHandle); + + // 清除映射 + this._entityBodies.delete(entity.id); + + this.logger.debug(`Removed physics body for entity ${entity.name}`); + } + + /** + * 同步 Transform 到物理世界 + */ + private _syncTransformsToPhysics(entities: readonly Entity[]): void { + for (const entity of entities) { + const rigidbody = entity.getComponent(Rigidbody2DComponent); + const transform = entity.getComponent(this._transformType); + const mapping = this._entityBodies.get(entity.id); + + if (!rigidbody || !transform || !mapping) continue; + + // 只有当需要同步时才更新物理世界 + if (rigidbody._needsSync) { + const position: Vector2 = { + x: transform.position.x, + y: transform.position.y + }; + const rotation = transform.rotation.z; + + this._world.setBodyTransform(mapping.bodyHandle, position, rotation); + rigidbody._needsSync = false; + } + + // 检查碰撞体是否需要重建 + const colliders = this._getColliders(entity); + for (const collider of colliders) { + if (collider._needsRebuild) { + // 移除旧碰撞体 + if (collider._colliderHandle !== null) { + this._world.removeCollider(collider._colliderHandle); + const handleIndex = mapping.colliderHandles.indexOf(collider._colliderHandle); + if (handleIndex >= 0) { + mapping.colliderHandles.splice(handleIndex, 1); + } + } + + // 创建新碰撞体 + const newHandle = this._world.createCollider(entity.id, collider, mapping.bodyHandle); + if (newHandle !== null) { + mapping.colliderHandles.push(newHandle); + } + + collider._needsRebuild = false; + } + } + } + } + + /** + * 应用待处理的力和冲量 + */ + private _applyPendingForces(entities: readonly Entity[]): void { + for (const entity of entities) { + const rigidbody = entity.getComponent(Rigidbody2DComponent); + const mapping = this._entityBodies.get(entity.id); + + if (!rigidbody || !mapping) continue; + + const bodyHandle = mapping.bodyHandle; + + // 应用力 + if (rigidbody._pendingForce.x !== 0 || rigidbody._pendingForce.y !== 0) { + this._world.applyForce(bodyHandle, rigidbody._pendingForce); + } + + // 应用冲量 + if (rigidbody._pendingImpulse.x !== 0 || rigidbody._pendingImpulse.y !== 0) { + this._world.applyImpulse(bodyHandle, rigidbody._pendingImpulse); + } + + // 应用扭矩 + if (rigidbody._pendingTorque !== 0) { + this._world.applyTorque(bodyHandle, rigidbody._pendingTorque); + } + + // 应用角冲量 + if (rigidbody._pendingAngularImpulse !== 0) { + this._world.applyAngularImpulse(bodyHandle, rigidbody._pendingAngularImpulse); + } + + // 设置目标速度 + if (rigidbody._hasTargetVelocity) { + this._world.setVelocity(bodyHandle, rigidbody._targetVelocity); + } + + // 设置目标角速度 + if (rigidbody._hasTargetAngularVelocity) { + this._world.setAngularVelocity(bodyHandle, rigidbody._targetAngularVelocity); + } + + // 唤醒/休眠 + if (rigidbody._shouldWakeUp) { + this._world.wakeUp(bodyHandle); + } + if (rigidbody._shouldSleep) { + this._world.sleep(bodyHandle); + } + + // 清除待处理状态 + rigidbody._clearPendingForces(); + } + } + + /** + * 同步物理世界到 Transform + */ + private _syncPhysicsToTransforms(entities: readonly Entity[]): void { + for (const entity of entities) { + const rigidbody = entity.getComponent(Rigidbody2DComponent); + const transform = entity.getComponent(this._transformType); + const mapping = this._entityBodies.get(entity.id); + + if (!rigidbody || !transform || !mapping) continue; + + // 存储上一帧位置用于插值 + rigidbody._previousPosition = { + x: transform.position.x, + y: transform.position.y + }; + rigidbody._previousRotation = transform.rotation.z; + + // 从物理世界获取新位置 + const newPosition = this._world.getBodyPosition(mapping.bodyHandle); + const newRotation = this._world.getBodyRotation(mapping.bodyHandle); + const newVelocity = this._world.getBodyVelocity(mapping.bodyHandle); + const newAngularVelocity = this._world.getBodyAngularVelocity(mapping.bodyHandle); + + if (newPosition) { + transform.position.x = newPosition.x; + transform.position.y = newPosition.y; + } + + if (newRotation !== null) { + transform.rotation.z = newRotation; + } + + if (newVelocity) { + rigidbody.velocity = newVelocity; + } + + if (newAngularVelocity !== null) { + rigidbody.angularVelocity = newAngularVelocity; + } + } + } + + /** + * 处理碰撞事件 + */ + private _handleCollisionEvent(event: CollisionEvent2D): void { + if (!this.scene) return; + + let eventName: string; + switch (event.type) { + case 'enter': + eventName = PHYSICS_EVENTS.COLLISION_ENTER; + break; + case 'stay': + eventName = PHYSICS_EVENTS.COLLISION_STAY; + break; + case 'exit': + eventName = PHYSICS_EVENTS.COLLISION_EXIT; + break; + } + + this.scene.eventSystem.emitSync(eventName, event); + } + + /** + * 处理触发器事件 + */ + private _handleTriggerEvent(event: TriggerEvent2D): void { + if (!this.scene) return; + + let eventName: string; + switch (event.type) { + case 'enter': + eventName = PHYSICS_EVENTS.TRIGGER_ENTER; + break; + case 'stay': + eventName = PHYSICS_EVENTS.TRIGGER_STAY; + break; + case 'exit': + eventName = PHYSICS_EVENTS.TRIGGER_EXIT; + break; + } + + this.scene.eventSystem.emitSync(eventName, event); + } + + /** + * 获取实体上的所有碰撞体组件 + * @param entity 实体 + */ + private _getColliders(entity: Entity): Collider2DBase[] { + const colliders: Collider2DBase[] = []; + + // 收集所有类型的碰撞体 + colliders.push(...entity.getComponents(BoxCollider2DComponent)); + colliders.push(...entity.getComponents(CircleCollider2DComponent)); + colliders.push(...entity.getComponents(CapsuleCollider2DComponent)); + colliders.push(...entity.getComponents(PolygonCollider2DComponent)); + + return colliders; + } +} diff --git a/packages/physics-rapier2d/src/systems/index.ts b/packages/physics-rapier2d/src/systems/index.ts new file mode 100644 index 00000000..d50c22d1 --- /dev/null +++ b/packages/physics-rapier2d/src/systems/index.ts @@ -0,0 +1,5 @@ +/** + * Physics 2D Systems exports + */ + +export { Physics2DSystem, type Physics2DSystemConfig } from './Physics2DSystem'; diff --git a/packages/physics-rapier2d/src/types/Physics2DEvents.ts b/packages/physics-rapier2d/src/types/Physics2DEvents.ts new file mode 100644 index 00000000..013a93e7 --- /dev/null +++ b/packages/physics-rapier2d/src/types/Physics2DEvents.ts @@ -0,0 +1,104 @@ +/** + * Physics 2D Events + * 2D 物理事件定义 + */ + +import type { Vector2 } from './Physics2DTypes'; + +/** + * 碰撞事件类型 + */ +export type CollisionEventType = 'enter' | 'stay' | 'exit'; + +/** + * 触发器事件类型 + */ +export type TriggerEventType = 'enter' | 'stay' | 'exit'; + +/** + * 碰撞接触点信息 + */ +export interface ContactPoint2D { + /** 接触点位置 */ + point: Vector2; + /** 接触点法线 */ + normal: Vector2; + /** 穿透深度 */ + penetration: number; + /** 冲量大小 */ + impulse: number; +} + +/** + * 碰撞事件数据 + */ +export interface CollisionEvent2D { + /** 事件类型 */ + type: CollisionEventType; + /** 实体 A 的 ID */ + entityA: number; + /** 实体 B 的 ID */ + entityB: number; + /** 碰撞体 A 的句柄 */ + colliderHandleA: number; + /** 碰撞体 B 的句柄 */ + colliderHandleB: number; + /** 接触点列表(仅在 enter 和 stay 时有效) */ + contacts: ContactPoint2D[]; + /** 相对速度 */ + relativeVelocity: Vector2; + /** 总冲量大小 */ + totalImpulse: number; +} + +/** + * 触发器事件数据 + */ +export interface TriggerEvent2D { + /** 事件类型 */ + type: TriggerEventType; + /** 触发器实体 ID */ + triggerEntityId: number; + /** 进入触发器的实体 ID */ + otherEntityId: number; + /** 触发器碰撞体句柄 */ + triggerColliderHandle: number; + /** 其他碰撞体句柄 */ + otherColliderHandle: number; +} + +/** + * 物理事件名称常量 + */ +export const PHYSICS_EVENTS = { + /** 碰撞开始 */ + COLLISION_ENTER: 'physics2d:collision-enter', + /** 碰撞持续 */ + COLLISION_STAY: 'physics2d:collision-stay', + /** 碰撞结束 */ + COLLISION_EXIT: 'physics2d:collision-exit', + /** 触发器进入 */ + TRIGGER_ENTER: 'physics2d:trigger-enter', + /** 触发器持续 */ + TRIGGER_STAY: 'physics2d:trigger-stay', + /** 触发器离开 */ + TRIGGER_EXIT: 'physics2d:trigger-exit', + /** 物理世界步进前 */ + PRE_STEP: 'physics2d:pre-step', + /** 物理世界步进后 */ + POST_STEP: 'physics2d:post-step' +} as const; + +/** + * 物理事件映射类型 + */ +export interface Physics2DEventMap { + [PHYSICS_EVENTS.COLLISION_ENTER]: CollisionEvent2D; + [PHYSICS_EVENTS.COLLISION_STAY]: CollisionEvent2D; + [PHYSICS_EVENTS.COLLISION_EXIT]: CollisionEvent2D; + [PHYSICS_EVENTS.TRIGGER_ENTER]: TriggerEvent2D; + [PHYSICS_EVENTS.TRIGGER_STAY]: TriggerEvent2D; + [PHYSICS_EVENTS.TRIGGER_EXIT]: TriggerEvent2D; + [PHYSICS_EVENTS.PRE_STEP]: { deltaTime: number }; + [PHYSICS_EVENTS.POST_STEP]: { deltaTime: number }; +} diff --git a/packages/physics-rapier2d/src/types/Physics2DTypes.ts b/packages/physics-rapier2d/src/types/Physics2DTypes.ts new file mode 100644 index 00000000..9845229e --- /dev/null +++ b/packages/physics-rapier2d/src/types/Physics2DTypes.ts @@ -0,0 +1,183 @@ +/** + * Physics 2D Types + * 2D 物理引擎类型定义 + */ + +/** + * 刚体类型 + */ +export enum RigidbodyType2D { + /** 动态刚体,受力和碰撞影响 */ + Dynamic = 0, + /** 运动学刚体,手动控制位置,不受力影响 */ + Kinematic = 1, + /** 静态刚体,不移动,用于地形等 */ + Static = 2 +} + +/** + * 碰撞检测模式 + */ +export enum CollisionDetectionMode2D { + /** 离散检测,性能好但可能穿透 */ + Discrete = 0, + /** 连续检测,防止高速物体穿透 */ + Continuous = 1 +} + +/** + * 2D 向量 + */ +export interface Vector2 { + x: number; + y: number; +} + +/** + * 物理配置 + */ +export interface Physics2DConfig { + /** 重力向量 */ + gravity: Vector2; + /** 固定时间步长(秒) */ + timestep: number; + /** 每帧最大子步数 */ + maxSubsteps: number; + /** 速度求解器迭代次数 */ + velocityIterations: number; + /** 位置求解器迭代次数 */ + positionIterations: number; + /** 是否启用休眠 */ + allowSleep: boolean; +} + +/** + * 默认物理配置 + */ +export const DEFAULT_PHYSICS_CONFIG: Physics2DConfig = { + gravity: { x: 0, y: -9.81 }, + timestep: 1 / 60, + maxSubsteps: 8, + velocityIterations: 4, + positionIterations: 1, + allowSleep: true +}; + +/** + * 碰撞层定义 + */ +export enum CollisionLayer2D { + Default = 1 << 0, + Player = 1 << 1, + Enemy = 1 << 2, + Projectile = 1 << 3, + Ground = 1 << 4, + Platform = 1 << 5, + Trigger = 1 << 6, + All = 0xFFFF +} + +/** + * 力的模式 + */ +export enum ForceMode2D { + /** 持续力(考虑质量) */ + Force = 0, + /** 瞬时冲量(考虑质量) */ + Impulse = 1, + /** 直接设置速度变化(不考虑质量) */ + VelocityChange = 2, + /** 持续加速度(不考虑质量) */ + Acceleration = 3 +} + +/** + * 射线检测结果 + */ +export interface RaycastHit2D { + /** 命中的实体 ID */ + entityId: number; + /** 命中点 */ + point: Vector2; + /** 命中面的法线 */ + normal: Vector2; + /** 射线起点到命中点的距离 */ + distance: number; + /** 命中的碰撞体句柄 */ + colliderHandle: number; +} + +/** + * 形状投射结果 + */ +export interface ShapeCastHit2D extends RaycastHit2D { + /** 投射开始时与命中物体的穿透深度 */ + penetration: number; +} + +/** + * 重叠检测结果 + */ +export interface OverlapResult2D { + /** 重叠的实体 ID 列表 */ + entityIds: number[]; + /** 重叠的碰撞体句柄列表 */ + colliderHandles: number[]; +} + +/** + * 物理材质预设 + */ +export enum PhysicsMaterial2DPreset { + /** 默认材质 */ + Default = 0, + /** 弹性材质 */ + Bouncy = 1, + /** 光滑材质(低摩擦) */ + Slippery = 2, + /** 粘性材质(高摩擦) */ + Sticky = 3, + /** 金属材质 */ + Metal = 4, + /** 橡胶材质 */ + Rubber = 5 +} + +/** + * 获取预设材质参数 + */ +export function getPhysicsMaterialPreset(preset: PhysicsMaterial2DPreset): { friction: number; restitution: number } { + switch (preset) { + case PhysicsMaterial2DPreset.Bouncy: + return { friction: 0.2, restitution: 0.9 }; + case PhysicsMaterial2DPreset.Slippery: + return { friction: 0.05, restitution: 0.1 }; + case PhysicsMaterial2DPreset.Sticky: + return { friction: 1.0, restitution: 0.0 }; + case PhysicsMaterial2DPreset.Metal: + return { friction: 0.4, restitution: 0.3 }; + case PhysicsMaterial2DPreset.Rubber: + return { friction: 0.8, restitution: 0.7 }; + case PhysicsMaterial2DPreset.Default: + default: + return { friction: 0.5, restitution: 0.0 }; + } +} + +/** + * 关节类型 + */ +export enum JointType2D { + /** 固定关节 */ + Fixed = 0, + /** 铰链关节(旋转) */ + Revolute = 1, + /** 棱柱关节(滑动) */ + Prismatic = 2, + /** 弹簧关节 */ + Spring = 3, + /** 绳索关节 */ + Rope = 4, + /** 距离关节 */ + Distance = 5 +} diff --git a/packages/physics-rapier2d/src/types/index.ts b/packages/physics-rapier2d/src/types/index.ts new file mode 100644 index 00000000..8b214450 --- /dev/null +++ b/packages/physics-rapier2d/src/types/index.ts @@ -0,0 +1,29 @@ +/** + * Physics 2D Types exports + */ + +export { + RigidbodyType2D, + CollisionDetectionMode2D, + type Vector2, + type Physics2DConfig, + DEFAULT_PHYSICS_CONFIG, + CollisionLayer2D, + ForceMode2D, + type RaycastHit2D, + type ShapeCastHit2D, + type OverlapResult2D, + PhysicsMaterial2DPreset, + getPhysicsMaterialPreset, + JointType2D +} from './Physics2DTypes'; + +export { + type CollisionEventType, + type TriggerEventType, + type ContactPoint2D, + type CollisionEvent2D, + type TriggerEvent2D, + PHYSICS_EVENTS, + type Physics2DEventMap +} from './Physics2DEvents'; diff --git a/packages/physics-rapier2d/src/world/Physics2DWorld.ts b/packages/physics-rapier2d/src/world/Physics2DWorld.ts new file mode 100644 index 00000000..2edc4275 --- /dev/null +++ b/packages/physics-rapier2d/src/world/Physics2DWorld.ts @@ -0,0 +1,1073 @@ +/** + * Physics2DWorld + * 2D 物理世界封装 + * + * 封装 Rapier2D 物理世界,提供确定性物理模拟 + */ + +import type RAPIER from '@dimforge/rapier2d-compat'; +import type { + Physics2DConfig, + Vector2, + RaycastHit2D, + OverlapResult2D +} from '../types/Physics2DTypes'; +import { DEFAULT_PHYSICS_CONFIG, RigidbodyType2D, CollisionDetectionMode2D } from '../types/Physics2DTypes'; +import type { CollisionEvent2D, TriggerEvent2D } from '../types/Physics2DEvents'; +import type { Rigidbody2DComponent } from '../components/Rigidbody2DComponent'; +import type { Collider2DBase } from '../components/Collider2DBase'; +import type { BoxCollider2DComponent } from '../components/BoxCollider2DComponent'; +import type { CircleCollider2DComponent } from '../components/CircleCollider2DComponent'; +import type { CapsuleCollider2DComponent } from '../components/CapsuleCollider2DComponent'; +import { CapsuleDirection2D } from '../components/CapsuleCollider2DComponent'; +import type { PolygonCollider2DComponent } from '../components/PolygonCollider2DComponent'; + +/** + * 物理世界状态 + */ +export interface Physics2DWorldState { + /** 是否已初始化 */ + initialized: boolean; + /** 累积时间(用于固定时间步长) */ + accumulator: number; + /** 当前插值因子 */ + alpha: number; +} + +/** + * 碰撞体到实体的映射信息 + */ +interface ColliderMapping { + entityId: number; + colliderComponent: Collider2DBase; +} + +/** + * 刚体到实体的映射信息 + */ +interface BodyMapping { + entityId: number; + rigidbodyComponent: Rigidbody2DComponent; +} + +/** + * 2D 物理世界 + * + * 封装 Rapier2D 物理引擎,提供: + * - 确定性物理模拟 + * - 刚体和碰撞体管理 + * - 碰撞检测和事件 + * - 射线检测和形状查询 + */ +export class Physics2DWorld { + private _rapier: typeof RAPIER | null = null; + private _world: RAPIER.World | null = null; + private _eventQueue: RAPIER.EventQueue | null = null; + private _config: Physics2DConfig; + private _state: Physics2DWorldState; + + // 句柄映射 + private _colliderMap: Map = new Map(); + private _bodyMap: Map = new Map(); + + // 事件回调 + private _collisionCallbacks: ((event: CollisionEvent2D) => void)[] = []; + private _triggerCallbacks: ((event: TriggerEvent2D) => void)[] = []; + + // 碰撞状态追踪 + private _activeCollisions: Set = new Set(); + private _activeTriggers: Set = new Set(); + + constructor(config: Partial = {}) { + this._config = { ...DEFAULT_PHYSICS_CONFIG, ...config }; + this._state = { + initialized: false, + accumulator: 0, + alpha: 0 + }; + } + + /** + * 初始化物理世界 + * @param rapier Rapier2D 模块 + */ + public async initialize(rapier: typeof RAPIER): Promise { + if (this._state.initialized) { + return; + } + + this._rapier = rapier; + + // 创建物理世界 + const gravity = new rapier.Vector2(this._config.gravity.x, this._config.gravity.y); + this._world = new rapier.World(gravity); + + // 创建事件队列 + this._eventQueue = new rapier.EventQueue(true); + + this._state.initialized = true; + } + + /** + * 销毁物理世界 + */ + public destroy(): void { + if (this._eventQueue) { + this._eventQueue.free(); + this._eventQueue = null; + } + + if (this._world) { + this._world.free(); + this._world = null; + } + + this._colliderMap.clear(); + this._bodyMap.clear(); + this._activeCollisions.clear(); + this._activeTriggers.clear(); + this._state.initialized = false; + } + + /** + * 重置物理世界状态(保持初始化状态,但清除所有物理对象) + * 用于场景重载/预览重置 + */ + public reset(): void { + if (!this._world || !this._rapier) return; + + // 移除所有刚体(这会同时移除附加的碰撞体) + for (const [handle, mapping] of this._bodyMap) { + const body = this._world.getRigidBody(handle); + if (body) { + this._world.removeRigidBody(body); + } + // 清除组件中的句柄引用 + mapping.rigidbodyComponent._bodyHandle = null; + } + + // 移除所有独立碰撞体(没有附加到刚体的) + for (const [handle, mapping] of this._colliderMap) { + const collider = this._world.getCollider(handle); + if (collider) { + this._world.removeCollider(collider, true); + } + // 清除组件中的句柄引用 + mapping.colliderComponent._colliderHandle = null; + } + + // 清除映射表 + this._colliderMap.clear(); + this._bodyMap.clear(); + this._activeCollisions.clear(); + this._activeTriggers.clear(); + + // 重置累积器 + this._state.accumulator = 0; + this._state.alpha = 0; + } + + /** + * 固定时间步长更新 + * @param deltaTime 帧间隔时间 + */ + public step(deltaTime: number): void { + if (!this._world || !this._eventQueue) return; + + this._state.accumulator += deltaTime; + + let steps = 0; + while (this._state.accumulator >= this._config.timestep && steps < this._config.maxSubsteps) { + this._world.step(this._eventQueue); + this._state.accumulator -= this._config.timestep; + steps++; + } + + // 计算插值因子 + this._state.alpha = this._state.accumulator / this._config.timestep; + + // 处理碰撞事件 + this._processCollisionEvents(); + } + + // ==================== 刚体管理 ==================== + + /** + * 创建刚体 + * @param entityId 实体 ID + * @param rigidbody 刚体组件 + * @param position 初始位置 + * @param rotation 初始旋转 + */ + public createBody( + entityId: number, + rigidbody: Rigidbody2DComponent, + position: Vector2, + rotation: number + ): number | null { + if (!this._world || !this._rapier) return null; + + // 创建刚体描述 + let bodyDesc: RAPIER.RigidBodyDesc; + switch (rigidbody.bodyType) { + case RigidbodyType2D.Dynamic: + bodyDesc = this._rapier.RigidBodyDesc.dynamic(); + break; + case RigidbodyType2D.Kinematic: + bodyDesc = this._rapier.RigidBodyDesc.kinematicPositionBased(); + break; + case RigidbodyType2D.Static: + default: + bodyDesc = this._rapier.RigidBodyDesc.fixed(); + break; + } + + // 设置刚体属性 + bodyDesc + .setTranslation(position.x, position.y) + .setRotation(rotation) + .setLinearDamping(rigidbody.linearDamping) + .setAngularDamping(rigidbody.angularDamping) + .setGravityScale(rigidbody.gravityScale) + .setCanSleep(rigidbody.canSleep); + + // CCD 设置 + if (rigidbody.collisionDetection === CollisionDetectionMode2D.Continuous) { + bodyDesc.setCcdEnabled(true); + } + + // 创建刚体 + const body = this._world.createRigidBody(bodyDesc); + const handle = body.handle; + + // 设置约束 + if (rigidbody.constraints.freezePositionX || rigidbody.constraints.freezePositionY) { + body.lockTranslations( + rigidbody.constraints.freezePositionX && rigidbody.constraints.freezePositionY, + true + ); + } + if (rigidbody.constraints.freezeRotation) { + body.lockRotations(true, true); + } + + // 记录映射 + this._bodyMap.set(handle, { entityId, rigidbodyComponent: rigidbody }); + rigidbody._bodyHandle = handle; + + return handle; + } + + /** + * 移除刚体 + * @param handle 刚体句柄 + */ + public removeBody(handle: number): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (body) { + this._world.removeRigidBody(body); + } + + const mapping = this._bodyMap.get(handle); + if (mapping) { + mapping.rigidbodyComponent._bodyHandle = null; + } + this._bodyMap.delete(handle); + } + + /** + * 更新刚体属性 + * @param handle 刚体句柄 + * @param rigidbody 刚体组件 + */ + public updateBodyProperties(handle: number, rigidbody: Rigidbody2DComponent): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.setLinearDamping(rigidbody.linearDamping); + body.setAngularDamping(rigidbody.angularDamping); + body.setGravityScale(rigidbody.gravityScale, true); + } + + /** + * 设置刚体位置 + * @param handle 刚体句柄 + * @param position 位置 + * @param rotation 旋转 + */ + public setBodyTransform(handle: number, position: Vector2, rotation: number): void { + if (!this._world || !this._rapier) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.setTranslation(new this._rapier.Vector2(position.x, position.y), true); + body.setRotation(rotation, true); + } + + /** + * 获取刚体位置 + * @param handle 刚体句柄 + */ + public getBodyPosition(handle: number): Vector2 | null { + if (!this._world) return null; + + const body = this._world.getRigidBody(handle); + if (!body) return null; + + const translation = body.translation(); + return { x: translation.x, y: translation.y }; + } + + /** + * 获取刚体旋转 + * @param handle 刚体句柄 + */ + public getBodyRotation(handle: number): number | null { + if (!this._world) return null; + + const body = this._world.getRigidBody(handle); + if (!body) return null; + + return body.rotation(); + } + + /** + * 获取刚体速度 + * @param handle 刚体句柄 + */ + public getBodyVelocity(handle: number): Vector2 | null { + if (!this._world) return null; + + const body = this._world.getRigidBody(handle); + if (!body) return null; + + const vel = body.linvel(); + return { x: vel.x, y: vel.y }; + } + + /** + * 获取刚体角速度 + * @param handle 刚体句柄 + */ + public getBodyAngularVelocity(handle: number): number | null { + if (!this._world) return null; + + const body = this._world.getRigidBody(handle); + if (!body) return null; + + return body.angvel(); + } + + /** + * 应用力 + * @param handle 刚体句柄 + * @param force 力向量 + */ + public applyForce(handle: number, force: Vector2): void { + if (!this._world || !this._rapier) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.addForce(new this._rapier.Vector2(force.x, force.y), true); + } + + /** + * 应用冲量 + * @param handle 刚体句柄 + * @param impulse 冲量向量 + */ + public applyImpulse(handle: number, impulse: Vector2): void { + if (!this._world || !this._rapier) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.applyImpulse(new this._rapier.Vector2(impulse.x, impulse.y), true); + } + + /** + * 应用扭矩 + * @param handle 刚体句柄 + * @param torque 扭矩值 + */ + public applyTorque(handle: number, torque: number): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.addTorque(torque, true); + } + + /** + * 应用角冲量 + * @param handle 刚体句柄 + * @param impulse 角冲量值 + */ + public applyAngularImpulse(handle: number, impulse: number): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.applyTorqueImpulse(impulse, true); + } + + /** + * 设置速度 + * @param handle 刚体句柄 + * @param velocity 速度向量 + */ + public setVelocity(handle: number, velocity: Vector2): void { + if (!this._world || !this._rapier) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.setLinvel(new this._rapier.Vector2(velocity.x, velocity.y), true); + } + + /** + * 设置角速度 + * @param handle 刚体句柄 + * @param angularVelocity 角速度值 + */ + public setAngularVelocity(handle: number, angularVelocity: number): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.setAngvel(angularVelocity, true); + } + + /** + * 唤醒刚体 + * @param handle 刚体句柄 + */ + public wakeUp(handle: number): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.wakeUp(); + } + + /** + * 使刚体休眠 + * @param handle 刚体句柄 + */ + public sleep(handle: number): void { + if (!this._world) return; + + const body = this._world.getRigidBody(handle); + if (!body) return; + + body.sleep(); + } + + // ==================== 碰撞体管理 ==================== + + /** + * 创建碰撞体 + * @param entityId 实体 ID + * @param collider 碰撞体组件 + * @param bodyHandle 关联的刚体句柄(可选) + */ + public createCollider(entityId: number, collider: Collider2DBase, bodyHandle?: number): number | null { + if (!this._world || !this._rapier) return null; + + // 创建碰撞体描述 + const colliderDesc = this._createColliderDesc(collider); + if (!colliderDesc) return null; + + // 创建碰撞体 + let rapierCollider: RAPIER.Collider; + if (bodyHandle !== undefined) { + const body = this._world.getRigidBody(bodyHandle); + if (!body) return null; + rapierCollider = this._world.createCollider(colliderDesc, body); + collider._attachedBodyEntityId = this._bodyMap.get(bodyHandle)?.entityId ?? null; + } else { + rapierCollider = this._world.createCollider(colliderDesc); + } + + const handle = rapierCollider.handle; + + // 记录映射 + this._colliderMap.set(handle, { entityId, colliderComponent: collider }); + collider._colliderHandle = handle; + + return handle; + } + + /** + * 移除碰撞体 + * @param handle 碰撞体句柄 + */ + public removeCollider(handle: number): void { + if (!this._world) return; + + const collider = this._world.getCollider(handle); + if (collider) { + this._world.removeCollider(collider, true); + } + + const mapping = this._colliderMap.get(handle); + if (mapping) { + mapping.colliderComponent._colliderHandle = null; + } + this._colliderMap.delete(handle); + } + + /** + * 更新碰撞体属性 + * @param handle 碰撞体句柄 + * @param colliderComponent 碰撞体组件 + */ + public updateColliderProperties(handle: number, colliderComponent: Collider2DBase): void { + if (!this._world) return; + + const collider = this._world.getCollider(handle); + if (!collider) return; + + collider.setFriction(colliderComponent.friction); + collider.setRestitution(colliderComponent.restitution); + collider.setDensity(colliderComponent.density); + collider.setSensor(colliderComponent.isTrigger); + collider.setCollisionGroups( + this._createCollisionGroups(colliderComponent.collisionLayer, colliderComponent.collisionMask) + ); + } + + // ==================== 射线检测 ==================== + + /** + * 射线检测 + * @param origin 起点 + * @param direction 方向(归一化) + * @param maxDistance 最大距离 + * @param collisionMask 碰撞掩码 + */ + public raycast( + origin: Vector2, + direction: Vector2, + maxDistance: number, + collisionMask: number = 0xffff + ): RaycastHit2D | null { + if (!this._world || !this._rapier) return null; + + const ray = new this._rapier.Ray( + new this._rapier.Vector2(origin.x, origin.y), + new this._rapier.Vector2(direction.x, direction.y) + ); + + const hit = this._world.castRay( + ray, + maxDistance, + true, + undefined, + collisionMask, + undefined, + undefined, + (collider) => { + const mapping = this._colliderMap.get(collider.handle); + return mapping !== undefined; + } + ); + + if (!hit) return null; + + const collider = hit.collider; + const mapping = this._colliderMap.get(collider.handle); + if (!mapping) return null; + + const point = ray.pointAt(hit.timeOfImpact); + const normal = collider.castRayAndGetNormal(ray, maxDistance, true)?.normal; + + return { + entityId: mapping.entityId, + point: { x: point.x, y: point.y }, + normal: normal ? { x: normal.x, y: normal.y } : { x: 0, y: 0 }, + distance: hit.timeOfImpact, + colliderHandle: collider.handle + }; + } + + /** + * 射线检测所有命中 + * @param origin 起点 + * @param direction 方向(归一化) + * @param maxDistance 最大距离 + * @param collisionMask 碰撞掩码 + */ + public raycastAll( + origin: Vector2, + direction: Vector2, + maxDistance: number, + collisionMask: number = 0xffff + ): RaycastHit2D[] { + if (!this._world || !this._rapier) return []; + + const ray = new this._rapier.Ray( + new this._rapier.Vector2(origin.x, origin.y), + new this._rapier.Vector2(direction.x, direction.y) + ); + + const results: RaycastHit2D[] = []; + + this._world.intersectionsWithRay(ray, maxDistance, true, (intersection) => { + const collider = intersection.collider; + const mapping = this._colliderMap.get(collider.handle); + + if (mapping && (collider.collisionGroups() & collisionMask) !== 0) { + const point = ray.pointAt(intersection.timeOfImpact); + const normal = intersection.normal; + + results.push({ + entityId: mapping.entityId, + point: { x: point.x, y: point.y }, + normal: { x: normal.x, y: normal.y }, + distance: intersection.timeOfImpact, + colliderHandle: collider.handle + }); + } + + return true; // 继续查找 + }); + + return results; + } + + // ==================== 重叠检测 ==================== + + /** + * 点重叠检测 + * @param point 检测点 + * @param collisionMask 碰撞掩码 + */ + public overlapPoint(point: Vector2, collisionMask: number = 0xffff): OverlapResult2D { + if (!this._world || !this._rapier) { + return { entityIds: [], colliderHandles: [] }; + } + + const entityIds: number[] = []; + const colliderHandles: number[] = []; + + this._world.intersectionsWithPoint(new this._rapier.Vector2(point.x, point.y), (collider) => { + const mapping = this._colliderMap.get(collider.handle); + if (mapping && (collider.collisionGroups() & collisionMask) !== 0) { + entityIds.push(mapping.entityId); + colliderHandles.push(collider.handle); + } + return true; + }); + + return { entityIds, colliderHandles }; + } + + /** + * 圆形重叠检测 + * @param center 圆心 + * @param radius 半径 + * @param collisionMask 碰撞掩码 + */ + public overlapCircle(center: Vector2, radius: number, collisionMask: number = 0xffff): OverlapResult2D { + if (!this._world || !this._rapier) { + return { entityIds: [], colliderHandles: [] }; + } + + const entityIds: number[] = []; + const colliderHandles: number[] = []; + + const shape = new this._rapier.Ball(radius); + const shapePos = new this._rapier.Vector2(center.x, center.y); + + this._world.intersectionsWithShape(shapePos, 0, shape, (collider) => { + const mapping = this._colliderMap.get(collider.handle); + if (mapping && (collider.collisionGroups() & collisionMask) !== 0) { + entityIds.push(mapping.entityId); + colliderHandles.push(collider.handle); + } + return true; + }); + + return { entityIds, colliderHandles }; + } + + /** + * 矩形重叠检测 + * @param center 中心 + * @param halfExtents 半宽高 + * @param rotation 旋转角度 + * @param collisionMask 碰撞掩码 + */ + public overlapBox( + center: Vector2, + halfExtents: Vector2, + rotation: number = 0, + collisionMask: number = 0xffff + ): OverlapResult2D { + if (!this._world || !this._rapier) { + return { entityIds: [], colliderHandles: [] }; + } + + const entityIds: number[] = []; + const colliderHandles: number[] = []; + + const shape = new this._rapier.Cuboid(halfExtents.x, halfExtents.y); + const shapePos = new this._rapier.Vector2(center.x, center.y); + + this._world.intersectionsWithShape(shapePos, rotation, shape, (collider) => { + const mapping = this._colliderMap.get(collider.handle); + if (mapping && (collider.collisionGroups() & collisionMask) !== 0) { + entityIds.push(mapping.entityId); + colliderHandles.push(collider.handle); + } + return true; + }); + + return { entityIds, colliderHandles }; + } + + // ==================== 事件处理 ==================== + + /** + * 注册碰撞事件回调 + * @param callback 回调函数 + */ + public onCollision(callback: (event: CollisionEvent2D) => void): void { + this._collisionCallbacks.push(callback); + } + + /** + * 注册触发器事件回调 + * @param callback 回调函数 + */ + public onTrigger(callback: (event: TriggerEvent2D) => void): void { + this._triggerCallbacks.push(callback); + } + + /** + * 移除所有事件回调 + */ + public clearEventCallbacks(): void { + this._collisionCallbacks = []; + this._triggerCallbacks = []; + } + + // ==================== 配置 ==================== + + /** + * 设置重力 + * @param gravity 重力向量 + */ + public setGravity(gravity: Vector2): void { + if (!this._world || !this._rapier) return; + + this._config.gravity = gravity; + this._world.gravity = new this._rapier.Vector2(gravity.x, gravity.y); + } + + /** + * 获取重力 + */ + public getGravity(): Vector2 { + return { ...this._config.gravity }; + } + + /** + * 获取配置 + */ + public getConfig(): Readonly { + return this._config; + } + + /** + * 获取状态 + */ + public getState(): Readonly { + return this._state; + } + + /** + * 获取插值因子 + */ + public getAlpha(): number { + return this._state.alpha; + } + + /** + * 获取实体映射 + * @param colliderHandle 碰撞体句柄 + */ + public getEntityByCollider(colliderHandle: number): number | null { + const mapping = this._colliderMap.get(colliderHandle); + return mapping?.entityId ?? null; + } + + /** + * 获取实体映射 + * @param bodyHandle 刚体句柄 + */ + public getEntityByBody(bodyHandle: number): number | null { + const mapping = this._bodyMap.get(bodyHandle); + return mapping?.entityId ?? null; + } + + // ==================== 私有方法 ==================== + + /** + * 创建碰撞形状 + */ + private _createShape(collider: Collider2DBase): RAPIER.Shape | null { + if (!this._rapier) return null; + + const shapeType = collider.getShapeType(); + + switch (shapeType) { + case 'box': { + const box = collider as BoxCollider2DComponent; + return new this._rapier.Cuboid(box.halfWidth, box.halfHeight); + } + case 'circle': { + const circle = collider as CircleCollider2DComponent; + return new this._rapier.Ball(circle.radius); + } + case 'capsule': { + const capsule = collider as CapsuleCollider2DComponent; + if (capsule.direction === CapsuleDirection2D.Vertical) { + return new this._rapier.Capsule(capsule.halfHeight, capsule.radius); + } else { + // 水平胶囊需要旋转处理,这里简化为使用相同的形状 + return new this._rapier.Capsule(capsule.halfHeight, capsule.radius); + } + } + case 'polygon': { + const polygon = collider as PolygonCollider2DComponent; + const vertices = new Float32Array(polygon.vertices.length * 2); + for (let i = 0; i < polygon.vertices.length; i++) { + vertices[i * 2] = polygon.vertices[i].x; + vertices[i * 2 + 1] = polygon.vertices[i].y; + } + // 第二个参数 false 表示让 Rapier 计算凸包 + return new this._rapier.ConvexPolygon(vertices, false); + } + default: + console.warn(`Unknown collider shape type: ${shapeType}`); + return null; + } + } + + /** + * 创建碰撞体描述 + */ + private _createColliderDesc(collider: Collider2DBase): RAPIER.ColliderDesc | null { + if (!this._rapier) return null; + + const shapeType = collider.getShapeType(); + let colliderDesc: RAPIER.ColliderDesc | null = null; + + switch (shapeType) { + case 'box': { + const box = collider as BoxCollider2DComponent; + colliderDesc = this._rapier.ColliderDesc.cuboid(box.halfWidth, box.halfHeight); + break; + } + case 'circle': { + const circle = collider as CircleCollider2DComponent; + colliderDesc = this._rapier.ColliderDesc.ball(circle.radius); + break; + } + case 'capsule': { + const capsule = collider as CapsuleCollider2DComponent; + colliderDesc = this._rapier.ColliderDesc.capsule(capsule.halfHeight, capsule.radius); + break; + } + case 'polygon': { + const polygon = collider as PolygonCollider2DComponent; + const vertices = new Float32Array(polygon.vertices.length * 2); + for (let i = 0; i < polygon.vertices.length; i++) { + vertices[i * 2] = polygon.vertices[i].x; + vertices[i * 2 + 1] = polygon.vertices[i].y; + } + colliderDesc = this._rapier.ColliderDesc.convexHull(vertices); + break; + } + default: + console.warn(`Unknown collider shape type: ${shapeType}`); + return null; + } + + if (!colliderDesc) return null; + + // 配置碰撞体属性 + colliderDesc + .setTranslation(collider.offset.x, collider.offset.y) + .setRotation(collider.rotationOffset) + .setFriction(collider.friction) + .setRestitution(collider.restitution) + .setDensity(collider.density) + .setSensor(collider.isTrigger) + .setCollisionGroups(this._createCollisionGroups(collider.collisionLayer, collider.collisionMask)) + .setActiveEvents(this._rapier.ActiveEvents.COLLISION_EVENTS); + + return colliderDesc; + } + + /** + * 创建碰撞组 + */ + private _createCollisionGroups(layer: number, mask: number): number { + // Rapier 使用 32 位整数,高 16 位是 membership,低 16 位是 filter + return ((layer & 0xffff) << 16) | (mask & 0xffff); + } + + /** + * 处理碰撞事件 + */ + private _processCollisionEvents(): void { + if (!this._eventQueue || !this._world) return; + + const newCollisions = new Set(); + const newTriggers = new Set(); + + // 处理碰撞事件 + this._eventQueue.drainCollisionEvents((handle1, handle2, started) => { + const mapping1 = this._colliderMap.get(handle1); + const mapping2 = this._colliderMap.get(handle2); + + if (!mapping1 || !mapping2) return; + + const pairKey = handle1 < handle2 ? `${handle1}-${handle2}` : `${handle2}-${handle1}`; + const isTrigger = mapping1.colliderComponent.isTrigger || mapping2.colliderComponent.isTrigger; + + if (isTrigger) { + // 触发器事件 + if (started) { + newTriggers.add(pairKey); + + if (!this._activeTriggers.has(pairKey)) { + // Enter + this._emitTriggerEvent('enter', mapping1, mapping2, handle1, handle2); + } + } + } else { + // 碰撞事件 + if (started) { + newCollisions.add(pairKey); + + if (!this._activeCollisions.has(pairKey)) { + // Enter + this._emitCollisionEvent('enter', mapping1, mapping2, handle1, handle2); + } + } + } + }); + + // 处理 Stay 和 Exit 事件 + this._processStayAndExitEvents(newCollisions, this._activeCollisions, false); + this._processStayAndExitEvents(newTriggers, this._activeTriggers, true); + + // 更新活跃碰撞 + this._activeCollisions = newCollisions; + this._activeTriggers = newTriggers; + } + + /** + * 处理持续和退出事件 + */ + private _processStayAndExitEvents(newSet: Set, activeSet: Set, isTrigger: boolean): void { + for (const pairKey of activeSet) { + if (newSet.has(pairKey)) { + // Stay + const [h1, h2] = pairKey.split('-').map(Number); + const mapping1 = this._colliderMap.get(h1); + const mapping2 = this._colliderMap.get(h2); + + if (mapping1 && mapping2) { + if (isTrigger) { + this._emitTriggerEvent('stay', mapping1, mapping2, h1, h2); + } else { + this._emitCollisionEvent('stay', mapping1, mapping2, h1, h2); + } + } + } else { + // Exit + const [h1, h2] = pairKey.split('-').map(Number); + const mapping1 = this._colliderMap.get(h1); + const mapping2 = this._colliderMap.get(h2); + + if (mapping1 && mapping2) { + if (isTrigger) { + this._emitTriggerEvent('exit', mapping1, mapping2, h1, h2); + } else { + this._emitCollisionEvent('exit', mapping1, mapping2, h1, h2); + } + } + } + } + } + + /** + * 发送碰撞事件 + */ + private _emitCollisionEvent( + type: 'enter' | 'stay' | 'exit', + mapping1: ColliderMapping, + mapping2: ColliderMapping, + handle1: number, + handle2: number + ): void { + const event: CollisionEvent2D = { + type, + entityA: mapping1.entityId, + entityB: mapping2.entityId, + colliderHandleA: handle1, + colliderHandleB: handle2, + contacts: [], // TODO: 提取接触点信息 + relativeVelocity: { x: 0, y: 0 }, // TODO: 计算相对速度 + totalImpulse: 0 // TODO: 计算总冲量 + }; + + for (const callback of this._collisionCallbacks) { + callback(event); + } + } + + /** + * 发送触发器事件 + */ + private _emitTriggerEvent( + type: 'enter' | 'stay' | 'exit', + mapping1: ColliderMapping, + mapping2: ColliderMapping, + handle1: number, + handle2: number + ): void { + // 确定哪个是触发器 + const trigger = mapping1.colliderComponent.isTrigger ? mapping1 : mapping2; + const other = mapping1.colliderComponent.isTrigger ? mapping2 : mapping1; + const triggerHandle = trigger === mapping1 ? handle1 : handle2; + const otherHandle = trigger === mapping1 ? handle2 : handle1; + + const event: TriggerEvent2D = { + type, + triggerEntityId: trigger.entityId, + otherEntityId: other.entityId, + triggerColliderHandle: triggerHandle, + otherColliderHandle: otherHandle + }; + + for (const callback of this._triggerCallbacks) { + callback(event); + } + } +} diff --git a/packages/physics-rapier2d/src/world/index.ts b/packages/physics-rapier2d/src/world/index.ts new file mode 100644 index 00000000..0a57d6ae --- /dev/null +++ b/packages/physics-rapier2d/src/world/index.ts @@ -0,0 +1,5 @@ +/** + * Physics 2D World exports + */ + +export { Physics2DWorld, type Physics2DWorldState } from './Physics2DWorld'; diff --git a/packages/physics-rapier2d/tsconfig.json b/packages/physics-rapier2d/tsconfig.json new file mode 100644 index 00000000..2d7897fe --- /dev/null +++ b/packages/physics-rapier2d/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "composite": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*", "plugin.json"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"], + "references": [ + { "path": "../core" }, + { "path": "../components" } + ] +} diff --git a/packages/physics-rapier2d/vite.config.ts b/packages/physics-rapier2d/vite.config.ts new file mode 100644 index 00000000..9a0f8c08 --- /dev/null +++ b/packages/physics-rapier2d/vite.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import dts from 'vite-plugin-dts'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [ + react(), + dts({ + include: ['src'], + outDir: 'dist', + rollupTypes: false, + tsconfigPath: './tsconfig.json' + }) + ], + esbuild: { + jsx: 'automatic', + }, + build: { + lib: { + entry: { + index: resolve(__dirname, 'src/index.ts'), + runtime: resolve(__dirname, 'src/runtime.ts'), + 'editor/index': resolve(__dirname, 'src/editor/index.ts') + }, + formats: ['es'], + fileName: (format, entryName) => `${entryName}.js` + }, + rollupOptions: { + external: [ + '@esengine/ecs-framework', + '@esengine/ecs-components', + '@esengine/editor-core', + 'react', + 'react/jsx-runtime', + /^@esengine\// + ], + output: { + exports: 'named', + preserveModules: false + } + }, + target: 'es2020', + minify: false, + sourcemap: true + } +}); diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 3fdbd3ee..b3134068 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -43,6 +43,7 @@ "@esengine/ecs-components": "workspace:*", "@esengine/ecs-engine-bindgen": "workspace:*", "@esengine/ecs-framework": "workspace:*", + "@esengine/physics-rapier2d": "workspace:*", "@esengine/platform-common": "workspace:*", "@esengine/tilemap": "workspace:*", "@esengine/ui": "workspace:*" diff --git a/packages/platform-web/rollup.runtime.config.js b/packages/platform-web/rollup.runtime.config.js index d401694a..b5b3ab39 100644 --- a/packages/platform-web/rollup.runtime.config.js +++ b/packages/platform-web/rollup.runtime.config.js @@ -29,7 +29,9 @@ export default { browser: true, preferBuiltins: false, // Only resolve main/module fields, not source - mainFields: ['module', 'main'] + mainFields: ['module', 'main'], + // Support package.json exports field for subpath imports + exportConditions: ['import', 'module', 'default'] }), commonjs(), typescript({ diff --git a/packages/platform-web/src/RuntimeSystems.ts b/packages/platform-web/src/RuntimeSystems.ts index af9af323..9227b452 100644 --- a/packages/platform-web/src/RuntimeSystems.ts +++ b/packages/platform-web/src/RuntimeSystems.ts @@ -8,10 +8,12 @@ import type { IScene } from '@esengine/ecs-framework'; import { EngineBridge, EngineRenderSystem, CameraSystem } from '@esengine/ecs-engine-bindgen'; import { TransformComponent, SpriteAnimatorSystem, CoreRuntimeModule } from '@esengine/ecs-components'; import type { SystemContext, IPluginLoader, IRuntimeModuleLoader, PluginDescriptor } from '@esengine/ecs-components'; -// Import from /runtime entry points to avoid editor dependencies (React, etc.) +// Import runtime modules +// 注意:这些模块需要从各自包的 runtime 入口点导入,以避免编辑器依赖(React 等) import { UIRuntimeModule, UIRenderDataProvider, UIInputSystem } from '@esengine/ui/runtime'; import { TilemapRuntimeModule, TilemapRenderingSystem } from '@esengine/tilemap/runtime'; import { BehaviorTreeRuntimeModule, BehaviorTreeExecutionSystem } from '@esengine/behavior-tree/runtime'; +import { PhysicsRuntimeModule, Physics2DSystem } from '@esengine/physics-rapier2d/runtime'; /** * 运行时系统集合 @@ -21,6 +23,7 @@ export interface RuntimeSystems { animatorSystem?: SpriteAnimatorSystem; tilemapSystem?: TilemapRenderingSystem; behaviorTreeSystem?: BehaviorTreeExecutionSystem; + physicsSystem?: Physics2DSystem; renderSystem: EngineRenderSystem; uiRenderProvider?: UIRenderDataProvider; } @@ -243,6 +246,16 @@ const behaviorTreeDescriptor: PluginDescriptor = { modules: [{ name: 'BehaviorTreeRuntime', type: 'runtime', entry: './src/index.ts' }] }; +const physicsDescriptor: PluginDescriptor = { + id: '@esengine/physics-rapier2d', + name: 'Rapier 2D Physics', + version: '1.0.0', + category: 'physics', + enabledByDefault: true, + isEnginePlugin: true, + modules: [{ name: 'PhysicsRuntime', type: 'runtime', entry: './src/runtime.ts' }] +}; + /** * 注册所有可用插件 * 仅注册插件描述信息,不初始化组件和服务 @@ -271,6 +284,12 @@ export function registerAvailablePlugins(): void { } catch (e) { console.error('[RuntimeSystems] Failed to register BehaviorTreeRuntimeModule:', e); } + + try { + runtimePluginManager.register(createRuntimeOnlyPlugin(physicsDescriptor, new PhysicsRuntimeModule())); + } catch (e) { + console.error('[RuntimeSystems] Failed to register PhysicsRuntimeModule:', e); + } } /** diff --git a/packages/tilemap/src/editor/index.ts b/packages/tilemap/src/editor/index.ts index 57e0f27e..bc6bef6b 100644 --- a/packages/tilemap/src/editor/index.ts +++ b/packages/tilemap/src/editor/index.ts @@ -99,7 +99,9 @@ export class TilemapEditorModule implements IEditorModuleLoader { id: 'tilemap-editor', title: 'Tilemap Editor', position: PanelPosition.Center, - render: () => React.createElement(TilemapEditorPanel), + closable: true, + component: TilemapEditorPanel, + isDynamic: true }, ]; } diff --git a/packages/tilemap/vite.config.ts b/packages/tilemap/vite.config.ts index 7d5ee1e0..0a7cabe6 100644 --- a/packages/tilemap/vite.config.ts +++ b/packages/tilemap/vite.config.ts @@ -21,10 +21,11 @@ function inlineCSS(): any { const cssContent = bundle[cssFile].source; if (!cssContent) return; - // 找到包含编辑器代码的主要 JS 文件(带 hash 的 chunk) + // 找到包含编辑器代码的主要 JS 文件 + // 优先查找 editor/index.js,然后是带 hash 的 index-*.js const mainJsFile = bundleKeys.find(key => + (key === 'editor/index.js' || key.includes('index-')) && key.endsWith('.js') && - key.includes('index-') && bundle[key].type === 'chunk' && bundle[key].code ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51814103..8a46f818 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: '@esengine/engine': specifier: workspace:* version: link:../engine + '@esengine/physics-rapier2d': + specifier: workspace:* + version: link:../physics-rapier2d '@esengine/tilemap': specifier: workspace:* version: link:../tilemap @@ -667,6 +670,43 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/physics-rapier2d: + dependencies: + '@dimforge/rapier2d-compat': + specifier: ^0.14.0 + version: 0.14.0 + '@esengine/ecs-components': + specifier: workspace:* + version: link:../components + '@esengine/ecs-framework': + specifier: '>=2.0.0' + version: link:../core + '@esengine/editor-core': + specifier: workspace:* + version: link:../editor-core + react: + specifier: ^18.3.1 + version: 18.3.1 + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.27 + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)) + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1) + vite-plugin-dts: + specifier: ^4.5.0 + version: 4.5.4(@types/node@20.19.25)(rollup@4.53.3)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)) + packages/platform-common: devDependencies: '@rollup/plugin-commonjs': @@ -708,6 +748,9 @@ importers: '@esengine/ecs-framework': specifier: workspace:* version: link:../core + '@esengine/physics-rapier2d': + specifier: workspace:* + version: link:../physics-rapier2d '@esengine/platform-common': specifier: workspace:* version: link:../platform-common @@ -1618,6 +1661,9 @@ packages: resolution: {integrity: sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==} engines: {node: '>=v18'} + '@dimforge/rapier2d-compat@0.14.0': + resolution: {integrity: sha512-sljQVPstRS63hVLnVNphsZUjH51TZoptVM0XlglKAdZ8CT+kWnmA6olwjkF7omPWYrlKMd/nHORxOUdJDOSoAQ==} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -9061,6 +9107,8 @@ snapshots: dependencies: chalk: 4.1.2 + '@dimforge/rapier2d-compat@0.14.0': {} + '@docsearch/css@3.8.2': {} '@docsearch/js@3.8.2(@algolia/client-search@5.44.0)(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)':