发射器形状新增

This commit is contained in:
tianmo
2026-09-05 17:31:32 +08:00
parent 0bde824c88
commit 026ee74d48
11 changed files with 1657 additions and 1336 deletions
+1239
View File
File diff suppressed because one or more lines are too long
-1239
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-C87f76bX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CJwt9TyR.css">
<script type="module" crossorigin src="/assets/index-B-Do_aFq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjobLXfp.css">
</head>
<body>
<div id="app"></div>
+150
View File
@@ -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
View File
@@ -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
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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