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