功能修复

This commit is contained in:
tianmo committed 2026-10-29 18:43:11 +08:00
1 parent fed1015849
commit bae179eb93
13 files changed
+274 -81

No files matched your search

+78 -42
View File
@@ -3,7 +3,7 @@ import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAng
import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail'
import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources'
import type { SceneCollider, EmitterConfig, ParticleState, ParticleInheritAttribute } from './particleTypes'
import type { SceneCollider, EmitterConfig, EmitMode, ParticleState, ParticleInheritAttribute } from './particleTypes'
import { defaultConfig, ensureEmitterConfig } from './particleConfig'
import { BLEND_MAP, MAX_PARTICLES } from './particleConstants'
import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration, addRgbColors } from './particleMath'
@@ -90,6 +90,19 @@ export interface ParticleInheritOptions {
data: ParticleInheritData
}
interface EventEmission {
mode: EmitMode
worldX: number
worldY: number
depth: number
inherit?: ParticleInheritOptions
count: number
rate: number
remainingDelay: number
remainingDuration: number
accumulator: number
}
export class ParticleEmitter extends Container {
private pool: Particle[] = []
private cfg: EmitterConfig
@@ -103,15 +116,8 @@ export class ParticleEmitter extends Container {
private _attractionWasEnabled = false
/** 子发射器关闭自动发射,只接收父粒子事件生成的粒子。 */
private autoEmission = true
/** 事件粒子延迟队列:子发射器 delay 表示在父事件之后再延迟多久发射。 */
private pendingEventSpawns: Array<{
worldX: number
worldY: number
depth: number
inherit?: ParticleInheritOptions
count: number
remaining: number
}> = []
/** 父事件启动的独立子发射窗口;持续模式会按 rate/duration 运行。 */
private eventEmissions: EventEmission[] = []
/** 活动粒子状态快照(供骨架 / 导出) */
states: ParticleState[] = []
/** 粒子出生/死亡/碰撞事件回调(由 EmitterSync 接入跨系统事件)。 */
@@ -287,23 +293,8 @@ export class ParticleEmitter extends Container {
}
}
// 处理子发射器的延迟发射队列;新粒子在本帧就会参与后续更新与录制。
if (this.pendingEventSpawns.length && dt > 0) {
const ready: typeof this.pendingEventSpawns = []
const waiting: typeof this.pendingEventSpawns = []
for (const pending of this.pendingEventSpawns) {
pending.remaining -= dt
if (pending.remaining <= 0) ready.push(pending)
else waiting.push(pending)
}
this.pendingEventSpawns = waiting
for (const pending of ready) {
const local = worldMathToLocal(this.cfg, pending.worldX, pending.worldY, this.simulationTransform)
for (let index = 0; index < pending.count; index++) {
if (!this.spawnOne({ x: local.x, y: local.y }, pending.depth, pending.inherit)) break
}
}
}
// 新粒子在本帧就会参与后续更新与录制。
this.updateEventEmissions(dt)
const states: ParticleState[] = []
for (let i = 0; i < this.pool.length; i++) {
@@ -557,27 +548,72 @@ export class ParticleEmitter extends Container {
}
}
/** 由跨系统事件触发:在给定的数学世界坐标处生成一颗目标系统的粒子。 */
/** 由跨系统事件触发:爆发模式生成一次,持续模式启动一个独立发射窗口。 */
spawnEventParticle(worldX: number, worldY: number, depth = 1, inherit?: ParticleInheritOptions, count?: number): boolean {
if (depth > 2) return false
// 子发射器按自己的发射模式决定每次事件生成多少颗:爆发=单次数量,持续=1。
const requested = count ?? (this.cfg.mode === 'burst' ? this.cfg.burstCount : 1)
const total = Math.max(1, Math.min(MAX_PARTICLES, Math.round(Number(requested) || 1)))
// 子发射器 delay 表示在父事件之后再延迟多久发射。
const delay = Math.max(0, Number(this.cfg.delay) || 0)
if (delay > 0) {
this.pendingEventSpawns.push({ worldX, worldY, depth, inherit, count: total, remaining: delay })
return true
const duration = this.cfg.streamBehavior === 'loop'
? Math.max(1 / 30, Number(this.cfg.loopDurationFrames) / 30 || 1 / 30)
: Math.max(1 / 30, Number(this.cfg.duration) || 0)
const emission: EventEmission = {
mode: this.cfg.mode,
worldX,
worldY,
depth,
inherit: inherit
? { ...inherit, attributes: [...inherit.attributes], data: { ...inherit.data } }
: undefined,
count: Math.max(1, Math.min(MAX_PARTICLES, Math.round(Number(count ?? this.cfg.burstCount) || 1))),
rate: Math.max(0, Number(this.cfg.rate) || 0),
remainingDelay: Math.max(0, Number(this.cfg.delay) || 0),
remainingDuration: duration,
accumulator: 0,
}
const local = worldMathToLocal(this.cfg, worldX, worldY, this.simulationTransform)
if (emission.mode === 'burst' && emission.remainingDelay <= 0) {
return this.spawnEventBurst(emission)
}
this.eventEmissions.push(emission)
return true
}
private spawnEventBurst(emission: EventEmission) {
const local = worldMathToLocal(this.cfg, emission.worldX, emission.worldY, this.simulationTransform)
let spawned = false
for (let index = 0; index < total; index++) {
if (!this.spawnOne({ x: local.x, y: local.y }, depth, inherit)) break
for (let index = 0; index < emission.count; index++) {
if (!this.spawnOne({ x: local.x, y: local.y }, emission.depth, emission.inherit)) break
spawned = true
}
return spawned
}
private updateEventEmissions(dt: number) {
if (!this.eventEmissions.length || dt <= 0) return
const waiting: EventEmission[] = []
for (const emission of this.eventEmissions) {
if (emission.remainingDelay > 0) {
if (emission.remainingDelay > dt) {
emission.remainingDelay -= dt
waiting.push(emission)
continue
}
emission.remainingDelay = 0
}
if (emission.mode === 'burst') {
this.spawnEventBurst(emission)
continue
}
const activeTime = Math.min(dt, emission.remainingDuration)
emission.remainingDuration -= activeTime
emission.accumulator += activeTime * emission.rate
const local = worldMathToLocal(this.cfg, emission.worldX, emission.worldY, this.simulationTransform)
while (emission.accumulator >= 1) {
emission.accumulator -= 1
this.spawnOne({ x: local.x, y: local.y }, emission.depth, emission.inherit)
}
if (emission.remainingDuration > 1e-8) waiting.push(emission)
}
this.eventEmissions = waiting
}
private emitWorldEvent(
kind: ParticleEventKind,
particle: Particle,
@@ -738,7 +774,7 @@ export class ParticleEmitter extends Container {
}
clear() {
this.pendingEventSpawns.length = 0
this.eventEmissions.length = 0
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
@@ -830,7 +866,7 @@ export class ParticleEmitter extends Container {
/** 清空所有粒子并重置发射计时 */
reset() {
this.pendingEventSpawns.length = 0
this.eventEmissions.length = 0
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
@@ -852,7 +888,7 @@ export class ParticleEmitter extends Container {
* 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。
*/
restartEmissionCycle() {
this.pendingEventSpawns.length = 0
this.eventEmissions.length = 0
this.accumulator = 0
this.burstTimer = 0
this._spawnSerial = 0
+11 -7
View File
@@ -34,7 +34,7 @@ const numberOrZero = (value: unknown) => Math.max(0, Number(value) || 0)
/**
* 计算子发射器在时间轴上的实际活动区间:
* 起点取第一次可能被触发的时刻,终点取最后一次触发 + 子粒子最大生命。
* 起点取第一次可能被触发的时刻,终点取最后一次触发 + 子发射持续时长 + 子粒子最大生命。
* 触发时机:出生=父粒子出生时;死亡=最早/最晚死亡时;碰撞=出生到死亡之间。
*/
export function subEmitterTriggerTimings(
@@ -66,13 +66,15 @@ export function subEmitterTriggerTimings(
const emissionStart = (config: SubEmitterTimingSource['config']) => numberOrZero(config.delay)
const delayOf = (config: SubEmitterTimingSource['config']) => numberOrZero(config.delay)
const emissionDuration = (config: SubEmitterTimingSource['config']) => {
if (config.mode !== 'stream') return 0
return config.streamBehavior === 'loop'
? loopSegmentTiming(config as any, safeFps).total / safeFps
: Math.max(1 / safeFps, Number(config.duration) || 0)
}
const emissionEnd = (config: SubEmitterTimingSource['config']) => {
const delay = emissionStart(config)
if (config.mode !== 'stream') return delay
if (config.streamBehavior === 'loop') {
return delay + loopSegmentTiming(config as any, safeFps).total / safeFps
}
return delay + Math.max(1 / safeFps, Number(config.duration) || 0)
return delay + emissionDuration(config)
}
const lifeMinOf = (config: SubEmitterTimingSource['config']) => {
const min = numberOrZero(config.lifeMin)
@@ -135,7 +137,9 @@ export function subEmitterTriggerTimings(
result.set(id, {
firstTrigger: first,
lastTrigger: last,
end: Math.max(first, last) + lifeMaxOf(system.config),
end: Math.max(first, last) + numberOrZero(system.config.delay)
+ emissionDuration(system.config)
+ lifeMaxOf(system.config),
})
}
return result
+6 -3
View File
@@ -10,7 +10,7 @@ import { collectSubEmitterIds } from '../core/subEmitterIds'
import { translate } from '../i18n'
type Track = { key: string; name: string; states: Array<ParticleState | null> }
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings; playbackEnd?: number }
const BLENDS = { normal: 'normal', add: 'additive', multiply: 'multiply', screen: 'screen' } as const
const PARTICLE_POSITION_TOLERANCE = 0.1
@@ -343,9 +343,12 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
for (const system of visibleSystems) {
const frameLength = system.frames?.length || 0
const playbackFrameCount = Number.isFinite(input.playbackEnd)
? Math.min(frameLength, Math.max(1, Math.floor(Number(input.playbackEnd)) + 1))
: frameLength
const sourceCount = subEmitterIds.has(system.id)
? frameLength
: Math.min(frameLength, particleDurationFrames(system, sourceFps))
? playbackFrameCount
: Math.min(playbackFrameCount, particleDurationFrames(system, sourceFps))
const frames = (system.frames || []).slice(0, sourceCount)
if (!frames.length) warnings.push({ code: 'empty-cache', message: translate('errors.emptyCacheWarning', { name: system.config.name }) })
const prefix = uniqueName(safeName(system.config.name, `system_${system.id}`), usedNames)
+76 -11
View File
@@ -6,6 +6,7 @@ import { cloneEditorValue } from '../editor/editorClone'
import { defaultSpineExportSettings, type SpineExportSettings } from '../export/spineExportSettings'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
import { lastFrameOfDuration, resizePlaybackRange, resolvePlaybackRange } from '../timeline/playbackRange'
export interface ParticleSystem {
@@ -117,6 +118,52 @@ export interface TimelineState {
animations: string[]
}
function timelineContentEndFrame(
systems: readonly any[],
spines: readonly any[],
colliders: readonly any[],
fps: number,
) {
const safeFps = Math.max(1, fps || 30)
const subEmitterIds = collectSubEmitterIds(systems)
const subEmitterTiming = subEmitterTriggerTimings(systems as any, safeFps)
let endFrame = 1
for (const system of systems) {
const config = system.config
if (subEmitterIds.has(system.id)) {
const timing = subEmitterTiming.get(system.id)
if (timing) endFrame = Math.max(endFrame, lastFrameOfDuration(timing.end, safeFps))
continue
}
const delay = Math.max(0, Number(config.delay) || 0)
if (config.mode === 'stream' && config.streamBehavior === 'loop') {
endFrame = Math.max(endFrame, lastFrameOfDuration(delay + loopSegmentTiming(config as any, safeFps).total / safeFps, safeFps))
continue
}
const emission = config.mode === 'stream' ? Math.max(1 / safeFps, Number(config.duration) || 0) : 0
const life = config.lifeMode === 'fixed'
? Math.max(0, Number(config.lifeMin) || 0)
: Math.max(0, Number(config.lifeMin) || 0, Number(config.lifeMax) || 0)
endFrame = Math.max(endFrame, lastFrameOfDuration(delay + emission + life, safeFps))
}
for (const spine of spines) {
const animation = spine.animations.find((item: any) => item.name === spine.selectedAnimation)
const duration = Math.max(0, Number(animation?.duration) || 0)
if (duration > 0) {
endFrame = Math.max(
endFrame,
lastFrameOfDuration(Math.max(0, Number(spine.timeOffset) || 0) + duration, safeFps),
)
}
}
for (const collider of colliders) {
if (collider.followPath && collider.followPathId) {
endFrame = Math.max(endFrame, lastFrameOfDuration(collider.followDuration, safeFps))
}
}
return endFrame
}
let seq = 1
let colliderSeq = 1
let pathSeq = 1
@@ -170,12 +217,17 @@ export const useParticleStore = defineStore('particle', {
}),
getters: {
activeSystemCount: (s) => s.systems.length,
playbackRange: (s) => resolvePlaybackRange(
s.timeline.abLoop,
s.timeline.playbackStart,
s.timeline.playbackEnd,
s.timeline.totalFrames,
),
playbackRange: (s) => {
const range = resolvePlaybackRange(
s.timeline.abLoop,
s.timeline.playbackStart,
s.timeline.playbackEnd,
s.timeline.totalFrames,
)
const contentEnd = timelineContentEndFrame(s.systems, s.spines, s.colliders, s.timeline.fps)
const end = Math.max(1, Math.min(range.end, contentEnd))
return { start: Math.min(range.start, end - 1), end }
},
},
actions: {
syncSceneSequences() {
@@ -511,7 +563,7 @@ export const useParticleStore = defineStore('particle', {
/** 依据所有粒子系统的"最晚消失时间"重算总帧数。
* 固定生命周期使用 lifeMin;随机生命周期使用上下限中的较大值。
* 单系统总时长 = delay + 持续模式 duration(爆发为 0)+ 最大粒子生命,
* 并递归计入子发射器链(出生立即触发、死亡/碰撞在最晚生命结束时触发)。
* 并递归计入子发射器的持续窗口与粒子链(出生立即触发、死亡/碰撞在最晚生命结束时触发)。
* 多系统取最大;转帧 = ceil(sec * fps)。 */
recalcTotalFrames() {
const fps = Math.max(1, this.timeline.fps || 30)
@@ -523,6 +575,13 @@ export const useParticleStore = defineStore('particle', {
const byId = new Map(this.systems.map((system) => [system.id, system]))
const tailCache = new Map<number, number>()
const visiting = new Set<number>()
const emissionDuration = (system: any) => {
const config = system.config
if (config.mode !== 'stream') return 0
return config.streamBehavior === 'loop'
? loopSegmentTiming(config, fps).total / fps
: Math.max(1 / fps, Number(config.duration) || 0)
}
/** 从一颗粒子出生算起,它及其子发射器链最晚持续多久。 */
const effectTail = (system: any): number => {
const cached = tailCache.get(system.id)
@@ -536,7 +595,7 @@ export const useParticleStore = defineStore('particle', {
for (const rule of config.events || []) {
const target = byId.get(rule.targetSystemId)
if (!target || target.id === system.id) continue
const childTail = effectTail(target)
const childTail = emissionDuration(target) + effectTail(target)
// 出生时立即触发;死亡/碰撞最晚发生在父粒子生命结束时。
const offset = rule.event === 'birth' ? 0 : life
// 子发射器自身的 delay 表示父事件之后再延迟多久发射。
@@ -559,10 +618,10 @@ export const useParticleStore = defineStore('particle', {
const isLoop = c.mode === 'stream' && c.streamBehavior === 'loop'
let end: number
if (c.mode === 'stream') {
const emissionDuration = isLoop
const rootEmissionDuration = isLoop
? loopSegmentTiming(c, fps).total / fps
: Math.max(1 / fps, Number(c.duration) || 0)
end = delay + emissionDuration
end = delay + rootEmissionDuration
if (isLoop) {
// 循环结束段已包含父系统自身消亡,只追加子发射器链的尾部时长。
let childTail = 0
@@ -572,7 +631,13 @@ export const useParticleStore = defineStore('particle', {
const target = byId.get(rule.targetSystemId)
if (!target || target.id === system.id) continue
const targetDelay = Math.max(0, Number(target.config.delay) || 0)
childTail = Math.max(childTail, (rule.event === 'birth' ? 0 : life) + targetDelay + effectTail(target))
childTail = Math.max(
childTail,
(rule.event === 'birth' ? 0 : life)
+ targetDelay
+ emissionDuration(target)
+ effectTail(target),
)
}
}
end += childTail
+7 -3
View File
@@ -238,6 +238,9 @@ const playbackEndDraft = ref(String(store.timeline.playbackEnd))
watch(() => store.timeline.playbackStart, (value) => { playbackStartDraft.value = String(value) })
watch(() => store.timeline.playbackEnd, (value) => { playbackEndDraft.value = String(value) })
watch(() => store.playbackRange.end, (end) => {
if (store.timeline.recorded && store.timeline.frame > end) store.timeline.frame = end
}, { immediate: true })
const totalFrames = computed(() => store.timeline.totalFrames)
// 子发射器延长时间轴后自动扩大预览范围;用户手动放大后保持其缩放。
@@ -335,7 +338,8 @@ function barWidthPx(sys: SysLike): number {
const timing = subEmitterTiming.value.get(sys.id)
const start = timing?.firstTrigger ?? 0
const end = timing?.end ?? start
return (Math.max(0, end - start) * fps.value) / pxPerF()
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number((sys.config as any).delay) || 0)
return (Math.max(0, end - start - delay) * fps.value) / pxPerF()
}
return lifeWidthPx(sys)
}
@@ -398,7 +402,7 @@ function onWheel(e: WheelEvent) {
viewFrames.value = Math.round(nv)
}
// 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1)
// 拖动时间轴滑动(scrub):只允许进入实际播放范围。
function onScrubDown(e: PointerEvent) {
const vp = vpRef.value!
// 用户接管时间轴时立即暂停播放,否则每帧推进会和拖动抢播放头。
@@ -406,7 +410,7 @@ function onScrubDown(e: PointerEvent) {
const move = (ev: PointerEvent) => {
const rect = vp.getBoundingClientRect()
const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value)
store.timeline.frame = Math.max(0, Math.min(f, totalFrames.value - 1))
store.timeline.frame = Math.max(0, Math.min(f, store.playbackRange.end))
}
move(e)
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
+12 -2
View File
@@ -137,7 +137,12 @@ async function exportSpineJson() {
exportMessage.value = t('export.recalculating')
try {
await prepareExportFrames()
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
const result = buildSpineJson({
systems: store.systems as unknown as ParticleSystem[],
timeline: store.timeline,
settings: exportSettings.value,
playbackEnd: store.playbackRange.end,
})
validateSpineJson(result.text, exportSettings.value.spineVersion)
downloadSpineJson(result, exportSettings.value.fileName)
const warning = result.warnings.length ? t('export.resourceWarning', { count: result.warnings.length }) : ''
@@ -170,7 +175,12 @@ async function exportSpineProject() {
await transfer.paint()
await prepareExportFrames((progress) => transfer.update(progress * 18, t('export.recalculating')))
transfer.update(20, t('export.generatingJson'))
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
const result = buildSpineJson({
systems: store.systems as unknown as ParticleSystem[],
timeline: store.timeline,
settings: exportSettings.value,
playbackEnd: store.playbackRange.end,
})
validateSpineJson(result.text, exportSettings.value.spineVersion)
exportMessage.value = t('export.preparingProject')
const projectName = exportSettings.value.fileName.replace(/\.json$/i, '') || 'SpineParticle'