创建碰撞与路径
This commit is contained in:
1 parent
f2fe653c04
commit
f27167621c
4 files changed
+783
-56
No files matched your search
+167
-7
@@ -14,6 +14,26 @@ export type ScaleAxisMode = 'uniform' | 'separate'
|
||||
export interface CurvePoint { x: number; y: number }
|
||||
export interface ColorGradientStop { position: number; color: string }
|
||||
|
||||
export type ColliderShape = 'circle' | 'ellipse' | 'rect' | 'polygon' | 'custom'
|
||||
export type CollisionResponse = 'physics' | 'pause' | 'destroy'
|
||||
export interface SceneCollider {
|
||||
id: number
|
||||
shape: ColliderShape
|
||||
enabled: boolean
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
radius: number
|
||||
radiusX: number
|
||||
radiusY: number
|
||||
width: number
|
||||
height: number
|
||||
sides: number
|
||||
response: CollisionResponse
|
||||
elasticity: number
|
||||
friction: number
|
||||
}
|
||||
|
||||
export interface ParticleImageResource {
|
||||
id: number
|
||||
texture: Texture<any> | null
|
||||
@@ -665,6 +685,7 @@ interface Particle {
|
||||
trailCurrentColor: number
|
||||
trailResourceId: number
|
||||
resourceId: number
|
||||
collisionPaused: boolean
|
||||
}
|
||||
|
||||
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
|
||||
@@ -689,6 +710,7 @@ export class ParticleEmitter extends Container {
|
||||
private rng: () => number = Math.random
|
||||
private _lastSeed = -1
|
||||
private trailDebugGfx = new Graphics()
|
||||
private colliders: SceneCollider[] = []
|
||||
|
||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||
super()
|
||||
@@ -737,11 +759,50 @@ export class ParticleEmitter extends Container {
|
||||
trailCurrentColor: 0xffffff,
|
||||
trailResourceId: 1,
|
||||
resourceId: 1,
|
||||
collisionPaused: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
|
||||
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
|
||||
|
||||
private applyColliders(p: Particle, particleIndex: number) {
|
||||
if (!this.colliders.length) return false
|
||||
const cfg = this.cfg
|
||||
let world = localToWorldMath(cfg, p.x, p.y)
|
||||
let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy)
|
||||
for (const collider of this.colliders) {
|
||||
if (!collider.enabled) continue
|
||||
const hit = resolveColliderPoint(collider, world.x, world.y)
|
||||
if (!hit) continue
|
||||
if (collider.response === 'destroy') {
|
||||
this.kill(particleIndex)
|
||||
return true
|
||||
}
|
||||
world = { x: hit.x, y: hit.y }
|
||||
if (collider.response === 'pause') {
|
||||
p.collisionPaused = true
|
||||
velocity = { x: 0, y: 0 }
|
||||
} else {
|
||||
const normalSpeed = velocity.x * hit.nx + velocity.y * hit.ny
|
||||
const tangentX = velocity.x - normalSpeed * hit.nx
|
||||
const tangentY = velocity.y - normalSpeed * hit.ny
|
||||
const elasticity = Math.min(1, Math.max(0, collider.elasticity))
|
||||
const tangentScale = 1 - Math.min(1, Math.max(0, collider.friction))
|
||||
const reflectedNormal = normalSpeed < 0 ? -normalSpeed * elasticity : normalSpeed
|
||||
velocity.x = tangentX * tangentScale + reflectedNormal * hit.nx
|
||||
velocity.y = tangentY * tangentScale + reflectedNormal * hit.ny
|
||||
}
|
||||
const local = worldMathToLocal(cfg, world.x, world.y)
|
||||
const localVelocity = worldVelocityToLocalMath(cfg, velocity.x, velocity.y)
|
||||
p.x = local.x
|
||||
p.y = local.y
|
||||
p.vx = localVelocity.x
|
||||
p.vy = localVelocity.y
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
|
||||
update(dt: number): ParticleState[] {
|
||||
@@ -791,7 +852,7 @@ export class ParticleEmitter extends Container {
|
||||
// 粒子自身生命周期优先:即使正在吸附途中,生命耗尽也立即死亡,不因吸附时长延寿。
|
||||
if (p.life <= 0) { this.kill(i); continue }
|
||||
const lifeT = tFromLife(p)
|
||||
if (cfg.useGravity) {
|
||||
if (!p.collisionPaused && cfg.useGravity) {
|
||||
const gravityMultiplier = Math.min(1, Math.max(0, curveAt(cfg.gravityCurve, lifeT)))
|
||||
const gravityForce = (cfg.gravityMode === 'random' ? p.gravityStrength : cfg.gravityMin) * gravityMultiplier
|
||||
const gravityAngle = cfg.gravityDirection * Math.PI / 180
|
||||
@@ -799,7 +860,7 @@ export class ParticleEmitter extends Container {
|
||||
p.vy += -Math.sin(gravityAngle) * gravityForce * dt
|
||||
}
|
||||
// 风力
|
||||
if (cfg.wind) {
|
||||
if (!p.collisionPaused && cfg.wind) {
|
||||
const windMultiplier = Math.min(1, Math.max(0, curveAt(cfg.windCurve, lifeT)))
|
||||
const windForce = (cfg.windMode === 'random' ? p.windStrength : cfg.windMin) * windMultiplier
|
||||
const windAngle = cfg.windDirection * Math.PI / 180
|
||||
@@ -807,7 +868,7 @@ export class ParticleEmitter extends Container {
|
||||
p.vy += -Math.sin(windAngle) * windForce * dt
|
||||
}
|
||||
// 全局力场:先把粒子局部坐标转换到场景数学坐标,计算力后再转回发射器局部坐标。
|
||||
if (cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) {
|
||||
if (!p.collisionPaused && cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) {
|
||||
const rootScaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const rootScaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const rootAngle = -cfg.rootRotation * Math.PI / 180
|
||||
@@ -833,15 +894,21 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
}
|
||||
// 阻尼:速度按衰减系数逐渐减小
|
||||
if (cfg.damping > 0) {
|
||||
if (!p.collisionPaused && cfg.damping > 0) {
|
||||
const f = Math.max(0, 1 - cfg.damping * dt)
|
||||
p.vx *= f; p.vy *= f
|
||||
}
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
if (!p.collisionPaused) {
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
if (this.applyColliders(p, i)) continue
|
||||
} else {
|
||||
p.vx = 0
|
||||
p.vy = 0
|
||||
}
|
||||
|
||||
// 吸附使用场景全局数学坐标。延迟结束时锁定当前位置,随后沿直线或确定性弧线到达目标。
|
||||
if (cfg.attraction) {
|
||||
if (cfg.attraction && !p.collisionPaused) {
|
||||
const age = p.maxLife - p.life
|
||||
const delayReached = cfg.attractionDelayMode === 'particle'
|
||||
? age >= Math.max(0, cfg.attractionDelay)
|
||||
@@ -1240,6 +1307,7 @@ export class ParticleEmitter extends Container {
|
||||
: cfg.windMin
|
||||
p.attractionActive = false
|
||||
p.attractionElapsed = 0
|
||||
p.collisionPaused = false
|
||||
p.attractionCurveSide = seededUnit(cfg.seed, idx, 0xa341316c) < 0.5 ? -1 : 1
|
||||
p.trailHistory.length = 0
|
||||
p.trailBones.length = 0
|
||||
@@ -1452,6 +1520,98 @@ function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number) {
|
||||
y: (-sin * screenX + cos * screenY) / scaleY,
|
||||
}
|
||||
}
|
||||
function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = cos * scaleX * vx - sin * scaleY * vy
|
||||
const screenY = sin * scaleX * vx + cos * scaleY * vy
|
||||
return { x: screenX, y: -screenY }
|
||||
}
|
||||
function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenY = -vy
|
||||
return {
|
||||
x: (cos * vx + sin * screenY) / scaleX,
|
||||
y: (-sin * vx + cos * screenY) / scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
type ColliderHit = { x: number; y: number; nx: number; ny: number }
|
||||
function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null {
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const dx = worldX - collider.x, dy = worldY - collider.y
|
||||
const localX = cos * dx + sin * dy
|
||||
const localY = -sin * dx + cos * dy
|
||||
const toWorld = (x: number, y: number, nx: number, ny: number): ColliderHit => ({
|
||||
x: collider.x + cos * x - sin * y + (cos * nx - sin * ny) * 0.05,
|
||||
y: collider.y + sin * x + cos * y + (sin * nx + cos * ny) * 0.05,
|
||||
nx: cos * nx - sin * ny,
|
||||
ny: sin * nx + cos * ny,
|
||||
})
|
||||
if (collider.shape === 'circle') {
|
||||
const radius = Math.max(0.001, collider.radius)
|
||||
const distance = Math.hypot(localX, localY)
|
||||
if (distance >= radius) return null
|
||||
const nx = distance > 0.0001 ? localX / distance : 1
|
||||
const ny = distance > 0.0001 ? localY / distance : 0
|
||||
return toWorld(nx * radius, ny * radius, nx, ny)
|
||||
}
|
||||
if (collider.shape === 'ellipse') {
|
||||
const radiusX = Math.max(0.001, collider.radiusX)
|
||||
const radiusY = Math.max(0.001, collider.radiusY)
|
||||
const normalized = Math.hypot(localX / radiusX, localY / radiusY)
|
||||
if (normalized >= 1) return null
|
||||
const scale = normalized > 0.0001 ? 1 / normalized : 1
|
||||
const x = normalized > 0.0001 ? localX * scale : radiusX
|
||||
const y = normalized > 0.0001 ? localY * scale : 0
|
||||
const gradientLength = Math.max(0.0001, Math.hypot(x / (radiusX * radiusX), y / (radiusY * radiusY)))
|
||||
return toWorld(x, y, x / (radiusX * radiusX) / gradientLength, y / (radiusY * radiusY) / gradientLength)
|
||||
}
|
||||
if (collider.shape === 'rect') {
|
||||
const halfWidth = Math.max(0.001, collider.width * 0.5)
|
||||
const halfHeight = Math.max(0.001, collider.height * 0.5)
|
||||
if (Math.abs(localX) >= halfWidth || Math.abs(localY) >= halfHeight) return null
|
||||
const penetrationX = halfWidth - Math.abs(localX)
|
||||
const penetrationY = halfHeight - Math.abs(localY)
|
||||
if (penetrationX < penetrationY) {
|
||||
const nx = localX >= 0 ? 1 : -1
|
||||
return toWorld(nx * halfWidth, localY, nx, 0)
|
||||
}
|
||||
const ny = localY >= 0 ? 1 : -1
|
||||
return toWorld(localX, ny * halfHeight, 0, ny)
|
||||
}
|
||||
const sides = Math.max(3, Math.min(16, Math.round(collider.sides || (collider.shape === 'custom' ? 5 : 6))))
|
||||
const radiusX = Math.max(0.001, collider.width * 0.5)
|
||||
const radiusY = Math.max(0.001, collider.height * 0.5)
|
||||
const points = Array.from({ length: sides }, (_, index) => {
|
||||
const theta = Math.PI * 0.5 + index / sides * Math.PI * 2
|
||||
return { x: Math.cos(theta) * radiusX, y: Math.sin(theta) * radiusY }
|
||||
})
|
||||
let inside = false
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const a = points[i], b = points[j]
|
||||
if ((a.y > localY) !== (b.y > localY) && localX < (b.x - a.x) * (localY - a.y) / (b.y - a.y) + a.x) inside = !inside
|
||||
}
|
||||
if (!inside) return null
|
||||
let nearestX = points[0].x, nearestY = points[0].y, nearestDistance = Infinity
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i], b = points[(i + 1) % points.length]
|
||||
const edgeX = b.x - a.x, edgeY = b.y - a.y
|
||||
const edgeLengthSq = Math.max(0.0001, edgeX * edgeX + edgeY * edgeY)
|
||||
const amount = Math.min(1, Math.max(0, ((localX - a.x) * edgeX + (localY - a.y) * edgeY) / edgeLengthSq))
|
||||
const x = a.x + edgeX * amount, y = a.y + edgeY * amount
|
||||
const distance = Math.hypot(x - localX, y - localY)
|
||||
if (distance < nearestDistance) { nearestDistance = distance; nearestX = x; nearestY = y }
|
||||
}
|
||||
const normalLength = Math.max(0.0001, Math.hypot(nearestX - localX, nearestY - localY))
|
||||
return toWorld(nearestX, nearestY, (nearestX - localX) / normalLength, (nearestY - localY) / normalLength)
|
||||
}
|
||||
function tFromLife(particle: Pick<Particle, 'life' | 'maxLife'>) {
|
||||
return Math.min(1, Math.max(0, 1 - particle.life / Math.max(0.0001, particle.maxLife)))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user