spine资源引入
This commit is contained in:
1 parent
166b5015c2
commit
5f4667f736
25 files changed
+31151
-9082
No files matched your search
+159
-71
@@ -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>
|
||||
Reference in new issue
Block a user