创建碰撞与路径

This commit is contained in:
tianmo committed 2026-08-28 18:24:29 +08:00
1 parent f2fe653c04
commit f27167621c
4 files changed
+783 -56

No files matched your search

+167 -7
View File
@@ -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)))
}