spine资源引入

This commit is contained in:
tianmo committed 2026-08-29 03:10:16 +08:00
1 parent 166b5015c2
commit 5f4667f736
25 files changed
+31151 -9082

No files matched your search

+72 -3
View File
@@ -5,10 +5,13 @@
<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" @click="store.toStart()" title="回到第一帧">⏮</button>
<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>
<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>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>骨架动画</span>
<label class="tl-anim">动画
<select v-model="store.timeline.animation" class="tl-sel">
<option value="animation">animation</option>
@@ -60,7 +63,19 @@
</span>
</div>
</div>
<div v-if="!store.systems.length" class="tl-empty">暂无粒子系统</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="拖动调整动画开始时间"
@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">暂无可显示的动画数据</div>
</div>
</div>
</div>
@@ -69,6 +84,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
type SysLike = { id: number; config: any }
@@ -83,9 +99,13 @@ const viewFrames = ref(120)
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
const draftDelays = ref<Record<number, number>>({})
const draftDurations = ref<Record<number, number>>({})
const draftSpineStarts = ref<Record<number, number>>({})
const totalFrames = computed(() => store.timeline.totalFrames)
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),
))
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() {
@@ -141,6 +161,19 @@ function lifeWidthPx(sys: SysLike) {
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
}
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 // 滚上放大(显示更少帧),滚下缩小(显示更多帧)
@@ -218,6 +251,34 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
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>
@@ -230,6 +291,7 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
.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-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; }
@@ -248,5 +310,12 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
.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); }
.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>