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

+35 -19
View File
@@ -12,7 +12,7 @@
<button class="scene-add" title="添加粒子系统" @click.stop="addSys">+粒子</button>
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
<button class="scene-add placeholder-action" title="添加 Spine 骨架(待开发)" @click.stop="showScenePlaceholder('Spine 骨架')">+骨架</button>
<button class="scene-add" title="添加 Spine 骨架" @click.stop="addSpine">+骨架</button>
</div>
</div>
<div class="group-body" v-show="!collapsed.scene">
@@ -53,7 +53,19 @@
<span class="scene-object-kind">路径</span>
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
</div>
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length" class="empty">场景中暂无对象</div>
<div
v-for="spine in store.spines"
:key="`spine-${spine.id}`"
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'spine' && spine.id === store.activeObjectId }"
@click="store.selectSceneObject('spine', spine.id)"
>
<span class="tdot spine-dot">◆</span>
<span class="tlabel">{{ spine.name }}</span>
<span class="scene-object-kind">骨架</span>
<button class="scene-object-delete" title="删除骨架" @click.stop="store.removeSpine(spine.id)">✕</button>
</div>
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">场景中暂无对象</div>
</div>
<template v-if="activeObjectType === 'particle' && sys">
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
@@ -92,6 +104,10 @@
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
</div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
<div class="field-grid">
<label class="compact-field"><span>缩放 X</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
@@ -114,6 +130,15 @@
<template v-else-if="activePath">
<div class="object-editor-title">路径属性</div>
<label class="field-block"><span>轨道名称</span><input v-model="activePath.name" class="inp" /></label>
<div class="field-grid">
<label class="compact-field"><span>X 位置</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
</div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
<div class="field-grid">
<label class="compact-field"><span>缩放 X</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<label class="setting-line"><span>闭合轨道</span><input v-model="activePath.closed" type="checkbox" /></label>
<label class="setting-line"><span>轨道颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
<div class="path-help"><span>操作说明:</span><ul>
@@ -127,8 +152,8 @@
<button class="path-action primary" @click="applyPathPreset">应用预设</button>
<button class="path-action" @click="clearPathAnchors">清空锚点</button>
</template>
<SpineObjectPanel v-else-if="activeSpine" :spine-object="activeSpine" />
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
<div v-if="sceneActionHint" class="scene-action-hint">{{ sceneActionHint }}</div>
</div>
</div>
@@ -681,7 +706,7 @@
</template>
<script setup lang="ts">
import { computed, markRaw, onUnmounted, reactive, ref, watchEffect } from 'vue'
import { computed, markRaw, reactive, ref, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../store/particleStore'
import { defaultConfig, ensureEmitterConfig } from '../core/particleEmitter'
@@ -690,6 +715,7 @@ import CurveEditor from './CurveEditor.vue'
import ParticleAttributeControl from './ParticleAttributeControl.vue'
import ColorGradientEditor from './ColorGradientEditor.vue'
import ImageResourceCard from './ImageResourceCard.vue'
import SpineObjectPanel from '../spine/SpineObjectPanel.vue'
const store = useParticleStore()
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
@@ -707,6 +733,9 @@ const activeCollider = computed(() => store.activeObjectType === 'collision'
const activePath = computed(() => store.activeObjectType === 'path'
? store.paths.find((item) => item.id === store.activeObjectId) || null
: null)
const activeSpine = computed(() => store.activeObjectType === 'spine'
? store.spines.find((item) => item.id === store.activeObjectId) || null
: null)
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
watchEffect(() => {
@@ -800,6 +829,7 @@ function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
function addSys() { store.addSystem() }
function addCollider() { store.addCollider() }
function addPath() { store.addPath() }
function addSpine() { store.addSpine() }
function addColliderTag() {
const collider = activeCollider.value
if (!collider) return
@@ -840,16 +870,6 @@ function applyPathPreset() {
function clearPathAnchors() {
if (activePath.value) activePath.value.points = []
}
const sceneActionHint = ref('')
let sceneActionHintTimer: ReturnType<typeof setTimeout> | null = null
function showScenePlaceholder(type: string) {
sceneActionHint.value = `${type}对象将在后续阶段开发`
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
sceneActionHintTimer = setTimeout(() => { sceneActionHint.value = '' }, 2400)
}
onUnmounted(() => {
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
})
function randomizeSeed() {
if (sys.value) sys.value.config.seed = Math.floor(Math.random() * 999999) + 1
}
@@ -1283,11 +1303,6 @@ function toggleResourceLock(resourceId: number) {
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
}
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
.scene-add.placeholder-action { background: #232938; color: #9da7be; }
.scene-action-hint {
margin-top: 8px; padding: 6px 8px; border: 1px dashed #3a4963; border-radius: 5px;
background: #181e2b; color: #8f9ab3; font-size: 11px;
}
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
@@ -1298,6 +1313,7 @@ function toggleResourceLock(resourceId: number) {
.tree-item .tdot.dim { color: #556; }
.tree-item .tdot.collision-dot { color: #ff7070; }
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
.tree-item .tlabel { flex: 1; }
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
.scene-object-item { min-height: 28px; cursor: pointer; }
+159 -71
View File
@@ -35,6 +35,9 @@
<label class="sp-chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
<label class="sp-chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
<label class="sp-chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
<label class="sp-chk" :class="{ disabled: !hasLoadedSpineAnimation }" :title="hasLoadedSpineAnimation ? '显示或隐藏已加载的 Spine 动画资源' : '加载包含动画的 Spine 资源后可用'">
<input type="checkbox" v-model="store.settings.showSpineResources" :disabled="!hasLoadedSpineAnimation" />启用 Spine 资源显示
</label>
<div class="sp-divider"></div>
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
<template v-if="store.settings.tickEnabled">
@@ -62,10 +65,11 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { useParticleStore, type ScenePath } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
const store = useParticleStore()
const holder = ref<HTMLElement | null>(null)
@@ -76,6 +80,8 @@ const showSkinMesh = ref(false)
const info = ref<{ systems: number; particles: number } | null>(null)
const showGrid = ref(true)
const showSettings = ref(false)
if (store.settings.showSpineResources == null) store.settings.showSpineResources = true
const hasLoadedSpineAnimation = computed(() => store.spines.some((spine) => spine.status === 'ready' && spine.animations.length > 0))
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
@@ -92,8 +98,11 @@ let pathGfx: Graphics | null = null
let gizmoGfx: Graphics | null = null
let axisGfx: Graphics | null = null
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
let spineRuntime: SpineRuntimeLayer | null = null
let axisLabels: Text[] = []
let lastT = 0
let timelineAccumulator = 0
let timelineDisplayFrame = 0
let raf = 0
let dotTex: Texture<any> | null = null
let trailTex: Texture<any> | null = null
@@ -122,6 +131,8 @@ function createLayers() {
app!.stage.addChild(axisLabelLayer)
axisGfx = new Graphics()
world.addChild(axisGfx)
spineRuntime = new SpineRuntimeLayer()
world.addChild(spineRuntime.container)
originGfx = new Graphics()
world.addChild(originGfx)
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
@@ -258,17 +269,17 @@ function onWheel(e: WheelEvent) {
// 鼠标左键长按拖动画布(平移)
function onPanDown(e: PointerEvent) {
if (e.button !== 0) return
// 激活变换工具时,始终操作场景树中当前选中的对象。
if (activeTool.value && selectedTransformTarget()) {
onGizmoDown(e)
return
}
if (onPathPointerDown(e)) return
if (onColliderPointerDown(e)) return
// 吸附点优先于力场;重叠时拖动红色吸附圆。
if (onAttractionDown(e)) return
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
if (onForceFieldDown(e)) return
// gizmo 工具激活且选中系统 → 拖拽根节点变换;否则画布平移
if (activeTool.value && store.systems.length) {
onGizmoDown(e)
return
}
const startX = e.clientX
const startY = e.clientY
const vx0 = store.editor.viewX
@@ -297,14 +308,18 @@ function pointerToWorldMath(clientX: number, clientY: number) {
function pathLocalToWorld(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
return { x: path.x + cos * x - sin * y, y: path.y + sin * x + cos * y }
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
return { x: path.x + cos * x * scaleX - sin * y * scaleY, y: path.y + sin * x * scaleX + cos * y * scaleY }
}
function worldToPathLocal(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
const dx = x - path.x, dy = y - path.y
return { x: cos * dx + sin * dy, y: -sin * dx + cos * dy }
return { x: (cos * dx + sin * dy) / scaleX, y: (-sin * dx + cos * dy) / scaleY }
}
function activePath() {
@@ -458,8 +473,10 @@ function onColliderPointerDown(e: PointerEvent) {
const pointer = pointerToWorldMath(e.clientX, e.clientY)
const angle = collider.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
const dx = pointer.x - collider.x, dy = pointer.y - collider.y
const localX = cos * dx + sin * dy, localY = -sin * dx + cos * dy
const localX = (cos * dx + sin * dy) / scaleX, localY = (-sin * dx + cos * dy) / scaleY
const hit = collider.shape === 'circle'
? Math.hypot(localX, localY) <= collider.radius
: collider.shape === 'ellipse'
@@ -534,21 +551,61 @@ function onAttractionDown(e: PointerEvent) {
return true
}
// root gizmo 拖拽:位移 / 旋转 / 缩放(改当前选中系统的 config,由 loop 实时应用)
type SceneTransformTarget = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
setPosition: (x: number, y: number) => void
setRotation: (rotation: number) => void
setScale: (scaleX: number, scaleY: number) => void
}
function selectedTransformTarget(): SceneTransformTarget | null {
const id = store.activeObjectId
if (store.activeObjectType === 'particle') {
const system = store.systems.find((item) => item.id === id)
if (!system) return null
const config = system.config
return {
x: config.centerX, y: config.centerY, rotation: config.rootRotation,
scaleX: config.rootScaleX ?? 1, scaleY: config.rootScaleY ?? 1,
setPosition: (x, y) => { config.centerX = x; config.centerY = y },
setRotation: (rotation) => { config.rootRotation = rotation },
setScale: (scaleX, scaleY) => { config.rootScaleX = scaleX; config.rootScaleY = scaleY },
}
}
const collection = store.activeObjectType === 'collision'
? store.colliders
: store.activeObjectType === 'path'
? store.paths
: store.spines
const object = collection.find((item) => item.id === id) as any
if (!object) return null
return {
x: Number(object.x) || 0, y: Number(object.y) || 0, rotation: Number(object.rotation) || 0,
scaleX: Math.max(0.05, Number(object.scaleX) || 1), scaleY: Math.max(0.05, Number(object.scaleY) || 1),
setPosition: (x, y) => { object.x = x; object.y = y },
setRotation: (rotation) => { object.rotation = rotation },
setScale: (scaleX, scaleY) => { object.scaleX = scaleX; object.scaleY = scaleY },
}
}
// 场景对象 gizmo 拖拽:作用于场景树当前选中对象。
function onGizmoDown(e: PointerEvent) {
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys) return
const cfg = sys.config
const target = selectedTransformTarget()
if (!target) return
const sc = store.editor.viewScale
// root 屏幕位置(中心)
const cx = app!.screen.width / 2 + store.editor.viewX + cfg.centerX * sc
const cy = app!.screen.height * 0.5 + store.editor.viewY + (-cfg.centerY) * sc
const rect = holder.value!.getBoundingClientRect()
const cx = rect.left + app!.screen.width / 2 + store.editor.viewX + target.x * sc
const cy = rect.top + app!.screen.height * 0.5 + store.editor.viewY + (-target.y) * sc
const startX = e.clientX, startY = e.clientY
const tool = activeTool.value
// 初始基准(按下时快照,后续基于快照 + 相对按下点的总增量,避免累积误差/抖动)
const sCx = cfg.centerX, sCy = cfg.centerY
const sRot = cfg.rootRotation
const sSx = cfg.rootScaleX, sSy = cfg.rootScaleY
const sCx = target.x, sCy = target.y
const sRot = target.rotation
const sSx = target.scaleX, sSy = target.scaleY
const startDist = Math.hypot(startX - cx, startY - cy) || 1
const startAngle = Math.atan2(startY - cy, startX - cx)
@@ -557,18 +614,16 @@ function onGizmoDown(e: PointerEvent) {
const dy = (ev.clientY - startY) / sc
if (tool === 'translate') {
// 位移:按下基准 + 总位移(离手势,不累积)
cfg.centerX = sCx + dx
cfg.centerY = sCy - dy // y 数学上正
target.setPosition(sCx + dx, sCy - dy)
} else if (tool === 'rotate') {
const a = Math.atan2(ev.clientY - cy, ev.clientX - cx)
// 跟手:拖拽环动方向 = 旋转方向(逆时针环动→逆时针,顺时针环动→顺时针)。
// 渲染层 em.rotation=-rootRotation(屏幕坐标),故这里对屏幕 atan2 增量取反,使 rootRotation 在数学坐标下与拖拽环动同向。
cfg.rootRotation = sRot - (a - startAngle) * 180 / Math.PI
target.setRotation(sRot - (a - startAngle) * 180 / Math.PI)
} else if (tool === 'scale') {
const nd = Math.hypot(ev.clientX - cx, ev.clientY - cy)
const f = nd / startDist
cfg.rootScaleX = Math.max(0.05, sSx * f)
cfg.rootScaleY = Math.max(0.05, sSy * f)
target.setScale(Math.max(0.05, sSx * f), Math.max(0.05, sSy * f))
}
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up) }
@@ -750,21 +805,18 @@ function drawAttraction() {
g.endFill()
}
// 根变换 gizmo 手柄:当前选中系统,按 activeTool 绘制(位移/旋转/缩放)。中心 = root 位置。
// 手柄几何在 root 局部坐标系定义(原点 = root 中心,ly 数学上正),经 root 缩放+旋转+平移到 root 中心,
// 与 drawShapeRange 的变换一致 → 手柄随 root 旋转/缩放/位移实时变化。
// 当前场景对象的变换手柄:跟随其位置、旋转和缩放。
function drawGizmo() {
if (!gizmoGfx) return
const g = gizmoGfx
g.clear()
if (!activeTool.value) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys) return
const c = sys.config
const cx = c.centerX ?? 0
const cy = -(c.centerY ?? 0) // 数学上正(屏幕取负)
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
const rot = -(c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
const target = selectedTransformTarget()
if (!target) return
const cx = target.x
const cy = -target.y
const sxr = target.scaleX, syr = target.scaleY
const rot = -target.rotation * Math.PI / 180
const cs = Math.cos(rot), sn = Math.sin(rot)
// 局部点(数学坐标,ly 上正) → 屏幕点(应用 root 缩放+旋转+平移到 root 中心)
const pt = (lx: number, ly: number): [number, number] => {
@@ -861,9 +913,11 @@ function drawCollisionBodies() {
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
const angle = collider.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
const point = (x: number, y: number) => ({
x: collider.x + cos * x - sin * y,
y: -(collider.y + sin * x + cos * y),
x: collider.x + cos * x * scaleX - sin * y * scaleY,
y: -(collider.y + sin * x * scaleX + cos * y * scaleY),
})
let points: Array<{ x: number; y: number }> = []
if (collider.shape === 'circle' || collider.shape === 'ellipse') {
@@ -1067,49 +1121,77 @@ function loop() {
const collected: BonePreview[] = []
const tl = store.timeline
const fps = tl.fps
const dt = 1 / fps // 固定帧步长,保证帧对齐
const frameDuration = 1 / Math.max(1, fps)
let displayFrame = timelineDisplayFrame
let advanceSteps = 0
if (tl.playing) {
if (!tl.recorded) {
// 录制阶段:从头开始,逐帧模拟(固定步长)并记录每帧粒子
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
for (const [, em] of emitterMap) {
const st = em.update(dt)
for (const s of st) if (s.active) appendStateBones(collected, s)
total += em.activeCount
// requestAnimationFrame 只负责刷新画面;真实时间累计满 1/fps 秒才推进一帧。
timelineAccumulator += dt
advanceSteps = Math.floor(timelineAccumulator / frameDuration)
if (advanceSteps > 0) timelineAccumulator -= advanceSteps * frameDuration
} else {
// 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。
timelineAccumulator = 0
}
if (tl.playing && advanceSteps > 0) {
// dt 已限制为最多 0.1 秒,因此30 FPS下单次最多补算3帧,不会出现无界追帧。
for (let step = 0; step < advanceSteps; step++) {
displayFrame = tl.frame
collected.length = 0
total = 0
if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
for (const [, em] of emitterMap) {
const states = em.update(frameDuration)
for (const state of states) if (state.active) appendStateBones(collected, state)
total += em.activeCount
}
for (const sys of store.systems) {
if (!sys.config.texture) continue
sys.frames = sys.frames || []
const emitter = emitterMap.get(sys.id)
if (emitter) sys.frames[tl.frame] = emitter.capture()
}
tl.frame++
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true }
} else {
// 回放阶段:每经过一个固定帧时长才应用下一帧。
for (const sys of store.systems) {
const emitter = emitterMap.get(sys.id)
const frame = sys.frames?.[tl.frame]
if (emitter && frame) {
emitter.apply(frame)
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
}
}
tl.frame++
if (tl.frame >= tl.totalFrames) {
if (tl.loop) tl.frame = 0
else { tl.frame = tl.totalFrames - 1; tl.playing = false; break }
}
}
for (const sys of store.systems) {
if (!sys.config.texture) continue
sys.frames = sys.frames || []
const em = emitterMap.get(sys.id)
if (em) sys.frames[tl.frame] = em.capture()
}
tl.frame++
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true } // 录完立即进入回放
} else {
// 回放阶段:按 frame 显示已录制帧,到尾帧时循环或停止
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
const fr = sys.frames?.[tl.frame]
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ } }
}
tl.frame++
if (tl.frame >= tl.totalFrames) { if (tl.loop) tl.frame = 0; else { tl.frame = tl.totalFrames - 1; tl.playing = false } }
}
} else {
// 暂停:若已录制,显示当前帧;否则实时显示
// 两个固定帧之间或暂停时保持当前画面,不推进粒子模拟。
if (!tl.playing) displayFrame = tl.frame
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
const fr = sys.frames?.[tl.frame]
if (em && tl.recorded && fr) {
em.apply(fr)
for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ }
} else if (em) {
const st = em.update(0) // 冻结预览
for (const s of st) if (s.active) appendStateBones(collected, s)
total += em.activeCount
const emitter = emitterMap.get(sys.id)
const frame = sys.frames?.[tl.frame]
if (!tl.playing && tl.recorded && emitter && frame) {
// 暂停、逐帧或手动拖动播放头时立即显示指定的录制帧。
emitter.apply(frame)
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
} else if (emitter) {
const states = emitter.update(0)
for (const state of states) if (state.active) appendStateBones(collected, state)
total += emitter.activeCount
}
}
}
timelineDisplayFrame = displayFrame
spineRuntime?.sync(store.spines, displayFrame, fps, store.settings.showSpineResources)
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
drawCollisionBodies()
drawPaths()
@@ -1146,6 +1228,8 @@ onMounted(async () => {
// 默认从头播放并循环
store.timeline.recorded = false
store.timeline.frame = 0
timelineAccumulator = 0
timelineDisplayFrame = 0
store.timeline.playing = true
store.timeline.loop = true
for (const sys of store.systems) sys.frames = []
@@ -1174,6 +1258,8 @@ onMounted(async () => {
onUnmounted(() => {
cancelAnimationFrame(raf)
spineRuntime?.destroy()
spineRuntime = null
if (app) { app.destroy(true, { children: true }); app = null }
})
</script>
@@ -1230,4 +1316,6 @@ b { color: #ffcc33; }
.sp-colr { width: 26px; height: 22px; border: none; background: none; cursor: pointer; }
.sp-hex { font-size: 11px; color: #889; font-variant-numeric: tabular-nums; }
.sp-chk { display: flex; align-items: center; gap: 6px; margin: 8px 0; color: #aab; }
.sp-chk.disabled { color: #5f687d; cursor: not-allowed; }
.sp-chk.disabled input { cursor: not-allowed; opacity: .55; }
</style>
+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>