显示调整

This commit is contained in:
tianmo committed 2026-08-29 12:32:11 +08:00
1 parent 178a7d8412
commit b5ac9552a1
9 files changed
+1064 -81

No files matched your search

+150 -6
View File
@@ -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; }