Files
SpineParticlesWeb/src/store/particleStore.ts
T
2026-10-22 11:16:50 +08:00

544 lines
20 KiB
TypeScript

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<EmitterConfig> }>) {
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
},
},
})