子发射器

This commit is contained in:
tianmo committed 2026-10-22 16:49:53 +08:00
1 parent d4ebb18777
commit d224939efd
33 files changed
+5690 -1355

No files matched your search

+34
View File
@@ -232,6 +232,8 @@ export function defaultConfig(): EmitterConfig {
collision: false,
collisionColliderIds: [],
pathFollow: false,
eventEnabled: false,
events: [],
emitterFollow: false,
emitterFollowMode: 'none',
emitterFollowPathId: 0,
@@ -306,6 +308,38 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
if (typeof config.renderLayerSlotName !== 'string') config.renderLayerSlotName = ''
if (config.renderLayerSlotPosition !== 'above' && config.renderLayerSlotPosition !== 'below') config.renderLayerSlotPosition = 'above'
if (typeof config.renderLayerFollowAttachment !== 'boolean') config.renderLayerFollowAttachment = false
if (typeof config.eventEnabled !== 'boolean') config.eventEnabled = false
if (!Array.isArray(config.events)) config.events = []
for (const rule of config.events) {
rule.id = Math.max(1, Math.round(Number(rule.id) || 1))
if (rule.event !== 'birth' && rule.event !== 'death' && rule.event !== 'collision') rule.event = 'birth'
rule.targetSystemId = Math.max(0, Math.round(Number(rule.targetSystemId) || 0))
const probability = Number(rule.probability)
rule.probability = Math.min(1, Math.max(0, Number.isFinite(probability) ? probability : 1))
const collisionLimit = Math.round(Number(rule.collisionLimit))
rule.collisionLimit = Math.min(4, Math.max(1, Number.isFinite(collisionLimit) ? collisionLimit : 1))
// 旧版本 inherit 是单字符串;统一迁移为可多选的属性数组。
const legacyInherit = rule.inherit as unknown
const allowedInherit = ['color', 'scale', 'rotation', 'life']
const inheritList = Array.isArray(legacyInherit)
? legacyInherit
: legacyInherit === 'all'
? [...allowedInherit]
: allowedInherit.includes(String(legacyInherit))
? [String(legacyInherit)]
: []
const normalizedInherit = [...new Set(inheritList.filter((name) => allowedInherit.includes(String(name))))] as typeof rule.inherit
// 只有值确实变化时才写回;ensureEmitterConfig 每帧都会调用,重复赋新数组会触发无限响应式更新。
const currentInherit = Array.isArray(legacyInherit) ? legacyInherit : null
if (
!currentInherit
|| currentInherit.length !== normalizedInherit.length
|| normalizedInherit.some((name, index) => name !== currentInherit[index])
) {
rule.inherit = normalizedInherit
}
if (rule.colorInheritMode !== 'add' && rule.colorInheritMode !== 'replace') rule.colorInheritMode = 'add'
}
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)
+189 -14
View File
@@ -3,10 +3,10 @@ import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAng
import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail'
import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources'
import type { SceneCollider, EmitterConfig, ParticleState } from './particleTypes'
import type { SceneCollider, EmitterConfig, 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 } from './particleMath'
import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration, addRgbColors } from './particleMath'
// 保留原有入口,现有调用方无需随内部拆分修改。
export type * from './particleTypes'
@@ -40,8 +40,54 @@ interface Particle {
resourceId: number
/** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */
colorSample: number
/** 父粒子颜色,逐通道相加到粒子自身颜色。 */
colorAdd: number
/** 父粒子颜色覆盖;null 表示不覆盖。 */
colorOverride: number | null
/** 当前渲染缩放,用于事件继承。 */
currentScaleX: number
currentScaleY: number
imageFrameIndex: number
collisionPaused: boolean
/** 当前粒子累计发生的碰撞次数。 */
collisionCount: number
/** 由事件链生成的层级;0 = 正常发射。 */
eventDepth: number
}
export type ParticleEventKind = 'birth' | 'death' | 'collision'
export interface ParticleEventInfo {
kind: ParticleEventKind
/** 事件位置的数学世界坐标。 */
worldX: number
worldY: number
rotation: number
spawnId: number
/** 0 = 正常发射;数值越大表示事件链越深。 */
depth: number
colorHex: number
scaleX: number
scaleY: number
maxLife: number
collisionCount: number
normalX?: number
normalY?: number
}
/** 子发射器从父粒子继承的数据。 */
export interface ParticleInheritData {
colorHex: number
scaleX: number
scaleY: number
rotation: number
life: number
}
export interface ParticleInheritOptions {
attributes: ParticleInheritAttribute[]
colorMode: 'add' | 'replace'
data: ParticleInheritData
}
export class ParticleEmitter extends Container {
@@ -55,8 +101,21 @@ export class ParticleEmitter extends Container {
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
private _attractionEnabledAt = 0
private _attractionWasEnabled = false
/** 子发射器关闭自动发射,只接收父粒子事件生成的粒子。 */
private autoEmission = true
/** 事件粒子延迟队列:子发射器 delay 表示在父事件之后再延迟多久发射。 */
private pendingEventSpawns: Array<{
worldX: number
worldY: number
depth: number
inherit?: ParticleInheritOptions
count: number
remaining: number
}> = []
/** 活动粒子状态快照(供骨架 / 导出) */
states: ParticleState[] = []
/** 粒子出生/死亡/碰撞事件回调(由 EmitterSync 接入跨系统事件)。 */
onParticleEvent: ((event: ParticleEventInfo) => void) | null = null
/** 确定性随机(种子>0 时可复现) */
private rng: () => number = Math.random
private _lastSeed = -1
@@ -118,8 +177,14 @@ export class ParticleEmitter extends Container {
trail: createTrailState(),
resourceId: 1,
colorSample: 0xffffff,
colorAdd: 0,
colorOverride: null,
currentScaleX: 1,
currentScaleY: 1,
imageFrameIndex: 0,
collisionPaused: false,
collisionCount: 0,
eventDepth: 0,
})
}
}
@@ -129,6 +194,7 @@ export class ParticleEmitter extends Container {
this.spawnPathClosed = closed
}
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
setAutoEmission(enabled: boolean) { this.autoEmission = enabled }
setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) {
this.followSpawnX = x
this.followSpawnY = y
@@ -147,6 +213,8 @@ export class ParticleEmitter extends Container {
if (!collider.enabled) continue
const hit = resolveColliderPoint(collider, world.x, world.y)
if (!hit) continue
p.collisionCount++
this.emitWorldEvent('collision', p, hit.x, hit.y, { normalX: hit.nx, normalY: hit.ny })
if (collider.response === 'destroy') {
this.kill(particleIndex)
return true
@@ -203,7 +271,7 @@ export class ParticleEmitter extends Container {
// 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。
const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)))
// 发射(仅在窗口内且未超时长)
if (inWindow && withinDuration) {
if (this.autoEmission && inWindow && withinDuration) {
if (cfg.mode === 'stream') {
this.accumulator += dt * cfg.rate
while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() }
@@ -219,6 +287,24 @@ 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
}
}
}
const states: ParticleState[] = []
for (let i = 0; i < this.pool.length; i++) {
const p = this.pool[i]
@@ -385,6 +471,8 @@ export class ParticleEmitter extends Container {
: scaleMulX
}
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
p.currentScaleX = s.scale.x
p.currentScaleY = s.scale.y
const baseAlpha = cfg.alphaMode === 'curve'
? curveAt(cfg.alphaCurve, t, this.resolveCurveMode(cfg.curveMode)) * cfg.alphaScale
: cfg.alpha * cfg.alphaScale
@@ -395,11 +483,13 @@ export class ParticleEmitter extends Container {
? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode))
: baseAlpha, 1)
const colorStart = resource?.colorStart || '#ffffff'
const rgb = resource?.colorMode === 'lifetime'
? gradientColorAt(resource.colorGradient, t)
: resource?.colorMode === 'random'
? p.colorSample
: hexToNumber(colorStart)
const baseRgb = resource?.colorMode === 'lifetime'
? gradientColorAt(resource.colorGradient, t)
: resource?.colorMode === 'random'
? p.colorSample
: hexToNumber(colorStart)
// 继承颜色:按配置逐通道相加,或直接覆盖目标系统颜色。
const rgb = p.colorOverride ?? addRgbColors(baseRgb, p.colorAdd)
s.tint = rgb
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
const trailState = this.trail.update(p.trail, cfg, p.boneName, renderX, renderY, t, s)
@@ -467,15 +557,70 @@ export class ParticleEmitter extends Container {
}
}
private spawnOne() {
/** 由跨系统事件触发:在给定的数学世界坐标处生成一颗目标系统的粒子。 */
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 local = worldMathToLocal(this.cfg, worldX, 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
spawned = true
}
return spawned
}
private emitWorldEvent(
kind: ParticleEventKind,
particle: Particle,
worldX: number,
worldY: number,
extra?: { normalX?: number; normalY?: number },
) {
if (!this.onParticleEvent) return
this.onParticleEvent({
kind,
worldX,
worldY,
rotation: particle.rotation,
spawnId: particle.spawnId,
depth: particle.eventDepth,
colorHex: particle.sprite.tint as number,
scaleX: particle.currentScaleX,
scaleY: particle.currentScaleY,
maxLife: particle.maxLife,
collisionCount: particle.collisionCount,
...extra,
})
}
private emitLocalEvent(kind: ParticleEventKind, particle: Particle, x: number, y: number) {
if (!this.onParticleEvent) return
const world = localToWorldMath(this.cfg, x, y, this.simulationTransform)
this.emitWorldEvent(kind, particle, world.x, world.y)
}
private spawnOne(originOverride?: { x: number; y: number }, eventDepth = 0, inherit?: ParticleInheritOptions): boolean {
const cfg = this.cfg
let idx = this.pool.findIndex((p) => !p.active)
if (idx === -1) return // 池满
const origin = this.spawnOrigin()
if (idx === -1) return false // 池满
const origin = originOverride
? { x: originOverride.x, y: originOverride.y, radialAngle: 0 }
: 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())
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng)
// 继承旋转作为整个子发射系统的方向偏移:旋转发射方向与粒子初始朝向。
const rotationOffset = inherit?.attributes.includes('rotation') ? inherit.data.rotation : 0
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng) + rotationOffset * Math.PI / 180
const p = this.pool[idx]
const resource = pickImageResource(cfg, this.rng)
const spawnSerial = this._spawnSerial++
@@ -494,6 +639,8 @@ export class ParticleEmitter extends Container {
p.attractionActive = false
p.attractionElapsed = 0
p.collisionPaused = false
p.collisionCount = 0
p.eventDepth = eventDepth
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
p.trail.history.length = 0
p.trail.bones.length = 0
@@ -531,7 +678,7 @@ export class ParticleEmitter extends Container {
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
p.rotation = cfg.initialRotationMode === 'direction'
? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin
: initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng())
: initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng()) + rotationOffset
p.rotationSpeed = cfg.rotationOverLifeEnabled
? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng())
: 0
@@ -549,23 +696,49 @@ export class ParticleEmitter extends Container {
p.colorSample = resource.colorMode === 'random'
? gradientColorAt(resource.colorGradient, seededUnit(cfg.seed, spawnSerial, 0x9e3779b9))
: hexToNumber(resource.colorStart)
p.colorAdd = 0
p.colorOverride = null
// 子发射器继承父粒子属性:颜色 / 大小 / 旋转 / 生命周期。
if (inherit) {
const attributes = inherit.attributes
if (attributes.includes('color')) {
if (inherit.colorMode === 'replace') p.colorOverride = inherit.data.colorHex
else p.colorAdd = inherit.data.colorHex
}
if (attributes.includes('scale')) {
// 大小作为整个子发射系统的缩放:目标粒子自身缩放 × 父粒子当前缩放。
p.scaleStartX *= inherit.data.scaleX
p.scaleStartY *= inherit.data.scaleY
}
// 旋转已在上面作为整体方向偏移应用,不再覆盖粒子自身旋转。
if (attributes.includes('life')) {
p.life = Math.max(0.0001, inherit.data.life)
p.maxLife = p.life
}
}
p.currentScaleX = p.scaleStartX
p.currentScaleY = p.scaleStartY
const initialFrame = imageFrameState(resource, 0)
p.imageFrameIndex = initialFrame.index
p.sprite.texture = imageFrameTexture(resource, cfg.texture, initialFrame.index)
p.sprite.anchor.set(resource.anchorX, resource.anchorY)
p.sprite.blendMode = BLEND_MAP[resource.blend] as any
p.sprite.tint = p.colorSample
p.sprite.tint = p.colorOverride ?? addRgbColors(p.colorSample, p.colorAdd)
p.sprite.visible = true
this.emitLocalEvent('birth', p, p.x, p.y)
return true
}
private kill(i: number) {
const p = this.pool[i]
if (p.active) this.emitLocalEvent('death', p, p.x, p.y)
p.active = false
p.sprite.visible = false
resetTrailState(p.trail)
}
clear() {
this.pendingEventSpawns.length = 0
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
@@ -657,6 +830,7 @@ export class ParticleEmitter extends Container {
/** 清空所有粒子并重置发射计时 */
reset() {
this.pendingEventSpawns.length = 0
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
@@ -678,6 +852,7 @@ export class ParticleEmitter extends Container {
* 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。
*/
restartEmissionCycle() {
this.pendingEventSpawns.length = 0
this.accumulator = 0
this.burstTimer = 0
this._spawnSerial = 0
+8
View File
@@ -247,3 +247,11 @@ export function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(h, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
/** 颜色相加:逐通道相加并钳制到 0~255。 */
export function addRgbColors(base: number, add: number): number {
const r = Math.min(255, ((base >> 16) & 255) + ((add >> 16) & 255))
const g = Math.min(255, ((base >> 8) & 255) + ((add >> 8) & 255))
const b = Math.min(255, (base & 255) + (add & 255))
return (r << 16) | (g << 8) | b
}
+25
View File
@@ -16,6 +16,28 @@ export type FieldFalloff = 'constant' | 'linear' | 'inverse'
/** 粒子系统在 Spine 绘制顺序中的位置(仅影响编辑器预览)。 */
export type RenderLayerMode = 'top' | 'slot'
export type RenderLayerPosition = 'above' | 'below'
/** 粒子事件类型。 */
export type ParticleEventType = 'birth' | 'death' | 'collision'
/** 子发射器可继承的单个属性(可多选)。 */
export type ParticleInheritAttribute = 'color' | 'scale' | 'rotation' | 'life'
/** 颜色继承方式:逐通道相加或直接覆盖。 */
export type ParticleColorInheritMode = 'add' | 'replace'
/** 一条事件规则:某个粒子事件以一定概率触发另一个粒子系统。 */
export interface ParticleEventRule {
id: number
event: ParticleEventType
/** 目标粒子系统 id;0 表示未选择。 */
targetSystemId: number
/** 每个粒子事件触发概率 0~1。 */
probability: number
/** 碰撞事件对同一父粒子的最大触发次数。 */
collisionLimit: number
/** 子发射器继承父粒子的属性;空数组 = 无。 */
inherit: ParticleInheritAttribute[]
/** 继承颜色时采用相加还是覆盖。 */
colorInheritMode: ParticleColorInheritMode
}
export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export interface ColorGradientStop { position: number; color: string }
@@ -352,6 +374,9 @@ export interface EmitterConfig {
collisionColliderIds: number[]
/** 开启路径跟随(预留) */
pathFollow: boolean
/** 开启事件(具体事件类型与行为后续接入)。 */
eventEnabled: boolean
events: ParticleEventRule[]
emitterFollow: boolean
emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowPathId: number
+50
View File
@@ -0,0 +1,50 @@
interface SubEmitterSource {
id: number
config: { eventEnabled?: boolean; events?: Array<{ targetSystemId: number }> }
}
/**
* 被任一子发射器规则引用的粒子系统属于父系统的一部分:
* 不在场景对象列表中单独显示,也不自动发射,只由父粒子事件驱动。
*/
export function collectSubEmitterIds(systems: readonly SubEmitterSource[]): Set<number> {
const ids = new Set<number>()
for (const system of systems) {
if (system.config?.eventEnabled === false) continue
const rules = system.config?.events
if (!Array.isArray(rules)) continue
for (const rule of rules) {
if (rule.targetSystemId > 0 && rule.targetSystemId !== system.id) ids.add(rule.targetSystemId)
}
}
return ids
}
/**
* 收集某个粒子系统的全部上游父系统(沿子发射器引用向上遍历)。
* 添加子发射器目标时必须排除这些系统,否则会形成循环依赖。
*/
export function collectEmitterAncestors(systems: readonly SubEmitterSource[], systemId: number): Set<number> {
const parentsOf = new Map<number, number[]>()
for (const system of systems) {
if (system.config?.eventEnabled === false) continue
const rules = system.config?.events
if (!Array.isArray(rules)) continue
for (const rule of rules) {
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id) continue
const parents = parentsOf.get(rule.targetSystemId)
if (parents) parents.push(system.id)
else parentsOf.set(rule.targetSystemId, [system.id])
}
}
const ancestors = new Set<number>()
const queue = [...(parentsOf.get(systemId) || [])]
while (queue.length) {
const current = queue.pop()!
if (ancestors.has(current)) continue
ancestors.add(current)
queue.push(...(parentsOf.get(current) || []))
}
ancestors.delete(systemId)
return ancestors
}
+142
View File
@@ -0,0 +1,142 @@
import { loopSegmentTiming } from './loopSegmentTiming'
export interface SubEmitterRuleSource {
event: 'birth' | 'death' | 'collision'
targetSystemId: number
}
export interface SubEmitterTimingSource {
id: number
config: {
mode: 'stream' | 'burst'
streamBehavior: 'normal' | 'loop'
delay: number
duration: number
loopDurationFrames: number
lifeMin: number
lifeMax: number
lifeMode: string
eventEnabled: boolean
events?: SubEmitterRuleSource[]
}
}
export interface SubEmitterTriggerTiming {
/** 第一次被父粒子事件触发的时刻(秒)。 */
firstTrigger: number
/** 最后一次可能被触发的时刻(秒)。 */
lastTrigger: number
/** 最后一颗粒子消失的时刻(秒)。 */
end: number
}
const numberOrZero = (value: unknown) => Math.max(0, Number(value) || 0)
/**
* 计算子发射器在时间轴上的实际活动区间:
* 起点取第一次可能被触发的时刻,终点取最后一次触发 + 子粒子最大生命。
* 触发时机:出生=父粒子出生时;死亡=最早/最晚死亡时;碰撞=出生到死亡之间。
*/
export function subEmitterTriggerTimings(
systems: readonly SubEmitterTimingSource[],
fps: number,
): Map<number, SubEmitterTriggerTiming> {
const safeFps = Math.max(1, fps)
const byId = new Map(systems.map((system) => [system.id, system]))
const subIds = new Set<number>()
for (const system of systems) {
if (!system.config.eventEnabled) continue
for (const rule of system.config.events || []) {
if (rule.targetSystemId > 0 && rule.targetSystemId !== system.id && byId.has(rule.targetSystemId)) {
subIds.add(rule.targetSystemId)
}
}
}
const parentsOf = new Map<number, Array<{ parent: SubEmitterTimingSource; rule: SubEmitterRuleSource }>>()
for (const system of systems) {
if (!system.config.eventEnabled) continue
for (const rule of system.config.events || []) {
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id || !byId.has(rule.targetSystemId)) continue
const parents = parentsOf.get(rule.targetSystemId)
if (parents) parents.push({ parent: system, rule })
else parentsOf.set(rule.targetSystemId, [{ parent: system, rule }])
}
}
const emissionStart = (config: SubEmitterTimingSource['config']) => numberOrZero(config.delay)
const delayOf = (config: SubEmitterTimingSource['config']) => numberOrZero(config.delay)
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)
}
const lifeMinOf = (config: SubEmitterTimingSource['config']) => {
const min = numberOrZero(config.lifeMin)
const max = numberOrZero(config.lifeMax)
return config.lifeMode === 'fixed' ? min : Math.min(min, max)
}
const lifeMaxOf = (config: SubEmitterTimingSource['config']) => {
const min = numberOrZero(config.lifeMin)
const max = numberOrZero(config.lifeMax)
return config.lifeMode === 'fixed' ? min : Math.max(min, max)
}
const firstCache = new Map<number, number>()
const lastCache = new Map<number, number>()
const firstVisiting = new Set<number>()
const lastVisiting = new Set<number>()
const firstTrigger = (system: SubEmitterTimingSource): number => {
const cached = firstCache.get(system.id)
if (cached != null) return cached
if (firstVisiting.has(system.id)) return emissionStart(system.config)
firstVisiting.add(system.id)
let first = Infinity
for (const { parent, rule } of parentsOf.get(system.id) || []) {
const parentFirst = subIds.has(parent.id)
? firstTrigger(parent) + delayOf(parent.config)
: emissionStart(parent.config)
const offset = rule.event === 'death' ? lifeMinOf(parent.config) : 0
first = Math.min(first, parentFirst + offset)
}
if (!Number.isFinite(first)) first = emissionStart(system.config)
firstVisiting.delete(system.id)
firstCache.set(system.id, first)
return first
}
const lastTrigger = (system: SubEmitterTimingSource): number => {
const cached = lastCache.get(system.id)
if (cached != null) return cached
if (lastVisiting.has(system.id)) return emissionEnd(system.config)
lastVisiting.add(system.id)
let last = -Infinity
for (const { parent, rule } of parentsOf.get(system.id) || []) {
const parentLast = subIds.has(parent.id)
? lastTrigger(parent) + delayOf(parent.config)
: emissionEnd(parent.config)
const offset = rule.event === 'birth' ? 0 : lifeMaxOf(parent.config)
last = Math.max(last, parentLast + offset)
}
if (!Number.isFinite(last)) last = emissionEnd(system.config)
lastVisiting.delete(system.id)
lastCache.set(system.id, last)
return last
}
const result = new Map<number, SubEmitterTriggerTiming>()
for (const id of subIds) {
const system = byId.get(id)
if (!system) continue
const first = firstTrigger(system)
const last = lastTrigger(system)
result.set(id, {
firstTrigger: first,
lastTrigger: last,
end: Math.max(first, last) + lifeMaxOf(system.config),
})
}
return result
}
+50 -1
View File
@@ -16,6 +16,8 @@ import { restoreSpineSource } from '../spine/spineSourceBundle'
import { registerSpineAsset, releaseSpineAsset } from '../spine/spineAssetRegistry'
import { cloneEditorValue } from './editorClone'
import { ensureSpineExportSettings } from '../export/spineExportSettings'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { lastFrameOfDuration } from '../timeline/playbackRange'
type EditorStore = ReturnType<typeof useParticleStore>
@@ -43,6 +45,10 @@ export interface EditorConfigFile {
loop: boolean
playbackStart?: number
playbackEnd?: number
/** B 是否跟随时间轴总长,而不是用户自定义的循环末帧。 */
playbackEndAuto?: boolean
/** 是否启用独立的 A/B 区间播放。 */
abLoop?: boolean
animations: string[]
}
selection: {
@@ -85,6 +91,7 @@ function withoutLegacyColliderTags(collider: CollisionBody): CollisionBody {
export function createEditorConfig(store: EditorStore): EditorConfigFile {
const settings = captureProjectSettings(store.settings)
const playbackEndAuto = store.timeline.playbackEnd >= Math.max(1, store.timeline.totalFrames - 1)
return jsonClone({
format: EDITOR_CONFIG_FORMAT,
version: EDITOR_CONFIG_VERSION,
@@ -96,6 +103,8 @@ export function createEditorConfig(store: EditorStore): EditorConfigFile {
loop: store.timeline.loop,
playbackStart: store.timeline.playbackStart,
playbackEnd: store.timeline.playbackEnd,
playbackEndAuto,
abLoop: store.timeline.abLoop,
animations: [...store.timeline.animations],
},
selection: {
@@ -111,6 +120,41 @@ export function createEditorConfig(store: EditorStore): EditorConfigFile {
})
}
/** v1 配置没有 playbackEndAuto,用旧版“各系统独立计时”的末帧判断 B 是否为自动全范围。 */
function legacyPlaybackLastFrame(
systems: ParticleSystem[],
colliders: CollisionBody[],
spines: SpineSceneObject[],
fps: number,
) {
const safeFps = Math.max(1, fps)
let maxSec = 0
for (const system of systems) {
const c = system.config
const lifeMin = Math.max(0, Number(c.lifeMin) || 0)
const lifeMax = Math.max(0, Number(c.lifeMax) || 0)
const life = c.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
const emissionDuration = c.mode === 'stream'
? c.streamBehavior === 'loop'
? loopSegmentTiming(c, safeFps).total / safeFps
: Math.max(1 / safeFps, Number(c.duration) || 0)
: 0
let end = Math.max(0, Number(c.delay) || 0) + emissionDuration + life
if (c.mode === 'stream' && c.streamBehavior === 'loop') {
end = Math.max(0, Number(c.delay) || 0) + loopSegmentTiming(c, safeFps).total / safeFps
}
maxSec = Math.max(maxSec, end)
}
for (const spine of spines) {
const animation = spine.animations.find((item) => item.name === spine.selectedAnimation)
if (animation) maxSec = Math.max(maxSec, Math.max(0, Number(spine.timeOffset) || 0) + Math.max(0, animation.duration || 0))
}
for (const collider of colliders) {
if (collider.followPath && collider.followPathId) maxSec = Math.max(maxSec, Math.max(0, collider.followDuration) || 0)
}
return lastFrameOfDuration(maxSec, safeFps)
}
function downloadName() {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-')
return `SpineParticle-${stamp}.json`
@@ -247,6 +291,10 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
store.settings.exportSettings = ensureSpineExportSettings(config.settings?.exportSettings)
store.settings.undoLimit = Math.min(200, Math.max(1, Math.round(Number(store.settings.undoLimit) || 20)))
store.timeline.fps = Math.max(1, Math.round(Number(config.timeline?.fps) || 30))
const savedPlaybackEnd = Math.max(1, Math.floor(Number(config.timeline?.playbackEnd) || Number.MAX_SAFE_INTEGER))
const playbackEndAuto = config.timeline?.playbackEndAuto
?? savedPlaybackEnd >= legacyPlaybackLastFrame(systems, colliders, spines, store.timeline.fps)
store.timeline.abLoop = config.timeline?.abLoop === true
store.timeline.loop = config.timeline?.loop !== false
store.timeline.animations = Array.isArray(config.timeline?.animations) && config.timeline.animations.length
? [...new Set(config.timeline.animations.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim()))]
@@ -279,7 +327,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
store.syncSceneSequences()
repairSelection(store, config.selection)
store.timeline.playbackStart = Math.max(0, Math.floor(Number(config.timeline?.playbackStart) || 0))
store.timeline.playbackEnd = Math.max(1, Math.floor(Number(config.timeline?.playbackEnd) || Number.MAX_SAFE_INTEGER))
store.timeline.playbackEnd = savedPlaybackEnd
store.timeline.frame = 0
store.timeline.recorded = false
store.timeline.playing = true
@@ -287,6 +335,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
if (!store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
}
store.recalcTotalFrames()
if (playbackEndAuto) store.timeline.playbackEnd = store.timeline.totalFrames - 1
store.timeline.frame = store.timeline.playbackStart
return config
}
+97 -26
View File
@@ -1,11 +1,12 @@
import { defineStore } from 'pinia'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { EmitterConfig, defaultConfig, ensureEmitterConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
import type { SpineSceneObject } from '../spine/spineTypes'
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
import { cloneEditorValue } from '../editor/editorClone'
import { defaultSpineExportSettings, type SpineExportSettings } from '../export/spineExportSettings'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { lastFrameOfDuration, normalizePlaybackRange, resizePlaybackRange } from '../timeline/playbackRange'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { lastFrameOfDuration, resizePlaybackRange, resolvePlaybackRange } from '../timeline/playbackRange'
export interface ParticleSystem {
id: number
@@ -98,6 +99,8 @@ export interface TimelineState {
/** 自定义播放范围,首尾帧均包含。 */
playbackStart: number
playbackEnd: number
/** 是否启用独立的 A/B 区间播放;关闭时播放完整时间轴。 */
abLoop: boolean
/** 帧率 */
fps: number
/** 是否已录制 */
@@ -133,7 +136,7 @@ export const useParticleStore = defineStore('particle', {
activeObjectType: 'particle' as SceneObjectType,
activeObjectId: 0,
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, playbackStart: 0, playbackEnd: 59, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
timeline: { playing: true, loop: true, abLoop: false, frame: 0, totalFrames: 60, playbackStart: 0, playbackEnd: 59, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
settings: {
parameterPanelOnRight: true,
tickEnabled: false,
@@ -158,6 +161,12 @@ 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,
),
},
actions: {
syncSceneSequences() {
@@ -447,7 +456,7 @@ export const useParticleStore = defineStore('particle', {
this.recalcTotalFrames()
},
play() {
const range = normalizePlaybackRange(this.timeline.playbackStart, this.timeline.playbackEnd, this.timeline.totalFrames)
const range = this.playbackRange
if (this.timeline.frame < range.start || this.timeline.frame >= range.end) this.timeline.frame = range.start
this.timeline.playing = true
},
@@ -456,57 +465,114 @@ export const useParticleStore = defineStore('particle', {
if (this.timeline.playing) this.pause()
else this.play()
},
toStart() { this.timeline.frame = this.timeline.playbackStart },
toStart() { this.timeline.frame = this.playbackRange.start },
previousFrame() {
this.timeline.playing = false
this.timeline.frame = Math.max(this.timeline.playbackStart, this.timeline.frame - 1)
this.timeline.frame = Math.max(this.playbackRange.start, this.timeline.frame - 1)
},
nextFrame() {
this.timeline.playing = false
this.timeline.frame = Math.min(this.timeline.playbackEnd, this.timeline.frame + 1)
this.timeline.frame = Math.min(this.playbackRange.end, this.timeline.frame + 1)
},
toEnd() {
this.timeline.playing = false
this.timeline.frame = this.timeline.playbackEnd
this.timeline.frame = this.playbackRange.end
},
toggleLoop() { this.timeline.loop = !this.timeline.loop },
toggleABLoop() {
this.timeline.abLoop = !this.timeline.abLoop
const range = this.playbackRange
if (this.timeline.frame < range.start || this.timeline.frame > range.end) this.timeline.frame = range.start
},
setFrame(f: number) {
this.timeline.frame = Math.max(0, Math.min(f, this.timeline.totalFrames - 1))
},
setPlaybackStart(value: number) {
const range = normalizePlaybackRange(value, this.timeline.playbackEnd, this.timeline.totalFrames)
const range = resolvePlaybackRange(true, value, this.timeline.playbackEnd, this.timeline.totalFrames)
this.timeline.playbackStart = range.start
this.timeline.playbackEnd = range.end
if (this.timeline.frame < range.start) this.timeline.frame = range.start
if (this.timeline.abLoop && this.timeline.frame < range.start) this.timeline.frame = range.start
},
setPlaybackEnd(value: number) {
const range = normalizePlaybackRange(this.timeline.playbackStart, value, this.timeline.totalFrames)
const range = resolvePlaybackRange(true, this.timeline.playbackStart, value, this.timeline.totalFrames)
this.timeline.playbackStart = range.start
this.timeline.playbackEnd = range.end
if (this.timeline.frame > range.end) this.timeline.frame = range.end
if (this.timeline.abLoop && this.timeline.frame > range.end) this.timeline.frame = range.end
},
/** 依据所有粒子系统的"最晚消失时间"重算总帧数。
* 固定生命周期使用 lifeMin;随机生命周期使用上下限中的较大值。
* 单系统总时长 = delay + 持续模式 duration(爆发为 0)+ 最大粒子生命,
* 并递归计入子发射器链(出生立即触发、死亡/碰撞在最晚生命结束时触发)。
* 多系统取最大;转帧 = ceil(sec * fps)。 */
recalcTotalFrames() {
const fps = this.timeline.fps || 30
let maxSec = 0
for (const sys of this.systems) {
const c = sys.config
const fps = Math.max(1, this.timeline.fps || 30)
const particleLifeOf = (c: any) => {
const lifeMin = Math.max(0, Number(c.lifeMin) || 0)
const lifeMax = Math.max(0, Number(c.lifeMax) || 0)
const particleLife = c.lifeMode === 'fixed'
? lifeMin
: Math.max(lifeMin, lifeMax)
const emissionDuration = c.mode === 'stream'
? c.streamBehavior === 'loop'
? Math.max(1 / fps, Math.ceil(Math.max(1, Number(c.loopDurationFrames) || 1)) / fps)
return c.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
}
const byId = new Map(this.systems.map((system) => [system.id, system]))
const tailCache = new Map<number, number>()
const visiting = new Set<number>()
/** 从一颗粒子出生算起,它及其子发射器链最晚持续多久。 */
const effectTail = (system: any): number => {
const cached = tailCache.get(system.id)
if (cached != null) return cached
if (visiting.has(system.id)) return 0
visiting.add(system.id)
const config = system.config
const life = particleLifeOf(config)
let tail = life
if (config.eventEnabled) {
for (const rule of config.events || []) {
const target = byId.get(rule.targetSystemId)
if (!target || target.id === system.id) continue
const childTail = effectTail(target)
// 出生时立即触发;死亡/碰撞最晚发生在父粒子生命结束时。
const offset = rule.event === 'birth' ? 0 : life
// 子发射器自身的 delay 表示父事件之后再延迟多久发射。
const targetDelay = Math.max(0, Number(target.config.delay) || 0)
tail = Math.max(tail, offset + targetDelay + childTail)
}
}
visiting.delete(system.id)
tailCache.set(system.id, tail)
return tail
}
const subEmitterIds = collectSubEmitterIds(this.systems)
let maxSec = 0
for (const system of this.systems) {
// 子发射器不独立发射,时长通过父系统的 effectTail 计算。
if (subEmitterIds.has(system.id)) continue
const c = system.config
const delay = Math.max(0, Number(c.delay) || 0)
const isLoop = c.mode === 'stream' && c.streamBehavior === 'loop'
let end: number
if (c.mode === 'stream') {
const emissionDuration = isLoop
? loopSegmentTiming(c, fps).total / fps
: Math.max(1 / fps, Number(c.duration) || 0)
: 0
let end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife
if (c.mode === 'stream' && c.streamBehavior === 'loop') {
end = Math.max(0, Number(c.delay) || 0) + loopSegmentTiming(c, fps).total / fps
end = delay + emissionDuration
if (isLoop) {
// 循环结束段已包含父系统自身消亡,只追加子发射器链的尾部时长。
let childTail = 0
if (c.eventEnabled) {
const life = particleLifeOf(c)
for (const rule of c.events || []) {
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))
}
}
end += childTail
} else {
end += effectTail(system)
}
} else {
// 爆发:delay 时刻触发一次,之后只计算生命与子发射器链。
end = delay + effectTail(system)
}
if (end > maxSec) maxSec = end
}
@@ -541,3 +607,8 @@ export const useParticleStore = defineStore('particle', {
},
},
})
// 开发环境接受 Pinia store 热更新,避免动作改动后仍执行旧引用(保留当前场景状态)。
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useParticleStore, import.meta.hot))
}
+6
View File
@@ -29,6 +29,12 @@ export function normalizePlaybackRange(start: unknown, end: unknown, totalFrames
return { start: safeStart, end: safeEnd }
}
/** AB 关闭时始终使用完整时间轴,A/B 只是保留给下次开启。 */
export function resolvePlaybackRange(abLoop: boolean, start: unknown, end: unknown, totalFrames: number) {
const last = Math.max(1, Math.floor(totalFrames) - 1)
return normalizePlaybackRange(abLoop ? start : 0, abLoop ? end : last, totalFrames)
}
export function resizePlaybackRange(range: PlaybackRange, previousTotal: number, nextTotal: number) {
const previousLast = Math.max(1, previousTotal - 1)
const nextLast = Math.max(1, nextTotal - 1)
+42 -23
View File
@@ -37,6 +37,7 @@ import { Application, Container, Graphics, type Text, type Texture } from 'pixi.
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { bakeLoopFrames } from '../core/loopFrameBaker'
import { loopPlaybackRange } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { installEditorHistory, undoLastOperation } from '../editor/editorHistory'
@@ -220,12 +221,21 @@ function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps:
*/
function ensureFramesBaked(fps: number) {
const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2)
const subEmitterIds = collectSubEmitterIds(store.systems)
let complete = true
for (const system of store.systems) {
if ((system.frames?.length || 0) >= totalFrames) continue
const emitter = emitterSync.emitters.get(system.id)
if (!emitter) { complete = false; continue }
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames)
// 烘焙预热会大量出生/死亡粒子,期间暂停跨系统事件,避免注入预热粒子。
emitterSync.suspendEvents(true)
try {
for (const system of store.systems) {
// 子发射器由父粒子事件驱动,不能脱离父系统单独烘焙。
if (subEmitterIds.has(system.id)) continue
if ((system.frames?.length || 0) >= totalFrames) continue
const emitter = emitterSync.emitters.get(system.id)
if (!emitter) { complete = false; continue }
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames)
}
} finally {
emitterSync.suspendEvents(false)
}
if (complete) store.timeline.recorded = true
}
@@ -351,6 +361,7 @@ function loop() {
appendStateBones(collected, state, emitter.position.x, emitter.position.y)
}
const tl = store.timeline
const playbackRange = store.playbackRange
const fps = tl.fps
const frameDuration = 1 / Math.max(1, fps)
let displayFrame = timelineDisplayFrame
@@ -376,18 +387,26 @@ function loop() {
total = 0
if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。
for (const sys of store.systems) {
const emitter = emitterSync.emitters.get(sys.id)
if (!emitter) continue
const isLoopStream = sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop'
// 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始
// (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙,
// 导致整段回放都没有可用帧。
if (isLoopStream) {
if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
} else if (tl.frame === 0) {
emitter.reset()
emitterSync.suspendEvents(true)
try {
const subEmitterIds = collectSubEmitterIds(store.systems)
for (const sys of store.systems) {
const emitter = emitterSync.emitters.get(sys.id)
if (!emitter) continue
const isLoopStream = !subEmitterIds.has(sys.id)
&& sys.config.mode === 'stream'
&& sys.config.streamBehavior === 'loop'
// 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始
// (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙,
// 导致整段回放都没有可用帧。
if (isLoopStream) {
if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
} else if (tl.frame === 0) {
emitter.reset()
}
}
} finally {
emitterSync.suspendEvents(false)
}
for (const [systemId, em] of emitterSync.emitters) {
const system = store.systems.find((item) => item.id === systemId)
@@ -412,7 +431,7 @@ function loop() {
}
tl.frame++
if (tl.frame >= tl.totalFrames) {
tl.frame = tl.playbackStart
tl.frame = playbackRange.start
tl.recorded = true
}
} else {
@@ -435,13 +454,13 @@ function loop() {
: null
// 烘焙数据保留“末帧 = 首帧”以便导出闭环。预览精确循环段时,末帧只作为
// 数学边界,不再与下一轮首帧连续显示两次,避免衔接处停顿一帧。
const closesActiveLoop = tl.loop
&& activeLoopRange?.start === tl.playbackStart
&& activeLoopRange.end === tl.playbackEnd
const playbackLastFrame = closesActiveLoop ? tl.playbackEnd - 1 : tl.playbackEnd
const closesActiveLoop = tl.loop && tl.abLoop
&& activeLoopRange?.start === playbackRange.start
&& activeLoopRange.end === playbackRange.end
const playbackLastFrame = closesActiveLoop ? playbackRange.end - 1 : playbackRange.end
if (tl.frame > playbackLastFrame) {
if (tl.loop) tl.frame = tl.playbackStart
else { tl.frame = tl.playbackEnd; tl.playing = false; break }
if (tl.loop) tl.frame = playbackRange.start
else { tl.frame = playbackRange.end; tl.playing = false; break }
}
}
}
+65 -29
View File
@@ -9,10 +9,17 @@
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环">⟳</button>
<label class="tl-range" title="自定义播放起始帧"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" aria-label="播放起始帧" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
<label class="tl-range" title="自定义播放结束帧"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" aria-label="播放结束帧" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
<button v-if="activeLoopPlaybackRange" class="tl-loop-range" :title="`设为闭合自循环范围 ${activeLoopPlaybackRange.start}–${activeLoopPlaybackRange.end}f`" @click="useActiveLoopPlaybackRange">循环段</button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" title="持续循环播放" aria-label="持续循环播放" @click="store.toggleLoop()">⟳</button>
<div class="tl-ab-loop">
<button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" title="独立 A/B 区间循环" @click="store.toggleABLoop()">
<span>AB 循环</span><i class="tl-switch"><b></b></i>
</button>
<div v-if="store.timeline.abLoop" class="tl-ab-popover" @pointerdown.stop>
<div class="tl-ab-title">AB 循环区间</div>
<label class="tl-range" title="自定义播放起始帧"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" aria-label="播放起始帧" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
<label class="tl-range" title="自定义播放结束帧"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" aria-label="播放结束帧" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
</div>
</div>
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ Math.max(0, store.timeline.totalFrames - 1) }}</b></span>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
@@ -84,23 +91,24 @@
<!-- 每系统一条轨道(粒子条) -->
<div class="tl-lanes">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico">●</span>
<span class="lane-ico" :class="{ 'sub-lane-ico': isSubEmitter(sys) }" :style="{ left: startMarkerPx(sys) + 'px' }">●</span>
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
<div
class="lane-bar"
:class="{ 'sub-emitter-bar': isSubEmitter(sys) }"
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
@pointerdown.stop="onBarDown($event, sys)"
>
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
<span class="bar-life" :class="{ 'loop-life': isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<template v-if="isLoopStream(sys)">
<span class="bar-life" :class="{ 'loop-life': !isSubEmitter(sys) && isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<template v-if="!isSubEmitter(sys) && isLoopStream(sys)">
<span v-if="loopStartWidthPx(sys)" class="bar-loop-transition bar-loop-start" :style="{ width: loopStartWidthPx(sys) + 'px' }"></span>
<span class="bar-emission bar-loop" :style="{ left: loopStartWidthPx(sys) + 'px', width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" title="拖动循环段时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span>
<span v-if="loopEndWidthPx(sys)" class="bar-loop-transition bar-loop-end" :style="{ left: (loopStartWidthPx(sys) + emissionWidthPx(sys)) + 'px', width: loopEndWidthPx(sys) + 'px' }"></span>
</template>
<span v-else-if="sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
<span v-else-if="!isSubEmitter(sys) && sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span>
</div>
@@ -127,7 +135,9 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
import { useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
import { loopPlaybackRange, loopSegmentTiming } from '../core/loopSegmentTiming'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
type SysLike = { id: number; config: any }
@@ -148,12 +158,6 @@ watchEffect(() => {
})
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
const activeLoopPlaybackRange = computed(() => {
const system = activeParticleSystem.value
if (!system || !isLoopStream(system)) return null
return loopPlaybackRange(system.config, fps.value)
})
function toggleAnimationMenu() {
if (!activeParticleSystem.value) return
animationMenuOpen.value = !animationMenuOpen.value
@@ -202,8 +206,8 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPoin
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
/** 视口当前能显示的帧数(滚轮缩放它)。默认显示 0~120f,起点恒为第 0 帧。 */
const viewFrames = ref(120)
/** 视口当前能显示的帧数(滚轮缩放它),默认至少显示完整总时长。 */
const viewFrames = ref(Math.max(120, store.timeline.totalFrames))
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
const draftDelays = ref<Record<number, number>>({})
@@ -217,6 +221,10 @@ watch(() => store.timeline.playbackStart, (value) => { playbackStartDraft.value
watch(() => store.timeline.playbackEnd, (value) => { playbackEndDraft.value = String(value) })
const totalFrames = computed(() => store.timeline.totalFrames)
// 子发射器延长时间轴后自动扩大预览范围;用户手动放大后保持其缩放。
watch(totalFrames, (value) => {
if (value > viewFrames.value) viewFrames.value = Math.min(2000, value)
})
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
const spineLanes = computed(() => store.spines.filter((spine) =>
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
@@ -246,14 +254,6 @@ function commitPlaybackEnd() {
playbackEndDraft.value = String(store.timeline.playbackEnd)
}
function useActiveLoopPlaybackRange() {
const range = activeLoopPlaybackRange.value
if (!range) return
// 先扩展结束端,再写入起始端,避免旧范围较小时对新起点产生临时钳制。
store.setPlaybackEnd(range.end)
store.setPlaybackStart(range.start)
}
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() {
const vw = vpRef.value?.clientWidth || 600
@@ -278,8 +278,27 @@ const ticks = computed(() => {
const playPx = computed(() => store.timeline.frame / pxPerF())
const subEmitterIds = computed(() => collectSubEmitterIds(store.systems))
const subEmitterTiming = computed(() => subEmitterTriggerTimings(store.systems as any, fps.value))
function isSubEmitter(sys: SysLike) {
return subEmitterIds.value.has(sys.id)
}
// 圆点标记系统启动时刻;子发射器的 delay 只移动时间块,不移动这个标记。
function startMarkerPx(sys: SysLike) {
const start = isSubEmitter(sys) ? subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0 : 0
return (start * fps.value) / pxPerF() + 4
}
function barLeftPx(sys: SysLike) {
const s = (sys.config as any)
if (isSubEmitter(sys)) {
// 子发射器从第一次父事件 + 自身延迟开始;拖左最多到事件发生位置。
const firstTrigger = subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return ((firstTrigger + delay) * fps.value) / pxPerF()
}
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return (delay * fps.value) / pxPerF()
@@ -292,11 +311,18 @@ function maxParticleLife(sys: SysLike) {
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
}
function barWidthPx(sys: SysLike) {
function barWidthPx(sys: SysLike): number {
if (isSubEmitter(sys)) {
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()
}
return lifeWidthPx(sys)
}
function emissionWidthPx(sys: SysLike) {
if (isSubEmitter(sys)) return 0
if ((sys.config as any).mode !== 'stream') return 0
if ((sys.config as any).streamBehavior === 'loop') {
const config = sys.config as any
@@ -307,7 +333,8 @@ function emissionWidthPx(sys: SysLike) {
return (duration * fps.value) / pxPerF()
}
function lifeWidthPx(sys: SysLike) {
function lifeWidthPx(sys: SysLike): number {
if (isSubEmitter(sys)) return barWidthPx(sys)
const config = sys.config as any
if (config.mode === 'stream' && config.streamBehavior === 'loop') {
return loopStartWidthPx(sys) + emissionWidthPx(sys) + loopEndWidthPx(sys)
@@ -468,11 +495,18 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.tl-controls { display: flex; align-items: center; gap: 8px; }
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.tl-ab-loop { position: relative; }
.tl-ab-toggle { display: flex; height: 26px; align-items: center; gap: 7px; padding: 0 7px; border: 1px solid #343a51; border-radius: 6px; background: #1e1e2e; color: #8f98ad; font-size: 10px; cursor: pointer; }
.tl-ab-toggle.on { border-color: #716eff; background: #2e2d50; color: #eef; }
.tl-switch { position: relative; width: 22px; height: 12px; border-radius: 99px; background: #343a4b; }
.tl-switch b { position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #8992a5; transition: transform .15s ease, background .15s ease; }
.tl-ab-toggle.on .tl-switch { background: #716eff; }
.tl-ab-toggle.on .tl-switch b { transform: translateX(10px); background: #fff; }
.tl-ab-popover { position: absolute; z-index: 40; left: 0; bottom: calc(100% + 8px); display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #4b4c73; border-radius: 8px; background: #171724; box-shadow: 0 10px 26px rgba(0,0,0,.5); }
.tl-ab-title { color: #b7bdd2; font-size: 10px; white-space: nowrap; }
.tl-range { display: flex; height: 25px; align-items: center; gap: 3px; color: #778198; font-size: 10px; }
.tl-range input { width: 43px; height: 25px; padding: 0 5px; box-sizing: border-box; border: 1px solid #3a3a51; border-radius: 5px; outline: none; background: #181824; color: #e1e4ed; font-size: 11px; text-align: center; }
.tl-range input:focus { border-color: #716eff; }
.tl-loop-range { height: 25px; padding: 0 7px; border: 1px solid #4e5680; border-radius: 5px; background: #252943; color: #bfc8e8; font-size: 10px; cursor: pointer; white-space: nowrap; }
.tl-loop-range:hover { border-color: #716eff; color: #fff; }
.tl-frame { display: inline-block; flex: 0 0 92px; width: 92px; box-sizing: border-box; font-size: 12px; color: #aab; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; white-space: nowrap; }
.tl-frame b { display: inline; color: inherit; font-weight: 400; }
.tl-legend { display: flex; align-items: center; gap: 4px; color: #778198; font-size: 10px; }
@@ -523,10 +557,12 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
.lane-ico.sub-lane-ico { color: #7f8999; }
.lane-bar { position: absolute; top: 4px; height: 18px; display: flex; align-items: stretch; cursor: grab; box-sizing: border-box; }
.lane-bar:active { cursor: grabbing; }
.lane-name { position: absolute; left: 14px; right: 5px; top: 2px; z-index: 2; pointer-events: none; font-size: 11px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-life { position: absolute; inset: 0 auto 0 0; z-index: 0; min-width: 1px; border: 1px solid #c05a66; border-radius: 5px; background: #a54551; box-sizing: border-box; }
.sub-emitter-bar .bar-life { border-color: #727d8f; background: #596274; }
.bar-life.loop-life { border-color: #667085; background: #242b39; }
.bar-emission { position: absolute; inset: 0 auto 0 0; z-index: 1; min-width: 1px; border: 1px solid #45a97d; border-radius: 5px 0 0 5px; background: #2f8b62; box-sizing: border-box; }
.bar-emission.bar-loop { border-radius: 0; }
@@ -6,6 +6,9 @@
<button class="group-reset" title="重置修改器参数" @click.stop="resetModule('mods')">重置</button>
</div>
<div class="group-body" v-show="!collapsed.mods">
<div class="modifier-item damping-item">
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.noise" />
@@ -345,12 +348,83 @@
<div class="modifier-hint">只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞</div>
</div>
</div>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
<div class="modifier-item events-item">
<label class="switch-row modifier-switch">
<input v-model="sys.config.eventEnabled" type="checkbox" />
<span class="switch-ui"></span><span>开启子发射器</span>
</label>
<div v-if="sys.config.eventEnabled" class="event-rules">
<div v-for="(rule, index) in sys.config.events" :key="rule.id" class="event-card">
<div class="event-selects">
<select v-model="rule.event" class="inp event-select" title="选择触发时机">
<option value="birth">出生</option>
<option value="death">死亡</option>
<option value="collision">碰撞</option>
</select>
<select v-model.number="rule.targetSystemId" class="inp event-target" title="选择目标粒子系统">
<option :value="0">无(粒子系统)</option>
<option v-for="system in eventTargetSystems" :key="system.id" :value="system.id">
{{ system.config.name || `ParticleSystem${system.id}` }}
</option>
</select>
<button type="button" class="event-remove" title="删除该子发射器" @click="removeEventRule(index)">✕</button>
</div>
<label class="event-probability">
<span>发射概率</span>
<input v-model.number="rule.probability" type="number" min="0" max="1" step="0.01" class="inp" />
</label>
<label v-if="rule.event === 'collision'" class="event-probability event-collision-limit">
<span>碰撞次数限制</span>
<input :value="rule.collisionLimit ?? 1" type="number" min="1" max="4" step="1" class="inp" @input="setCollisionLimit(rule, $event)" />
</label>
<div class="event-probability event-inherit">
<span>继承</span>
<button type="button" class="inherit-trigger" @pointerdown.stop @click.stop="toggleInheritMenu(rule, $event)">
<span class="inherit-summary">{{ inheritSummary(rule) }}</span><i>⌄</i>
</button>
</div>
<label v-if="hasInherit(rule, 'color')" class="event-probability event-color-mode">
<span>颜色继承方式</span>
<select v-model="rule.colorInheritMode" class="inp">
<option value="add">相加</option>
<option value="replace">覆盖</option>
</select>
</label>
</div>
<button type="button" class="event-add" @click="addEventRule">+ 新增子发射器</button>
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="inheritMenu && activeInheritRule"
class="inherit-menu"
:style="{ left: `${inheritMenu.left}px`, top: `${inheritMenu.top}px`, width: `${inheritMenu.width}px` }"
@pointerdown.stop
>
<button type="button" class="inherit-option" @click="selectInheritNone(activeInheritRule)">
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 0 ? '✓' : '' }}</span>无(默认值)
</button>
<button type="button" class="inherit-option" @click="selectInheritAll(activeInheritRule)">
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 4 ? '✓' : '' }}</span>所有属性
</button>
<div class="inherit-divider"></div>
<button
v-for="attribute in INHERIT_ATTRIBUTES"
:key="attribute.key"
type="button"
class="inherit-option"
@click="toggleInherit(activeInheritRule, attribute.key)"
>
<span class="inherit-check">{{ hasInherit(activeInheritRule, attribute.key) ? '✓' : '' }}</span>{{ attribute.label }}
</button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
@@ -358,7 +432,124 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext'
import { collectEmitterAncestors } from '../../core/subEmitterIds'
const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, onEmitterFollowModeChange, bindSelectedBone, resetModule, select, LINEAR_CURVE, FLAT_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } = useParticlePanelContext()
// 不能指向自身或任何上游父系统,避免循环依赖。
const eventTargetSystems = computed(() => {
const currentId = sys.value?.id
if (!currentId) return []
const ancestors = collectEmitterAncestors(store.systems, currentId)
return store.systems.filter((system) => system.id !== currentId && !ancestors.has(system.id))
})
type InheritRule = { id: number; inherit: string[] }
const INHERIT_ATTRIBUTES = [
{ key: 'color', label: '颜色' },
{ key: 'scale', label: '大小' },
{ key: 'rotation', label: '旋转' },
{ key: 'life', label: '生命周期' },
] as const
const inheritMenu = ref<{ ruleId: number; left: number; top: number; width: number } | null>(null)
const activeInheritRule = computed<InheritRule | null>(() => {
const menu = inheritMenu.value
const events = sys.value?.config.events
if (!menu || !Array.isArray(events)) return null
return events.find((rule) => rule.id === menu.ruleId) || null
})
function inheritList(rule: InheritRule) {
if (!Array.isArray(rule.inherit)) (rule as { inherit: string[] }).inherit = []
return rule.inherit
}
function inheritSummary(rule: InheritRule) {
const list = inheritList(rule)
if (!list.length) return '无(默认值)'
if (list.length === INHERIT_ATTRIBUTES.length) return '所有属性'
return INHERIT_ATTRIBUTES.filter((attribute) => list.includes(attribute.key)).map((attribute) => attribute.label).join('、')
}
function hasInherit(rule: InheritRule, key: string) {
return inheritList(rule).includes(key)
}
function toggleInherit(rule: InheritRule, key: string) {
const list = inheritList(rule)
const index = list.indexOf(key)
if (index >= 0) list.splice(index, 1)
else list.push(key)
}
function selectInheritNone(rule: InheritRule) {
inheritList(rule).splice(0)
}
function selectInheritAll(rule: InheritRule) {
inheritList(rule).splice(0, inheritList(rule).length, ...INHERIT_ATTRIBUTES.map((attribute) => attribute.key))
}
function toggleInheritMenu(rule: InheritRule, event: MouseEvent) {
if (inheritMenu.value?.ruleId === rule.id) {
inheritMenu.value = null
return
}
const trigger = event.currentTarget as HTMLElement | null
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const width = Math.max(168, rect.width)
const estimatedHeight = 212
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8))
let top = rect.bottom + 4
if (top + estimatedHeight > window.innerHeight - 8) top = Math.max(8, rect.top - estimatedHeight - 4)
inheritMenu.value = { ruleId: rule.id, left, top, width }
}
function closeInheritMenu() {
inheritMenu.value = null
}
function onInheritMenuViewportChange() {
closeInheritMenu()
}
function onInheritMenuKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') closeInheritMenu()
}
onMounted(() => {
document.addEventListener('pointerdown', closeInheritMenu)
window.addEventListener('scroll', onInheritMenuViewportChange, true)
window.addEventListener('resize', onInheritMenuViewportChange)
window.addEventListener('keydown', onInheritMenuKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeInheritMenu)
window.removeEventListener('scroll', onInheritMenuViewportChange, true)
window.removeEventListener('resize', onInheritMenuViewportChange)
window.removeEventListener('keydown', onInheritMenuKeydown)
})
function addEventRule() {
const config = sys.value?.config
if (!config) return
if (!Array.isArray(config.events)) config.events = []
const nextId = Math.max(0, ...config.events.map((rule) => rule.id)) + 1
config.events.push({ id: nextId, event: 'birth', targetSystemId: 0, probability: 1, collisionLimit: 1, inherit: [], colorInheritMode: 'add' })
}
function removeEventRule(index: number) {
sys.value?.config.events.splice(index, 1)
}
function setCollisionLimit(rule: { collisionLimit: number }, event: Event) {
const input = event.target as HTMLInputElement
const value = Math.round(Number(input.value))
rule.collisionLimit = Math.min(4, Math.max(1, Number.isFinite(value) ? value : 1))
input.value = String(rule.collisionLimit)
}
</script>
<style scoped>
@@ -410,6 +601,34 @@ const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePic
.switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.modifier-item { padding: 8px 0 10px; border-bottom: 1px solid #2a3549; }
.events-item { margin-top: 4px; padding-bottom: 2px; border-bottom: 0; }
.event-rules { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; }
.event-card { padding: 7px; border: 1px solid #2b3a54; border-radius: 7px; background: #121a28; }
.event-selects { display: flex; align-items: center; gap: 5px; }
.event-selects .inp { height: 26px; min-height: 26px; box-sizing: border-box; font-size: 11px; }
.event-select { flex: 0 0 34%; }
.event-target { flex: 1; min-width: 0; }
.event-remove { width: 24px; height: 26px; flex: 0 0 24px; padding: 0; border: 1px solid #3a465f; border-radius: 5px; background: #1c2638; color: #8794ad; font-size: 11px; cursor: pointer; }
.event-remove:hover { border-color: #a05260; background: #3a2230; color: #ffb3c0; }
.event-probability { display: flex; align-items: center; gap: 8px; margin-top: 6px; color: #8d9ab5; font-size: 10px; }
.event-probability > span { flex: 0 0 56px; }
.event-probability .inp { flex: 1; height: 26px; box-sizing: border-box; font-size: 11px; }
.event-inherit { margin-top: 4px; }
.event-color-mode { margin-top: 4px; }
.event-color-mode > span { flex: 0 0 84px; }
.event-color-mode .inp { height: 26px; min-height: 26px; box-sizing: border-box; font-size: 11px; }
.event-collision-limit > span { flex-basis: 72px; }
.inherit-trigger { flex: 1; min-width: 0; height: 26px; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 0 7px; border: 1px solid #34435e; border-radius: 4px; background: #101827; color: #cdd6e8; font-size: 11px; text-align: left; cursor: pointer; }
.inherit-trigger:hover { border-color: #5d5aa8; }
.inherit-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inherit-trigger i { color: #7f8db0; font-style: normal; font-size: 10px; }
.inherit-menu { position: fixed; z-index: 9999; padding: 5px; border: 1px solid #3b3f4a; border-radius: 6px; background: #232427; box-shadow: 0 12px 30px rgba(0, 0, 0, .55); }
.inherit-option { display: flex; align-items: center; gap: 6px; width: 100%; height: 26px; padding: 0 7px; border: 0; border-radius: 4px; background: transparent; color: #c9ccd3; font-size: 12px; text-align: left; cursor: pointer; }
.inherit-option:hover { background: #3a5a8c; color: #fff; }
.inherit-check { width: 14px; flex: 0 0 14px; color: #d8dbe2; font-size: 12px; }
.inherit-divider { height: 1px; margin: 4px; background: #35373d; }
.event-add { height: 28px; border: 1px dashed #536079; border-radius: 6px; background: transparent; color: #9aa7c2; font-size: 11px; cursor: pointer; }
.event-add:hover { border-color: #7774ff; background: #232a45; color: #fff; }
.modifier-switch { font-weight: 600; }
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
@@ -14,19 +14,21 @@
<div class="group-body" v-show="!collapsed.scene">
<div class="tree scene-tree">
<div
v-for="(system, i) in store.systems"
:key="`particle-${system.id}`"
v-for="entry in displaySystems"
:key="`particle-${entry.system.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'particle' && system.id === store.activeObjectId }"
@click="select(i)"
:class="{ on: activeObjectType === 'particle' && entry.system.id === store.activeObjectId, 'scene-object-child': entry.depth > 0 }"
:style="entry.depth > 0 ? { paddingLeft: `${entry.depth * 18}px` } : undefined"
@click="store.selectSystem(entry.system.id)"
>
<span class="tdot">●</span>
<span class="tlabel">{{ system.config.name || 'ParticleSystem' + (i + 1) }}</span>
<button class="scene-object-eye" :class="{ off: system.visible === false }" :title="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="system.visible = system.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
<span v-if="entry.depth > 0" class="sub-branch">└</span>
<span v-else class="tdot">●</span>
<span class="tlabel">{{ entry.system.config.name || 'ParticleSystem' }}</span>
<button class="scene-object-eye" :class="{ off: entry.system.visible === false }" :title="entry.system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="entry.system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="entry.system.visible = entry.system.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="entry.system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">粒子</span>
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(system.id)">✕</button>
<span class="scene-object-kind" :class="{ 'sub-emitter-kind': entry.depth > 0 }">{{ entry.depth > 0 ? '子发射器' : '粒子' }}</span>
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(entry.system.id)">✕</button>
</div>
<div
v-for="collider in store.colliders"
@@ -182,6 +184,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
@@ -189,7 +192,44 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext'
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
type DisplaySystem = { system: (typeof store.systems)[number]; depth: number }
/** 子发射器嵌在父系统下方显示,方便直接选中;移除引用后自动回到普通行。 */
const displaySystems = computed<DisplaySystem[]>(() => {
const byId = new Map(store.systems.map((system) => [system.id, system]))
const parentOf = new Map<number, number>()
for (const system of store.systems) {
if (system.config.eventEnabled === false) continue
for (const rule of system.config.events || []) {
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id) continue
if (!byId.has(rule.targetSystemId) || parentOf.has(rule.targetSystemId)) continue
parentOf.set(rule.targetSystemId, system.id)
}
}
const children = new Map<number, number[]>()
for (const [child, parent] of parentOf) {
const list = children.get(parent)
if (list) list.push(child)
else children.set(parent, [child])
}
const result: DisplaySystem[] = []
const visited = new Set<number>()
const walk = (system: (typeof store.systems)[number], depth: number) => {
if (visited.has(system.id)) return
visited.add(system.id)
result.push({ system, depth })
for (const childId of children.get(system.id) || []) {
const child = byId.get(childId)
if (child) walk(child, depth + 1)
}
}
for (const system of store.systems) if (!parentOf.has(system.id)) walk(system, 0)
// 环形引用兜底:保证所有系统仍然可见、可选中。
for (const system of store.systems) if (!visited.has(system.id)) walk(system, 0)
return result
})
</script>
<style scoped>
@@ -219,11 +259,14 @@ const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, a
.tree-item .tdot.collision-dot { color: #ff7070; }
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
.tree-item .sub-branch { color: #5f7090; font-size: 12px; }
.tree-item .tlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
.scene-object-item { min-height: 28px; cursor: pointer; }
.scene-object-item:hover { background: #202b40; }
.scene-object-child { background: rgba(30, 42, 62, .35); }
.scene-object-kind { color: #75829c; font-size: 10px; }
.scene-object-kind.sub-emitter-kind { color: #8f8cff; }
.scene-object-eye {
width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px;
background: transparent; color: #9aa6bf; cursor: pointer;
@@ -24,6 +24,7 @@ import {
type SpineConnectorHealth,
} from '../../export/spineConnectorClient'
import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming'
import { collectEmitterAncestors } from '../../core/subEmitterIds'
import { LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } from './particlePanelCurves'
import { useParticleExport } from './useParticleExport'
@@ -239,6 +240,7 @@ watchEffect(() => {
// 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。
watchEffect(() => {
const colliderIds = new Set(store.colliders.map((collider) => collider.id))
const systemIds = new Set(store.systems.map((system) => system.id))
for (const system of store.systems) {
const config = ensureEmitterConfig(system.config as any)
const validCollisionIds = config.collisionColliderIds.filter((id) => colliderIds.has(id))
@@ -247,6 +249,16 @@ watchEffect(() => {
config.spawnPathId = 0
if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed'
}
// 目标被删除、指向自身或指向上游父系统(循环依赖)时回退为“无(粒子系统)”。
const ancestors = collectEmitterAncestors(store.systems, system.id)
for (const rule of config.events || []) {
if (
rule.targetSystemId > 0
&& (rule.targetSystemId === system.id || ancestors.has(rule.targetSystemId) || !systemIds.has(rule.targetSystemId))
) {
rule.targetSystemId = 0
}
}
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)
@@ -451,6 +463,7 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode',
'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve',
'trailGridRows', 'trailGridCols', 'collision', 'collisionColliderIds', 'pathFollow',
'eventEnabled', 'events',
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId',
'emitterFollowSpineId', 'emitterFollowBoneName',
'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation',
+66 -1
View File
@@ -1,7 +1,9 @@
import { Container, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig } from '../../core/particleEmitter'
import { ParticleEmitter, ensureEmitterConfig, type ParticleEventInfo } from '../../core/particleEmitter'
import { sampleScenePath } from '../../core/scenePathSampler'
import type { EmitterConfig } from '../../core/particleTypes'
import { seededUnit } from '../../core/particleMath'
import { collectSubEmitterIds } from '../../core/subEmitterIds'
import { followCurveValue } from './stageGeometry'
import type { useParticleStore, CollisionBody } from '../../store/particleStore'
import type { ScenePath } from '../../store/particleStore'
@@ -69,6 +71,9 @@ export function buildPreviewColliders(store: EditorStore, timeSeconds: number):
export class EmitterSync {
readonly emitters = new Map<number, ParticleEmitter>()
private configs = new Map<number, object>()
private store: EditorStore | null = null
private eventsSuspended = false
private subEmitterState = new Map<number, boolean>()
/** 渲染层级:一个插槽对应一个 wrapper(同插槽多系统共用),内部 compensator 抵消插槽骨骼变换。 */
private slotGroups = new Map<string, {
key: string
@@ -87,6 +92,7 @@ export class EmitterSync {
trailTex: Texture<any> | null,
spineRuntime: SpineRuntimeLayer | null = null,
) {
this.store = store
for (const [id, em] of this.emitters) {
const system = store.systems.find((item) => item.id === id)
if (!system || this.configs.get(id) !== system.config) {
@@ -112,10 +118,26 @@ export class EmitterSync {
}
if (this.emitters.has(sys.id)) continue
const em = new ParticleEmitter(sys.config as any)
em.onParticleEvent = (event) => this.handleParticleEvent(sys.id, event)
world.addChild(em)
this.emitters.set(sys.id, em)
this.configs.set(sys.id, sys.config)
}
// 兼容 HMR/旧实例:确保每个发射器都接上事件回调。
const subEmitterIds = collectSubEmitterIds(store.systems)
for (const sys of store.systems) {
const em = this.emitters.get(sys.id)
if (!em) continue
if (!em.onParticleEvent) em.onParticleEvent = (event) => this.handleParticleEvent(sys.id, event)
// 子发射器不自动发射,只由父系统事件驱动。
const isSubEmitter = subEmitterIds.has(sys.id)
if (this.subEmitterState.get(sys.id) !== isSubEmitter) {
// 刚被设为子发射器时清掉它此前自动发射留下的粒子。
if (isSubEmitter) em.clear()
this.subEmitterState.set(sys.id, isSubEmitter)
}
em.setAutoEmission(!isSubEmitter)
}
const activeGroups = new Set<string>()
for (const sys of store.systems) {
@@ -193,6 +215,49 @@ export class EmitterSync {
}
}
/** 循环烘焙期间暂停跨系统事件,避免预热过程向目标系统注入粒子。 */
suspendEvents(value: boolean) {
this.eventsSuspended = value
}
private handleParticleEvent(sourceId: number, event: ParticleEventInfo) {
if (this.eventsSuspended) return
const store = this.store
if (!store) return
const source = store.systems.find((system) => system.id === sourceId)
const config = source?.config
if (!config?.eventEnabled || !Array.isArray(config.events) || !config.events.length) return
// 事件链最多两级,防止跨系统互相触发形成死循环。
if (event.depth >= 2) return
for (const rule of config.events) {
if (rule.event !== event.kind) continue
if (rule.event === 'collision' && event.collisionCount > rule.collisionLimit) continue
if (rule.targetSystemId <= 0 || rule.targetSystemId === sourceId) continue
const probability = Math.min(1, Math.max(0, Number(rule.probability) || 0))
if (probability <= 0) continue
const salt = (Math.imul(rule.id + 1, 0x9e3779b1) ^ Math.imul(event.depth + 1, 0x85ebca6b)) >>> 0
const roll = seededUnit(config.seed >>> 0, event.spawnId + 1, salt)
if (roll >= probability) continue
const target = this.emitters.get(rule.targetSystemId)
if (!target) continue
const inheritAttributes = Array.isArray(rule.inherit) ? rule.inherit : []
const inherit = inheritAttributes.length
? {
attributes: inheritAttributes,
colorMode: rule.colorInheritMode === 'replace' ? 'replace' as const : 'add' as const,
data: {
colorHex: event.colorHex,
scaleX: event.scaleX,
scaleY: event.scaleY,
rotation: event.rotation,
life: event.maxLife,
},
}
: undefined
target.spawnEventParticle(event.worldX, event.worldY, event.depth + 1, inherit)
}
}
/** 卸载时销毁全部发射器。 */
destroy(world: Container) {
for (const group of this.slotGroups.values()) {