发射器形状新增
This commit is contained in:
Vendored
+1239
File diff suppressed because one or more lines are too long
Vendored
-1239
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
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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