发射器形状新增
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
export type EmissionAngleMode = 'fixed' | 'random' | 'radial' | 'path-normal' | 'path-tangent'
|
||||
|
||||
export interface EmitterShapeSettings {
|
||||
shape: 'point' | 'circle' | 'rect' | 'cone' | 'path' | 'orbit'
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
innerRectW: number
|
||||
innerRectH: number
|
||||
outerRectW: number
|
||||
outerRectH: number
|
||||
coneAngle: number
|
||||
coneDirection: number
|
||||
direction: number
|
||||
spread: number
|
||||
emissionAngleMode: EmissionAngleMode
|
||||
}
|
||||
|
||||
export interface EmitterShapeSample {
|
||||
/** 屏幕局部坐标,Y 向下。 */
|
||||
x: number
|
||||
y: number
|
||||
/** 从形状中心指向出生点的屏幕角度。 */
|
||||
radialAngle: number
|
||||
/** 路径在出生点处的屏幕局部切线角度。 */
|
||||
pathAngle?: number
|
||||
}
|
||||
|
||||
export function normalizedRadialRange(innerValue: number, outerValue: number) {
|
||||
const first = Math.max(0, Number(innerValue) || 0)
|
||||
const second = Math.max(0, Number(outerValue) || 0)
|
||||
return { inner: Math.min(first, second), outer: Math.max(first, second) }
|
||||
}
|
||||
|
||||
export function normalizedRectRange(config: Pick<EmitterShapeSettings, 'innerRectW' | 'innerRectH' | 'outerRectW' | 'outerRectH'>) {
|
||||
const outerW = Math.max(0, Number(config.outerRectW) || 0)
|
||||
const outerH = Math.max(0, Number(config.outerRectH) || 0)
|
||||
return {
|
||||
innerW: Math.min(outerW, Math.max(0, Number(config.innerRectW) || 0)),
|
||||
innerH: Math.min(outerH, Math.max(0, Number(config.innerRectH) || 0)),
|
||||
outerW,
|
||||
outerH,
|
||||
}
|
||||
}
|
||||
|
||||
export function pathDistributionProgress(
|
||||
mode: 'fixed' | 'random',
|
||||
spawnSerial: number,
|
||||
particleCount: number,
|
||||
closed: boolean,
|
||||
random: () => number,
|
||||
) {
|
||||
if (mode === 'random') return random()
|
||||
const count = Math.max(1, Math.round(particleCount))
|
||||
const index = Math.max(0, Math.round(spawnSerial)) % count
|
||||
if (closed) return index / count
|
||||
return count <= 1 ? 0.5 : index / (count - 1)
|
||||
}
|
||||
|
||||
function sampleAnnulus(inner: number, outer: number, angle: number, random: () => number): EmitterShapeSample {
|
||||
const radius = Math.sqrt(inner * inner + random() * (outer * outer - inner * inner))
|
||||
return {
|
||||
x: Math.cos(angle) * radius,
|
||||
y: -Math.sin(angle) * radius,
|
||||
radialAngle: -angle * 180 / Math.PI,
|
||||
}
|
||||
}
|
||||
|
||||
function sampleRectRing(config: EmitterShapeSettings, random: () => number): EmitterShapeSample {
|
||||
const { innerW, innerH, outerW, outerH } = normalizedRectRange(config)
|
||||
const sideWidth = Math.max(0, (outerW - innerW) / 2)
|
||||
const capHeight = Math.max(0, (outerH - innerH) / 2)
|
||||
const sideArea = sideWidth * outerH
|
||||
const capArea = innerW * capHeight
|
||||
const totalArea = sideArea * 2 + capArea * 2
|
||||
if (totalArea <= 0) {
|
||||
const perimeter = outerW * 2 + outerH * 2
|
||||
if (perimeter <= 0) return { x: 0, y: 0, radialAngle: 0 }
|
||||
let edge = random() * perimeter
|
||||
let x: number
|
||||
let y: number
|
||||
if (edge < outerW) {
|
||||
x = -outerW / 2 + edge
|
||||
y = -outerH / 2
|
||||
} else if ((edge -= outerW) < outerH) {
|
||||
x = outerW / 2
|
||||
y = -outerH / 2 + edge
|
||||
} else if ((edge -= outerH) < outerW) {
|
||||
x = outerW / 2 - edge
|
||||
y = outerH / 2
|
||||
} else {
|
||||
edge -= outerW
|
||||
x = -outerW / 2
|
||||
y = outerH / 2 - edge
|
||||
}
|
||||
return { x, y, radialAngle: Math.atan2(y, x) * 180 / Math.PI }
|
||||
}
|
||||
|
||||
let cursor = random() * totalArea
|
||||
let x = 0
|
||||
let y = 0
|
||||
if (cursor < sideArea) {
|
||||
x = -outerW / 2 + random() * sideWidth
|
||||
y = (random() - 0.5) * outerH
|
||||
} else if ((cursor -= sideArea) < sideArea) {
|
||||
x = innerW / 2 + random() * sideWidth
|
||||
y = (random() - 0.5) * outerH
|
||||
} else if ((cursor -= sideArea) < capArea) {
|
||||
x = (random() - 0.5) * innerW
|
||||
y = -outerH / 2 + random() * capHeight
|
||||
} else {
|
||||
x = (random() - 0.5) * innerW
|
||||
y = innerH / 2 + random() * capHeight
|
||||
}
|
||||
return { x, y, radialAngle: Math.atan2(y, x) * 180 / Math.PI }
|
||||
}
|
||||
|
||||
export function sampleEmitterShape(config: EmitterShapeSettings, random: () => number): EmitterShapeSample {
|
||||
if (config.shape === 'circle') {
|
||||
const { inner, outer } = normalizedRadialRange(config.innerRadius, config.outerRadius)
|
||||
return sampleAnnulus(inner, outer, random() * Math.PI * 2, random)
|
||||
}
|
||||
if (config.shape === 'rect') return sampleRectRing(config, random)
|
||||
if (config.shape === 'cone') {
|
||||
const { inner, outer } = normalizedRadialRange(config.innerRadius, config.outerRadius)
|
||||
const halfAngle = Math.max(0, Math.min(360, Number(config.coneAngle) || 0)) / 2
|
||||
const angleDegrees = (Number(config.coneDirection) || 0) + (random() - 0.5) * halfAngle * 2
|
||||
return sampleAnnulus(inner, outer, angleDegrees * Math.PI / 180, random)
|
||||
}
|
||||
return { x: 0, y: 0, radialAngle: 0 }
|
||||
}
|
||||
|
||||
export function sampleEmissionVelocityAngle(config: EmitterShapeSettings, sample: EmitterShapeSample, followAngle: number, random: () => number) {
|
||||
if (config.shape === 'path' || config.shape === 'orbit') {
|
||||
if (config.emissionAngleMode === 'path-tangent' && Number.isFinite(sample.pathAngle)) {
|
||||
return (sample.pathAngle! + followAngle) * Math.PI / 180
|
||||
}
|
||||
if (config.emissionAngleMode === 'path-normal' && Number.isFinite(sample.pathAngle)) {
|
||||
return (sample.pathAngle! - 90 + followAngle) * Math.PI / 180
|
||||
}
|
||||
}
|
||||
if (config.emissionAngleMode === 'radial' && config.shape !== 'point' && config.shape !== 'orbit') {
|
||||
return (sample.radialAngle + followAngle) * Math.PI / 180
|
||||
}
|
||||
const randomOffset = config.emissionAngleMode === 'random'
|
||||
? (random() - 0.5) * Math.max(0, Number(config.spread) || 0)
|
||||
: 0
|
||||
const mathematicalAngle = ((Number(config.direction) || 0) + followAngle + randomOffset) * Math.PI / 180
|
||||
// 旧配置中锥形使用数学角度,其余形状使用屏幕角度;保留这一方向约定以兼容既有工程。
|
||||
return config.shape === 'cone' ? -mathematicalAngle : mathematicalAngle
|
||||
}
|
||||
+91
-21
@@ -1,9 +1,10 @@
|
||||
import { Container, Sprite, Texture, IPointData, SimpleMesh, Graphics } from 'pixi.js'
|
||||
import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmissionAngleMode, type EmitterShapeSample } from './emitterShape'
|
||||
|
||||
/** 发射模式 */
|
||||
export type EmitMode = 'stream' | 'burst'
|
||||
/** 发射形状 */
|
||||
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit'
|
||||
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'path' | 'orbit'
|
||||
/** 混合模式 */
|
||||
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
|
||||
/** 粒子属性取值方式 */
|
||||
@@ -140,6 +141,20 @@ export interface EmitterConfig {
|
||||
/** 持续发射时长(秒)= 时间轴绿色段;爆发模式不使用该时长 */
|
||||
duration: number
|
||||
shape: EmitShape // 发射形状
|
||||
/** 发射范围结构版本,用于旧配置迁移。 */
|
||||
shapeRangeVersion: number
|
||||
/** 圆形和锥形发射范围的内外半径。 */
|
||||
innerRadius: number
|
||||
outerRadius: number
|
||||
/** 矩形发射范围:外矩形减去内矩形。 */
|
||||
innerRectW: number
|
||||
innerRectH: number
|
||||
outerRectW: number
|
||||
outerRectH: number
|
||||
/** 粒子出生路径;与修改器中的发射器整体跟随路径完全独立。 */
|
||||
spawnPathId: number
|
||||
spawnPathDistribution: 'fixed' | 'random'
|
||||
/** 旧版形状尺寸字段,保留用于配置迁移。 */
|
||||
radius: number
|
||||
rectW: number
|
||||
rectH: number
|
||||
@@ -151,8 +166,11 @@ export interface EmitterConfig {
|
||||
rootScaleX: number
|
||||
rootScaleY: number
|
||||
// 方向
|
||||
emissionAngleMode: EmissionAngleMode
|
||||
direction: number
|
||||
spread: number
|
||||
/** 锥形区域朝向;与粒子发射角度独立。 */
|
||||
coneDirection: number
|
||||
/** 锥形开口角度(发电器形状=锥体时,粒子在锥形扇区内均匀散开) */
|
||||
coneAngle: number
|
||||
// 属性
|
||||
@@ -423,6 +441,15 @@ export function defaultConfig(): EmitterConfig {
|
||||
delay: 0,
|
||||
duration: 1, // 持续发射时长;默认 1 秒(30f)。爆发模式运行时按 0 处理。
|
||||
shape: 'point',
|
||||
shapeRangeVersion: 1,
|
||||
innerRadius: 0,
|
||||
outerRadius: 30,
|
||||
innerRectW: 0,
|
||||
innerRectH: 0,
|
||||
outerRectW: 60,
|
||||
outerRectH: 40,
|
||||
spawnPathId: 0,
|
||||
spawnPathDistribution: 'fixed',
|
||||
radius: 30,
|
||||
rectW: 60,
|
||||
rectH: 40,
|
||||
@@ -431,8 +458,10 @@ export function defaultConfig(): EmitterConfig {
|
||||
rootRotation: 0,
|
||||
rootScaleX: 1,
|
||||
rootScaleY: 1,
|
||||
emissionAngleMode: 'random',
|
||||
direction: 0,
|
||||
spread: 360,
|
||||
coneDirection: 90,
|
||||
coneAngle: 60,
|
||||
lifeMin: 0.6,
|
||||
lifeMax: 1.8,
|
||||
@@ -604,6 +633,13 @@ export function defaultConfig(): EmitterConfig {
|
||||
export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
const defaults = defaultConfig() as unknown as Record<string, unknown>
|
||||
const target = config as unknown as Record<string, unknown>
|
||||
const needsShapeRangeMigration = target.shapeRangeVersion !== 1
|
||||
const legacyRadius = Number(target.radius)
|
||||
const legacyRectW = Number(target.rectW)
|
||||
const legacyRectH = Number(target.rectH)
|
||||
const legacyDirection = Number(target.direction)
|
||||
const legacySpread = Number(target.spread)
|
||||
const legacyShape = target.shape
|
||||
const needsImageWeightMigration = target.imageWeightVersion !== 1
|
||||
const needsLoopDurationMigration = target.loopDurationFrames == null
|
||||
const needsGravityMigration = target.gravityMode == null
|
||||
@@ -625,6 +661,30 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item)
|
||||
: value
|
||||
}
|
||||
if (needsShapeRangeMigration) {
|
||||
config.innerRadius = 0
|
||||
config.outerRadius = Math.max(0, Number.isFinite(legacyRadius) ? legacyRadius : defaults.outerRadius as number)
|
||||
config.innerRectW = 0
|
||||
config.innerRectH = 0
|
||||
config.outerRectW = Math.max(0, Number.isFinite(legacyRectW) ? legacyRectW : defaults.outerRectW as number)
|
||||
config.outerRectH = Math.max(0, Number.isFinite(legacyRectH) ? legacyRectH : defaults.outerRectH as number)
|
||||
config.coneDirection = Number.isFinite(legacyDirection) ? legacyDirection : 90
|
||||
config.emissionAngleMode = Number.isFinite(legacySpread) && legacySpread > 0 ? 'random' : 'fixed'
|
||||
if (legacyShape === 'cone') config.spread = Math.max(0, Number(config.coneAngle) || 0)
|
||||
config.shapeRangeVersion = 1
|
||||
}
|
||||
if (config.shape === 'orbit') config.shape = 'path'
|
||||
if (!['fixed', 'random', 'radial', 'path-normal', 'path-tangent'].includes(config.emissionAngleMode)) config.emissionAngleMode = 'fixed'
|
||||
if (!['circle', 'rect', 'cone'].includes(config.shape) && config.emissionAngleMode === 'radial') config.emissionAngleMode = 'fixed'
|
||||
if (config.shape !== 'path' && (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent')) config.emissionAngleMode = 'fixed'
|
||||
config.spawnPathId = Math.max(0, Math.round(Number(config.spawnPathId) || 0))
|
||||
if (config.spawnPathDistribution !== 'fixed' && config.spawnPathDistribution !== 'random') config.spawnPathDistribution = 'fixed'
|
||||
config.innerRadius = Math.max(0, Number(config.innerRadius) || 0)
|
||||
config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0)
|
||||
config.innerRectW = Math.max(0, Number(config.innerRectW) || 0)
|
||||
config.innerRectH = Math.max(0, Number(config.innerRectH) || 0)
|
||||
config.outerRectW = Math.max(config.innerRectW, Number(config.outerRectW) || 0)
|
||||
config.outerRectH = Math.max(config.innerRectH, Number(config.outerRectH) || 0)
|
||||
if (config.streamBehavior !== 'normal' && config.streamBehavior !== 'loop') config.streamBehavior = 'normal'
|
||||
if (needsLoopDurationMigration || !Number.isFinite(config.loopDurationFrames)) config.loopDurationFrames = Math.ceil(Math.max(1 / 30, Number(config.duration) || 1) * 30)
|
||||
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
|
||||
@@ -869,6 +929,8 @@ export class ParticleEmitter extends Container {
|
||||
private followSpawnX = 0
|
||||
private followSpawnY = 0
|
||||
private followSpawnAngle = 0
|
||||
private spawnPathSampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null = null
|
||||
private spawnPathClosed = false
|
||||
private followExportCurveMode: '线性' | '贝塞尔' = '线性'
|
||||
private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null
|
||||
|
||||
@@ -936,6 +998,10 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
|
||||
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
|
||||
setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) {
|
||||
this.spawnPathSampler = sampler
|
||||
this.spawnPathClosed = closed
|
||||
}
|
||||
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
|
||||
setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) {
|
||||
this.followSpawnX = x
|
||||
@@ -989,6 +1055,7 @@ export class ParticleEmitter extends Container {
|
||||
const cfg = this.cfg
|
||||
if (
|
||||
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
|
||||
cfg.shapeRangeVersion !== 1 ||
|
||||
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
|
||||
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
|
||||
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null
|
||||
@@ -1484,23 +1551,31 @@ export class ParticleEmitter extends Container {
|
||||
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
|
||||
}
|
||||
|
||||
private spawnOrigin(): { x: number; y: number } {
|
||||
private spawnOrigin(): EmitterShapeSample {
|
||||
const cfg = this.cfg
|
||||
// 局部原点 = 0(root 中心,centerX 由发射器容器 position 补回);粒子相对 root 中心 = 偏移X/Y
|
||||
let ox = cfg.offsetX, oy = cfg.offsetY
|
||||
// 采用数学坐标系(Y 向上为正):world 为屏幕坐标(向下为正),故 y 取负
|
||||
oy = -oy
|
||||
const s = cfg.shape
|
||||
if (s === 'circle') {
|
||||
const a = this.rng() * Math.PI * 2, r = this.rng() * cfg.radius
|
||||
return this.applyFollowToOrigin(ox + Math.cos(a) * r, oy + Math.sin(a) * r)
|
||||
} else if (s === 'rect') {
|
||||
return this.applyFollowToOrigin(ox + (this.rng() - 0.5) * cfg.rectW, oy + (this.rng() - 0.5) * cfg.rectH)
|
||||
} else if (s === 'cone') {
|
||||
// 锥体:粒子从中心(锥形顶点)发射,方向由 spawnOne 在 [direction ± coneAngle/2] 扇形内随机
|
||||
return this.applyFollowToOrigin(ox, oy)
|
||||
if (cfg.shape === 'path' && cfg.spawnPathId > 0 && this.spawnPathSampler) {
|
||||
const count = this.estimatedEmissionCount()
|
||||
const progress = pathDistributionProgress(cfg.spawnPathDistribution, this._spawnSerial, count, this.spawnPathClosed, this.rng)
|
||||
const pathSample = this.spawnPathSampler(progress)
|
||||
if (pathSample) {
|
||||
const origin = this.applyFollowToOrigin(pathSample.x + ox, pathSample.y + oy)
|
||||
return { ...origin, radialAngle: 0, pathAngle: pathSample.angle }
|
||||
}
|
||||
}
|
||||
return this.applyFollowToOrigin(ox, oy)
|
||||
const sample = sampleEmitterShape(cfg, this.rng)
|
||||
const origin = this.applyFollowToOrigin(ox + sample.x, oy + sample.y)
|
||||
return { ...origin, radialAngle: sample.radialAngle }
|
||||
}
|
||||
|
||||
private estimatedEmissionCount() {
|
||||
const cfg = this.cfg
|
||||
return cfg.mode === 'burst'
|
||||
? Math.max(1, Math.round(cfg.burstCount))
|
||||
: Math.max(1, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
|
||||
}
|
||||
|
||||
private applyFollowToOrigin(x: number, y: number) {
|
||||
@@ -1516,16 +1591,11 @@ export class ParticleEmitter extends Container {
|
||||
const cfg = this.cfg
|
||||
let idx = this.pool.findIndex((p) => !p.active)
|
||||
if (idx === -1) return // 池满
|
||||
const { x: ox, y: oy } = this.spawnOrigin()
|
||||
const origin = this.spawnOrigin()
|
||||
const { x: ox, y: oy } = origin
|
||||
const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng())
|
||||
const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
|
||||
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
|
||||
let dirAng: number
|
||||
if (cfg.shape === 'cone') {
|
||||
dirAng = (cfg.direction + this.followSpawnAngle + lerp(-cfg.coneAngle / 2, cfg.coneAngle / 2, this.rng())) * Math.PI / 180
|
||||
} else {
|
||||
dirAng = (cfg.direction + this.followSpawnAngle + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
|
||||
}
|
||||
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng)
|
||||
const p = this.pool[idx]
|
||||
const resource = this.pickImageResource()
|
||||
const spawnSerial = this._spawnSerial++
|
||||
@@ -1572,7 +1642,7 @@ export class ParticleEmitter extends Container {
|
||||
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
|
||||
if (p.trailEnabled) p.trailHistory.push({ x: ox, y: oy })
|
||||
p.vx = Math.cos(dirAng) * speed
|
||||
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
|
||||
p.vy = Math.sin(dirAng) * speed
|
||||
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
|
||||
p.scaleStartY = cfg.scaleAxisMode === 'separate'
|
||||
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng()) * resource.scale
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface ScenePathSampleSource {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
closed: boolean
|
||||
points: Array<{ x: number; y: number; inX: number; inY: number; outX: number; outY: number }>
|
||||
}
|
||||
|
||||
function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
|
||||
const u = 1 - t
|
||||
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
|
||||
}
|
||||
|
||||
function pathLocalToWorld(path: ScenePathSampleSource, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
|
||||
return { x: path.x + cos * x * scaleX - sin * y * scaleY, y: path.y + sin * x * scaleX + cos * y * scaleY }
|
||||
}
|
||||
|
||||
/** 按近似弧长采样贝塞尔场景路径,并返回数学世界坐标和切线角度。 */
|
||||
export function sampleScenePath(path: ScenePathSampleSource, progress: number) {
|
||||
if (!path.points.length) return null
|
||||
if (path.points.length === 1) {
|
||||
const point = pathLocalToWorld(path, path.points[0].x, path.points[0].y)
|
||||
return { ...point, angle: path.rotation }
|
||||
}
|
||||
const segmentCount = path.closed ? path.points.length : path.points.length - 1
|
||||
const samples: Array<{ x: number; y: number; distance: number }> = []
|
||||
let totalDistance = 0
|
||||
for (let segment = 0; segment < segmentCount; segment++) {
|
||||
const a = path.points[segment]
|
||||
const b = path.points[(segment + 1) % path.points.length]
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
if (segment > 0 && step === 0) continue
|
||||
const t = step / 20
|
||||
const localX = cubicPoint(a.x, a.x + a.outX, b.x + b.inX, b.x, t)
|
||||
const localY = cubicPoint(a.y, a.y + a.outY, b.y + b.inY, b.y, t)
|
||||
const worldPoint = pathLocalToWorld(path, localX, localY)
|
||||
const previous = samples[samples.length - 1]
|
||||
if (previous) totalDistance += Math.hypot(worldPoint.x - previous.x, worldPoint.y - previous.y)
|
||||
samples.push({ ...worldPoint, distance: totalDistance })
|
||||
}
|
||||
}
|
||||
const target = Math.min(1, Math.max(0, progress)) * totalDistance
|
||||
let index = samples.findIndex((sample) => sample.distance >= target)
|
||||
if (index < 0) index = samples.length - 1
|
||||
const current = samples[index]
|
||||
const previous = samples[Math.max(0, index - 1)]
|
||||
const next = samples[Math.min(samples.length - 1, index + 1)]
|
||||
const beforeDistance = previous.distance
|
||||
const amount = index > 0 ? (target - beforeDistance) / Math.max(0.0001, current.distance - beforeDistance) : 0
|
||||
const x = previous.x + (current.x - previous.x) * Math.min(1, Math.max(0, amount))
|
||||
const y = previous.y + (current.y - previous.y) * Math.min(1, Math.max(0, amount))
|
||||
return { x, y, angle: Math.atan2(next.y - previous.y, next.x - previous.x) * 180 / Math.PI }
|
||||
}
|
||||
+56
-13
@@ -287,23 +287,53 @@
|
||||
<option value="point">点</option>
|
||||
<option value="circle">圆形</option>
|
||||
<option value="rect">矩形</option>
|
||||
<option value="cone">锥体</option>
|
||||
<option value="orbit">轨道</option>
|
||||
<option value="cone">锥形</option>
|
||||
<option value="path">路径</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="sys.config.shape === 'circle' || sys.config.shape === 'cone'">
|
||||
<NumSlider label="半径" :min="0" :max="300" :step="1" v-model="sys.config.radius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '内径' : '内半径'" :min="0" :max="sys.config.outerRadius" :step="1" v-model="sys.config.innerRadius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '外径' : '外半径'" :min="sys.config.innerRadius" :max="300" :step="1" v-model="sys.config.outerRadius" />
|
||||
</template>
|
||||
<template v-if="sys.config.shape === 'cone'">
|
||||
<NumSlider label="锥形朝向" :min="-360" :max="360" :step="1" v-model="sys.config.coneDirection" />
|
||||
<NumSlider label="锥形开口角度" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
|
||||
</template>
|
||||
<template v-else-if="sys.config.shape === 'rect'">
|
||||
<NumSlider label="宽" :min="1" :max="400" :step="1" v-model="sys.config.rectW" />
|
||||
<NumSlider label="高" :min="1" :max="400" :step="1" v-model="sys.config.rectH" />
|
||||
<NumSlider label="内宽" :min="0" :max="sys.config.outerRectW" :step="1" v-model="sys.config.innerRectW" />
|
||||
<NumSlider label="内高" :min="0" :max="sys.config.outerRectH" :step="1" v-model="sys.config.innerRectH" />
|
||||
<NumSlider label="外宽" :min="sys.config.innerRectW" :max="400" :step="1" v-model="sys.config.outerRectW" />
|
||||
<NumSlider label="外高" :min="sys.config.innerRectH" :max="400" :step="1" v-model="sys.config.outerRectH" />
|
||||
</template>
|
||||
<template v-else-if="sys.config.shape === 'path'">
|
||||
<label class="row"><span>选择路径</span>
|
||||
<select v-model.number="sys.config.spawnPathId" class="inp" @change="onSpawnPathChange">
|
||||
<option :value="0">无路径(点发射)</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id" :disabled="!path.enabled">{{ path.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="sys.config.spawnPathId > 0" class="row"><span>出生分布</span>
|
||||
<select v-model="sys.config.spawnPathDistribution" class="inp">
|
||||
<option value="fixed">等间距固定分布</option>
|
||||
<option value="random">随机分布</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<div class="divider"></div>
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '锥形朝向' : '发射角度'" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider v-if="sys.config.shape !== 'cone'" label="扩散角度" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
|
||||
<label class="row"><span>发射角度</span>
|
||||
<select v-model="sys.config.emissionAngleMode" class="inp">
|
||||
<option value="fixed">固定</option>
|
||||
<option value="random">随机</option>
|
||||
<option v-if="sys.config.shape === 'circle' || sys.config.shape === 'rect' || sys.config.shape === 'cone'" value="radial">径向(中心向外)</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-normal">垂直于路径</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-tangent">沿路径方向</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumSlider v-if="sys.config.emissionAngleMode === 'fixed'" label="固定角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<template v-else-if="sys.config.emissionAngleMode === 'random'">
|
||||
<NumSlider label="基准角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider label="随机范围" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1245,6 +1275,10 @@ watchEffect(() => {
|
||||
watchEffect(() => {
|
||||
for (const system of store.systems) {
|
||||
const config = ensureEmitterConfig(system.config as any)
|
||||
if (config.spawnPathId > 0 && !store.paths.some((path) => path.id === config.spawnPathId)) {
|
||||
config.spawnPathId = 0
|
||||
if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed'
|
||||
}
|
||||
if (config.emitterFollowMode !== 'bone') continue
|
||||
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
|
||||
const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName)
|
||||
@@ -1404,7 +1438,11 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
'generateLoopEndAnimation', 'loopEndUseCustomDuration', 'loopEndDurationFrames',
|
||||
'burstCount', 'burstLoop', 'delay', 'duration',
|
||||
],
|
||||
shape: ['shape', 'radius', 'rectW', 'rectH', 'direction', 'spread', 'coneAngle'],
|
||||
shape: [
|
||||
'shape', 'shapeRangeVersion',
|
||||
'innerRadius', 'outerRadius', 'innerRectW', 'innerRectH', 'outerRectW', 'outerRectH', 'spawnPathId', 'spawnPathDistribution',
|
||||
'radius', 'rectW', 'rectH', 'emissionAngleMode', 'direction', 'spread', 'coneDirection', 'coneAngle',
|
||||
],
|
||||
attr: [
|
||||
'lifeMin', 'lifeMax', 'lifeMode', 'lifeCurve',
|
||||
'speedMin', 'speedMax', 'speedMode', 'speedCurve',
|
||||
@@ -1545,11 +1583,11 @@ function setScaleOverLifeMode(mode: 'fixed' | 'random' | 'curve' | 'direction')
|
||||
|
||||
// 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他)
|
||||
const SHAPE_DEFAULTS: Record<string, Partial<any>> = {
|
||||
point: { direction: 0, spread: 360 },
|
||||
circle: { radius: 100, direction: -90, spread: 0 },
|
||||
rect: { rectW: 200, rectH: 100, direction: -90, spread: 0 },
|
||||
cone: { radius: 100, coneAngle: 90, direction: 90 },
|
||||
orbit: {},
|
||||
point: { emissionAngleMode: 'random', direction: 0, spread: 360 },
|
||||
circle: { innerRadius: 0, outerRadius: 100, emissionAngleMode: 'fixed', direction: -90, spread: 0 },
|
||||
rect: { innerRectW: 0, innerRectH: 0, outerRectW: 200, outerRectH: 100, emissionAngleMode: 'fixed', direction: -90, spread: 0 },
|
||||
cone: { innerRadius: 0, outerRadius: 100, coneAngle: 90, coneDirection: 90, emissionAngleMode: 'random', direction: 90, spread: 90 },
|
||||
path: { spawnPathId: 0, spawnPathDistribution: 'fixed', emissionAngleMode: 'fixed', direction: 0, spread: 0 },
|
||||
}
|
||||
function onShapeChange() {
|
||||
const cfg = sys.value?.config
|
||||
@@ -1558,6 +1596,11 @@ function onShapeChange() {
|
||||
if (!d) return
|
||||
Object.assign(cfg, d)
|
||||
}
|
||||
function onSpawnPathChange() {
|
||||
const cfg = sys.value?.config
|
||||
if (!cfg || cfg.spawnPathId > 0) return
|
||||
if (cfg.emissionAngleMode === 'path-normal' || cfg.emissionAngleMode === 'path-tangent') cfg.emissionAngleMode = 'fixed'
|
||||
}
|
||||
// 重置/删除图片 → 回到默认 star.png
|
||||
const textureFileInput = ref<HTMLInputElement | null>(null)
|
||||
const sequenceFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
+57
-58
@@ -188,6 +188,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { normalizedRadialRange, normalizedRectRange } from '../core/emitterShape'
|
||||
import { sampleScenePath } from '../core/scenePathSampler'
|
||||
import { bakeLoopFrames } from '../core/loopFrameBaker'
|
||||
import { loopPlaybackRange } from '../core/loopSegmentTiming'
|
||||
import { useParticleStore, type CollisionBody, type ParticleSystem, type ScenePath } from '../store/particleStore'
|
||||
@@ -985,7 +987,7 @@ function drawBoneFollowTargets() {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制当前选中系统的发射器形状范围(点/轨道不绘制),随参数实时响应
|
||||
// 绘制当前选中系统的发射器形状范围(点/路径不额外绘制),随参数实时响应。
|
||||
function drawShapeRange() {
|
||||
if (!shapeGfx) return
|
||||
const g = shapeGfx
|
||||
@@ -999,7 +1001,7 @@ function drawShapeRange() {
|
||||
const cx = preview?.x ?? c.centerX ?? 0
|
||||
const cy = -(preview?.y ?? c.centerY ?? 0)
|
||||
const shape = c.shape
|
||||
if (shape === 'point' || shape === 'orbit') return // 点不绘制,轨道保留空位
|
||||
if (shape === 'point' || shape === 'path') return
|
||||
// 相对 root 中心的局部点(lx,ly,数学坐标)先加偏移(发射源 = root 中心 + offset),再应用 root 缩放+旋转,加 root 中心 → 世界坐标
|
||||
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
|
||||
const rot = -(preview?.rotation ?? c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
|
||||
@@ -1012,36 +1014,48 @@ function drawShapeRange() {
|
||||
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
|
||||
}
|
||||
if (shape === 'circle') {
|
||||
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
|
||||
g.lineStyle(2, 0x3ce86a, 0.7)
|
||||
// 圆:以 root 变换后的椭圆近似(逐点)
|
||||
// 圆环:以 root 变换后的椭圆近似(逐点)
|
||||
const steps = 48
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = i / steps * Math.PI * 2
|
||||
const [px, py] = pt(Math.cos(a) * c.radius, Math.sin(a) * c.radius)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
for (const radius of [range.outer, range.inner]) {
|
||||
if (radius <= 0) continue
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = i / steps * Math.PI * 2
|
||||
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
}
|
||||
}
|
||||
} else if (shape === 'rect') {
|
||||
const hw = c.rectW / 2, hh = c.rectH / 2
|
||||
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
|
||||
const range = normalizedRectRange(c)
|
||||
g.lineStyle(2, 0x4a8cff, 0.7)
|
||||
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
|
||||
for (const [width, height] of [[range.outerW, range.outerH], [range.innerW, range.innerH]]) {
|
||||
if (width <= 0 || height <= 0) continue
|
||||
const hw = width / 2, hh = height / 2
|
||||
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
|
||||
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
|
||||
}
|
||||
} else if (shape === 'cone') {
|
||||
const r = c.radius
|
||||
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
|
||||
const half = (c.coneAngle / 2) * Math.PI / 180
|
||||
const base = c.direction * Math.PI / 180
|
||||
// 锥形两条边 + 弧
|
||||
const edge1 = pt(Math.cos(base - half) * r, Math.sin(base - half) * r)
|
||||
const edge2 = pt(Math.cos(base + half) * r, Math.sin(base + half) * r)
|
||||
const base = c.coneDirection * Math.PI / 180
|
||||
// 锥形环带的两条边、内弧和外弧。
|
||||
const innerEdge1 = pt(Math.cos(base - half) * range.inner, Math.sin(base - half) * range.inner)
|
||||
const innerEdge2 = pt(Math.cos(base + half) * range.inner, Math.sin(base + half) * range.inner)
|
||||
const outerEdge1 = pt(Math.cos(base - half) * range.outer, Math.sin(base - half) * range.outer)
|
||||
const outerEdge2 = pt(Math.cos(base + half) * range.outer, Math.sin(base + half) * range.outer)
|
||||
g.lineStyle(2, 0x3ce86a, 0.7)
|
||||
g.moveTo(cx, cy); g.lineTo(edge1[0], edge1[1])
|
||||
g.moveTo(cx, cy); g.lineTo(edge2[0], edge2[1])
|
||||
// 弧
|
||||
g.moveTo(innerEdge1[0], innerEdge1[1]); g.lineTo(outerEdge1[0], outerEdge1[1])
|
||||
g.moveTo(innerEdge2[0], innerEdge2[1]); g.lineTo(outerEdge2[0], outerEdge2[1])
|
||||
g.lineStyle(1, 0x3ce86a, 0.5)
|
||||
const steps = 24
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = base - half + (2 * half * i / steps)
|
||||
const [px, py] = pt(Math.cos(a) * r, Math.sin(a) * r)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
for (const radius of [range.outer, range.inner]) {
|
||||
if (radius <= 0) continue
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = base - half + (2 * half * i / steps)
|
||||
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1343,42 +1357,6 @@ function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
|
||||
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
|
||||
}
|
||||
|
||||
function sampleScenePath(path: ScenePath, progress: number) {
|
||||
if (!path.points.length) return null
|
||||
if (path.points.length === 1) {
|
||||
const point = pathLocalToWorld(path, path.points[0].x, path.points[0].y)
|
||||
return { ...point, angle: path.rotation }
|
||||
}
|
||||
const segmentCount = path.closed ? path.points.length : path.points.length - 1
|
||||
const samples: Array<{ x: number; y: number; distance: number }> = []
|
||||
let totalDistance = 0
|
||||
for (let segment = 0; segment < segmentCount; segment++) {
|
||||
const a = path.points[segment]
|
||||
const b = path.points[(segment + 1) % path.points.length]
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
if (segment > 0 && step === 0) continue
|
||||
const t = step / 20
|
||||
const localX = cubicPoint(a.x, a.x + a.outX, b.x + b.inX, b.x, t)
|
||||
const localY = cubicPoint(a.y, a.y + a.outY, b.y + b.inY, b.y, t)
|
||||
const worldPoint = pathLocalToWorld(path, localX, localY)
|
||||
const previous = samples[samples.length - 1]
|
||||
if (previous) totalDistance += Math.hypot(worldPoint.x - previous.x, worldPoint.y - previous.y)
|
||||
samples.push({ ...worldPoint, distance: totalDistance })
|
||||
}
|
||||
}
|
||||
const target = Math.min(1, Math.max(0, progress)) * totalDistance
|
||||
let index = samples.findIndex((sample) => sample.distance >= target)
|
||||
if (index < 0) index = samples.length - 1
|
||||
const current = samples[index]
|
||||
const previous = samples[Math.max(0, index - 1)]
|
||||
const next = samples[Math.min(samples.length - 1, index + 1)]
|
||||
const beforeDistance = previous.distance
|
||||
const amount = index > 0 ? (target - beforeDistance) / Math.max(0.0001, current.distance - beforeDistance) : 0
|
||||
const x = previous.x + (current.x - previous.x) * Math.min(1, Math.max(0, amount))
|
||||
const y = previous.y + (current.y - previous.y) * Math.min(1, Math.max(0, amount))
|
||||
return { x, y, angle: Math.atan2(next.y - previous.y, next.x - previous.x) * 180 / Math.PI }
|
||||
}
|
||||
|
||||
function buildPreviewColliders(timeSeconds: number) {
|
||||
previewColliders = store.colliders.map((collider) => {
|
||||
if (!collider.followPath || !collider.followPathId) return collider
|
||||
@@ -1417,6 +1395,20 @@ function worldPointToEmitterLocal(
|
||||
return { x: (cos * screenX + sin * screenY) / scaleX, y: (-sin * screenX + cos * screenY) / scaleY }
|
||||
}
|
||||
|
||||
function sampleSpawnPathForEmitter(
|
||||
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||
path: ScenePath,
|
||||
progress: number,
|
||||
) {
|
||||
const sample = sampleScenePath(path, progress)
|
||||
if (!sample) return null
|
||||
const local = worldPointToEmitterLocal(config, sample.x, sample.y)
|
||||
const radians = sample.angle * Math.PI / 180
|
||||
const tangentWorld = worldPointToEmitterLocal(config, sample.x + Math.cos(radians), sample.y + Math.sin(radians))
|
||||
const angle = Math.atan2(tangentWorld.y - local.y, tangentWorld.x - local.x) * 180 / Math.PI
|
||||
return { ...local, angle }
|
||||
}
|
||||
|
||||
function loop() {
|
||||
raf = requestAnimationFrame(loop)
|
||||
const now = performance.now() / 1000
|
||||
@@ -1517,6 +1509,13 @@ function loop() {
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
||||
}
|
||||
const spawnPath = c.shape === 'path' && c.spawnPathId > 0
|
||||
? store.paths.find((path) => path.id === c.spawnPathId && path.enabled && path.points.length > 0) || null
|
||||
: null
|
||||
em.setSpawnPathSampler(
|
||||
spawnPath ? (progress) => sampleSpawnPathForEmitter(c, spawnPath, progress) : null,
|
||||
spawnPath?.closed === true,
|
||||
)
|
||||
emitterPreviewTransform.set(sys.id, preview)
|
||||
}
|
||||
let total = 0
|
||||
|
||||
Reference in New Issue
Block a user