544 lines
20 KiB
TypeScript
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
|
|
},
|
|
},
|
|
})
|