子发射器

This commit is contained in:
tianmo committed 2026-10-22 16:49:53 +08:00
1 parent d4ebb18777
commit d224939efd
33 files changed
+5690 -1355

No files matched your search

+65 -29
View File
@@ -9,10 +9,17 @@
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
<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>
<button class="tl-btn" :class="{ on: store.timeline.loop }" title="持续循环播放" aria-label="持续循环播放" @click="store.toggleLoop()">⟳</button>
<div class="tl-ab-loop">
<button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" title="独立 A/B 区间循环" @click="store.toggleABLoop()">
<span>AB 循环</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">AB 循环区间</div>
<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>
</div>
</div>
<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">
@@ -84,23 +91,24 @@
<!-- 每系统一条轨道(粒子条) -->
<div class="tl-lanes">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico">●</span>
<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 || '系统 ' + (i + 1) }}</span>
<span class="bar-life" :class="{ 'loop-life': isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<template v-if="isLoopStream(sys)">
<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="拖动循环段时长" @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 v-else-if="!isSubEmitter(sys) && 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>
@@ -127,7 +135,9 @@
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'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
type SysLike = { id: number; config: any }
@@ -148,12 +158,6 @@ 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
animationMenuOpen.value = !animationMenuOpen.value
@@ -202,8 +206,8 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPoin
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
/** 视口当前能显示的帧数(滚轮缩放它)。默认显示 0~120f,起点恒为第 0 帧。 */
const viewFrames = ref(120)
/** 视口当前能显示的帧数(滚轮缩放它),默认至少显示完整总时长。 */
const viewFrames = ref(Math.max(120, store.timeline.totalFrames))
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
const draftDelays = ref<Record<number, number>>({})
@@ -217,6 +221,10 @@ watch(() => store.timeline.playbackStart, (value) => { playbackStartDraft.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 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
const spineLanes = computed(() => store.spines.filter((spine) =>
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
@@ -246,14 +254,6 @@ function commitPlaybackEnd() {
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
@@ -278,8 +278,27 @@ const ticks = computed(() => {
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()
@@ -292,11 +311,18 @@ function maxParticleLife(sys: SysLike) {
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
}
function barWidthPx(sys: SysLike) {
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
@@ -307,7 +333,8 @@ function emissionWidthPx(sys: SysLike) {
return (duration * fps.value) / pxPerF()
}
function lifeWidthPx(sys: SysLike) {
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)
@@ -468,11 +495,18 @@ 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-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-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; }
@@ -523,10 +557,12 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.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; }