显示调整

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

+120 -23
View File
@@ -34,9 +34,6 @@
<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">
@@ -57,7 +54,7 @@
</div>
<div class="hud" v-if="info">
系统: <b>{{ info.systems }}</b> · 粒子(骨骼): <b>{{ info.particles }}</b> · 根骨骼: <b>root</b>
系统: <b class="hud-number hud-system-count">{{ info.systems }}</b> · 粒子(骨骼): <b class="hud-number hud-particle-count">{{ info.particles }}</b> · 根骨骼: <b>root</b>
<span class="hud-tip">滚轮缩放 · 左键拖动画布</span>
</div>
</div>
@@ -78,8 +75,6 @@ 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 }
@@ -87,6 +82,7 @@ function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value =
let app: Application | null = null
let world: Container | null = null
let originGfx: Graphics | null = null
let boneFollowTargetGfx: Graphics | null = null
let boneGfx: Graphics | null = null
let shapeGfx: Graphics | null = null
let forceFieldGfx: Graphics | null = null
@@ -132,6 +128,8 @@ function createLayers() {
world.addChild(axisGfx)
spineRuntime = new SpineRuntimeLayer()
world.addChild(spineRuntime.container)
boneFollowTargetGfx = new Graphics()
world.addChild(boneFollowTargetGfx)
originGfx = new Graphics()
world.addChild(originGfx)
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
@@ -323,7 +321,8 @@ function worldToPathLocal(path: ScenePath, x: number, y: number) {
function activePath() {
if (store.activeObjectType !== 'path') return null
return store.paths.find((item) => item.id === store.activeObjectId) || null
const path = store.paths.find((item) => item.id === store.activeObjectId) || null
return path?.visible === false ? null : path
}
function pathInsertIndex(path: ScenePath, x: number, y: number) {
@@ -468,7 +467,7 @@ function installPointerDrag(move: (event: PointerEvent) => void) {
function onColliderPointerDown(e: PointerEvent) {
if (store.activeObjectType !== 'collision') return false
const collider = store.colliders.find((item) => item.id === store.activeObjectId)
if (!collider?.enabled) return false
if (!collider?.enabled || collider.visible === false) return false
const pointer = pointerToWorldMath(e.clientX, e.clientY)
const angle = collider.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
@@ -494,6 +493,7 @@ function onColliderPointerDown(e: PointerEvent) {
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
function onForceFieldDown(e: PointerEvent) {
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return false
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
@@ -523,6 +523,7 @@ function onForceFieldDown(e: PointerEvent) {
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
function onAttractionDown(e: PointerEvent) {
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return false
const cfg = sys?.config
if (!cfg?.attraction) return false
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
@@ -565,7 +566,7 @@ 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
if (!system || system.visible === false) return null
const config = system.config
return {
x: config.centerX, y: config.centerY, rotation: config.rootRotation,
@@ -581,7 +582,7 @@ function selectedTransformTarget(): SceneTransformTarget | null {
? store.paths
: store.spines
const object = collection.find((item) => item.id === id) as any
if (!object) return null
if (!object || object.visible === false) 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),
@@ -677,6 +678,7 @@ function drawOrigin() {
// 每个系统对应一个发射点(坐标点);没有系统时不画
if (!store.systems.length) return
for (const sys of store.systems) {
if (sys.visible === false) continue
const preview = emitterPreviewTransform.get(sys.id)
const cx = preview?.x ?? sys.config.centerX ?? 0
const cy = -(preview?.y ?? sys.config.centerY ?? 0)
@@ -694,6 +696,44 @@ function drawOrigin() {
}
}
/** 标出层级树当前选中的骨骼;发射器骨骼跟随复用同一目标点。 */
function drawBoneFollowTargets() {
if (!boneFollowTargetGfx) return
const g = boneFollowTargetGfx
g.parent?.addChild(g)
g.clear()
const targets = new Map<string, { spineId: number; boneName: string }>()
for (const spine of store.spines) {
if (spine.visible === false || !spine.selectedBone || spine.status !== 'ready') continue
targets.set(`${spine.id}:${spine.selectedBone}`, { spineId: spine.id, boneName: spine.selectedBone })
}
for (const system of store.systems) {
if (system.visible === false) continue
const config = system.config
if (!config.emitterFollow || config.emitterFollowMode !== 'bone') continue
if (!config.emitterFollowSpineId || !config.emitterFollowBoneName) continue
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
if (!spine || spine.visible === false) continue
targets.set(`${config.emitterFollowSpineId}:${config.emitterFollowBoneName}`, {
spineId: config.emitterFollowSpineId,
boneName: config.emitterFollowBoneName,
})
}
for (const item of targets.values()) {
const target = spineRuntime?.getBoneWorldTransform(item.spineId, item.boneName)
if (!target) continue
const x = target.x
const y = -target.y
g.lineStyle(2, 0xc48cff, 0.95)
g.beginFill(0xc48cff, 0.2)
g.drawCircle(x, y, 8)
g.endFill()
g.beginFill(0xf2e7ff, 1)
g.drawCircle(x, y, 3.5)
g.endFill()
}
}
// 绘制当前选中系统的发射器形状范围(点/轨道不绘制),随参数实时响应
function drawShapeRange() {
if (!shapeGfx) return
@@ -701,7 +741,7 @@ function drawShapeRange() {
g.clear()
if (!showEmitter.value) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys) return
if (!sys || sys.visible === false) return
const c = sys.config
const preview = emitterPreviewTransform.get(sys.id)
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
@@ -761,6 +801,7 @@ function drawForceField() {
const g = forceFieldGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return
const x = cfg.forceCenterX
@@ -784,6 +825,7 @@ function drawAttraction() {
const g = attractionGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.attraction) return
const x = cfg.attractionCenterX
@@ -908,6 +950,7 @@ function drawCollisionBodies() {
const g = collisionGfx
g.clear()
for (const collider of previewColliders) {
if (collider.visible === false) continue
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
const angle = collider.rotation * Math.PI / 180
@@ -954,7 +997,7 @@ function drawPaths() {
const g = pathGfx
g.clear()
for (const path of store.paths) {
if (!path.enabled || !path.points.length) continue
if (path.visible === false || !path.enabled || !path.points.length) continue
const selected = store.activeObjectType === 'path' && store.activeObjectId === path.id
const color = colorNumber(path.color, 0xff933e)
const screenPoint = (x: number, y: number) => {
@@ -1072,9 +1115,13 @@ function buildPreviewColliders(timeSeconds: number) {
})
}
function worldPointToEmitterLocal(config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number }, worldX: number, worldY: number) {
const scaleX = Math.max(0.0001, Math.abs(config.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(config.rootScaleY))
function worldPointToEmitterLocal(
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
worldX: number,
worldY: number,
scaleX = Math.max(0.0001, Math.abs(config.rootScaleX)),
scaleY = Math.max(0.0001, Math.abs(config.rootScaleY)),
) {
const angle = -config.rootRotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = worldX - config.centerX
@@ -1096,16 +1143,29 @@ function loop() {
drawAxis()
syncEmitters()
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
const followTime = store.timeline.frame / Math.max(1, store.timeline.fps)
const followFps = Math.max(1, store.timeline.fps)
const followFrame = store.timeline.frame
const followTime = followFrame / followFps
// 发射器读取骨骼变换前,先把 Spine 运行时同步到同一时间轴帧。
spineRuntime?.sync(store.spines, followFrame, followFps)
buildPreviewColliders(followTime)
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
if (!em) continue
const visible = sys.visible !== false
em.visible = visible
em.renderable = visible
const c = sys.config
em.scale.set(c.rootScaleX, c.rootScaleY)
em.setColliders(previewColliders)
em.setEmitterFollowSpawn(0, 0, 0)
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
const followSpine = c.emitterFollow && c.emitterFollowMode === 'bone'
? store.spines.find((spine) => spine.id === c.emitterFollowSpineId && spine.enabled && spine.status === 'ready')
: null
const boneTransform = followSpine
? spineRuntime?.getBoneWorldTransform(followSpine.id, c.emitterFollowBoneName) || null
: null
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'
? store.paths.find((path) => path.id === c.emitterFollowPathId && path.enabled)
: null
@@ -1117,7 +1177,33 @@ function loop() {
? (1 - offset) * (1 - eased)
: offset + (1 - offset) * eased
const pathSample = followPath ? sampleScenePath(followPath, pathProgress) : null
if (pathSample) {
if (boneTransform) {
// 骨骼只提供位置;旋转和缩放不传递给粒子,偏移使用世界坐标单位。
const offsetX = c.emitterFollowBoneOffsetX
const offsetY = c.emitterFollowBoneOffsetY
const targetX = boneTransform.x + offsetX
const targetY = boneTransform.y + offsetY
const targetRotation = c.rootRotation + c.emitterFollowBoneRotation
const targetScaleX = Math.max(0.0001, Math.abs(c.rootScaleX * c.emitterFollowBoneScaleX))
const targetScaleY = Math.max(0.0001, Math.abs(c.rootScaleY * c.emitterFollowBoneScaleY))
preview = { x: targetX, y: targetY, rotation: targetRotation }
if (c.emitterFollowSpace === 'local') {
// 本地空间:整个发射器容器随骨骼移动,已出生粒子也继承后续骨骼变换。
em.position.set(targetX, -targetY)
em.rotation = -targetRotation * Math.PI / 180
em.scale.set(targetScaleX, targetScaleY)
em.setSimulationTransform(targetX, targetY, targetRotation, targetScaleX, targetScaleY)
} else {
// 世界空间:保持粒子系统根容器不动,只把新粒子的出生点和方向移动到骨骼。
em.position.set(c.centerX, -c.centerY)
em.rotation = -c.rootRotation * Math.PI / 180
em.scale.set(targetScaleX, targetScaleY)
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation, targetScaleX, targetScaleY)
const local = worldPointToEmitterLocal(c, targetX, targetY, targetScaleX, targetScaleY)
const relativeDirection = -(targetRotation - c.rootRotation)
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
}
} else if (pathSample) {
const movementAngle = pathSample.angle + (c.emitterFollowDirection === 'reverse' ? 180 : 0)
const effectiveAngle = c.emitterAngleMode === 'direction' ? movementAngle : c.rootRotation
preview = { x: pathSample.x, y: pathSample.y, rotation: effectiveAngle }
@@ -1166,9 +1252,10 @@ function loop() {
if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
for (const [, em] of emitterMap) {
for (const [systemId, em] of emitterMap) {
const states = em.update(frameDuration)
for (const state of states) if (state.active) appendStateBones(collected, state)
const systemVisible = store.systems.find((item) => item.id === systemId)?.visible !== false
if (systemVisible) for (const state of states) if (state.active) appendStateBones(collected, state)
total += em.activeCount
}
for (const sys of store.systems) {
@@ -1186,7 +1273,10 @@ function loop() {
const frame = sys.frames?.[tl.frame]
if (emitter && frame) {
emitter.apply(frame)
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
for (const state of frame) if (state.active) {
if (sys.visible !== false) appendStateBones(collected, state)
total++
}
}
}
tl.frame++
@@ -1205,19 +1295,23 @@ function loop() {
if (!tl.playing && tl.recorded && emitter && frame) {
// 暂停、逐帧或手动拖动播放头时立即显示指定的录制帧。
emitter.apply(frame)
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
for (const state of frame) if (state.active) {
if (sys.visible !== false) appendStateBones(collected, state)
total++
}
} else if (emitter) {
const states = emitter.update(0)
for (const state of states) if (state.active) appendStateBones(collected, state)
if (sys.visible !== false) 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)
spineRuntime?.sync(store.spines, displayFrame, fps)
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
drawCollisionBodies()
drawPaths()
drawBoneFollowTargets()
drawOrigin()
drawShapeRange()
drawForceField()
@@ -1315,6 +1409,9 @@ onUnmounted(() => {
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
.hud-system-count { width: 3ch; }
.hud-particle-count { width: 7ch; text-align: left; }
.hud-tip { margin-left: 12px; color: #5f6a8a; font-size: 11px; }
b { color: #ffcc33; }
.settings-btn {