import { 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' export interface ParticleSystem { id: number visible: boolean /** 导出时所属的动画名称。 */ animation: string config: EmitterConfig /** 录制到的帧序列(每帧 = 该帧活动粒子快照) */ frames?: ParticleState[][] } export interface CollisionBody extends SceneCollider { name: string visible: boolean followPath: boolean followPathId: number followDuration: number followPathRotation: boolean followCurveMode: '跟随导出' | '线性' | '贝塞尔' followCurve: Array<{ x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }> } export interface ScenePath { id: number name: string visible: boolean enabled: boolean x: number y: number rotation: number scaleX: number scaleY: number closed: boolean color: string preset: '' | 'circle' | 'square' | 'polygon' | 'star' points: Array<{ x: number; y: number; inX: number; inY: number; outX: number; outY: number }> } export type SceneObjectType = 'particle' | 'collision' | 'path' | 'spine' export interface EditorState { viewScale: number viewX: number viewY: number } /** 系统设置(可通过右上角设置面板调整) */ export interface SettingsState { /** 参数面板是否显示在页面右侧;关闭时显示在左侧。 */ parameterPanelOnRight: boolean /** 是否启用刻度(主开关;启用=显示坐标轴+刻度线+数值,可调整字号/粗细) */ tickEnabled: boolean /** 坐标轴刻度文字大小(px),默认 12 */ axisFontSize: number /** 刻度线粗细(px) */ tickWidth: number /** 是否显示坐标轴刻度数值 */ showAxisLabels: boolean /** 坐标轴数值颜色(#rrggbb) */ axisColor: string /** 最大撤回操作数。 */ undoLimit: number /** 背景图世界坐标与透明度。 */ backgroundX: number backgroundY: number backgroundOpacity: number backgroundImageName: string backgroundImageData: string showEmitter: boolean showBones: boolean /** 骨骼调试点的屏幕固定尺寸倍率。 */ bonePointDrawSize: number /** 骨骼局部方向轴的屏幕固定尺寸倍率。 */ boneAxisDrawSize: number showSkinMesh: boolean showGrid: boolean /** Spine JSON 导出的全局参数。 */ exportSettings: SpineExportSettings } export interface TimelineState { /** 播放模式 */ playing: boolean /** 循环 */ loop: boolean /** 当前帧 */ frame: number /** 总帧数 */ totalFrames: number /** 自定义播放范围,首尾帧均包含。 */ playbackStart: number playbackEnd: number /** 帧率 */ fps: number /** 是否已录制 */ recorded: boolean /** 当前选中的所属动画。 */ animation: string /** 项目中可供粒子系统归属的动画列表,至少保留一个。 */ animations: string[] } let seq = 1 let colliderSeq = 1 let pathSeq = 1 let spineSeq = 1 function copyName(name: string, names: string[]) { const existing = new Set(names) const base = `${name} Copy` if (!existing.has(base)) return base let index = 2 while (existing.has(`${base} ${index}`)) index++ return `${base} ${index}` } export const useParticleStore = defineStore('particle', { state: () => ({ editor: { viewScale: 1.0, viewX: 0, viewY: 0 } as EditorState, systems: [] as ParticleSystem[], colliders: [] as CollisionBody[], paths: [] as ScenePath[], spines: [] as SpineSceneObject[], activeId: 0, 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, settings: { parameterPanelOnRight: true, tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad', undoLimit: 20, backgroundX: 0, backgroundY: 0, backgroundOpacity: 1, backgroundImageName: '', backgroundImageData: '', showEmitter: true, showBones: false, bonePointDrawSize: 1, boneAxisDrawSize: 2, showSkinMesh: false, showGrid: true, exportSettings: defaultSpineExportSettings(), } as SettingsState, }), getters: { activeSystemCount: (s) => s.systems.length, }, actions: { syncSceneSequences() { seq = Math.max(seq, ...this.systems.map((item) => item.id + 1), 1) colliderSeq = Math.max(colliderSeq, ...this.colliders.map((item) => item.id + 1), 1) pathSeq = Math.max(pathSeq, ...this.paths.map((item) => item.id + 1), 1) spineSeq = Math.max(spineSeq, ...this.spines.map((item) => item.id + 1), 1) }, addSystem(initial?: Partial<{ config: Partial }>) { const cfg = defaultConfig() if (initial?.config) Object.assign(cfg, initial.config) // 自动生成不重复名字:若"ParticleSystemN"已存在,取已有数字最大值+1 const existing = new Set(this.systems.map((s) => s.config.name)) if (existing.has(cfg.name)) { let maxN = 0 for (const s of this.systems) { const m = /^ParticleSystem(\d+)$/.exec(s.config.name) if (m) maxN = Math.max(maxN, parseInt(m[1], 10)) } cfg.name = `ParticleSystem${maxN + 1}` let i = 2 while (existing.has(cfg.name)) { cfg.name = `ParticleSystem${maxN + i}`; i++ } } const animation = this.timeline.animations.includes(this.timeline.animation) ? this.timeline.animation : this.timeline.animations[0] || 'animation' const sys: ParticleSystem = { id: seq++, visible: true, animation, config: cfg } this.systems.push(sys) this.activeId = sys.id this.activeObjectType = 'particle' this.activeObjectId = sys.id return sys }, removeSystem(id: number) { const i = this.systems.findIndex((s) => s.id === id) if (i >= 0) this.systems.splice(i, 1) if (this.activeId === id) this.activeId = this.systems[0]?.id ?? 0 const activeSystem = this.systems.find((system) => system.id === this.activeId) if (activeSystem) this.timeline.animation = activeSystem.animation if (this.activeObjectType === 'particle' && this.activeObjectId === id) { const next = this.systems[0] this.activeObjectId = next?.id ?? 0 } }, selectSystem(id: number) { this.activeId = id this.activeObjectType = 'particle' this.activeObjectId = id const system = this.systems.find((item) => item.id === id) if (system) this.timeline.animation = system.animation }, setSystemAnimation(id: number, animation: string) { if (!this.timeline.animations.includes(animation)) return const system = this.systems.find((item) => item.id === id) if (!system) return system.animation = animation this.timeline.animation = animation }, addAnimation() { const existing = new Set(this.timeline.animations) let index = 2 let name = `animation${index}` while (existing.has(name)) name = `animation${++index}` this.timeline.animations.push(name) this.timeline.animation = name const system = this.systems.find((item) => item.id === this.activeId) if (system) system.animation = name return name }, renameAnimation(previousName: string, nextName: string) { const name = nextName.trim() const index = this.timeline.animations.indexOf(previousName) if (index < 0 || !name || (name !== previousName && this.timeline.animations.includes(name))) return false this.timeline.animations[index] = name for (const system of this.systems) if (system.animation === previousName) system.animation = name if (this.timeline.animation === previousName) this.timeline.animation = name return true }, removeAnimation(name: string) { if (this.timeline.animations.length <= 1) return false const index = this.timeline.animations.indexOf(name) if (index < 0) return false this.timeline.animations.splice(index, 1) const fallback = this.timeline.animations[Math.min(index, this.timeline.animations.length - 1)] for (const system of this.systems) if (system.animation === name) system.animation = fallback if (this.timeline.animation === name) this.timeline.animation = fallback return true }, addCollider() { const id = colliderSeq++ const collider: CollisionBody = { id, name: `Collider${id}`, visible: true, shape: 'circle', enabled: true, x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, radius: 50, radiusX: 70, radiusY: 45, width: 120, height: 80, sides: 6, response: 'physics', elasticity: 0.8, friction: 0.3, followPath: false, followPathId: 0, followDuration: 2, followPathRotation: false, followCurveMode: '跟随导出', followCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], } this.colliders.push(collider) this.activeObjectType = 'collision' this.activeObjectId = collider.id return collider }, addPath() { const id = pathSeq++ const path: ScenePath = { id, name: `Path${id}`, visible: true, enabled: true, x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, closed: false, color: '#ff933e', preset: '', points: [], } this.paths.push(path) this.activeObjectType = 'path' this.activeObjectId = path.id return path }, addSpine() { const id = spineSeq++ const spine: SpineSceneObject = { id, name: `SpineSkeleton${id}`, enabled: true, visible: true, x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, timeOffset: 0, skeletonFileName: '', atlasFileName: '', textureFileNames: [], dataVersion: '', sourceBundle: null, selectedAnimation: '', selectedBone: '', selectedSlot: '', animations: [], bones: [], slots: [], assetVersion: 0, status: 'empty', error: '', } this.spines.push(spine) this.activeObjectType = 'spine' this.activeObjectId = spine.id return spine }, duplicateSelectedObject() { const id = this.activeObjectId if (!id) return null if (this.activeObjectType === 'particle') { const source = this.systems.find((item) => item.id === id) if (!source) return null const copy = cloneEditorValue(source) copy.id = seq++ copy.config.name = copyName(source.config.name, this.systems.map((item) => item.config.name)) // 录制帧属于运行时缓存,副本由相同配置重新独立生成。 copy.frames = undefined this.systems.push(copy) this.activeId = copy.id this.activeObjectId = copy.id this.timeline.animation = copy.animation return copy } if (this.activeObjectType === 'collision') { const source = this.colliders.find((item) => item.id === id) if (!source) return null const copy = cloneEditorValue(source) copy.id = colliderSeq++ copy.name = copyName(source.name, this.colliders.map((item) => item.name)) this.colliders.push(copy) this.activeObjectId = copy.id return copy } if (this.activeObjectType === 'path') { const source = this.paths.find((item) => item.id === id) if (!source) return null const copy = cloneEditorValue(source) copy.id = pathSeq++ copy.name = copyName(source.name, this.paths.map((item) => item.name)) this.paths.push(copy) this.activeObjectId = copy.id return copy } const source = this.spines.find((item) => item.id === id) if (!source) return null const copy = cloneEditorValue(source) copy.id = spineSeq++ copy.name = copyName(source.name, this.spines.map((item) => item.name)) // 骨骼选择是层级树的临时操作状态,不在两个对象之间复用。 copy.selectedBone = '' copy.selectedSlot = '' retainSpineAsset(source.id, copy.id) this.spines.push(copy) this.activeObjectId = copy.id return copy }, selectSceneObject(type: SceneObjectType, id: number) { this.activeObjectType = type this.activeObjectId = id if (type === 'particle') { this.activeId = id const system = this.systems.find((item) => item.id === id) if (system) this.timeline.animation = system.animation } }, removeCollider(id: number) { const index = this.colliders.findIndex((item) => item.id === id) if (index >= 0) this.colliders.splice(index, 1) for (const system of this.systems) { const config = ensureEmitterConfig(system.config as any) config.collisionColliderIds = config.collisionColliderIds.filter((colliderId) => colliderId !== id) } if (this.activeObjectType === 'collision' && this.activeObjectId === id) { const fallback = this.systems[0] this.activeObjectType = fallback ? 'particle' : 'collision' this.activeObjectId = fallback?.id ?? this.colliders[0]?.id ?? 0 } }, removePath(id: number) { const index = this.paths.findIndex((item) => item.id === id) if (index >= 0) this.paths.splice(index, 1) if (this.activeObjectType === 'path' && this.activeObjectId === id) { const fallback = this.systems[0] this.activeObjectType = fallback ? 'particle' : 'path' this.activeObjectId = fallback?.id ?? this.paths[0]?.id ?? 0 } }, removeSpine(id: number) { releaseSpineAsset(id) const index = this.spines.findIndex((item) => item.id === id) if (index >= 0) this.spines.splice(index, 1) if (this.activeObjectType === 'spine' && this.activeObjectId === id) { const particle = this.systems[0] const collider = this.colliders[0] const path = this.paths[0] const spine = this.spines[0] if (particle) { this.activeObjectType = 'particle' this.activeObjectId = particle.id this.activeId = particle.id } else if (collider) { this.activeObjectType = 'collision' this.activeObjectId = collider.id } else if (path) { this.activeObjectType = 'path' this.activeObjectId = path.id } else { this.activeObjectType = 'spine' this.activeObjectId = spine?.id ?? 0 } } this.recalcTotalFrames() }, play() { const range = normalizePlaybackRange(this.timeline.playbackStart, this.timeline.playbackEnd, this.timeline.totalFrames) if (this.timeline.frame < range.start || this.timeline.frame >= range.end) this.timeline.frame = range.start this.timeline.playing = true }, pause() { this.timeline.playing = false }, togglePlay() { if (this.timeline.playing) this.pause() else this.play() }, toStart() { this.timeline.frame = this.timeline.playbackStart }, previousFrame() { this.timeline.playing = false this.timeline.frame = Math.max(this.timeline.playbackStart, this.timeline.frame - 1) }, nextFrame() { this.timeline.playing = false this.timeline.frame = Math.min(this.timeline.playbackEnd, this.timeline.frame + 1) }, toEnd() { this.timeline.playing = false this.timeline.frame = this.timeline.playbackEnd }, toggleLoop() { this.timeline.loop = !this.timeline.loop }, 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) this.timeline.playbackStart = range.start this.timeline.playbackEnd = range.end if (this.timeline.frame < range.start) this.timeline.frame = range.start }, setPlaybackEnd(value: number) { const range = normalizePlaybackRange(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 }, /** 依据所有粒子系统的"最晚消失时间"重算总帧数。 * 固定生命周期使用 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 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) : 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 } if (end > maxSec) maxSec = end } for (const spine of this.spines) { const animation = spine.animations.find((item) => item.name === spine.selectedAnimation) if (!animation) continue const end = Math.max(0, Number(spine.timeOffset) || 0) + Math.max(0, animation.duration || 0) if (end > maxSec) maxSec = end } for (const collider of this.colliders) { if (!collider.followPath || !collider.followPathId) continue const end = Math.max(0, Number(collider.followDuration) || 0) if (end > maxSec) maxSec = end } // totalFrames 表示快照数量;0 秒和末端点都可选,因此比末端帧编号多 1。 // 末帧编号按“时长覆盖到的最后一个整数帧”计算,不能直接用 ceil: // Spine 的小数舍入会让 80 帧的时长变成 80.01 帧,从而凭空多出第 81 帧, // 回放时表现为末帧之后多停一帧。 const previousFrames = this.timeline.totalFrames const frames = Math.max(2, lastFrameOfDuration(maxSec, fps) + 1) this.timeline.totalFrames = frames const range = resizePlaybackRange( { start: this.timeline.playbackStart, end: this.timeline.playbackEnd }, previousFrames, frames, ) this.timeline.playbackStart = range.start this.timeline.playbackEnd = range.end // clamp 当前帧 if (this.timeline.frame >= frames) this.timeline.frame = frames - 1 return frames }, }, })