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