首次上传
This commit is contained in:
1 parent
af9bda066c
commit
9b156a32c9
29 files changed
+14567
No files matched your search
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<!-- 控制按钮 -->
|
||||
<div class="tl-controls">
|
||||
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停' : '播放'">
|
||||
{{ store.timeline.playing ? '❚❚' : '▶' }}
|
||||
</button>
|
||||
<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>
|
||||
<label class="tl-anim">动画
|
||||
<select v-model="store.timeline.animation" class="tl-sel">
|
||||
<option value="animation">animation</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="tl-zoom">{{ zoomLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴视口 -->
|
||||
<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">●</span>
|
||||
<!-- 粒子条:left=start(delay), width=duration -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!store.systems.length" class="tl-empty">暂无粒子系统</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
|
||||
type SysLike = { id: number; config: any }
|
||||
|
||||
const store = useParticleStore()
|
||||
const vpRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const fps = 60
|
||||
/** 视口当前能显示的帧数(滚轮缩放它)。视口起点恒为第 0 帧,不滚动 */
|
||||
const viewFrames = ref(51)
|
||||
|
||||
const totalFrames = computed(() => store.timeline.totalFrames)
|
||||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
|
||||
|
||||
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
|
||||
function pxPerF() {
|
||||
const vw = 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())
|
||||
|
||||
function barLeftPx(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
|
||||
return ((s.delay || 0) * fps) / pxPerF()
|
||||
}
|
||||
function barWidthPx(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 粒子最大生命(lifeMax)
|
||||
// 即从 delay 开始,覆盖到最后一个粒子消失,右端与 totalFrames 对齐
|
||||
return ((s.duration || 0) * fps + (s.lifeMax || 0) * fps) / 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!
|
||||
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 = (sys.config as any).delay || 0
|
||||
const secPerPx = pxPerF() / fps
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dSec = (ev.clientX - startX) * secPerPx
|
||||
let nd = Math.max(0, Math.min(startDelay + dSec, (store.timeline.totalFrames / fps)))
|
||||
;(sys.config as any).delay = Math.round(nd * fps) / fps
|
||||
}
|
||||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
// 拖动粒子条右缘:调整发射时长 duration
|
||||
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startDur = (sys.config as any).duration || 0.1
|
||||
const secPerPx = pxPerF() / fps
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dSec = (ev.clientX - startX) * secPerPx
|
||||
let nd = Math.max(0.1, Math.min(startDur + dSec, store.timeline.totalFrames / fps))
|
||||
;(sys.config as any).duration = Math.round(nd * fps) / fps
|
||||
}
|
||||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
</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; 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-frame { font-size: 12px; color: #aab; }
|
||||
.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; }
|
||||
.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-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: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; }
|
||||
.bar-handle:hover { background: rgba(255,255,255,0.35); }
|
||||
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
|
||||
</style>
|
||||
Reference in new issue
Block a user