feat(physics): 集成 Rapier2D 物理引擎并修复预览重置问题 (#244)

* feat(physics): 集成 Rapier2D 物理引擎并修复预览重置问题

* fix: 修复 CI 流程并清理代码
This commit is contained in:
YHH
2025-11-28 10:32:28 +08:00
committed by GitHub
parent cabb625a17
commit 673f5e5855
56 changed files with 4934 additions and 218 deletions

View File

@@ -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"
}
}

View File

@@ -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"
]
}

View File

@@ -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;

View File

@@ -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<void> {
// 初始化 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();

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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<void> {
// 注册组件到编辑器组件注册表
// 组件检视器现在通过 @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<void> {
// 清理资源
}
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;

View File

@@ -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<BoxCollider2DComponent> {
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 (
<div className="entity-inspector">
<div className="inspector-section">
<div className="section-title">Box Collider 2D</div>
{/* Size */}
<div className="section-subtitle">Size</div>
<div className="property-row">
<label>Width</label>
<input
type="number"
value={component.width}
min={0.001}
step={0.1}
onChange={(e) => handleChange('width', parseFloat(e.target.value) || 1)}
className="property-input"
/>
</div>
<div className="property-row">
<label>Height</label>
<input
type="number"
value={component.height}
min={0.001}
step={0.1}
onChange={(e) => handleChange('height', parseFloat(e.target.value) || 1)}
className="property-input"
/>
</div>
{/* Offset */}
<div className="section-subtitle">Offset</div>
<div className="property-row">
<label>X</label>
<input
type="number"
value={component.offset.x}
step={0.1}
onChange={(e) => handleChange('offset', {
...component.offset,
x: parseFloat(e.target.value) || 0
})}
className="property-input"
/>
</div>
<div className="property-row">
<label>Y</label>
<input
type="number"
value={component.offset.y}
step={0.1}
onChange={(e) => handleChange('offset', {
...component.offset,
y: parseFloat(e.target.value) || 0
})}
className="property-input"
/>
</div>
{/* Material */}
<div className="section-subtitle">Material</div>
<div className="property-row">
<label>Friction</label>
<input
type="number"
value={component.friction}
min={0}
max={1}
step={0.01}
onChange={(e) => handleChange('friction', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
<div className="property-row">
<label>Restitution</label>
<input
type="number"
value={component.restitution}
min={0}
max={1}
step={0.01}
onChange={(e) => handleChange('restitution', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
<div className="property-row">
<label>Density</label>
<input
type="number"
value={component.density}
min={0.001}
step={0.1}
onChange={(e) => handleChange('density', parseFloat(e.target.value) || 1)}
className="property-input"
/>
</div>
{/* Collision */}
<div className="section-subtitle">Collision</div>
<div className="property-row">
<label>Is Trigger</label>
<input
type="checkbox"
checked={component.isTrigger}
onChange={(e) => handleChange('isTrigger', e.target.checked)}
className="property-checkbox"
/>
</div>
<div className="property-row">
<label>Layer</label>
<select
value={component.collisionLayer}
onChange={(e) => handleChange('collisionLayer', parseInt(e.target.value, 10))}
className="property-select"
>
<option value={CollisionLayer2D.Default}>Default</option>
<option value={CollisionLayer2D.Player}>Player</option>
<option value={CollisionLayer2D.Enemy}>Enemy</option>
<option value={CollisionLayer2D.Ground}>Ground</option>
<option value={CollisionLayer2D.Projectile}>Projectile</option>
<option value={CollisionLayer2D.Trigger}>Trigger</option>
</select>
</div>
</div>
</div>
);
}
}

View File

@@ -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<CircleCollider2DComponent> {
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 (
<div className="entity-inspector">
<div className="inspector-section">
<div className="section-title">Circle Collider 2D</div>
{/* Radius */}
<div className="property-row">
<label>Radius</label>
<input
type="number"
value={component.radius}
min={0.001}
step={0.1}
onChange={(e) => handleChange('radius', parseFloat(e.target.value) || 0.5)}
className="property-input"
/>
</div>
{/* Offset */}
<div className="section-subtitle">Offset</div>
<div className="property-row">
<label>X</label>
<input
type="number"
value={component.offset.x}
step={0.1}
onChange={(e) => handleChange('offset', {
...component.offset,
x: parseFloat(e.target.value) || 0
})}
className="property-input"
/>
</div>
<div className="property-row">
<label>Y</label>
<input
type="number"
value={component.offset.y}
step={0.1}
onChange={(e) => handleChange('offset', {
...component.offset,
y: parseFloat(e.target.value) || 0
})}
className="property-input"
/>
</div>
{/* Material */}
<div className="section-subtitle">Material</div>
<div className="property-row">
<label>Friction</label>
<input
type="number"
value={component.friction}
min={0}
max={1}
step={0.01}
onChange={(e) => handleChange('friction', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
<div className="property-row">
<label>Restitution</label>
<input
type="number"
value={component.restitution}
min={0}
max={1}
step={0.01}
onChange={(e) => handleChange('restitution', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
<div className="property-row">
<label>Density</label>
<input
type="number"
value={component.density}
min={0.001}
step={0.1}
onChange={(e) => handleChange('density', parseFloat(e.target.value) || 1)}
className="property-input"
/>
</div>
{/* Collision */}
<div className="section-subtitle">Collision</div>
<div className="property-row">
<label>Is Trigger</label>
<input
type="checkbox"
checked={component.isTrigger}
onChange={(e) => handleChange('isTrigger', e.target.checked)}
className="property-checkbox"
/>
</div>
<div className="property-row">
<label>Layer</label>
<select
value={component.collisionLayer}
onChange={(e) => handleChange('collisionLayer', parseInt(e.target.value, 10))}
className="property-select"
>
<option value={CollisionLayer2D.Default}>Default</option>
<option value={CollisionLayer2D.Player}>Player</option>
<option value={CollisionLayer2D.Enemy}>Enemy</option>
<option value={CollisionLayer2D.Ground}>Ground</option>
<option value={CollisionLayer2D.Projectile}>Projectile</option>
<option value={CollisionLayer2D.Trigger}>Trigger</option>
</select>
</div>
</div>
</div>
);
}
}

View File

@@ -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<Rigidbody2DComponent> {
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 (
<div className="entity-inspector">
<div className="inspector-section">
<div className="section-title">Rigidbody 2D</div>
{/* Body Type */}
<div className="property-row">
<label>Body Type</label>
<select
value={component.bodyType}
onChange={(e) => handleChange('bodyType', parseInt(e.target.value, 10) as RigidbodyType2D)}
className="property-select"
>
<option value={RigidbodyType2D.Dynamic}>Dynamic</option>
<option value={RigidbodyType2D.Kinematic}>Kinematic</option>
<option value={RigidbodyType2D.Static}>Static</option>
</select>
</div>
{/* Mass - only for Dynamic */}
{component.bodyType === RigidbodyType2D.Dynamic && (
<div className="property-row">
<label>Mass</label>
<input
type="number"
value={component.mass}
min={0.001}
step={0.1}
onChange={(e) => handleChange('mass', parseFloat(e.target.value) || 1)}
className="property-input"
/>
</div>
)}
{/* Gravity Scale */}
<div className="property-row">
<label>Gravity Scale</label>
<input
type="number"
value={component.gravityScale}
step={0.1}
onChange={(e) => handleChange('gravityScale', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
{/* Damping Section */}
<div className="section-subtitle">Damping</div>
<div className="property-row">
<label>Linear</label>
<input
type="number"
value={component.linearDamping}
min={0}
step={0.01}
onChange={(e) => handleChange('linearDamping', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
<div className="property-row">
<label>Angular</label>
<input
type="number"
value={component.angularDamping}
min={0}
step={0.01}
onChange={(e) => handleChange('angularDamping', parseFloat(e.target.value) || 0)}
className="property-input"
/>
</div>
{/* Constraints Section */}
<div className="section-subtitle">Constraints</div>
<div className="property-row">
<label>Freeze Position X</label>
<input
type="checkbox"
checked={component.constraints.freezePositionX}
onChange={(e) => handleChange('constraints', {
...component.constraints,
freezePositionX: e.target.checked
})}
className="property-checkbox"
/>
</div>
<div className="property-row">
<label>Freeze Position Y</label>
<input
type="checkbox"
checked={component.constraints.freezePositionY}
onChange={(e) => handleChange('constraints', {
...component.constraints,
freezePositionY: e.target.checked
})}
className="property-checkbox"
/>
</div>
<div className="property-row">
<label>Freeze Rotation</label>
<input
type="checkbox"
checked={component.constraints.freezeRotation}
onChange={(e) => handleChange('constraints', {
...component.constraints,
freezeRotation: e.target.checked
})}
className="property-checkbox"
/>
</div>
{/* Collision Detection */}
<div className="section-subtitle">Collision</div>
<div className="property-row">
<label>Detection</label>
<select
value={component.collisionDetection}
onChange={(e) => handleChange('collisionDetection', parseInt(e.target.value, 10) as CollisionDetectionMode2D)}
className="property-select"
>
<option value={CollisionDetectionMode2D.Discrete}>Discrete</option>
<option value={CollisionDetectionMode2D.Continuous}>Continuous</option>
</select>
</div>
{/* Sleep */}
<div className="section-subtitle">Sleep</div>
<div className="property-row">
<label>Can Sleep</label>
<input
type="checkbox"
checked={component.canSleep}
onChange={(e) => handleChange('canSleep', e.target.checked)}
className="property-checkbox"
/>
</div>
{/* Runtime Info (read-only) */}
<div className="section-subtitle">Runtime Info</div>
<div className="property-row">
<label>Velocity</label>
<span className="property-readonly">
({component.velocity.x.toFixed(2)}, {component.velocity.y.toFixed(2)})
</span>
</div>
<div className="property-row">
<label>Angular Vel</label>
<span className="property-readonly">
{component.angularVelocity.toFixed(2)} rad/s
</span>
</div>
<div className="property-row">
<label>Is Awake</label>
<span className="property-readonly">
{component.isAwake ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
);
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<Physics2DConfig> {
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;
}
}

View File

@@ -0,0 +1,5 @@
/**
* Physics 2D Services exports
*/
export { Physics2DService } from './Physics2DService';

View File

@@ -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<Physics2DConfig>;
/** 是否在 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<number, { bodyHandle: number; colliderHandles: number[] }> = 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<void> {
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;
}
}

View File

@@ -0,0 +1,5 @@
/**
* Physics 2D Systems exports
*/
export { Physics2DSystem, type Physics2DSystemConfig } from './Physics2DSystem';

View File

@@ -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 };
}

View File

@@ -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
}

View File

@@ -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';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
/**
* Physics 2D World exports
*/
export { Physics2DWorld, type Physics2DWorldState } from './Physics2DWorld';

View File

@@ -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" }
]
}

View File

@@ -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
}
});