解决粒子首位帧衔接问题
This commit is contained in:
Vendored
+4
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "单个烟花",
|
||||
"file": "单个烟花.json"
|
||||
},
|
||||
{
|
||||
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
|
||||
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
|
||||
|
||||
Vendored
+571
File diff suppressed because one or more lines are too long
Vendored
-1239
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1239
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-Bx8XTHrS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dg-D8CvM.css">
|
||||
<script type="module" crossorigin src="/assets/index-F1tWOWFT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-qxv07JSp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"test:loop-seam": "node scripts/verify-loop-seam.mjs",
|
||||
"test:timeline": "node scripts/verify-timeline.mjs",
|
||||
"test:spine-json": "node scripts/verify-spine-json.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "单个烟花",
|
||||
"file": "单个烟花.json"
|
||||
},
|
||||
{
|
||||
"name": "粒子-序列帧-拖尾-碰撞-路径-spine",
|
||||
"file": "粒子-序列帧-拖尾-碰撞-路径-spine.json"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,188 @@
|
||||
import type { ParticleEmitter, ParticleState } from './particleEmitter'
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import { loopSegmentTiming } from './loopSegmentTiming'
|
||||
|
||||
export function cloneParticleFrame(frame: ParticleState[]): ParticleState[] {
|
||||
return frame.map((state) => ({
|
||||
...state,
|
||||
trailState: state.trailState
|
||||
? { ...state.trailState, bones: state.trailState.bones.map((bone) => ({ ...bone })) }
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 在粒子不可见时保留骨骼,避免循环片段中途增删骨骼。 */
|
||||
function stabilizeLoopParticleBones(frames: ParticleState[][]) {
|
||||
const templates = new Map<string, ParticleState>()
|
||||
for (const frame of frames) {
|
||||
for (const state of frame) if (!templates.has(state.boneName)) templates.set(state.boneName, cloneParticleFrame([state])[0])
|
||||
}
|
||||
const boneNames = [...templates.keys()].sort((a, b) => {
|
||||
const ai = Number(a.replace(/^p_/, ''))
|
||||
const bi = Number(b.replace(/^p_/, ''))
|
||||
return Number.isFinite(ai) && Number.isFinite(bi) ? ai - bi : a.localeCompare(b)
|
||||
})
|
||||
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
|
||||
const byName = new Map(frames[frameIndex].map((state) => [state.boneName, state]))
|
||||
frames[frameIndex] = boneNames.map((boneName) => {
|
||||
const current = byName.get(boneName)
|
||||
if (current) return current
|
||||
const template = cloneParticleFrame([templates.get(boneName)!])[0]
|
||||
return { ...template, alpha: 0, imageVisible: false, trailState: undefined, active: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 让循环片段的末帧复用首帧的粒子身份与完整状态。 */
|
||||
export function closeLoopFrameSeam(frames: ParticleState[][]) {
|
||||
if (frames.length < 2) return
|
||||
frames[frames.length - 1] = cloneParticleFrame(frames[0])
|
||||
}
|
||||
|
||||
/** 将自然演算的完整过程压缩或拉伸到指定帧数,并保留精确端点。 */
|
||||
function resampleFrames(source: ParticleState[][], durationFrames: number) {
|
||||
const last = Math.max(0, source.length - 1)
|
||||
const result: ParticleState[][] = []
|
||||
for (let frame = 0; frame <= durationFrames; frame++) {
|
||||
const index = Math.min(last, Math.round(frame / durationFrames * last))
|
||||
result.push(cloneParticleFrame(source[index] || []))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function particleIdentity(state: ParticleState) {
|
||||
return `${state.boneName}:${state.spawnId ?? 'legacy'}`
|
||||
}
|
||||
|
||||
function hiddenParticleState(state: ParticleState): ParticleState {
|
||||
return {
|
||||
...cloneParticleFrame([state])[0],
|
||||
alpha: 0,
|
||||
imageVisible: false,
|
||||
trailState: undefined,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动段只反推衔接帧中这一批粒子,不把内部预热的多轮发射直接拿来播放。
|
||||
* 已在衔接帧出现的粒子沿自己的出生历史前进;出生前保持隐藏。
|
||||
* 衔接帧中原本不可见的占位骨骼则全程保持衔接状态。
|
||||
*/
|
||||
function buildLoopStartFrames(
|
||||
warmup: ParticleState[][],
|
||||
seam: ParticleState[],
|
||||
durationFrames: number,
|
||||
maxLifeFrames: number,
|
||||
) {
|
||||
const tailLength = Math.min(warmup.length, maxLifeFrames + 1)
|
||||
const tail = warmup.slice(Math.max(0, warmup.length - tailLength))
|
||||
const histories = new Map<string, Map<number, ParticleState>>()
|
||||
|
||||
for (const seamState of seam) {
|
||||
if (seamState.imageVisible === false || seamState.alpha <= 0) continue
|
||||
const identity = particleIdentity(seamState)
|
||||
const history = new Map<number, ParticleState>()
|
||||
// 只接受与末端相连的同一次出生记录,避免骨骼池复用时串入更早的循环。
|
||||
let foundLatest = false
|
||||
for (let index = tail.length - 1; index >= 0; index--) {
|
||||
const match = tail[index].find((state) => particleIdentity(state) === identity)
|
||||
if (match) {
|
||||
foundLatest = true
|
||||
history.set(index, match)
|
||||
} else if (foundLatest) {
|
||||
break
|
||||
}
|
||||
}
|
||||
histories.set(identity, history)
|
||||
}
|
||||
|
||||
const last = Math.max(0, tail.length - 1)
|
||||
const frames: ParticleState[][] = []
|
||||
for (let frame = 0; frame <= durationFrames; frame++) {
|
||||
const sourceIndex = Math.min(last, Math.round(frame / durationFrames * last))
|
||||
frames.push(seam.map((seamState) => {
|
||||
if (seamState.imageVisible === false || seamState.alpha <= 0) return cloneParticleFrame([seamState])[0]
|
||||
const historyState = histories.get(particleIdentity(seamState))?.get(sourceIndex)
|
||||
return historyState ? cloneParticleFrame([historyState])[0] : hiddenParticleState(seamState)
|
||||
}))
|
||||
}
|
||||
// 无论采样取整如何,两个片段共用的边界都必须逐字段完全一致。
|
||||
frames[0] = frames[0].map(hiddenParticleState)
|
||||
frames[frames.length - 1] = cloneParticleFrame(seam)
|
||||
return frames
|
||||
}
|
||||
|
||||
function appendSegment(target: ParticleState[][], segment: ParticleState[][]) {
|
||||
const start = target.length ? 1 : 0
|
||||
for (let index = start; index < segment.length; index++) target.push(cloneParticleFrame(segment[index]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 烘焙循环持续的三类独立片段。
|
||||
* 边界约定:启动末帧 = 循环首帧 = 循环末帧 = 结束首帧;
|
||||
* 启动与结束片段本身不闭合,结束末帧保持粒子消亡后的空状态。
|
||||
*/
|
||||
export function bakeLoopFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number) {
|
||||
const config = system.config
|
||||
const frameDuration = 1 / Math.max(1, fps)
|
||||
const maxLife = config.lifeMode === 'fixed'
|
||||
? Math.max(0, Number(config.lifeMin) || 0)
|
||||
: Math.max(0, Number(config.lifeMin) || 0, Number(config.lifeMax) || 0)
|
||||
const timing = loopSegmentTiming(config, fps)
|
||||
const maxLifeFrames = Math.max(1, Math.ceil(maxLife * fps))
|
||||
const warmupCycles = Math.max(1, Math.ceil(maxLifeFrames / timing.loop))
|
||||
const delayFrames = Math.max(0, Math.ceil(Math.max(0, Number(config.delay) || 0) * fps))
|
||||
const frames: ParticleState[][] = []
|
||||
|
||||
const prepareSeam = (captureWarmup: boolean) => {
|
||||
const warmup: ParticleState[][] = captureWarmup ? [[]] : []
|
||||
emitter.reset()
|
||||
for (let cycle = 0; cycle < warmupCycles; cycle++) {
|
||||
if (cycle > 0) emitter.restartEmissionCycle()
|
||||
for (let index = 0; index < timing.loop; index++) {
|
||||
emitter.update(frameDuration, 'force')
|
||||
if (captureWarmup) warmup.push(emitter.capture())
|
||||
}
|
||||
}
|
||||
return { seam: emitter.capture(), warmup }
|
||||
}
|
||||
|
||||
const prepared = prepareSeam(config.generateLoopStartAnimation)
|
||||
const seam = cloneParticleFrame(prepared.seam)
|
||||
const loopFrames: ParticleState[][] = [cloneParticleFrame(seam)]
|
||||
emitter.restartEmissionCycle()
|
||||
for (let index = 0; index < timing.loop; index++) {
|
||||
emitter.update(frameDuration, 'force')
|
||||
loopFrames.push(emitter.capture())
|
||||
}
|
||||
stabilizeLoopParticleBones(loopFrames)
|
||||
closeLoopFrameSeam(loopFrames)
|
||||
|
||||
for (let index = 0; index < delayFrames; index++) frames.push([])
|
||||
if (config.generateLoopStartAnimation) {
|
||||
const startFrames = buildLoopStartFrames(prepared.warmup, loopFrames[0], timing.start, maxLifeFrames)
|
||||
for (const frame of startFrames) frames.push(cloneParticleFrame(frame))
|
||||
} else {
|
||||
frames.push(cloneParticleFrame(loopFrames[0]))
|
||||
}
|
||||
appendSegment(frames, loopFrames)
|
||||
|
||||
if (config.generateLoopEndAnimation) {
|
||||
prepareSeam(false)
|
||||
const naturalEnd: ParticleState[][] = [cloneParticleFrame(loopFrames[0])]
|
||||
const safetyFrames = Math.max(1, maxLifeFrames + 2)
|
||||
for (let index = 0; index < safetyFrames && emitter.activeCount > 0; index++) {
|
||||
emitter.update(frameDuration, 'off')
|
||||
naturalEnd.push(emitter.capture())
|
||||
}
|
||||
if (naturalEnd[naturalEnd.length - 1]?.length) naturalEnd.push([])
|
||||
appendSegment(frames, resampleFrames(naturalEnd, timing.end))
|
||||
}
|
||||
|
||||
const finalFrame = cloneParticleFrame(frames[frames.length - 1] || [])
|
||||
if (frames.length > totalFrames) frames.length = totalFrames
|
||||
while (frames.length < totalFrames) frames.push(cloneParticleFrame(finalFrame))
|
||||
system.frames = frames
|
||||
emitter.apply(frames[0] || [])
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const DEFAULT_LOOP_TRANSITION_FRAMES = 30
|
||||
|
||||
export type LoopSegmentConfig = {
|
||||
loopDurationFrames: number
|
||||
generateLoopStartAnimation: boolean
|
||||
generateLoopEndAnimation: boolean
|
||||
loopStartUseCustomDuration?: boolean
|
||||
loopEndUseCustomDuration?: boolean
|
||||
loopStartDurationFrames: number
|
||||
loopEndDurationFrames: number
|
||||
lifeMode?: string
|
||||
lifeMin?: number
|
||||
lifeMax?: number
|
||||
}
|
||||
|
||||
export type DelayedLoopSegmentConfig = LoopSegmentConfig & { delay?: number }
|
||||
|
||||
export function positiveFrameCount(value: unknown, fallback = 1) {
|
||||
return Math.min(1800, Math.max(1, Math.ceil(Number(value) || fallback)))
|
||||
}
|
||||
|
||||
export function automaticLoopTransitionFrames(config: LoopSegmentConfig, fps = 30) {
|
||||
const lifeMin = Math.max(0, Number(config.lifeMin) || 0)
|
||||
const lifeMax = Math.max(0, Number(config.lifeMax) || 0)
|
||||
const maxLife = config.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
|
||||
return positiveFrameCount(Math.ceil(maxLife * Math.max(1, fps)), DEFAULT_LOOP_TRANSITION_FRAMES)
|
||||
}
|
||||
|
||||
export function loopSegmentTiming(config: LoopSegmentConfig, fps = 30) {
|
||||
const loop = positiveFrameCount(config.loopDurationFrames)
|
||||
const automatic = automaticLoopTransitionFrames(config, fps)
|
||||
const start = config.generateLoopStartAnimation
|
||||
? config.loopStartUseCustomDuration
|
||||
? positiveFrameCount(config.loopStartDurationFrames, DEFAULT_LOOP_TRANSITION_FRAMES)
|
||||
: automatic
|
||||
: 0
|
||||
const end = config.generateLoopEndAnimation
|
||||
? config.loopEndUseCustomDuration
|
||||
? positiveFrameCount(config.loopEndDurationFrames, DEFAULT_LOOP_TRANSITION_FRAMES)
|
||||
: automatic
|
||||
: 0
|
||||
return { start, loop, end, total: start + loop + end }
|
||||
}
|
||||
|
||||
|
||||
/** 返回时间轴上闭合自循环段的绝对起止帧;结束帧与起始帧是同一衔接状态。 */
|
||||
export function loopPlaybackRange(config: DelayedLoopSegmentConfig, fps: number) {
|
||||
const timing = loopSegmentTiming(config, fps)
|
||||
const delay = Math.max(0, Math.ceil(Math.max(0, Number(config.delay) || 0) * Math.max(1, fps)))
|
||||
const start = delay + timing.start
|
||||
return { start, end: start + timing.loop }
|
||||
}
|
||||
@@ -123,8 +123,16 @@ export interface EmitterConfig {
|
||||
loopDurationFrames: number
|
||||
/** 循环持续模式下是否生成循环开始动画。 */
|
||||
generateLoopStartAnimation: boolean
|
||||
/** 是否手动指定进入循环稳定状态的时长;关闭时按最长粒子生命周期计算。 */
|
||||
loopStartUseCustomDuration: boolean
|
||||
/** 进入循环稳定状态的时长(帧)。 */
|
||||
loopStartDurationFrames: number
|
||||
/** 循环持续模式下是否生成循环结束动画。 */
|
||||
generateLoopEndAnimation: boolean
|
||||
/** 是否手动指定从循环稳定状态消亡的时长;关闭时按最长粒子生命周期计算。 */
|
||||
loopEndUseCustomDuration: boolean
|
||||
/** 从循环稳定状态消亡的时长(帧)。 */
|
||||
loopEndDurationFrames: number
|
||||
burstCount: number // 爆发:单次数量
|
||||
burstLoop: boolean // 爆发是否每秒重复
|
||||
/** 延迟发射(秒)= 粒子条左端位置 */
|
||||
@@ -405,7 +413,11 @@ export function defaultConfig(): EmitterConfig {
|
||||
streamBehavior: 'normal',
|
||||
loopDurationFrames: 30,
|
||||
generateLoopStartAnimation: false,
|
||||
loopStartUseCustomDuration: false,
|
||||
loopStartDurationFrames: 30,
|
||||
generateLoopEndAnimation: false,
|
||||
loopEndUseCustomDuration: false,
|
||||
loopEndDurationFrames: 30,
|
||||
burstCount: 50,
|
||||
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
|
||||
delay: 0,
|
||||
@@ -618,7 +630,10 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
|
||||
if (typeof config.generateLoopStartAnimation !== 'boolean') config.generateLoopStartAnimation = false
|
||||
if (typeof config.generateLoopEndAnimation !== 'boolean') config.generateLoopEndAnimation = false
|
||||
if (config.generateLoopStartAnimation && config.generateLoopEndAnimation) config.generateLoopEndAnimation = false
|
||||
if (typeof config.loopStartUseCustomDuration !== 'boolean') config.loopStartUseCustomDuration = false
|
||||
if (typeof config.loopEndUseCustomDuration !== 'boolean') config.loopEndUseCustomDuration = false
|
||||
config.loopStartDurationFrames = Math.min(1800, Math.max(1, Math.ceil(Number(config.loopStartDurationFrames) || 30)))
|
||||
config.loopEndDurationFrames = Math.min(1800, Math.max(1, Math.ceil(Number(config.loopEndDurationFrames) || 30)))
|
||||
if (!Array.isArray(config.imageResources) || config.imageResources.length === 0) {
|
||||
config.imageResources = defaultConfig().imageResources
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface EditorConfigFile {
|
||||
timeline: {
|
||||
fps: number
|
||||
loop: boolean
|
||||
playbackStart?: number
|
||||
playbackEnd?: number
|
||||
animations: string[]
|
||||
}
|
||||
selection: {
|
||||
@@ -85,6 +87,8 @@ export function createEditorConfig(store: EditorStore): EditorConfigFile {
|
||||
timeline: {
|
||||
fps: store.timeline.fps,
|
||||
loop: store.timeline.loop,
|
||||
playbackStart: store.timeline.playbackStart,
|
||||
playbackEnd: store.timeline.playbackEnd,
|
||||
animations: [...store.timeline.animations],
|
||||
},
|
||||
selection: {
|
||||
@@ -262,6 +266,8 @@ 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.frame = 0
|
||||
store.timeline.recorded = false
|
||||
store.timeline.playing = true
|
||||
@@ -269,6 +275,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
|
||||
if (!store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
|
||||
}
|
||||
store.recalcTotalFrames()
|
||||
store.timeline.frame = store.timeline.playbackStart
|
||||
return config
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SpineExportResult, SpineExportWarning, SpineJsonDocument, SpineJso
|
||||
import { downgradeSpineJsonTo38 } from './spine38JsonExporter'
|
||||
import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, type NumericSample } from './spineKeyframeReducer'
|
||||
import { buildWeightedTrailMesh } from './spineMeshBuilder'
|
||||
import { loopSegmentTiming } from '../core/loopSegmentTiming'
|
||||
|
||||
type Track = { key: string; name: string; states: Array<ParticleState | null> }
|
||||
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
|
||||
@@ -51,10 +52,7 @@ function particleDurationFrames(system: ParticleSystem, fps: number) {
|
||||
if (c.mode !== 'stream') end = delay + life
|
||||
else if (c.streamBehavior !== 'loop') end = delay + Math.max(1 / fps, Number(c.duration) || 0) + life
|
||||
else {
|
||||
const loop = Math.max(1, Math.ceil(Number(c.loopDurationFrames) || 1)) / fps
|
||||
if (c.generateLoopEndAnimation) end = delay + life + 1 / fps
|
||||
else if (c.generateLoopStartAnimation) end = delay + loop * Math.max(1, Math.ceil(life / loop))
|
||||
else end = delay + loop
|
||||
end = delay + loopSegmentTiming(c, fps).total / fps
|
||||
}
|
||||
return Math.max(1, Math.ceil(end * fps) + 1)
|
||||
}
|
||||
|
||||
+34
-19
@@ -4,6 +4,8 @@ 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 { normalizePlaybackRange, resizePlaybackRange } from '../timeline/playbackRange'
|
||||
|
||||
export interface ParticleSystem {
|
||||
id: number
|
||||
@@ -95,6 +97,9 @@ export interface TimelineState {
|
||||
frame: number
|
||||
/** 总帧数 */
|
||||
totalFrames: number
|
||||
/** 自定义播放范围,首尾帧均包含。 */
|
||||
playbackStart: number
|
||||
playbackEnd: number
|
||||
/** 帧率 */
|
||||
fps: number
|
||||
/** 是否已录制 */
|
||||
@@ -130,7 +135,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
activeObjectType: 'particle' as SceneObjectType,
|
||||
activeObjectId: 0,
|
||||
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
|
||||
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
|
||||
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,
|
||||
@@ -439,8 +444,8 @@ export const useParticleStore = defineStore('particle', {
|
||||
this.recalcTotalFrames()
|
||||
},
|
||||
play() {
|
||||
// 非循环播放停在末帧后,再次播放应从头开始;中途暂停则继续当前位置。
|
||||
if (this.timeline.frame >= Math.max(0, this.timeline.totalFrames - 1)) this.timeline.frame = 0
|
||||
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 },
|
||||
@@ -448,23 +453,35 @@ export const useParticleStore = defineStore('particle', {
|
||||
if (this.timeline.playing) this.pause()
|
||||
else this.play()
|
||||
},
|
||||
toStart() { this.timeline.frame = 0 },
|
||||
toStart() { this.timeline.frame = this.timeline.playbackStart },
|
||||
previousFrame() {
|
||||
this.timeline.playing = false
|
||||
this.timeline.frame = Math.max(0, this.timeline.frame - 1)
|
||||
this.timeline.frame = Math.max(this.timeline.playbackStart, this.timeline.frame - 1)
|
||||
},
|
||||
nextFrame() {
|
||||
this.timeline.playing = false
|
||||
this.timeline.frame = Math.min(Math.max(0, this.timeline.totalFrames - 1), this.timeline.frame + 1)
|
||||
this.timeline.frame = Math.min(this.timeline.playbackEnd, this.timeline.frame + 1)
|
||||
},
|
||||
toEnd() {
|
||||
this.timeline.playing = false
|
||||
this.timeline.frame = Math.max(0, this.timeline.totalFrames - 1)
|
||||
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)+ 最大粒子生命,
|
||||
@@ -486,17 +503,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
: 0
|
||||
let end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife
|
||||
if (c.mode === 'stream' && c.streamBehavior === 'loop') {
|
||||
if (c.generateLoopEndAnimation) {
|
||||
// 结束段从循环衔接帧开始,立即停止生成;额外保留一帧用于明确记录“全部死亡”。
|
||||
end = Math.max(0, Number(c.delay) || 0) + particleLife + 1 / fps
|
||||
} else if (c.generateLoopStartAnimation) {
|
||||
// 启动段从空状态自然预热到稳定循环衔接帧;寿命跨越多个周期时自动增加预热周期。
|
||||
const warmupCycles = Math.max(1, Math.ceil(particleLife / emissionDuration))
|
||||
end = Math.max(0, Number(c.delay) || 0) + emissionDuration * warmupCycles
|
||||
} else {
|
||||
// 纯循环段延迟期间保持衔接帧,之后播放一个完整周期,不追加常规生命周期尾段。
|
||||
end = Math.max(0, Number(c.delay) || 0) + emissionDuration
|
||||
}
|
||||
end = Math.max(0, Number(c.delay) || 0) + loopSegmentTiming(c, fps).total / fps
|
||||
}
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
@@ -512,8 +519,16 @@ export const useParticleStore = defineStore('particle', {
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
// totalFrames 表示快照数量;0 秒和末端点都可选,因此比末端帧编号多 1。
|
||||
const frames = Math.max(1, Math.ceil(maxSec * fps) + 1)
|
||||
const previousFrames = this.timeline.totalFrames
|
||||
const frames = Math.max(2, Math.ceil(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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type PlaybackRange = { start: number; end: number }
|
||||
|
||||
export function normalizePlaybackRange(start: unknown, end: unknown, totalFrames: number): PlaybackRange {
|
||||
const last = Math.max(1, Math.floor(totalFrames) - 1)
|
||||
const safeStart = Math.min(last - 1, Math.max(0, Math.floor(Number(start) || 0)))
|
||||
const safeEnd = Math.min(last, Math.max(safeStart + 1, Math.floor(Number(end) || last)))
|
||||
return { start: safeStart, end: safeEnd }
|
||||
}
|
||||
|
||||
export function resizePlaybackRange(range: PlaybackRange, previousTotal: number, nextTotal: number) {
|
||||
const previousLast = Math.max(1, previousTotal - 1)
|
||||
const nextLast = Math.max(1, nextTotal - 1)
|
||||
const end = range.end >= previousLast ? nextLast : range.end
|
||||
return normalizePlaybackRange(range.start, end, nextTotal)
|
||||
}
|
||||
+65
-11
@@ -233,14 +233,38 @@
|
||||
<NumSlider v-if="sys.config.streamBehavior === 'normal'" label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
|
||||
<NumSlider v-else label="发射时长 (f)" :min="1" :max="1800" :step="1" :model-value="loopDurationFrames" @update:model-value="setLoopDurationFrames" />
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="stream-loop-options">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopStartAnimation" @change="setLoopSection('start', $event)" />
|
||||
<span class="switch-ui"></span><span>生成开始循环动画</span>
|
||||
</label>
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopEndAnimation" @change="setLoopSection('end', $event)" />
|
||||
<span class="switch-ui"></span><span>生成结束循环动画</span>
|
||||
</label>
|
||||
<div class="loop-section-row">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopStartAnimation" @change="setLoopSection('start', $event)" />
|
||||
<span class="switch-ui"></span><span>生成开始循环动画</span>
|
||||
</label>
|
||||
<div v-if="sys.config.generateLoopStartAnimation" class="loop-duration-control">
|
||||
<label class="switch-row loop-duration-toggle">
|
||||
<input type="checkbox" :checked="sys.config.loopStartUseCustomDuration" @change="setLoopCustomDuration('start', $event)" />
|
||||
<span class="switch-ui"></span><span>指定时长</span>
|
||||
</label>
|
||||
<label class="loop-section-duration">
|
||||
<input v-if="sys.config.loopStartUseCustomDuration" :value="sys.config.loopStartDurationFrames" type="number" min="1" max="1800" step="1" aria-label="开始循环段时长" @change="setLoopSectionDuration('start', $event)" /><span v-if="sys.config.loopStartUseCustomDuration">f</span>
|
||||
<span v-else class="loop-auto-duration">自动 {{ automaticLoopDuration }}f</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loop-section-row">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopEndAnimation" @change="setLoopSection('end', $event)" />
|
||||
<span class="switch-ui"></span><span>生成结束循环动画</span>
|
||||
</label>
|
||||
<div v-if="sys.config.generateLoopEndAnimation" class="loop-duration-control">
|
||||
<label class="switch-row loop-duration-toggle">
|
||||
<input type="checkbox" :checked="sys.config.loopEndUseCustomDuration" @change="setLoopCustomDuration('end', $event)" />
|
||||
<span class="switch-ui"></span><span>指定时长</span>
|
||||
</label>
|
||||
<label class="loop-section-duration">
|
||||
<input v-if="sys.config.loopEndUseCustomDuration" :value="sys.config.loopEndDurationFrames" type="number" min="1" max="1800" step="1" aria-label="结束循环段时长" @change="setLoopSectionDuration('end', $event)" /><span v-if="sys.config.loopEndUseCustomDuration">f</span>
|
||||
<span v-else class="loop-auto-duration">自动 {{ automaticLoopDuration }}f</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="modifier-hint stream-loop-hint">循环持续模式不支持骨骼跟随和路径跟随;进入或退出时会将粒子跟随设置恢复默认值。</div>
|
||||
</template>
|
||||
@@ -897,6 +921,7 @@ import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from
|
||||
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
|
||||
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
|
||||
import { validateSpineJson } from '../export/spineJsonValidator'
|
||||
import { automaticLoopTransitionFrames } from '../core/loopSegmentTiming'
|
||||
|
||||
const store = useParticleStore()
|
||||
store.settings.exportSettings = ensureSpineExportSettings(store.settings.exportSettings)
|
||||
@@ -1022,6 +1047,9 @@ const activeIdx = computed(() => {
|
||||
return i >= 0 ? i : 0
|
||||
})
|
||||
const sys = computed(() => store.systems[activeIdx.value] || null)
|
||||
const automaticLoopDuration = computed(() => sys.value
|
||||
? automaticLoopTransitionFrames(sys.value.config, store.timeline.fps)
|
||||
: 30)
|
||||
const loopDurationFrames = computed(() => {
|
||||
const config = sys.value?.config
|
||||
if (!config) return 1
|
||||
@@ -1180,13 +1208,27 @@ function setLoopSection(section: 'start' | 'end', event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
if (section === 'start') {
|
||||
config.generateLoopStartAnimation = checked
|
||||
if (checked) config.generateLoopEndAnimation = false
|
||||
} else {
|
||||
config.generateLoopEndAnimation = checked
|
||||
if (checked) config.generateLoopStartAnimation = false
|
||||
}
|
||||
}
|
||||
|
||||
function setLoopSectionDuration(section: 'start' | 'end', event: Event) {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
const frames = Math.min(1800, Math.max(1, Math.ceil(Number((event.target as HTMLInputElement).value) || 30)))
|
||||
if (section === 'start') config.loopStartDurationFrames = frames
|
||||
else config.loopEndDurationFrames = frames
|
||||
}
|
||||
|
||||
function setLoopCustomDuration(section: 'start' | 'end', event: Event) {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
if (section === 'start') config.loopStartUseCustomDuration = checked
|
||||
else config.loopEndUseCustomDuration = checked
|
||||
}
|
||||
|
||||
function onEmitterFollowModeChange(event: Event, config: {
|
||||
emitterFollowMode: 'none' | 'path' | 'bone'
|
||||
emitterFollowSpineId: number
|
||||
@@ -1223,7 +1265,9 @@ type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export'
|
||||
|
||||
const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
emitMode: [
|
||||
'mode', 'rate', 'streamBehavior', 'loopDurationFrames', 'generateLoopStartAnimation', 'generateLoopEndAnimation',
|
||||
'mode', 'rate', 'streamBehavior', 'loopDurationFrames',
|
||||
'generateLoopStartAnimation', 'loopStartUseCustomDuration', 'loopStartDurationFrames',
|
||||
'generateLoopEndAnimation', 'loopEndUseCustomDuration', 'loopEndDurationFrames',
|
||||
'burstCount', 'burstLoop', 'delay', 'duration',
|
||||
],
|
||||
shape: ['shape', 'radius', 'rectW', 'rectH', 'direction', 'spread', 'coneAngle'],
|
||||
@@ -1888,6 +1932,16 @@ function toggleResourceLock(resourceId: number) {
|
||||
.segs { display: flex; gap: 6px; margin: 6px 0; }
|
||||
.stream-behavior-segs { margin-top: 10px; }
|
||||
.stream-loop-options { display: grid; gap: 8px; margin: 8px 0 4px; padding: 9px 10px; border: 1px solid #32405a; border-radius: 6px; background: #171f2e; }
|
||||
.loop-section-row { display: flex; min-height: 27px; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.loop-duration-control { display: flex; flex: 0 0 auto; align-items: center; gap: 7px; }
|
||||
.loop-duration-toggle { gap: 5px; font-size: 10px; white-space: nowrap; }
|
||||
.loop-duration-toggle .switch-ui { width: 26px; height: 14px; }
|
||||
.loop-duration-toggle .switch-ui::after { width: 10px; height: 10px; }
|
||||
.loop-duration-toggle input:checked + .switch-ui::after { transform: translateX(12px); }
|
||||
.loop-section-duration { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; color: #7f8ba4; font-size: 11px; }
|
||||
.loop-section-duration input { position: static; width: 54px; height: 25px; padding: 0 6px; border: 1px solid #3a465f; border-radius: 5px; outline: none; background: #101725; color: #e4e8f2; text-align: right; opacity: 1; pointer-events: auto; }
|
||||
.loop-section-duration input:focus { border-color: #716eff; }
|
||||
.loop-auto-duration { min-width: 54px; color: #77839a; text-align: right; white-space: nowrap; }
|
||||
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
|
||||
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||
.mini-btn { background: #2e3a55; border: 1px solid #3a4a6a; color: #cdf; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
|
||||
|
||||
+19
-106
@@ -188,6 +188,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { bakeLoopFrames } from '../core/loopFrameBaker'
|
||||
import { loopPlaybackRange } from '../core/loopSegmentTiming'
|
||||
import { useParticleStore, type CollisionBody, type ParticleSystem, type ScenePath } from '../store/particleStore'
|
||||
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
||||
import { editorHistoryState, installEditorHistory, setUndoLimit, undoLastOperation } from '../editor/editorHistory'
|
||||
@@ -1171,108 +1173,6 @@ function appendStateBones(target: BonePreview[], state: ParticleState) {
|
||||
})
|
||||
}
|
||||
|
||||
function cloneParticleFrame(frame: ParticleState[]): ParticleState[] {
|
||||
return frame.map((state) => ({
|
||||
...state,
|
||||
trailState: state.trailState
|
||||
? { ...state.trailState, bones: state.trailState.bones.map((bone) => ({ ...bone })) }
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯循环段保持同一组粒子骨骼贯穿整个片段。
|
||||
* 某一粒子在正常生命周期之外不删除骨骼,而是生成 alpha=0 的“假死”快照;
|
||||
* 因此视觉上仍按正常时机消失,结构上却不会因骨骼增删破坏首尾衔接。
|
||||
*/
|
||||
function stabilizeLoopParticleBones(frames: ParticleState[][]) {
|
||||
const templates = new Map<string, ParticleState>()
|
||||
for (const frame of frames) {
|
||||
for (const state of frame) if (!templates.has(state.boneName)) templates.set(state.boneName, cloneParticleFrame([state])[0])
|
||||
}
|
||||
const boneNames = [...templates.keys()].sort((a, b) => {
|
||||
const ai = Number(a.replace(/^p_/, ''))
|
||||
const bi = Number(b.replace(/^p_/, ''))
|
||||
return Number.isFinite(ai) && Number.isFinite(bi) ? ai - bi : a.localeCompare(b)
|
||||
})
|
||||
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
|
||||
const byName = new Map(frames[frameIndex].map((state) => [state.boneName, state]))
|
||||
frames[frameIndex] = boneNames.map((boneName) => {
|
||||
const current = byName.get(boneName)
|
||||
if (current) return current
|
||||
const template = cloneParticleFrame([templates.get(boneName)!])[0]
|
||||
return { ...template, alpha: 0, imageVisible: false, trailState: undefined, active: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预先生成循环持续的独立片段。
|
||||
* 三种片段使用相同种子、相同固定步长和相同预热步数,因此启动末帧、纯循环首尾帧、结束首帧完全一致。
|
||||
*/
|
||||
function bakeLoopFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number) {
|
||||
const config = system.config
|
||||
const frameDuration = 1 / Math.max(1, fps)
|
||||
const maxLife = config.lifeMode === 'fixed'
|
||||
? Math.max(0, Number(config.lifeMin) || 0)
|
||||
: Math.max(0, Number(config.lifeMin) || 0, Number(config.lifeMax) || 0)
|
||||
const durationFrames = Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
|
||||
const maxLifeFrames = Math.max(1, Math.ceil(maxLife * fps))
|
||||
const warmupCycles = Math.max(1, Math.ceil(maxLifeFrames / durationFrames))
|
||||
const delayFrames = Math.max(0, Math.ceil(Math.max(0, Number(config.delay) || 0) * fps))
|
||||
const frames: ParticleState[][] = []
|
||||
|
||||
emitter.reset()
|
||||
if (config.generateLoopStartAnimation) {
|
||||
for (let index = 0; index < delayFrames; index++) frames.push([])
|
||||
// 启动段的第一帧严格为空;随后自然预热到和纯循环段完全相同的稳定衔接状态。
|
||||
frames.push([])
|
||||
for (let cycle = 0; cycle < warmupCycles; cycle++) {
|
||||
if (cycle > 0) emitter.restartEmissionCycle()
|
||||
for (let index = 0; index < durationFrames; index++) {
|
||||
emitter.update(frameDuration, 'force')
|
||||
frames.push(emitter.capture())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 预跑足够多的完整周期,让跨周期存活的粒子进入稳定分布。
|
||||
for (let cycle = 0; cycle < warmupCycles; cycle++) {
|
||||
if (cycle > 0) emitter.restartEmissionCycle()
|
||||
for (let index = 0; index < durationFrames; index++) emitter.update(frameDuration, 'force')
|
||||
}
|
||||
const seam = emitter.capture()
|
||||
// 纯循环和结束段在延迟期间保持循环衔接帧;延迟结束后才开始推进周期或消亡。
|
||||
for (let index = 0; index < delayFrames; index++) frames.push(cloneParticleFrame(seam))
|
||||
frames.push(cloneParticleFrame(seam))
|
||||
|
||||
if (config.generateLoopEndAnimation) {
|
||||
// 结束段从衔接帧开始,停止产生新粒子,直到粒子和其拖尾全部消失。
|
||||
const safetyFrames = Math.max(1, Math.ceil(maxLife * fps) + 2)
|
||||
for (let index = 0; index < safetyFrames && emitter.activeCount > 0; index++) {
|
||||
emitter.update(frameDuration, 'off')
|
||||
frames.push(emitter.capture())
|
||||
}
|
||||
if (frames[frames.length - 1]?.length) frames.push([])
|
||||
} else {
|
||||
// 重新开始相同的发射周期,并保留跨周期粒子;末端点由真实演算产生,不再硬复制首帧。
|
||||
emitter.restartEmissionCycle()
|
||||
for (let index = 0; index < durationFrames; index++) {
|
||||
emitter.update(frameDuration, 'force')
|
||||
frames.push(emitter.capture())
|
||||
}
|
||||
stabilizeLoopParticleBones(frames)
|
||||
}
|
||||
}
|
||||
|
||||
const finalFrame = cloneParticleFrame(frames[frames.length - 1] || [])
|
||||
if (frames.length > totalFrames) frames.length = totalFrames
|
||||
while (frames.length < totalFrames) frames.push(cloneParticleFrame(finalFrame))
|
||||
// 循环持续拥有自己的衔接帧规则:纯循环和结束段允许第 0 帧保留骨骼;
|
||||
// 启动段则已在上方按定义生成空首帧。
|
||||
system.frames = frames
|
||||
emitter.apply(frames[0] || [])
|
||||
}
|
||||
|
||||
function drawBones(bones: BonePreview[]) {
|
||||
if (!boneGfx) return
|
||||
const g = boneGfx
|
||||
@@ -1676,7 +1576,10 @@ function loop() {
|
||||
if (emitter && !(sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop')) sys.frames[tl.frame] = emitter.capture()
|
||||
}
|
||||
tl.frame++
|
||||
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true }
|
||||
if (tl.frame >= tl.totalFrames) {
|
||||
tl.frame = tl.playbackStart
|
||||
tl.recorded = true
|
||||
}
|
||||
} else {
|
||||
// 回放阶段:每经过一个固定帧时长才应用下一帧。
|
||||
for (const sys of store.systems) {
|
||||
@@ -1691,9 +1594,19 @@ function loop() {
|
||||
}
|
||||
}
|
||||
tl.frame++
|
||||
if (tl.frame >= tl.totalFrames) {
|
||||
if (tl.loop) tl.frame = 0
|
||||
else { tl.frame = tl.totalFrames - 1; tl.playing = false; break }
|
||||
const activeLoopSystem = store.systems.find((system) => system.id === store.activeId)
|
||||
const activeLoopRange = activeLoopSystem?.config.mode === 'stream' && activeLoopSystem.config.streamBehavior === 'loop'
|
||||
? loopPlaybackRange(activeLoopSystem.config, fps)
|
||||
: null
|
||||
// 烘焙数据保留“末帧 = 首帧”以便导出闭环。预览精确循环段时,末帧只作为
|
||||
// 数学边界,不再与下一轮首帧连续显示两次,避免衔接处停顿一帧。
|
||||
const closesActiveLoop = tl.loop
|
||||
&& activeLoopRange?.start === tl.playbackStart
|
||||
&& activeLoopRange.end === tl.playbackEnd
|
||||
const playbackLastFrame = closesActiveLoop ? tl.playbackEnd - 1 : tl.playbackEnd
|
||||
if (tl.frame > playbackLastFrame) {
|
||||
if (tl.loop) tl.frame = tl.playbackStart
|
||||
else { tl.frame = tl.playbackEnd; tl.playing = false; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-22
@@ -10,6 +10,9 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -89,12 +92,15 @@
|
||||
@pointerdown.stop="onBarDown($event, sys)"
|
||||
>
|
||||
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
|
||||
<span class="bar-life" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
|
||||
<span
|
||||
v-if="sys.config.mode === 'stream' && !isLoopEnd(sys)"
|
||||
class="bar-emission"
|
||||
:style="{ width: emissionWidthPx(sys) + 'px' }"
|
||||
>
|
||||
<span class="bar-life" :class="{ 'loop-life': isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
|
||||
<template v-if="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 class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -118,9 +124,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
|
||||
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'
|
||||
|
||||
type SysLike = { id: number; config: any }
|
||||
|
||||
@@ -141,6 +148,11 @@ 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
|
||||
@@ -196,7 +208,13 @@ const viewFrames = ref(120)
|
||||
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
|
||||
const draftDelays = ref<Record<number, number>>({})
|
||||
const draftDurations = ref<Record<number, number>>({})
|
||||
const draftLoopDurations = ref<Record<number, number>>({})
|
||||
const draftSpineStarts = ref<Record<number, number>>({})
|
||||
const playbackStartDraft = ref(String(store.timeline.playbackStart))
|
||||
const playbackEndDraft = ref(String(store.timeline.playbackEnd))
|
||||
|
||||
watch(() => store.timeline.playbackStart, (value) => { playbackStartDraft.value = String(value) })
|
||||
watch(() => store.timeline.playbackEnd, (value) => { playbackEndDraft.value = String(value) })
|
||||
|
||||
const totalFrames = computed(() => store.timeline.totalFrames)
|
||||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
|
||||
@@ -218,6 +236,24 @@ function onSpinePreviewAnimationChange(event: Event) {
|
||||
store.timeline.frame = 0
|
||||
}
|
||||
|
||||
function commitPlaybackStart() {
|
||||
store.setPlaybackStart(Number(playbackStartDraft.value))
|
||||
playbackStartDraft.value = String(store.timeline.playbackStart)
|
||||
}
|
||||
|
||||
function commitPlaybackEnd() {
|
||||
store.setPlaybackEnd(Number(playbackEndDraft.value))
|
||||
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
|
||||
@@ -262,14 +298,9 @@ function barWidthPx(sys: SysLike) {
|
||||
|
||||
function emissionWidthPx(sys: SysLike) {
|
||||
if ((sys.config as any).mode !== 'stream') return 0
|
||||
if (isLoopEnd(sys)) return 0
|
||||
if ((sys.config as any).streamBehavior === 'loop') {
|
||||
const config = sys.config as any
|
||||
const durationFrames = Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
|
||||
if (config.generateLoopStartAnimation) {
|
||||
const warmupCycles = Math.max(1, Math.ceil((maxParticleLife(sys) * fps.value) / durationFrames))
|
||||
return (durationFrames * warmupCycles) / pxPerF()
|
||||
}
|
||||
const durationFrames = draftLoopDurations.value[sys.id] ?? loopSegmentTiming(config, fps.value).loop
|
||||
return durationFrames / pxPerF()
|
||||
}
|
||||
const duration = draftDurations.value[sys.id] ?? Math.max(1 / fps.value, Number((sys.config as any).duration) || 1)
|
||||
@@ -279,18 +310,24 @@ function emissionWidthPx(sys: SysLike) {
|
||||
function lifeWidthPx(sys: SysLike) {
|
||||
const config = sys.config as any
|
||||
if (config.mode === 'stream' && config.streamBehavior === 'loop') {
|
||||
// 启动段和纯循环段的存在时长与发射段完全重合;结束段只显示剩余生命/拖尾。
|
||||
if (!config.generateLoopEndAnimation) return emissionWidthPx(sys)
|
||||
return ((maxParticleLife(sys) * fps.value) + 1) / pxPerF()
|
||||
return loopStartWidthPx(sys) + emissionWidthPx(sys) + loopEndWidthPx(sys)
|
||||
}
|
||||
// 红色底条表示整个系统“仍有粒子存在”的时长:持续发射窗口 + 最长粒子生命。
|
||||
// 爆发模式的发射窗口为 0,因此只保留最长粒子生命。
|
||||
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
function isLoopEnd(sys: SysLike) {
|
||||
function isLoopStream(sys: SysLike) {
|
||||
const config = sys.config as any
|
||||
return config.mode === 'stream' && config.streamBehavior === 'loop' && config.generateLoopEndAnimation
|
||||
return config.mode === 'stream' && config.streamBehavior === 'loop'
|
||||
}
|
||||
|
||||
function loopStartWidthPx(sys: SysLike) {
|
||||
return isLoopStream(sys) ? loopSegmentTiming(sys.config as any, fps.value).start / pxPerF() : 0
|
||||
}
|
||||
|
||||
function loopEndWidthPx(sys: SysLike) {
|
||||
return isLoopStream(sys) ? loopSegmentTiming(sys.config as any, fps.value).end / pxPerF() : 0
|
||||
}
|
||||
|
||||
function selectedSpineDuration(spine: SpineSceneObject) {
|
||||
@@ -358,25 +395,35 @@ function onBarDown(e: PointerEvent, sys: SysLike) {
|
||||
// 拖动粒子条右缘:调整发射时长 duration
|
||||
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
e.stopPropagation()
|
||||
if (isLoopEnd(sys)) return
|
||||
const loopStream = isLoopStream(sys)
|
||||
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
|
||||
const startLoopFrames = loopSegmentTiming(sys.config as any, fps.value).loop
|
||||
const secPerPx = pxPerF() / fps.value
|
||||
const barLeft = (e.currentTarget as HTMLElement).closest('.lane-bar')?.getBoundingClientRect().left ?? e.clientX
|
||||
const startOffsetPx = loopStartWidthPx(sys)
|
||||
let moved = false
|
||||
const move = (ev: PointerEvent) => {
|
||||
moved = true
|
||||
// 使用右端相对粒子条左端的绝对距离换算发射时长。
|
||||
// 因此拖到 60f 就得到 60f,而不是减去粒子生命周期后的剩余帧数。
|
||||
const nd = Math.max(1 / fps.value, Math.min((ev.clientX - barLeft) * secPerPx, 600))
|
||||
draftDurations.value[sys.id] = Math.round(nd * fps.value) / fps.value
|
||||
if (loopStream) {
|
||||
const frames = Math.min(1800, Math.max(1, Math.round((ev.clientX - barLeft - startOffsetPx) / pxPerF())))
|
||||
draftLoopDurations.value[sys.id] = frames
|
||||
} else {
|
||||
const nd = Math.max(1 / fps.value, Math.min((ev.clientX - barLeft) * secPerPx, 600))
|
||||
draftDurations.value[sys.id] = Math.round(nd * fps.value) / fps.value
|
||||
}
|
||||
}
|
||||
const finish = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', cancel)
|
||||
const value = draftDurations.value[sys.id]
|
||||
const loopValue = draftLoopDurations.value[sys.id]
|
||||
delete draftDurations.value[sys.id]
|
||||
if (commit && moved && value != null && value !== startDur) (sys.config as any).duration = value
|
||||
delete draftLoopDurations.value[sys.id]
|
||||
if (commit && moved && loopStream && loopValue != null && loopValue !== startLoopFrames) (sys.config as any).loopDurationFrames = loopValue
|
||||
else if (commit && moved && !loopStream && value != null && value !== startDur) (sys.config as any).duration = value
|
||||
}
|
||||
const up = () => finish(true)
|
||||
const cancel = () => finish(false)
|
||||
@@ -419,6 +466,11 @@ 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-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; }
|
||||
@@ -473,7 +525,12 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
|
||||
.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; }
|
||||
.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; }
|
||||
.bar-loop-transition { position: absolute; top: 0; bottom: 0; z-index: 1; border: 1px solid #7d8799; background: #50596a; box-sizing: border-box; }
|
||||
.bar-loop-start { left: 0; border-radius: 5px 0 0 5px; }
|
||||
.bar-loop-end { border-radius: 0 5px 5px 0; }
|
||||
.bar-handle { position: absolute; z-index: 4; right: -4px; top: -1px; bottom: -1px; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.18); }
|
||||
.bar-handle:hover { background: rgba(255,255,255,0.35); }
|
||||
.spine-ico { color: #c08cff; }
|
||||
|
||||
Reference in New Issue
Block a user