首次上传

This commit is contained in:
tianmo committed 2026-08-27 17:23:39 +08:00
1 parent af9bda066c
commit 9b156a32c9
29 files changed
+14567

No files matched your search

+192
View File
@@ -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>