系统优化

This commit is contained in:
tianmo committed 2026-08-29 01:59:58 +08:00
1 parent b98d67fdab
commit c37cf09273
5 files changed
+489 -32

No files matched your search

+43 -21
View File
@@ -8,6 +8,7 @@
<button class="tl-btn" @click="store.toStart()" title="回到起点">⏮</button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环">⟳</button>
<span class="tl-frame">帧 {{ store.timeline.frame }} / {{ store.timeline.totalFrames }}</span>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在</span>
<label class="tl-anim">动画
<select v-model="store.timeline.animation" class="tl-sel">
<option value="animation">animation</option>
@@ -42,14 +43,21 @@
<div class="tl-lanes">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico">●</span>
<!-- 粒子条:left=start(delay), width=duration -->
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
<div
class="lane-bar"
: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-handle" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
<span class="bar-life" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<span
v-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>
</div>
<div v-if="!store.systems.length" class="tl-empty">暂无粒子系统</div>
@@ -67,7 +75,8 @@ type SysLike = { id: number; config: any }
const store = useParticleStore()
const vpRef = ref<HTMLElement | null>(null)
const fps = 60
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
/** 视口当前能显示的帧数(滚轮缩放它)。视口起点恒为第 0 帧,不滚动 */
const viewFrames = ref(51)
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
@@ -106,7 +115,7 @@ function barLeftPx(sys: SysLike) {
const s = (sys.config as any)
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return (delay * fps) / pxPerF()
return (delay * fps.value) / pxPerF()
}
function maxParticleLife(sys: SysLike) {
@@ -117,11 +126,19 @@ function maxParticleLife(sys: SysLike) {
}
function barWidthPx(sys: SysLike) {
const s = (sys.config as any)
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 当前模式下的最大粒子生命
// 即从 delay 开始,覆盖到最后一个粒子消失,右端与 totalFrames 对齐
const duration = draftDurations.value[sys.id] ?? Math.max(0, Number(s.duration) || 0)
return ((duration + maxParticleLife(sys)) * fps) / pxPerF()
return lifeWidthPx(sys)
}
function emissionWidthPx(sys: SysLike) {
if ((sys.config as any).mode !== 'stream') return 0
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) {
// 红色底条表示整个系统“仍有粒子存在”的时长:持续发射窗口 + 最长粒子生命。
// 爆发模式的发射窗口为 0,因此只保留最长粒子生命。
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
}
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
@@ -152,11 +169,11 @@ 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
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) / fps
draftDelays.value[sys.id] = Math.round(nd * fps.value) / fps.value
}
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
@@ -176,17 +193,16 @@ function onBarDown(e: PointerEvent, sys: SysLike) {
// 拖动粒子条右缘:调整发射时长 duration
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const startX = e.clientX
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
const secPerPx = pxPerF() / fps
const secPerPx = pxPerF() / fps.value
const barLeft = (e.currentTarget as HTMLElement).closest('.lane-bar')?.getBoundingClientRect().left ?? e.clientX
let moved = false
const move = (ev: PointerEvent) => {
moved = true
const dSec = (ev.clientX - startX) * secPerPx
let nd = Math.max(0, Math.min(startDur + dSec, 600))
// 有限发射最短为 1 帧;0 继续保留“无限发射”的既有语义。
if (nd > 0) nd = Math.max(1 / fps, nd)
draftDurations.value[sys.id] = Math.round(nd * fps) / fps
// 使用右端相对粒子条左端的绝对距离换算发射时长。
// 因此拖到 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
}
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
@@ -210,6 +226,10 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
.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-frame { font-size: 12px; color: #aab; }
.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; }
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
@@ -221,10 +241,12 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
.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-bar { position: absolute; top: 4px; height: 18px; background: #2b4a8c; border: 1px solid #4a6aa8; border-radius: 5px; display: flex; align-items: center; padding-left: 14px; cursor: grab; box-sizing: border-box; }
.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 { font-size: 11px; color: #dfe; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-handle { position: absolute; right: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.15); border-radius: 0 4px 4px 0; }
.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-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-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); }
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
</style>