diff --git a/packages/particle/package.json b/packages/particle/package.json index 5ef8b53c..44b260f2 100644 --- a/packages/particle/package.json +++ b/packages/particle/package.json @@ -30,10 +30,19 @@ "dependencies": { "@esengine/asset-system": "workspace:*" }, + "peerDependencies": { + "@esengine/physics-rapier2d": "workspace:*" + }, + "peerDependenciesMeta": { + "@esengine/physics-rapier2d": { + "optional": true + } + }, "devDependencies": { "@esengine/ecs-framework": "workspace:*", "@esengine/ecs-framework-math": "workspace:*", "@esengine/engine-core": "workspace:*", + "@esengine/physics-rapier2d": "workspace:*", "@esengine/build-config": "workspace:*", "rimraf": "^5.0.5", "tsup": "^8.0.0", diff --git a/packages/particle/src/ParticleRuntimeModule.ts b/packages/particle/src/ParticleRuntimeModule.ts index e5c90047..58189af0 100644 --- a/packages/particle/src/ParticleRuntimeModule.ts +++ b/packages/particle/src/ParticleRuntimeModule.ts @@ -4,6 +4,7 @@ import { assetManager as globalAssetManager, type AssetManager } from '@esengine import { ParticleSystemComponent } from './ParticleSystemComponent'; import { ParticleUpdateSystem } from './systems/ParticleSystem'; import { ParticleLoader, ParticleAssetType } from './loaders/ParticleLoader'; +import type { IPhysics2DQuery } from './modules/Physics2DCollisionModule'; export type { SystemContext, ModuleManifest, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader }; @@ -42,6 +43,17 @@ export interface ParticleSystemContext extends SystemContext { engineBridge?: IEngineBridge; /** 资产管理器(用于注册加载器)| Asset manager (for registering loaders) */ assetManager?: AssetManager; + /** + * 2D 物理查询接口(可选) + * 2D Physics query interface (optional) + * + * 如果提供,将自动注入到使用 Physics2DCollisionModule 的粒子系统中。 + * 通常传入 Physics2DService 实例。 + * + * If provided, will be auto-injected into particle systems using Physics2DCollisionModule. + * Typically pass a Physics2DService instance. + */ + physics2DQuery?: IPhysics2DQuery; } class ParticleRuntimeModule implements IRuntimeModule { @@ -90,6 +102,11 @@ class ParticleRuntimeModule implements IRuntimeModule { this._updateSystem.setEngineBridge(particleContext.engineBridge); } + // 设置 2D 物理查询(用于粒子与场景碰撞)| Set 2D physics query (for particle-scene collision) + if (particleContext.physics2DQuery) { + this._updateSystem.setPhysics2DQuery(particleContext.physics2DQuery); + } + scene.addSystem(this._updateSystem); particleContext.particleUpdateSystem = this._updateSystem; diff --git a/packages/particle/src/ParticleSystemComponent.ts b/packages/particle/src/ParticleSystemComponent.ts index e05ae81d..b9d15429 100644 --- a/packages/particle/src/ParticleSystemComponent.ts +++ b/packages/particle/src/ParticleSystemComponent.ts @@ -7,6 +7,7 @@ import { ColorOverLifetimeModule } from './modules/ColorOverLifetimeModule'; import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule'; import { CollisionModule } from './modules/CollisionModule'; import { ForceFieldModule } from './modules/ForceFieldModule'; +import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule'; import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader'; // Re-export for backward compatibility @@ -751,7 +752,33 @@ export class ParticleSystemComponent extends Component { const normalizedAge = p.age / p.lifetime; for (const module of this._modules) { if (module.enabled) { - module.update(p, normalizedAge, dt); + module.update(p, dt, normalizedAge); + } + } + } + + // 处理碰撞模块标记的需销毁粒子 | Process particles marked for death by collision modules + for (const module of this._modules) { + if (module.enabled) { + // 处理边界碰撞模块 | Handle boundary collision module + if (module instanceof CollisionModule) { + const toKill = module.getParticlesToKill(); + for (const p of toKill) { + if (p.alive) { + particlesToRecycle.push(p); + } + } + module.clearDeathFlags(); + } + // 处理物理碰撞模块 | Handle physics collision module + if (module instanceof Physics2DCollisionModule) { + const toKill = module.getParticlesToKill(); + for (const p of toKill) { + if (p.alive) { + particlesToRecycle.push(p); + } + } + module.clearDeathFlags(); } } } diff --git a/packages/particle/src/index.ts b/packages/particle/src/index.ts index 06a2bc7e..ebead23f 100644 --- a/packages/particle/src/index.ts +++ b/packages/particle/src/index.ts @@ -41,7 +41,12 @@ export { ForceFieldModule, ForceFieldType, createDefaultForceField, - type ForceField + type ForceField, + // Physics 2D collision module | 2D 物理碰撞模块 + Physics2DCollisionModule, + Physics2DCollisionBehavior, + type IPhysics2DQuery, + type ParticleCollisionInfo } from './modules'; // Rendering diff --git a/packages/particle/src/modules/Physics2DCollisionModule.ts b/packages/particle/src/modules/Physics2DCollisionModule.ts new file mode 100644 index 00000000..14b8ece3 --- /dev/null +++ b/packages/particle/src/modules/Physics2DCollisionModule.ts @@ -0,0 +1,380 @@ +import type { Particle } from '../Particle'; +import type { IParticleModule } from './IParticleModule'; + +/** + * 物理碰撞行为 + * Physics collision behavior + */ +export enum Physics2DCollisionBehavior { + /** 销毁粒子 | Kill particle */ + Kill = 'kill', + /** 反弹 | Bounce */ + Bounce = 'bounce', + /** 停止运动 | Stop movement */ + Stop = 'stop' +} + +/** + * 物理查询接口 + * Physics query interface + * + * 抽象物理服务查询,避免直接依赖 physics-rapier2d 包 + * Abstract physics service query to avoid direct dependency on physics-rapier2d + */ +export interface IPhysics2DQuery { + /** + * 圆形重叠检测 + * Circle overlap detection + * + * @param center - 圆心位置 | Center position + * @param radius - 半径 | Radius + * @param collisionMask - 碰撞掩码 | Collision mask + * @returns 重叠结果 | Overlap result + */ + overlapCircle( + center: { x: number; y: number }, + radius: number, + collisionMask?: number + ): { entityIds: number[]; colliderHandles: number[] }; + + /** + * 射线检测 + * Raycast detection + * + * @param origin - 起点 | Origin + * @param direction - 方向(归一化)| Direction (normalized) + * @param maxDistance - 最大距离 | Max distance + * @param collisionMask - 碰撞掩码 | Collision mask + * @returns 射线结果或 null | Raycast result or null + */ + raycast( + origin: { x: number; y: number }, + direction: { x: number; y: number }, + maxDistance: number, + collisionMask?: number + ): { + entityId: number; + point: { x: number; y: number }; + normal: { x: number; y: number }; + distance: number; + colliderHandle: number; + } | null; +} + +/** + * 碰撞回调数据 + * Collision callback data + */ +export interface ParticleCollisionInfo { + /** 粒子引用 | Particle reference */ + particle: Particle; + /** 碰撞位置 | Collision point */ + point: { x: number; y: number }; + /** 碰撞法线 | Collision normal */ + normal: { x: number; y: number }; + /** 碰撞的实体 ID | Collided entity ID */ + entityId: number; + /** 碰撞体句柄 | Collider handle */ + colliderHandle: number; +} + +/** + * 2D 物理碰撞模块 + * 2D Physics collision module + * + * 使用 Physics2DService 的查询 API 检测粒子与场景碰撞体的碰撞。 + * Uses Physics2DService query API to detect particle collisions with scene colliders. + * + * @example + * ```typescript + * // 获取物理服务 + * const physicsService = scene.services.resolve(Physics2DService); + * + * // 创建模块 + * const collisionModule = new Physics2DCollisionModule(); + * collisionModule.setPhysicsQuery(physicsService); + * collisionModule.particleRadius = 4; + * collisionModule.behavior = Physics2DCollisionBehavior.Bounce; + * + * // 添加到粒子系统 + * particleSystem.addModule(collisionModule); + * + * // 监听碰撞事件 + * collisionModule.onCollision = (info) => { + * console.log('Particle hit entity:', info.entityId); + * }; + * ``` + */ +export class Physics2DCollisionModule implements IParticleModule { + readonly name = 'Physics2DCollision'; + enabled = true; + + // ============= 物理查询 | Physics Query ============= + + /** 物理查询接口 | Physics query interface */ + private _physicsQuery: IPhysics2DQuery | null = null; + + // ============= 碰撞设置 | Collision Settings ============= + + /** + * 粒子碰撞半径 + * Particle collision radius + * + * 用于圆形重叠检测的半径 + */ + particleRadius: number = 4; + + /** + * 碰撞行为 + * Collision behavior + */ + behavior: Physics2DCollisionBehavior = Physics2DCollisionBehavior.Bounce; + + /** + * 碰撞层掩码 + * Collision layer mask + * + * 默认 0xFFFF 表示与所有层碰撞 + * Default 0xFFFF means collide with all layers + */ + collisionMask: number = 0xFFFF; + + /** + * 反弹系数 (0-1) + * Bounce factor (0-1) + * + * 1 = 完全弹性,0 = 无弹性 + * 1 = fully elastic, 0 = no bounce + */ + bounceFactor: number = 0.6; + + /** + * 反弹时的生命损失 (0-1) + * Life loss on bounce (0-1) + */ + lifeLossOnBounce: number = 0; + + /** + * 最小速度阈值 + * Minimum velocity threshold + * + * 低于此速度时销毁粒子(防止无限小弹跳) + * Kill particle when velocity falls below this (prevents infinite tiny bounces) + */ + minVelocityThreshold: number = 5; + + /** + * 使用射线检测代替重叠检测 + * Use raycast instead of overlap detection + * + * 射线检测更精确,可防止快速粒子穿透,但性能开销更大 + * Raycast is more accurate and prevents fast particle tunneling, but more expensive + */ + useRaycast: boolean = false; + + /** + * 检测频率(每 N 帧检测一次) + * Detection frequency (detect every N frames) + * + * 增大此值可提高性能,但降低精度 + * Increase to improve performance at cost of accuracy + */ + detectionInterval: number = 1; + + // ============= 内部状态 | Internal State ============= + + /** 帧计数器 | Frame counter */ + private _frameCounter: number = 0; + + /** 需要销毁的粒子 | Particles to kill */ + private _particlesToKill: Set = new Set(); + + // ============= 回调 | Callbacks ============= + + /** + * 碰撞回调 + * Collision callback + * + * 每次粒子碰撞时调用 + */ + onCollision: ((info: ParticleCollisionInfo) => void) | null = null; + + // ============= 公开方法 | Public Methods ============= + + /** + * 设置物理查询接口 + * Set physics query interface + * + * @param query - 物理查询接口(通常是 Physics2DService)| Physics query (usually Physics2DService) + */ + setPhysicsQuery(query: IPhysics2DQuery | null): void { + this._physicsQuery = query; + } + + /** + * 获取需要销毁的粒子 + * Get particles to kill + */ + getParticlesToKill(): Set { + return this._particlesToKill; + } + + /** + * 清除死亡标记 + * Clear death flags + */ + clearDeathFlags(): void { + this._particlesToKill.clear(); + } + + /** + * 重置帧计数器 + * Reset frame counter + */ + resetFrameCounter(): void { + this._frameCounter = 0; + } + + // ============= IParticleModule 实现 | IParticleModule Implementation ============= + + update(p: Particle, dt: number, _normalizedAge: number): void { + if (!this._physicsQuery) return; + + // 检测频率控制 | Detection frequency control + this._frameCounter++; + if (this._frameCounter % this.detectionInterval !== 0) { + return; + } + + if (this.useRaycast) { + this._updateWithRaycast(p, dt); + } else { + this._updateWithOverlap(p); + } + } + + // ============= 私有方法 | Private Methods ============= + + /** + * 使用圆形重叠检测 + * Update using circle overlap detection + */ + private _updateWithOverlap(p: Particle): void { + if (!this._physicsQuery) return; + + const result = this._physicsQuery.overlapCircle( + { x: p.x, y: p.y }, + this.particleRadius, + this.collisionMask + ); + + if (result.entityIds.length > 0) { + // 发生碰撞 | Collision occurred + const entityId = result.entityIds[0]; + const colliderHandle = result.colliderHandles[0]; + + // 估算法线(从粒子速度反向)| Estimate normal (from particle velocity) + const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); + const normal = speed > 0.001 + ? { x: -p.vx / speed, y: -p.vy / speed } + : { x: 0, y: 1 }; + + this._handleCollision(p, { x: p.x, y: p.y }, normal, entityId, colliderHandle); + } + } + + /** + * 使用射线检测 + * Update using raycast detection + */ + private _updateWithRaycast(p: Particle, dt: number): void { + if (!this._physicsQuery) return; + + const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); + if (speed < 0.001) return; + + // 归一化方向 | Normalize direction + const direction = { x: p.vx / speed, y: p.vy / speed }; + const distance = speed * dt + this.particleRadius; + + const hit = this._physicsQuery.raycast( + { x: p.x, y: p.y }, + direction, + distance, + this.collisionMask + ); + + if (hit) { + this._handleCollision(p, hit.point, hit.normal, hit.entityId, hit.colliderHandle); + } + } + + /** + * 处理碰撞 + * Handle collision + */ + private _handleCollision( + p: Particle, + point: { x: number; y: number }, + normal: { x: number; y: number }, + entityId: number, + colliderHandle: number + ): void { + // 触发回调 | Trigger callback + if (this.onCollision) { + this.onCollision({ + particle: p, + point, + normal, + entityId, + colliderHandle + }); + } + + switch (this.behavior) { + case Physics2DCollisionBehavior.Kill: + this._particlesToKill.add(p); + break; + + case Physics2DCollisionBehavior.Bounce: + this._applyBounce(p, normal); + break; + + case Physics2DCollisionBehavior.Stop: + p.vx = 0; + p.vy = 0; + break; + } + } + + /** + * 应用反弹 + * Apply bounce effect + */ + private _applyBounce(p: Particle, normal: { x: number; y: number }): void { + // 反射公式: v' = v - 2 * (v · n) * n + // Reflection formula: v' = v - 2 * (v · n) * n + const dot = p.vx * normal.x + p.vy * normal.y; + + // 只在粒子朝向表面时反弹 | Only bounce if particle is moving towards surface + if (dot < 0) { + p.vx = (p.vx - 2 * dot * normal.x) * this.bounceFactor; + p.vy = (p.vy - 2 * dot * normal.y) * this.bounceFactor; + + // 稍微移开粒子防止重复碰撞 | Move particle slightly away to prevent repeated collision + p.x += normal.x * this.particleRadius * 0.1; + p.y += normal.y * this.particleRadius * 0.1; + } + + // 应用生命损失 | Apply life loss + if (this.lifeLossOnBounce > 0) { + p.lifetime *= (1 - this.lifeLossOnBounce); + } + + // 检查最小速度 | Check minimum velocity + const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); + if (speed < this.minVelocityThreshold) { + this._particlesToKill.add(p); + } + } +} diff --git a/packages/particle/src/modules/index.ts b/packages/particle/src/modules/index.ts index 294e7469..3a58643b 100644 --- a/packages/particle/src/modules/index.ts +++ b/packages/particle/src/modules/index.ts @@ -20,3 +20,9 @@ export { createDefaultForceField, type ForceField } from './ForceFieldModule'; +export { + Physics2DCollisionModule, + Physics2DCollisionBehavior, + type IPhysics2DQuery, + type ParticleCollisionInfo +} from './Physics2DCollisionModule'; diff --git a/packages/particle/src/systems/ParticleSystem.ts b/packages/particle/src/systems/ParticleSystem.ts index d0c2d4f3..1c5ad1f3 100644 --- a/packages/particle/src/systems/ParticleSystem.ts +++ b/packages/particle/src/systems/ParticleSystem.ts @@ -2,6 +2,7 @@ import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-fr import { ParticleSystemComponent } from '../ParticleSystemComponent'; import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvider'; import type { IEngineIntegration, IEngineBridge } from '../ParticleRuntimeModule'; +import { Physics2DCollisionModule, type IPhysics2DQuery } from '../modules/Physics2DCollisionModule'; /** * 默认粒子纹理 ID @@ -69,12 +70,15 @@ export class ParticleUpdateSystem extends EntitySystem { private _renderDataProvider: ParticleRenderDataProvider; private _engineIntegration: IEngineIntegration | null = null; private _engineBridge: IEngineBridge | null = null; + private _physics2DQuery: IPhysics2DQuery | null = null; private _defaultTextureLoaded: boolean = false; private _defaultTextureLoading: boolean = false; /** 追踪每个粒子组件上次加载的资产 GUID | Track last loaded asset GUID for each particle component */ private _lastLoadedGuids: WeakMap = new WeakMap(); /** 正在加载资产的粒子组件 | Particle components currently loading assets */ private _loadingComponents: WeakSet = new WeakSet(); + /** 已注入物理查询的粒子组件 | Particle components with physics query injected */ + private _physicsInjectedComponents: WeakSet = new WeakSet(); constructor() { super(Matcher.empty().all(ParticleSystemComponent)); @@ -107,6 +111,19 @@ export class ParticleUpdateSystem extends EntitySystem { this._engineBridge = bridge; } + /** + * 设置 2D 物理查询接口 + * Set 2D physics query interface + * + * 如果设置,将自动注入到所有使用 Physics2DCollisionModule 的粒子系统。 + * If set, will be auto-injected into all particle systems using Physics2DCollisionModule. + * + * @param query - 物理查询接口(通常是 Physics2DService)| Physics query (usually Physics2DService) + */ + setPhysics2DQuery(query: IPhysics2DQuery | null): void { + this._physics2DQuery = query; + } + /** * 获取渲染数据提供者 * Get render data provider @@ -164,6 +181,11 @@ export class ParticleUpdateSystem extends EntitySystem { // This allows property changes to take effect immediately in the editor particle.ensureBuilt(); + // 自动注入物理查询到 Physics2DCollisionModule | Auto-inject physics query to Physics2DCollisionModule + if (this._physics2DQuery && !this._physicsInjectedComponents.has(particle)) { + this._injectPhysics2DQuery(particle); + } + // 更新粒子系统 | Update particle system if (particle.isPlaying) { particle.update(deltaTime, worldX, worldY, worldRotation, worldScaleX, worldScaleY); @@ -366,9 +388,27 @@ export class ParticleUpdateSystem extends EntitySystem { if (particle) { // 从渲染数据提供者注销 | Unregister from render data provider this._renderDataProvider.unregister(particle); + // 清除物理注入标记 | Clear physics injection mark + this._physicsInjectedComponents.delete(particle); } } + /** + * 注入物理查询到粒子系统的 Physics2DCollisionModule + * Inject physics query into particle system's Physics2DCollisionModule + */ + private _injectPhysics2DQuery(particle: ParticleSystemComponent): void { + if (!this._physics2DQuery) return; + + for (const module of particle.modules) { + if (module instanceof Physics2DCollisionModule) { + module.setPhysicsQuery(this._physics2DQuery); + } + } + + this._physicsInjectedComponents.add(particle); + } + /** * 系统销毁时清理 * Cleanup on system destroy diff --git a/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts b/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts index e2034a52..c59e2d51 100644 --- a/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts +++ b/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts @@ -21,6 +21,34 @@ import { Physics2DService } from './services/Physics2DService'; // 注册 Rapier2D 加载器 import './loaders'; +/** + * 2D 物理查询接口 + * 2D Physics query interface + * + * 用于粒子系统等模块查询物理世界 + * Used by particle system and other modules to query physics world + */ +export interface IPhysics2DQuery { + overlapCircle( + center: { x: number; y: number }, + radius: number, + collisionMask?: number + ): { entityIds: number[]; colliderHandles: number[] }; + + raycast( + origin: { x: number; y: number }, + direction: { x: number; y: number }, + maxDistance: number, + collisionMask?: number + ): { + entityId: number; + point: { x: number; y: number }; + normal: { x: number; y: number }; + distance: number; + colliderHandle: number; + } | null; +} + /** * 物理系统上下文扩展 */ @@ -39,6 +67,18 @@ export interface PhysicsSystemContext extends SystemContext { * 物理配置 */ physicsConfig?: any; + + /** + * 2D 物理查询接口 + * 2D Physics query interface + * + * 供粒子系统等模块使用,用于检测粒子与物理碰撞体的碰撞。 + * 实际上是 Physics2DSystem 的引用,因为它实现了 IPhysics2DQuery 接口。 + * + * For particle system and other modules to detect collision with physics colliders. + * Actually a reference to Physics2DSystem which implements IPhysics2DQuery interface. + */ + physics2DQuery?: IPhysics2DQuery; } /** @@ -143,6 +183,9 @@ class PhysicsRuntimeModule implements IRuntimeModule { physicsContext.physicsSystem = physicsSystem; physicsContext.physics2DWorld = physicsSystem.world; + // 同时暴露 physics2DQuery,供粒子系统等模块使用 + // Also expose physics2DQuery for particle system and other modules + physicsContext.physics2DQuery = physicsSystem; } /** diff --git a/packages/physics-rapier2d/src/index.ts b/packages/physics-rapier2d/src/index.ts index 5383ab60..5e67c1f4 100644 --- a/packages/physics-rapier2d/src/index.ts +++ b/packages/physics-rapier2d/src/index.ts @@ -27,3 +27,6 @@ export { Physics2DPlugin } from './PhysicsEditorPlugin'; // Runtime plugin (for game builds) export { PhysicsPlugin } from './PhysicsRuntimeModule'; + +// Physics query interface (for particle system integration) +export type { IPhysics2DQuery, PhysicsSystemContext } from './PhysicsRuntimeModule'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3287d6ee..8e98e938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1067,6 +1067,9 @@ importers: '@esengine/engine-core': specifier: workspace:* version: link:../engine-core + '@esengine/physics-rapier2d': + specifier: workspace:* + version: link:../physics-rapier2d rimraf: specifier: ^5.0.5 version: 5.0.10