解决粒子首位帧衔接问题
This commit is contained in:
1 parent
0656684a92
commit
9c2421110c
22 files changed
+2873
-1407
No files matched your search
+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