602 lines
32 KiB
Vue
602 lines
32 KiB
Vue
<template>
|
||
<div class="timeline">
|
||
<!-- 控制按钮 -->
|
||
<Teleport defer to="#timeline-controls-host">
|
||
<div class="tl-controls">
|
||
<button class="tl-btn" data-matomo-event="timeline.play-toggle" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? $t('timeline.pause') : $t('timeline.play')">
|
||
{{ store.timeline.playing ? '❚❚' : '▶' }}
|
||
</button>
|
||
<button class="tl-btn" data-matomo-event="timeline.to-start" @click="store.toStart()" :title="$t('timeline.toStart')">⏮</button>
|
||
<button class="tl-btn" data-matomo-event="timeline.previous-frame" @click="store.previousFrame()" :title="$t('timeline.previous')">◀</button>
|
||
<button class="tl-btn" data-matomo-event="timeline.next-frame" @click="store.nextFrame()" :title="$t('timeline.next')">▶</button>
|
||
<button class="tl-btn" data-matomo-event="timeline.to-end" @click="store.toEnd()" :title="$t('timeline.toEnd')">⏭</button>
|
||
<button class="tl-btn" data-matomo-event="timeline.loop-toggle" :class="{ on: store.timeline.loop }" :title="$t('timeline.continuousLoop')" :aria-label="$t('timeline.continuousLoop')" @click="store.toggleLoop()">⟳</button>
|
||
<div class="tl-ab-loop">
|
||
<button class="tl-ab-toggle" data-matomo-event="timeline.ab-toggle" :class="{ on: store.timeline.abLoop }" :title="$t('timeline.abLoop')" @click="store.toggleABLoop()">
|
||
<span>{{ $t('timeline.abLoopShort') }}</span><i class="tl-switch"><b></b></i>
|
||
</button>
|
||
<div v-if="store.timeline.abLoop" class="tl-ab-popover" @pointerdown.stop>
|
||
<div class="tl-ab-title">{{ $t('timeline.abRange') }}</div>
|
||
<label class="tl-range" :title="$t('timeline.playbackStart')"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" :aria-label="$t('timeline.startFrame')" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
|
||
<label class="tl-range" :title="$t('timeline.playbackEnd')"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" :aria-label="$t('timeline.endFrame')" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
|
||
</div>
|
||
</div>
|
||
<span class="tl-frame">{{ $t('timeline.frameStatus', { current: store.timeline.frame, total: Math.max(0, store.timeline.totalFrames - 1) }) }}</span>
|
||
<span class="tl-legend"><i class="legend-emission"></i>{{ $t('timeline.emission') }} <i class="legend-life"></i>{{ $t('timeline.particleLife') }} <i class="legend-spine"></i>{{ $t('timeline.spineAnimation') }}</span>
|
||
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
|
||
<span>{{ $t('timeline.ownerAnimation') }}</span>
|
||
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" :title="$t('timeline.setOwnerAnimation')" @click.stop="toggleAnimationMenu">
|
||
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i>⌄</i>
|
||
</button>
|
||
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
|
||
<div class="tl-animation-menu-title">{{ $t('timeline.chooseOwnerAnimation') }}</div>
|
||
<button
|
||
v-for="animation in store.timeline.animations"
|
||
:key="animation"
|
||
class="tl-animation-option"
|
||
:class="{ on: activeParticleSystem?.animation === animation }"
|
||
@click="selectOwnerAnimation(animation)"
|
||
>
|
||
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation">✓</i>
|
||
</button>
|
||
<div class="tl-animation-editor">
|
||
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" :aria-label="$t('timeline.animationName')" @keydown.enter="renameCurrentAnimation" />
|
||
<button :title="$t('timeline.renameAnimation')" @click="renameCurrentAnimation">{{ $t('timeline.rename') }}</button>
|
||
</div>
|
||
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
|
||
<div class="tl-animation-actions">
|
||
<button @click="addAnimation">{{ $t('timeline.newAnimation') }}</button>
|
||
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">{{ $t('common.delete') }}</button>
|
||
</div>
|
||
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">{{ $t('timeline.keepOneAnimation') }}</div>
|
||
</div>
|
||
</div>
|
||
<label class="tl-anim tl-spine-preview">{{ $t('timeline.spinePreview') }}
|
||
<select
|
||
class="tl-sel"
|
||
:value="previewSpine?.selectedAnimation || ''"
|
||
:disabled="!previewSpine"
|
||
@change="onSpinePreviewAnimationChange"
|
||
>
|
||
<option value="">{{ previewSpine ? $t('timeline.none') : $t('timeline.noSpineAnimation') }}</option>
|
||
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
|
||
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<span class="tl-zoom">{{ zoomLabel }}</span>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- 时间轴视口 -->
|
||
<div
|
||
class="tl-viewport"
|
||
ref="vpRef"
|
||
@wheel.prevent="onWheel"
|
||
@pointerdown="onScrubDown"
|
||
>
|
||
<!-- 帧刻度 -->
|
||
<div class="tl-ticks">
|
||
<div
|
||
v-for="t in ticks"
|
||
:key="t.f"
|
||
class="tl-tickline"
|
||
:style="{ left: t.px + 'px' }"
|
||
>
|
||
<span class="tl-ticklabel">{{ t.f }}f</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 播放头 -->
|
||
<div class="tl-playhead" :style="{ left: playPx + 'px' }"></div>
|
||
|
||
<!-- 每系统一条轨道(粒子条) -->
|
||
<div class="tl-lanes">
|
||
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
|
||
<span class="lane-ico" :class="{ 'sub-lane-ico': isSubEmitter(sys) }" :style="{ left: startMarkerPx(sys) + 'px' }">●</span>
|
||
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
|
||
<div
|
||
class="lane-bar"
|
||
:class="{ 'sub-emitter-bar': isSubEmitter(sys) }"
|
||
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
|
||
@pointerdown.stop="onBarDown($event, sys)"
|
||
>
|
||
<span class="lane-name">{{ (sys.config as any).name || $t('timeline.systemName', { index: i + 1 }) }}</span>
|
||
<span class="bar-life" :class="{ 'loop-life': !isSubEmitter(sys) && isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
|
||
<template v-if="!isSubEmitter(sys) && 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="$t('timeline.dragLoopDuration')" @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="!isSubEmitter(sys) && sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
|
||
<span class="bar-handle" :title="$t('timeline.dragEmissionDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- Spine 动画条:紫色长度由动画数据决定,只允许整体拖动开始位置。 -->
|
||
<div v-for="spine in spineLanes" :key="`spine-${spine.id}`" class="tl-lane spine-lane">
|
||
<span class="lane-ico spine-ico">◆</span>
|
||
<div
|
||
class="spine-bar"
|
||
:style="{ left: spineLeftPx(spine) + 'px', width: spineWidthPx(spine) + 'px' }"
|
||
:title="$t('timeline.dragSpineStart')"
|
||
@pointerdown.stop="onSpineBarDown($event, spine)"
|
||
>
|
||
<span class="lane-name">{{ spine.name }} · {{ spine.selectedAnimation }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">{{ $t('timeline.noAnimationData') }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useParticleStore } from '../store/particleStore'
|
||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||
import { loopSegmentTiming } from '../core/loopSegmentTiming'
|
||
import { collectSubEmitterIds } from '../core/subEmitterIds'
|
||
import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
|
||
|
||
type SysLike = { id: number; config: any }
|
||
|
||
const store = useParticleStore()
|
||
const { t } = useI18n()
|
||
const vpRef = ref<HTMLElement | null>(null)
|
||
const viewportWidth = ref(0)
|
||
let viewportResizeObserver: ResizeObserver | null = null
|
||
const animationMenuRef = ref<HTMLElement | null>(null)
|
||
const animationMenuOpen = ref(false)
|
||
const animationNameDraft = ref('animation')
|
||
const animationError = ref('')
|
||
|
||
// 兼容旧场景与开发热更新:补齐动画列表和粒子系统归属。
|
||
watchEffect(() => {
|
||
if (!Array.isArray(store.timeline.animations) || !store.timeline.animations.length) store.timeline.animations = ['animation']
|
||
if (!store.timeline.animations.includes(store.timeline.animation)) store.timeline.animation = store.timeline.animations[0]
|
||
for (const system of store.systems) {
|
||
if (!system.animation || !store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
|
||
}
|
||
})
|
||
|
||
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
|
||
function toggleAnimationMenu() {
|
||
if (!activeParticleSystem.value) return
|
||
animationMenuOpen.value = !animationMenuOpen.value
|
||
animationNameDraft.value = activeParticleSystem.value.animation
|
||
animationError.value = ''
|
||
}
|
||
|
||
function selectOwnerAnimation(animation: string) {
|
||
const system = activeParticleSystem.value
|
||
if (!system) return
|
||
store.setSystemAnimation(system.id, animation)
|
||
animationNameDraft.value = animation
|
||
animationError.value = ''
|
||
}
|
||
|
||
function addAnimation() {
|
||
const name = store.addAnimation()
|
||
animationNameDraft.value = name
|
||
animationError.value = ''
|
||
}
|
||
|
||
function renameCurrentAnimation() {
|
||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||
const next = animationNameDraft.value.trim()
|
||
if (!next) { animationError.value = t('timeline.animationNameEmpty'); return }
|
||
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = t('timeline.animationNameDuplicate'); return }
|
||
if (!store.renameAnimation(current, next)) { animationError.value = t('timeline.animationRenameFailed'); return }
|
||
animationNameDraft.value = next
|
||
animationError.value = ''
|
||
}
|
||
|
||
function deleteCurrentAnimation() {
|
||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||
if (!store.removeAnimation(current)) { animationError.value = t('timeline.keepOneAnimation'); return }
|
||
const next = activeParticleSystem.value?.animation || store.timeline.animation
|
||
animationNameDraft.value = next
|
||
animationError.value = ''
|
||
}
|
||
|
||
function onDocumentPointerDown(event: PointerEvent) {
|
||
if (!animationMenuRef.value?.contains(event.target as Node)) animationMenuOpen.value = false
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||
const viewport = vpRef.value
|
||
if (!viewport) return
|
||
viewportWidth.value = viewport.clientWidth
|
||
viewportResizeObserver = new ResizeObserver(([entry]) => {
|
||
viewportWidth.value = Math.max(1, entry.contentRect.width)
|
||
})
|
||
viewportResizeObserver.observe(viewport)
|
||
})
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||
viewportResizeObserver?.disconnect()
|
||
viewportResizeObserver = null
|
||
})
|
||
|
||
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
|
||
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
|
||
/** 视口当前能显示的帧数(滚轮缩放它),默认至少显示完整总时长。 */
|
||
const viewFrames = ref(Math.max(120, store.timeline.totalFrames))
|
||
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
|
||
// 避免 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)
|
||
// 子发射器延长时间轴后自动扩大预览范围;用户手动放大后保持其缩放。
|
||
watch(totalFrames, (value) => {
|
||
if (value > viewFrames.value) viewFrames.value = Math.min(2000, value)
|
||
})
|
||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? t('timeline.zoomIn') : viewFrames.value >= 300 ? t('timeline.zoomOut') : t('timeline.zoomFit')))
|
||
const spineLanes = computed(() => store.spines.filter((spine) =>
|
||
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
|
||
))
|
||
const previewSpine = computed(() => {
|
||
const active = store.activeObjectType === 'spine'
|
||
? store.spines.find((spine) => spine.id === store.activeObjectId && spine.animations.length)
|
||
: null
|
||
return active || store.spines.find((spine) => spine.animations.length) || null
|
||
})
|
||
|
||
function onSpinePreviewAnimationChange(event: Event) {
|
||
const spine = previewSpine.value
|
||
if (!spine) return
|
||
spine.selectedAnimation = (event.target as HTMLSelectElement).value
|
||
store.recalcTotalFrames()
|
||
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)
|
||
}
|
||
|
||
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
|
||
function pxPerF() {
|
||
const vw = viewportWidth.value || vpRef.value?.clientWidth || 600
|
||
return viewFrames.value / vw
|
||
}
|
||
|
||
// 帧刻度:从第 0 帧开始,撑满 viewFrames;取整数帧,间隔随缩放自适应
|
||
const ticks = computed(() => {
|
||
const out: { f: number; px: number }[] = []
|
||
const endF = viewFrames.value
|
||
let step = 1
|
||
if (viewFrames.value <= 10) step = 1
|
||
else if (viewFrames.value <= 50) step = 5
|
||
else if (viewFrames.value <= 200) step = 10
|
||
else if (viewFrames.value <= 500) step = 50
|
||
else step = 100
|
||
for (let f = 0; f <= endF; f += step) {
|
||
out.push({ f, px: f / pxPerF() })
|
||
}
|
||
return out
|
||
})
|
||
|
||
const playPx = computed(() => store.timeline.frame / pxPerF())
|
||
|
||
const subEmitterIds = computed(() => collectSubEmitterIds(store.systems))
|
||
const subEmitterTiming = computed(() => subEmitterTriggerTimings(store.systems as any, fps.value))
|
||
|
||
function isSubEmitter(sys: SysLike) {
|
||
return subEmitterIds.value.has(sys.id)
|
||
}
|
||
|
||
// 圆点标记系统启动时刻;子发射器的 delay 只移动时间块,不移动这个标记。
|
||
function startMarkerPx(sys: SysLike) {
|
||
const start = isSubEmitter(sys) ? subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0 : 0
|
||
return (start * fps.value) / pxPerF() + 4
|
||
}
|
||
|
||
function barLeftPx(sys: SysLike) {
|
||
const s = (sys.config as any)
|
||
if (isSubEmitter(sys)) {
|
||
// 子发射器从第一次父事件 + 自身延迟开始;拖左最多到事件发生位置。
|
||
const firstTrigger = subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0
|
||
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
|
||
return ((firstTrigger + delay) * fps.value) / pxPerF()
|
||
}
|
||
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
|
||
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
|
||
return (delay * fps.value) / pxPerF()
|
||
}
|
||
|
||
function maxParticleLife(sys: SysLike) {
|
||
const s = (sys.config as any)
|
||
const lifeMin = Math.max(0, Number(s.lifeMin) || 0)
|
||
const lifeMax = Math.max(0, Number(s.lifeMax) || 0)
|
||
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
|
||
}
|
||
|
||
function barWidthPx(sys: SysLike): number {
|
||
if (isSubEmitter(sys)) {
|
||
const timing = subEmitterTiming.value.get(sys.id)
|
||
const start = timing?.firstTrigger ?? 0
|
||
const end = timing?.end ?? start
|
||
return (Math.max(0, end - start) * fps.value) / pxPerF()
|
||
}
|
||
return lifeWidthPx(sys)
|
||
}
|
||
|
||
function emissionWidthPx(sys: SysLike) {
|
||
if (isSubEmitter(sys)) return 0
|
||
if ((sys.config as any).mode !== 'stream') return 0
|
||
if ((sys.config as any).streamBehavior === 'loop') {
|
||
const config = sys.config as any
|
||
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)
|
||
return (duration * fps.value) / pxPerF()
|
||
}
|
||
|
||
function lifeWidthPx(sys: SysLike): number {
|
||
if (isSubEmitter(sys)) return barWidthPx(sys)
|
||
const config = sys.config as any
|
||
if (config.mode === 'stream' && config.streamBehavior === 'loop') {
|
||
return loopStartWidthPx(sys) + emissionWidthPx(sys) + loopEndWidthPx(sys)
|
||
}
|
||
// 红色底条表示整个系统“仍有粒子存在”的时长:持续发射窗口 + 最长粒子生命。
|
||
// 爆发模式的发射窗口为 0,因此只保留最长粒子生命。
|
||
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
|
||
}
|
||
|
||
function isLoopStream(sys: SysLike) {
|
||
const config = sys.config as any
|
||
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) {
|
||
return spine.animations.find((animation) => animation.name === spine.selectedAnimation)?.duration || 0
|
||
}
|
||
|
||
function spineLeftPx(spine: SpineSceneObject) {
|
||
const start = draftSpineStarts.value[spine.id] ?? Math.max(0, Number(spine.timeOffset) || 0)
|
||
return (start * fps.value) / pxPerF()
|
||
}
|
||
|
||
function spineWidthPx(spine: SpineSceneObject) {
|
||
return (selectedSpineDuration(spine) * fps.value) / pxPerF()
|
||
}
|
||
|
||
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
|
||
function onWheel(e: WheelEvent) {
|
||
const factor = e.deltaY < 0 ? 0.8 : 1.25 // 滚上放大(显示更少帧),滚下缩小(显示更多帧)
|
||
let nv = viewFrames.value * factor
|
||
nv = Math.min(2000, Math.max(10, nv)) // 10f ~ 2000f
|
||
// 整数帧,最小值 0 起
|
||
viewFrames.value = Math.round(nv)
|
||
}
|
||
|
||
// 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1)
|
||
function onScrubDown(e: PointerEvent) {
|
||
const vp = vpRef.value!
|
||
// 用户接管时间轴时立即暂停播放,否则每帧推进会和拖动抢播放头。
|
||
store.pause()
|
||
const move = (ev: PointerEvent) => {
|
||
const rect = vp.getBoundingClientRect()
|
||
const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value)
|
||
store.timeline.frame = Math.max(0, Math.min(f, totalFrames.value - 1))
|
||
}
|
||
move(e)
|
||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
||
window.addEventListener('pointermove', move)
|
||
window.addEventListener('pointerup', up)
|
||
}
|
||
|
||
// 拖动粒子条:整体移动 = 改 delay
|
||
function onBarDown(e: PointerEvent, sys: SysLike) {
|
||
e.stopPropagation()
|
||
const startX = e.clientX
|
||
const startDelay = Math.max(0, Number((sys.config as any).delay) || 0)
|
||
const secPerPx = pxPerF() / fps.value
|
||
const move = (ev: PointerEvent) => {
|
||
const dSec = (ev.clientX - startX) * secPerPx
|
||
const nd = Math.max(0, Math.min(startDelay + dSec, 600))
|
||
draftDelays.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 = draftDelays.value[sys.id]
|
||
delete draftDelays.value[sys.id]
|
||
if (commit && value != null && value !== startDelay) (sys.config as any).delay = value
|
||
}
|
||
const up = () => finish(true)
|
||
const cancel = () => finish(false)
|
||
window.addEventListener('pointermove', move)
|
||
window.addEventListener('pointerup', up)
|
||
window.addEventListener('pointercancel', cancel)
|
||
}
|
||
|
||
// 拖动粒子条右缘:调整发射时长 duration
|
||
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||
e.stopPropagation()
|
||
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,而不是减去粒子生命周期后的剩余帧数。
|
||
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]
|
||
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)
|
||
window.addEventListener('pointermove', move)
|
||
window.addEventListener('pointerup', up)
|
||
window.addEventListener('pointercancel', cancel)
|
||
}
|
||
|
||
// Spine 动画长度来自文件,不能缩放;拖动整条只修改延迟播放时间。
|
||
function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
|
||
e.stopPropagation()
|
||
const startX = e.clientX
|
||
const startTime = Math.max(0, Number(spine.timeOffset) || 0)
|
||
const secPerPx = pxPerF() / fps.value
|
||
const move = (ev: PointerEvent) => {
|
||
const seconds = Math.max(0, Math.min(startTime + (ev.clientX - startX) * secPerPx, 600))
|
||
draftSpineStarts.value[spine.id] = Math.round(seconds * fps.value) / fps.value
|
||
}
|
||
const finish = (commit: boolean) => {
|
||
window.removeEventListener('pointermove', move)
|
||
window.removeEventListener('pointerup', up)
|
||
window.removeEventListener('pointercancel', cancel)
|
||
const value = draftSpineStarts.value[spine.id]
|
||
delete draftSpineStarts.value[spine.id]
|
||
if (commit && value != null && value !== startTime) {
|
||
spine.timeOffset = value
|
||
store.recalcTotalFrames()
|
||
}
|
||
}
|
||
const up = () => finish(true)
|
||
const cancel = () => finish(false)
|
||
window.addEventListener('pointermove', move)
|
||
window.addEventListener('pointerup', up)
|
||
window.addEventListener('pointercancel', cancel)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.timeline { height: 100%; background: #12121c; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; padding: 6px 10px; gap: 4px; }
|
||
.tl-controls { display: flex; width: 100%; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.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-ab-loop { position: relative; }
|
||
.tl-ab-toggle { display: flex; height: 26px; align-items: center; gap: 7px; padding: 0 7px; border: 1px solid #343a51; border-radius: 6px; background: #1e1e2e; color: #8f98ad; font-size: 10px; cursor: pointer; }
|
||
.tl-ab-toggle.on { border-color: #716eff; background: #2e2d50; color: #eef; }
|
||
.tl-switch { position: relative; width: 22px; height: 12px; border-radius: 99px; background: #343a4b; }
|
||
.tl-switch b { position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #8992a5; transition: transform .15s ease, background .15s ease; }
|
||
.tl-ab-toggle.on .tl-switch { background: #716eff; }
|
||
.tl-ab-toggle.on .tl-switch b { transform: translateX(10px); background: #fff; }
|
||
.tl-ab-popover { position: absolute; z-index: 40; left: 0; bottom: calc(100% + 8px); display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #4b4c73; border-radius: 8px; background: #171724; box-shadow: 0 10px 26px rgba(0,0,0,.5); }
|
||
.tl-ab-title { color: #b7bdd2; font-size: 10px; white-space: nowrap; }
|
||
.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-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; }
|
||
.tl-legend i { display: inline-block; width: 10px; height: 7px; border-radius: 2px; }
|
||
.legend-emission { margin-left: 4px; background: #2f8b62; }
|
||
.legend-life { margin-left: 5px; background: #a54551; }
|
||
.legend-spine { margin-left: 5px; background: #7448b8; }
|
||
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
|
||
.tl-owner-animation { position: relative; }
|
||
.tl-animation-trigger {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 116px; height: 25px; padding: 0 7px;
|
||
overflow: hidden; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; cursor: pointer;
|
||
}
|
||
.tl-animation-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
|
||
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
|
||
.tl-animation-menu {
|
||
position: absolute; z-index: 30; right: 0; bottom: calc(100% + 6px); width: 226px; padding: 7px;
|
||
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
|
||
}
|
||
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
||
.tl-animation-option {
|
||
display: flex; align-items: center; justify-content: space-between; width: 100%; height: 27px; padding: 0 7px;
|
||
border: 0; border-radius: 5px; background: transparent; color: #b8c1d5; text-align: left; cursor: pointer;
|
||
}
|
||
.tl-animation-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tl-animation-option:hover { background: #252e42; }
|
||
.tl-animation-option.on { background: #34335d; color: #fff; }
|
||
.tl-animation-option > i { color: #8d8aff; font-style: normal; }
|
||
.tl-animation-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #2b3346; }
|
||
.tl-animation-name { min-width: 0; height: 27px; padding: 0 7px; box-sizing: border-box; border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2; outline: none; }
|
||
.tl-animation-name:focus { border-color: #6865d8; }
|
||
.tl-animation-editor button, .tl-animation-actions button { height: 27px; padding: 0 8px; border: 1px solid #3c4861; border-radius: 5px; background: #29344a; color: #cbd3e3; cursor: pointer; }
|
||
.tl-animation-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 6px; }
|
||
.tl-animation-actions button:last-child { border-color: #5b3942; background: #3b282f; color: #e2a8b2; }
|
||
.tl-animation-actions button:disabled { opacity: .4; cursor: not-allowed; }
|
||
.tl-animation-error { margin-top: 5px; color: #ef8997; font-size: 10px; }
|
||
.tl-animation-hint { margin-top: 5px; color: #69758d; font-size: 9px; text-align: center; }
|
||
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
|
||
.tl-sel:disabled { opacity: .5; cursor: not-allowed; }
|
||
.tl-spine-preview .tl-sel { max-width: 190px; }
|
||
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
|
||
.tl-viewport { position: relative; flex: 1; overflow: hidden; background: #0d0d14; border: 1px solid #2a2a3c; border-radius: 6px; cursor: ew-resize; }
|
||
.tl-ticks { position: absolute; top: 0; left: 0; right: 0; height: 18px; }
|
||
.tl-tickline { position: absolute; top: 0; bottom: 0; width: 1px; background: #263040; }
|
||
.tl-ticklabel { position: absolute; top: 2px; transform: translateX(-50%); font-size: 9px; color: #667; }
|
||
.tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #ffcc33; z-index: 5; pointer-events: none; }
|
||
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
|
||
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
|
||
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
|
||
.lane-ico.sub-lane-ico { color: #7f8999; }
|
||
.lane-bar { position: absolute; top: 4px; height: 18px; display: flex; align-items: stretch; cursor: grab; box-sizing: border-box; }
|
||
.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; }
|
||
.sub-emitter-bar .bar-life { border-color: #727d8f; background: #596274; }
|
||
.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; }
|
||
.spine-bar {
|
||
position: absolute; top: 4px; height: 18px; min-width: 1px; box-sizing: border-box;
|
||
border: 1px solid #a575ed; border-radius: 5px; background: #7448b8; cursor: grab;
|
||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.05);
|
||
}
|
||
.spine-bar:active { cursor: grabbing; }
|
||
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
|
||
</style>
|