显示调整
This commit is contained in:
1 parent
178a7d8412
commit
b5ac9552a1
9 files changed
+1064
-81
No files matched your search
+150
-6
@@ -10,11 +10,47 @@
|
||||
<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-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ store.timeline.totalFrames }}</b></span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
|
||||
<label class="tl-anim">动画
|
||||
<select v-model="store.timeline.animation" class="tl-sel">
|
||||
<option value="animation">animation</option>
|
||||
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
|
||||
<span>所属动画</span>
|
||||
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" title="设置当前粒子系统所属的动画" @click.stop="toggleAnimationMenu">
|
||||
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i>⌄</i>
|
||||
</button>
|
||||
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
|
||||
<div class="tl-animation-menu-title">选择所属动画</div>
|
||||
<button
|
||||
v-for="animation in store.timeline.animations"
|
||||
:key="animation"
|
||||
class="tl-animation-option"
|
||||
:class="{ on: activeParticleSystem?.animation === animation }"
|
||||
@click="selectOwnerAnimation(animation)"
|
||||
>
|
||||
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation">✓</i>
|
||||
</button>
|
||||
<div class="tl-animation-editor">
|
||||
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" aria-label="动画名称" @keydown.enter="renameCurrentAnimation" />
|
||||
<button title="重命名当前动画" @click="renameCurrentAnimation">重命名</button>
|
||||
</div>
|
||||
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
|
||||
<div class="tl-animation-actions">
|
||||
<button @click="addAnimation">+ 新增</button>
|
||||
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">删除</button>
|
||||
</div>
|
||||
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">至少需要保留一个动画</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="tl-anim tl-spine-preview">Spine 预览动画
|
||||
<select
|
||||
class="tl-sel"
|
||||
:value="previewSpine?.selectedAnimation || ''"
|
||||
:disabled="!previewSpine"
|
||||
@change="onSpinePreviewAnimationChange"
|
||||
>
|
||||
<option value="">{{ previewSpine ? '无' : '无可用 Spine 动画' }}</option>
|
||||
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
|
||||
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="tl-zoom">{{ zoomLabel }}</span>
|
||||
@@ -82,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
|
||||
@@ -90,6 +126,67 @@ type SysLike = { id: number; config: any }
|
||||
|
||||
const store = useParticleStore()
|
||||
const vpRef = ref<HTMLElement | null>(null)
|
||||
const animationMenuRef = ref<HTMLElement | null>(null)
|
||||
const animationMenuOpen = ref(false)
|
||||
const animationNameDraft = ref('animation')
|
||||
const animationError = ref('')
|
||||
|
||||
// 兼容旧场景与开发热更新:补齐动画列表和粒子系统归属。
|
||||
watchEffect(() => {
|
||||
if (!Array.isArray(store.timeline.animations) || !store.timeline.animations.length) store.timeline.animations = ['animation']
|
||||
if (!store.timeline.animations.includes(store.timeline.animation)) store.timeline.animation = store.timeline.animations[0]
|
||||
for (const system of store.systems) {
|
||||
if (!system.animation || !store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
|
||||
}
|
||||
})
|
||||
|
||||
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
|
||||
|
||||
function toggleAnimationMenu() {
|
||||
if (!activeParticleSystem.value) return
|
||||
animationMenuOpen.value = !animationMenuOpen.value
|
||||
animationNameDraft.value = activeParticleSystem.value.animation
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function selectOwnerAnimation(animation: string) {
|
||||
const system = activeParticleSystem.value
|
||||
if (!system) return
|
||||
store.setSystemAnimation(system.id, animation)
|
||||
animationNameDraft.value = animation
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function addAnimation() {
|
||||
const name = store.addAnimation()
|
||||
animationNameDraft.value = name
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function renameCurrentAnimation() {
|
||||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
const next = animationNameDraft.value.trim()
|
||||
if (!next) { animationError.value = '动画名称不能为空'; return }
|
||||
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = '动画名称不能重复'; return }
|
||||
if (!store.renameAnimation(current, next)) { animationError.value = '无法修改动画名称'; return }
|
||||
animationNameDraft.value = next
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function deleteCurrentAnimation() {
|
||||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
if (!store.removeAnimation(current)) { animationError.value = '至少需要保留一个动画'; return }
|
||||
const next = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
animationNameDraft.value = next
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!animationMenuRef.value?.contains(event.target as Node)) animationMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', onDocumentPointerDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPointerDown))
|
||||
|
||||
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
|
||||
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
|
||||
@@ -106,6 +203,20 @@ const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames
|
||||
const spineLanes = computed(() => store.spines.filter((spine) =>
|
||||
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
|
||||
))
|
||||
const previewSpine = computed(() => {
|
||||
const active = store.activeObjectType === 'spine'
|
||||
? store.spines.find((spine) => spine.id === store.activeObjectId && spine.animations.length)
|
||||
: null
|
||||
return active || store.spines.find((spine) => spine.animations.length) || null
|
||||
})
|
||||
|
||||
function onSpinePreviewAnimationChange(event: Event) {
|
||||
const spine = previewSpine.value
|
||||
if (!spine) return
|
||||
spine.selectedAnimation = (event.target as HTMLSelectElement).value
|
||||
store.recalcTotalFrames()
|
||||
store.timeline.frame = 0
|
||||
}
|
||||
|
||||
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
|
||||
function pxPerF() {
|
||||
@@ -286,14 +397,47 @@ 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-frame { font-size: 12px; color: #aab; }
|
||||
.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; }
|
||||
.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-owner-animation { position: relative; }
|
||||
.tl-animation-trigger {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 116px; height: 25px; padding: 0 7px;
|
||||
overflow: hidden; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; cursor: pointer;
|
||||
}
|
||||
.tl-animation-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
|
||||
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tl-animation-menu {
|
||||
position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; width: 226px; padding: 7px;
|
||||
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
|
||||
}
|
||||
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
||||
.tl-animation-option {
|
||||
display: flex; align-items: center; justify-content: space-between; width: 100%; height: 27px; padding: 0 7px;
|
||||
border: 0; border-radius: 5px; background: transparent; color: #b8c1d5; text-align: left; cursor: pointer;
|
||||
}
|
||||
.tl-animation-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tl-animation-option:hover { background: #252e42; }
|
||||
.tl-animation-option.on { background: #34335d; color: #fff; }
|
||||
.tl-animation-option > i { color: #8d8aff; font-style: normal; }
|
||||
.tl-animation-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #2b3346; }
|
||||
.tl-animation-name { min-width: 0; height: 27px; padding: 0 7px; box-sizing: border-box; border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2; outline: none; }
|
||||
.tl-animation-name:focus { border-color: #6865d8; }
|
||||
.tl-animation-editor button, .tl-animation-actions button { height: 27px; padding: 0 8px; border: 1px solid #3c4861; border-radius: 5px; background: #29344a; color: #cbd3e3; cursor: pointer; }
|
||||
.tl-animation-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 6px; }
|
||||
.tl-animation-actions button:last-child { border-color: #5b3942; background: #3b282f; color: #e2a8b2; }
|
||||
.tl-animation-actions button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.tl-animation-error { margin-top: 5px; color: #ef8997; font-size: 10px; }
|
||||
.tl-animation-hint { margin-top: 5px; color: #69758d; font-size: 9px; text-align: center; }
|
||||
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
|
||||
.tl-sel:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tl-spine-preview .tl-sel { max-width: 190px; }
|
||||
.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; }
|
||||
|
||||
Reference in new issue
Block a user