显示调整

This commit is contained in:
tianmo
2026-08-29 12:32:11 +08:00
parent 178a7d8412
commit b5ac9552a1
9 changed files with 1064 additions and 81 deletions
+27 -13
View File
@@ -275,8 +275,15 @@ export interface EmitterConfig {
/** 开启路径跟随(预留) */
pathFollow: boolean
emitterFollow: boolean
emitterFollowMode: 'none' | 'path'
emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowPathId: number
emitterFollowSpineId: number
emitterFollowBoneName: string
emitterFollowBoneOffsetX: number
emitterFollowBoneOffsetY: number
emitterFollowBoneRotation: number
emitterFollowBoneScaleX: number
emitterFollowBoneScaleY: number
emitterAngleMode: 'fixed' | 'direction'
emitterFollowCurve: CurvePoint[]
emitterFollowDuration: number
@@ -504,6 +511,13 @@ export function defaultConfig(): EmitterConfig {
emitterFollow: false,
emitterFollowMode: 'none',
emitterFollowPathId: 0,
emitterFollowSpineId: 0,
emitterFollowBoneName: '',
emitterFollowBoneOffsetX: 0,
emitterFollowBoneOffsetY: 0,
emitterFollowBoneRotation: 0,
emitterFollowBoneScaleX: 1,
emitterFollowBoneScaleY: 1,
emitterAngleMode: 'fixed',
emitterFollowCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
emitterFollowDuration: 2,
@@ -734,7 +748,7 @@ export class ParticleEmitter extends Container {
private followSpawnX = 0
private followSpawnY = 0
private followSpawnAngle = 0
private simulationTransform: { centerX: number; centerY: number; rotation: number } | null = null
private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
super()
@@ -795,8 +809,8 @@ export class ParticleEmitter extends Container {
this.followSpawnY = y
this.followSpawnAngle = angleDegrees
}
setSimulationTransform(centerX: number, centerY: number, rotation: number) {
this.simulationTransform = { centerX, centerY, rotation }
setSimulationTransform(centerX: number, centerY: number, rotation: number, scaleX?: number, scaleY?: number) {
this.simulationTransform = { centerX, centerY, rotation, scaleX, scaleY }
}
private applyColliders(p: Particle, particleIndex: number) {
@@ -1543,10 +1557,10 @@ export class ParticleEmitter extends Container {
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
function finite(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback }
type SimulationTransform = { centerX: number; centerY: number; rotation: number } | null
type SimulationTransform = { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null
function localToWorldMath(cfg: EmitterConfig, localX: number, localY: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const centerX = transform?.centerX ?? cfg.centerX
@@ -1556,8 +1570,8 @@ function localToWorldMath(cfg: EmitterConfig, localX: number, localY: number, tr
return { x: screenX, y: -screenY }
}
function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = worldX - (transform?.centerX ?? cfg.centerX)
@@ -1568,8 +1582,8 @@ function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number, tr
}
}
function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = cos * scaleX * vx - sin * scaleY * vy
@@ -1577,8 +1591,8 @@ function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number, tr
return { x: screenX, y: -screenY }
}
function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: number, transform: SimulationTransform = null) {
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
const scaleX = Math.max(0.0001, Math.abs(transform?.scaleX ?? cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(transform?.scaleY ?? cfg.rootScaleY))
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenY = -vy
+57 -4
View File
@@ -55,16 +55,41 @@
<div class="section-box">
<div class="tree-title-row">
<div class="section-title">层级树</div>
<button
class="selected-bone"
:class="{ active: spineObject.selectedBone }"
:disabled="!spineObject.selectedBone"
:title="spineObject.selectedBone ? '点击清除骨骼选择' : '当前未选择骨骼'"
@click="spineObject.selectedBone = ''"
>
<template v-if="spineObject.selectedBone">清除选中骨骼<strong>{{ spineObject.selectedBone }}</strong></template>
<template v-else>当前骨骼<strong>未选择</strong></template>
</button>
<button v-if="spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
</div>
<div v-if="!spineObject.bones.length" class="empty">未加载 Spine</div>
<div v-else class="bone-tree">
<div v-for="bone in visibleBones" :key="bone.name" class="bone" :style="{ paddingLeft: `${6 + bone.depth * 16}px` }">
<div v-else class="bone-search">
<input v-model.trim="boneSearch" type="search" aria-label="搜索骨骼" placeholder="搜索骨骼名称…" />
<button v-if="boneSearch" title="清空搜索" aria-label="清空骨骼搜索" @click="boneSearch = ''"></button>
</div>
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">未找到匹配的骨骼</div>
<div v-else-if="spineObject.bones.length" class="bone-tree">
<div
v-for="bone in visibleBones"
:key="bone.name"
class="bone"
:class="{ selected: spineObject.selectedBone === bone.name }"
:style="{ paddingLeft: `${6 + bone.depth * 16}px` }"
role="button"
tabindex="0"
@click="selectBone(bone.name)"
@keydown.enter.prevent="selectBone(bone.name)"
>
<button
v-if="parentBoneNames.has(bone.name)"
class="bone-toggle"
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
@click="toggleBone(bone.name)"
@click.stop="toggleBone(bone.name)"
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
<span v-else class="bone-leaf"></span>
<span class="bone-name">{{ bone.name }}</span>
@@ -87,10 +112,14 @@ const skeletonInput = ref<HTMLInputElement | null>(null)
const atlasInput = ref<HTMLInputElement | null>(null)
const textureInput = ref<HTMLInputElement | null>(null)
const collapsedBones = ref(new Set<string>())
const boneSearch = ref('')
const skeletonFile = ref<File | null>(null)
const atlasFile = ref<File | null>(null)
const textureFiles = ref<File[]>([])
// 兼容热更新或旧场景数据;骨骼选择默认保持为空。
if (props.spineObject.selectedBone == null) props.spineObject.selectedBone = ''
const skeletonName = computed(() => skeletonFile.value?.name || props.spineObject.skeletonFileName)
const atlasName = computed(() => atlasFile.value?.name || props.spineObject.atlasFileName)
const textureNames = computed(() => textureFiles.value.length ? textureFiles.value.map((file) => file.name) : props.spineObject.textureFileNames)
@@ -98,6 +127,8 @@ const textureSummary = computed(() => textureNames.value.length ? `${textureName
const parentBoneNames = computed(() => new Set(props.spineObject.bones.map((bone) => bone.parent).filter((name): name is string => !!name)))
const boneByName = computed(() => new Map(props.spineObject.bones.map((bone) => [bone.name, bone])))
const visibleBones = computed(() => props.spineObject.bones.filter((bone) => {
const query = boneSearch.value.trim().toLocaleLowerCase()
if (query) return bone.name.toLocaleLowerCase().includes(query)
let parent = bone.parent
while (parent) {
if (collapsedBones.value.has(parent)) return false
@@ -107,6 +138,12 @@ const visibleBones = computed(() => props.spineObject.bones.filter((bone) => {
}))
const allBonesCollapsed = computed(() => parentBoneNames.value.size > 0 && [...parentBoneNames.value].every((name) => collapsedBones.value.has(name)))
function selectBone(name: string) {
const shouldClear = props.spineObject.selectedBone === name
for (const spine of store.spines) spine.selectedBone = ''
if (!shouldClear) props.spineObject.selectedBone = name
}
function toggleBone(name: string) {
if (collapsedBones.value.has(name)) collapsedBones.value.delete(name)
else collapsedBones.value.add(name)
@@ -150,6 +187,8 @@ async function loadBundle() {
props.spineObject.textureFileNames = textureFiles.value.map((file) => file.name)
props.spineObject.animations = parsed.animations
props.spineObject.bones = parsed.bones
props.spineObject.selectedBone = ''
boneSearch.value = ''
collapsedBones.value = new Set()
props.spineObject.selectedAnimation = parsed.animations[0]?.name || ''
props.spineObject.assetVersion++
@@ -175,6 +214,8 @@ function clearBundle() {
props.spineObject.textureFileNames = []
props.spineObject.animations = []
props.spineObject.bones = []
props.spineObject.selectedBone = ''
boneSearch.value = ''
collapsedBones.value = new Set()
props.spineObject.selectedAnimation = ''
props.spineObject.assetVersion++
@@ -207,8 +248,20 @@ button:disabled { opacity: .55; cursor: wait; }
.empty { color: #667187; font-size: 12px; padding: 5px 0; }
.bone-tree { max-height: 240px; overflow: auto; padding: 4px 0; background: #111827; border-radius: 5px; }
.tree-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.selected-bone { width: auto; min-width: 0; max-width: 100%; height: 24px; flex: 0 1 auto; overflow: hidden; padding: 0 7px; color: #75819c; background: #182132; border: 1px solid transparent; border-radius: 5px; font-size: 10px; text-align: right; white-space: nowrap; text-overflow: ellipsis; }
.selected-bone.active { color: #aeb9d4; background: #242f44; border-color: #35445e; cursor: pointer; }
.selected-bone.active:hover { border-color: #675ec4; background: #302b57; }
.selected-bone:disabled { opacity: 1; cursor: default; }
.selected-bone strong { color: #c5cde3; font-weight: 600; }
.tree-action { height: 24px; padding: 0 8px; color: #9aa7c2; background: #242f44; border-color: #35445e; font-size: 10px; }
.bone { display: flex; align-items: center; gap: 4px; min-height: 25px; color: #aeb8d2; font-size: 12px; border-bottom: 1px solid rgba(72,84,112,.22); }
.bone-search { position: relative; }
.bone-search input { box-sizing: border-box; width: 100%; height: 30px; padding: 0 32px 0 9px; color: #dce2f2; background: #101827; border: 1px solid #33415b; border-radius: 5px; font-size: 11px; outline: none; }
.bone-search input:focus { border-color: #7065df; box-shadow: 0 0 0 1px rgba(112,101,223,.22); }
.bone-search button { position: absolute; top: 3px; right: 3px; width: 24px; height: 24px; padding: 0; border: 0; color: #8d98b0; background: transparent; }
.bone { display: flex; align-items: center; gap: 4px; min-height: 25px; color: #aeb8d2; font-size: 12px; border-bottom: 1px solid rgba(72,84,112,.22); cursor: pointer; outline: none; }
.bone:hover { background: rgba(69,86,126,.22); }
.bone.selected { color: #f0edff; background: rgba(107,83,221,.32); box-shadow: inset 2px 0 #8e72ff; }
.bone:focus-visible { box-shadow: inset 0 0 0 1px #7d72dd; }
.bone-toggle { width: 22px; height: 22px; padding: 0; border: 0; background: transparent; color: #9aa7c2; }
.bone-toggle:hover { background: #26324a; }
.bone-leaf { display: inline-flex; width: 22px; justify-content: center; color: #58647d; }
+39 -2
View File
@@ -10,11 +10,19 @@ interface RuntimeEntry {
animation: string
}
export interface SpineBoneWorldTransform {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
}
export class SpineRuntimeLayer {
readonly container = new Container()
private entries = new Map<number, RuntimeEntry>()
sync(objects: SpineSceneObject[], frame: number, fps: number, showResources = true) {
sync(objects: SpineSceneObject[], frame: number, fps: number) {
for (const [id, entry] of this.entries) {
if (objects.some((object) => object.id === id)) continue
this.container.removeChild(entry.root)
@@ -47,7 +55,7 @@ export class SpineRuntimeLayer {
const root = entry.root
const spine = entry.spine
const visible = showResources && object.enabled
const visible = object.visible !== false && object.enabled
root.visible = visible
root.renderable = visible
root.position.set(object.x, -object.y)
@@ -71,6 +79,35 @@ export class SpineRuntimeLayer {
}
}
getBoneWorldTransform(objectId: number, boneName: string): SpineBoneWorldTransform | null {
const entry = this.entries.get(objectId)
const bone = entry?.spine.skeleton.findBone(boneName)
if (!entry || !bone || !bone.active) return null
// Spine 骨骼矩阵位于 Spine 对象局部空间;与外层 Pixi 容器矩阵组合后,
// 再把屏幕 Y 向下坐标转换为编辑器使用的数学坐标(Y 向上)。
const root = entry.root
const cos = Math.cos(root.rotation)
const sin = Math.sin(root.rotation)
const ra = cos * root.scale.x
const rb = -sin * root.scale.y
const rc = sin * root.scale.x
const rd = cos * root.scale.y
const a = ra * bone.a + rb * bone.c
const b = ra * bone.b + rb * bone.d
const c = rc * bone.a + rd * bone.c
const d = rc * bone.b + rd * bone.d
const screenX = root.position.x + ra * bone.worldX + rb * bone.worldY
const screenY = root.position.y + rc * bone.worldX + rd * bone.worldY
return {
x: screenX,
y: -screenY,
rotation: -Math.atan2(c, a) * 180 / Math.PI,
scaleX: Math.max(0.0001, Math.hypot(a, c)),
scaleY: Math.max(0.0001, Math.hypot(b, d)),
}
}
destroy() {
for (const entry of this.entries.values()) {
entry.root.removeFromParent()
+2 -1
View File
@@ -13,6 +13,7 @@ export interface SpineSceneObject {
id: number
name: string
enabled: boolean
visible: boolean
x: number
y: number
rotation: number
@@ -23,10 +24,10 @@ export interface SpineSceneObject {
atlasFileName: string
textureFileNames: string[]
selectedAnimation: string
selectedBone: string
animations: SpineAnimationInfo[]
bones: SpineBoneInfo[]
assetVersion: number
status: 'empty' | 'loading' | 'ready' | 'error'
error: string
}
+64 -7
View File
@@ -5,6 +5,9 @@ import { releaseSpineAsset } from '../spine/spineAssetRegistry'
export interface ParticleSystem {
id: number
visible: boolean
/** 导出时所属的动画名称。 */
animation: string
config: EmitterConfig
/** 录制到的帧序列(每帧 = 该帧活动粒子快照) */
frames?: ParticleState[][]
@@ -12,6 +15,7 @@ export interface ParticleSystem {
export interface CollisionBody extends SceneCollider {
name: string
visible: boolean
tags: string[]
selectedTag: string
followPath: boolean
@@ -25,6 +29,7 @@ export interface CollisionBody extends SceneCollider {
export interface ScenePath {
id: number
name: string
visible: boolean
enabled: boolean
x: number
y: number
@@ -57,8 +62,6 @@ export interface SettingsState {
showAxisLabels: boolean
/** 坐标轴数值颜色(#rrggbb) */
axisColor: string
/** 是否在画布中显示已加载的 Spine 动画资源 */
showSpineResources: boolean
}
export interface TimelineState {
@@ -74,8 +77,10 @@ export interface TimelineState {
fps: number
/** 是否已录制 */
recorded: boolean
/** 动画名(暂用于显示) */
/** 当前选中的所属动画。 */
animation: string
/** 项目中可供粒子系统归属的动画列表,至少保留一个。 */
animations: string[]
}
let seq = 1
@@ -94,8 +99,8 @@ export const useParticleStore = defineStore('particle', {
activeObjectType: 'particle' as SceneObjectType,
activeObjectId: 0,
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation' } as TimelineState,
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad', showSpineResources: true } as SettingsState,
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad' } as SettingsState,
}),
getters: {
activeSystemCount: (s) => s.systems.length,
@@ -116,7 +121,10 @@ export const useParticleStore = defineStore('particle', {
let i = 2
while (existing.has(cfg.name)) { cfg.name = `ParticleSystem${maxN + i}`; i++ }
}
const sys: ParticleSystem = { id: seq++, config: cfg }
const animation = this.timeline.animations.includes(this.timeline.animation)
? this.timeline.animation
: this.timeline.animations[0] || 'animation'
const sys: ParticleSystem = { id: seq++, visible: true, animation, config: cfg }
this.systems.push(sys)
this.activeId = sys.id
this.activeObjectType = 'particle'
@@ -127,6 +135,8 @@ export const useParticleStore = defineStore('particle', {
const i = this.systems.findIndex((s) => s.id === id)
if (i >= 0) this.systems.splice(i, 1)
if (this.activeId === id) this.activeId = this.systems[0]?.id ?? 0
const activeSystem = this.systems.find((system) => system.id === this.activeId)
if (activeSystem) this.timeline.animation = activeSystem.animation
if (this.activeObjectType === 'particle' && this.activeObjectId === id) {
const next = this.systems[0]
this.activeObjectId = next?.id ?? 0
@@ -136,12 +146,52 @@ export const useParticleStore = defineStore('particle', {
this.activeId = id
this.activeObjectType = 'particle'
this.activeObjectId = id
const system = this.systems.find((item) => item.id === id)
if (system) this.timeline.animation = system.animation
},
setSystemAnimation(id: number, animation: string) {
if (!this.timeline.animations.includes(animation)) return
const system = this.systems.find((item) => item.id === id)
if (!system) return
system.animation = animation
this.timeline.animation = animation
},
addAnimation() {
const existing = new Set(this.timeline.animations)
let index = 2
let name = `animation${index}`
while (existing.has(name)) name = `animation${++index}`
this.timeline.animations.push(name)
this.timeline.animation = name
const system = this.systems.find((item) => item.id === this.activeId)
if (system) system.animation = name
return name
},
renameAnimation(previousName: string, nextName: string) {
const name = nextName.trim()
const index = this.timeline.animations.indexOf(previousName)
if (index < 0 || !name || (name !== previousName && this.timeline.animations.includes(name))) return false
this.timeline.animations[index] = name
for (const system of this.systems) if (system.animation === previousName) system.animation = name
if (this.timeline.animation === previousName) this.timeline.animation = name
return true
},
removeAnimation(name: string) {
if (this.timeline.animations.length <= 1) return false
const index = this.timeline.animations.indexOf(name)
if (index < 0) return false
this.timeline.animations.splice(index, 1)
const fallback = this.timeline.animations[Math.min(index, this.timeline.animations.length - 1)]
for (const system of this.systems) if (system.animation === name) system.animation = fallback
if (this.timeline.animation === name) this.timeline.animation = fallback
return true
},
addCollider() {
const id = colliderSeq++
const collider: CollisionBody = {
id,
name: `Collider${id}`,
visible: true,
tags: ['Default'],
selectedTag: 'Default',
shape: 'circle',
@@ -177,6 +227,7 @@ export const useParticleStore = defineStore('particle', {
const path: ScenePath = {
id,
name: `Path${id}`,
visible: true,
enabled: true,
x: 0,
y: 0,
@@ -199,6 +250,7 @@ export const useParticleStore = defineStore('particle', {
id,
name: `SpineSkeleton${id}`,
enabled: true,
visible: true,
x: 0,
y: 0,
rotation: 0,
@@ -209,6 +261,7 @@ export const useParticleStore = defineStore('particle', {
atlasFileName: '',
textureFileNames: [],
selectedAnimation: '',
selectedBone: '',
animations: [],
bones: [],
assetVersion: 0,
@@ -223,7 +276,11 @@ export const useParticleStore = defineStore('particle', {
selectSceneObject(type: SceneObjectType, id: number) {
this.activeObjectType = type
this.activeObjectId = id
if (type === 'particle') this.activeId = id
if (type === 'particle') {
this.activeId = id
const system = this.systems.find((item) => item.id === id)
if (system) this.timeline.animation = system.animation
}
},
removeCollider(id: number) {
const index = this.colliders.findIndex((item) => item.id === id)
+149 -25
View File
@@ -26,6 +26,9 @@
>
<span class="tdot"></span>
<span class="tlabel">{{ system.config.name || 'ParticleSystem' + (i + 1) }}</span>
<button class="scene-object-eye" :class="{ off: system.visible === false }" :title="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="system.visible = system.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">粒子</span>
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(system.id)"></button>
</div>
@@ -38,6 +41,9 @@
>
<span class="tdot collision-dot"></span>
<span class="tlabel">{{ collider.name }}</span>
<button class="scene-object-eye" :class="{ off: collider.visible === false }" :title="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" :aria-label="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" @click.stop="collider.visible = collider.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="collider.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">碰撞</span>
<button class="scene-object-delete" title="删除碰撞体" @click.stop="store.removeCollider(collider.id)"></button>
</div>
@@ -50,6 +56,9 @@
>
<span class="tdot path-dot"></span>
<span class="tlabel">{{ path.name }}</span>
<button class="scene-object-eye" :class="{ off: path.visible === false }" :title="path.visible === false ? '显示路径' : '隐藏路径'" :aria-label="path.visible === false ? '显示路径' : '隐藏路径'" @click.stop="path.visible = path.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="path.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">路径</span>
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)"></button>
</div>
@@ -62,6 +71,9 @@
>
<span class="tdot spine-dot"></span>
<span class="tlabel">{{ spine.name }}</span>
<button class="scene-object-eye" :class="{ off: spine.visible === false }" :title="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" :aria-label="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" @click.stop="spine.visible = spine.visible === false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="spine.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button>
<span class="scene-object-kind">Spine</span>
<button class="scene-object-delete" title="删除 Spine" @click.stop="store.removeSpine(spine.id)"></button>
</div>
@@ -679,33 +691,61 @@
<span class="switch-ui"></span><span>开启发射器跟随</span>
</label>
<div v-if="sys.config.emitterFollow" class="modifier-params emitter-follow-params">
<label class="field-block"><span>跟随模式</span><select v-model="sys.config.emitterFollowMode" class="inp">
<label class="field-block"><span>跟随模式</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
<option value="none"></option>
<option value="path">跟随路径</option>
<option v-if="selectedSpineBone || sys.config.emitterFollowMode === 'bone'" value="bone">选中的骨骼</option>
</select></label>
<label v-if="sys.config.emitterFollowMode === 'path'" class="field-block"><span>跟随路径</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
<option :value="0"></option>
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
</select></label>
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
<option value="fixed">固定角度</option>
<option value="direction">跟随移动方向</option>
</select></label>
<div class="gravity-curve-title"><span>移动进度曲线 (01)</span></div>
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
<div class="field-grid follow-grid">
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
<option value="local">局部空间GEN 移动</option>
<option value="world">世界空间粒子生成点移动</option>
<template v-if="sys.config.emitterFollowMode === 'path'">
<label class="field-block"><span>跟随路径</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
<option :value="0"></option>
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
</select></label>
</div>
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
<option value="forward">正向起点 终点</option>
<option value="reverse">反向终点 起点</option>
</select></label>
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
<div class="modifier-hint">局部空间会移动整个发射器世界空间只移动新粒子的生成点已出生粒子留在世界位置</div>
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
<option value="fixed">固定角度</option>
<option value="direction">跟随移动方向</option>
</select></label>
<div class="gravity-curve-title"><span>移动进度曲线 (01)</span></div>
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
<div class="field-grid follow-grid">
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
<option value="local">局部空间GEN 移动</option>
<option value="world">世界空间粒子生成点移动</option>
</select></label>
</div>
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
<option value="forward">正向起点 终点</option>
<option value="reverse">反向终点 起点</option>
</select></label>
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
<div class="modifier-hint">局部空间会移动整个发射器世界空间只移动新粒子的生成点已出生粒子留在世界位置</div>
</template>
<template v-else-if="sys.config.emitterFollowMode === 'bone'">
<div v-if="boundSpineBone" class="bone-follow-target">跟随目标<strong>{{ boundSpineBone.spine.name }} / {{ boundSpineBone.boneName }}</strong></div>
<div v-else class="bone-follow-missing">尚未绑定有效骨骼请先在 Spine 层级树中选择骨骼</div>
<button
v-if="selectedSpineBone && (!boundSpineBone || selectedSpineBone.spine.id !== boundSpineBone.spine.id || selectedSpineBone.boneName !== boundSpineBone.boneName)"
class="bone-follow-rebind"
@click="bindSelectedBone(sys.config)"
>{{ boundSpineBone ? '重新绑定' : '绑定' }}当前选中骨骼{{ selectedSpineBone.spine.name }} / {{ selectedSpineBone.boneName }}</button>
<template v-if="boundSpineBone">
<label class="field-block"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
<option value="local">本地空间随骨骼移动</option>
<option value="world">世界空间粒子生成后保持位置</option>
</select></label>
<div class="field-grid follow-grid">
<label class="compact-field"><span>位移偏移 X</span><input v-model.number="sys.config.emitterFollowBoneOffsetX" type="number" step="1" class="inp" /></label>
<label class="compact-field"><span>位移偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneOffsetY" type="number" step="1" class="inp" /></label>
</div>
<div class="bone-follow-transform-grid">
<label class="compact-field"><span>旋转偏移</span><input v-model.number="sys.config.emitterFollowBoneRotation" type="number" step="1" class="inp" /></label>
<label class="compact-field"><span>缩放偏移 X</span><input v-model.number="sys.config.emitterFollowBoneScaleX" type="number" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneScaleY" type="number" step="0.05" class="inp" /></label>
</div>
<div class="modifier-hint">发射器只跟随所选 Spine 骨骼的位置不继承 Spine 的旋转或缩放粒子变换使用粒子系统自身参数和上方偏移</div>
</template>
</template>
</div>
</div>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
@@ -757,6 +797,25 @@ const activePath = computed(() => store.activeObjectType === 'path'
const activeSpine = computed(() => store.activeObjectType === 'spine'
? store.spines.find((item) => item.id === store.activeObjectId) || null
: null)
const selectedSpineBone = computed(() => {
const spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone))
return spine ? { spine, boneName: spine.selectedBone } : null
})
const boundSpineBone = computed(() => {
const config = sys.value?.config
if (!config || config.emitterFollowMode !== 'bone') return null
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
if (!spine || !spine.bones.some((bone) => bone.name === config.emitterFollowBoneName)) return null
return { spine, boneName: config.emitterFollowBoneName }
})
// 兼容开发热更新和旧场景:缺少显隐字段的对象按“显示”处理。
watchEffect(() => {
for (const system of store.systems) if (system.visible == null) system.visible = true
for (const collider of store.colliders) if (collider.visible == null) collider.visible = true
for (const path of store.paths) if (path.visible == null) path.visible = true
for (const spine of store.spines) if (spine.visible == null) spine.visible = true
})
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
watchEffect(() => {
@@ -770,6 +829,22 @@ watchEffect(() => {
if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1
})
// 每个粒子系统永久保存自己的 Spine ID 与骨骼名;层级树当前选择不会覆盖既有绑定。
// 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。
watchEffect(() => {
for (const system of store.systems) {
const config = ensureEmitterConfig(system.config as any)
if (config.emitterFollowMode !== 'bone') continue
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName)
if (!targetExists) {
config.emitterFollowMode = 'none'
config.emitterFollowSpineId = 0
config.emitterFollowBoneName = ''
}
}
})
// HMR 或旧场景中的碰撞体缺少路径跟随字段时即时补齐。
watchEffect(() => {
for (const collider of store.colliders) {
@@ -806,6 +881,38 @@ function setEmitMode(mode: 'stream' | 'burst') {
if (mode === 'stream' && (!(config.duration > 0))) config.duration = 1
}
function onEmitterFollowModeChange(event: Event, config: {
emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowSpineId: number
emitterFollowBoneName: string
}) {
const mode = (event.target as HTMLSelectElement).value as 'none' | 'path' | 'bone'
config.emitterFollowMode = mode
if (mode !== 'bone') {
config.emitterFollowSpineId = 0
config.emitterFollowBoneName = ''
return
}
bindSelectedBone(config)
}
function bindSelectedBone(config: {
emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowSpineId: number
emitterFollowBoneName: string
}) {
const target = selectedSpineBone.value
if (!target) {
config.emitterFollowMode = 'none'
config.emitterFollowSpineId = 0
config.emitterFollowBoneName = ''
return
}
config.emitterFollowMode = 'bone'
config.emitterFollowSpineId = target.spine.id
config.emitterFollowBoneName = target.boneName
}
type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export'
const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
@@ -843,7 +950,10 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode',
'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve',
'trailGridRows', 'trailGridCols', 'collision', 'pathFollow',
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId', 'emitterAngleMode',
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId',
'emitterFollowSpineId', 'emitterFollowBoneName',
'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation',
'emitterFollowBoneScaleX', 'emitterFollowBoneScaleY', 'emitterAngleMode',
'emitterFollowCurve', 'emitterFollowDuration', 'emitterFollowSpace',
'emitterFollowDirection', 'emitterFollowOffset', 'damping',
],
@@ -1346,11 +1456,19 @@ function toggleResourceLock(resourceId: number) {
.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; }
.tree-item .tlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
.scene-object-item { min-height: 28px; cursor: pointer; }
.scene-object-item:hover { background: #202b40; }
.scene-object-kind { color: #75829c; font-size: 10px; }
.scene-object-eye {
width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px;
background: transparent; color: #9aa6bf; cursor: pointer;
}
.scene-object-eye:hover { border-color: #4b5972; background: #29354a; color: #e1e7f4; }
.scene-object-eye.off { color: #59647a; }
.scene-object-eye svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.scene-object-eye .eye-slash { stroke: #d47b88; stroke-width: 2; }
.scene-object-delete {
width: 24px; height: 22px; padding: 0; border: 1px solid #59414a; border-radius: 5px;
background: #3a272f; color: #efb4bd; font-size: 11px; cursor: pointer;
@@ -1470,6 +1588,12 @@ function toggleResourceLock(resourceId: number) {
.modifier-switch { font-weight: 600; }
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
.bone-follow-target { margin: 7px 0 9px; padding: 7px 8px; overflow: hidden; color: #7f8ba3; background: #121a29; border: 1px solid #2f3c53; border-radius: 5px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.bone-follow-target strong { color: #c8d0e4; font-weight: 600; }
.bone-follow-missing { margin: 7px 0 9px; padding: 7px 8px; color: #d49aa3; background: #2a1e26; border: 1px solid #513540; border-radius: 5px; font-size: 10px; line-height: 1.5; }
.bone-follow-rebind { width: 100%; min-height: 28px; margin: 0 0 8px; padding: 4px 8px; overflow: hidden; border: 1px solid #4a436e; border-radius: 5px; background: #2d2947; color: #c8c2ee; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.bone-follow-rebind:hover { border-color: #7165bd; background: #3a335d; color: #fff; }
.bone-follow-transform-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.modifier-mode-row { display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 6px; }
.gravity-curve-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
+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 {
+150 -6
View File
@@ -10,11 +10,47 @@
<button class="tl-btn" @click="store.nextFrame()" title="后一帧"></button>
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧"></button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环"></button>
<span class="tl-frame"> {{ store.timeline.frame }} / {{ store.timeline.totalFrames }}</span>
<span class="tl-frame"> <b>{{ store.timeline.frame }}</b> / <b>{{ store.timeline.totalFrames }}</b></span>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
<label class="tl-anim">动画
<select v-model="store.timeline.animation" class="tl-sel">
<option value="animation">animation</option>
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
<span>所属动画</span>
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" title="设置当前粒子系统所属的动画" @click.stop="toggleAnimationMenu">
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i></i>
</button>
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
<div class="tl-animation-menu-title">选择所属动画</div>
<button
v-for="animation in store.timeline.animations"
:key="animation"
class="tl-animation-option"
:class="{ on: activeParticleSystem?.animation === animation }"
@click="selectOwnerAnimation(animation)"
>
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation"></i>
</button>
<div class="tl-animation-editor">
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" aria-label="动画名称" @keydown.enter="renameCurrentAnimation" />
<button title="重命名当前动画" @click="renameCurrentAnimation">重命名</button>
</div>
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
<div class="tl-animation-actions">
<button @click="addAnimation"> 新增</button>
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">删除</button>
</div>
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">至少需要保留一个动画</div>
</div>
</div>
<label class="tl-anim tl-spine-preview">Spine 预览动画
<select
class="tl-sel"
:value="previewSpine?.selectedAnimation || ''"
:disabled="!previewSpine"
@change="onSpinePreviewAnimationChange"
>
<option value="">{{ previewSpine ? '无' : '无可用 Spine 动画' }}</option>
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
{{ animation.name }}{{ animation.duration.toFixed(2) }}s
</option>
</select>
</label>
<span class="tl-zoom">{{ zoomLabel }}</span>
@@ -82,7 +118,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
import { useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
@@ -90,6 +126,67 @@ type SysLike = { id: number; config: any }
const store = useParticleStore()
const vpRef = ref<HTMLElement | null>(null)
const animationMenuRef = ref<HTMLElement | null>(null)
const animationMenuOpen = ref(false)
const animationNameDraft = ref('animation')
const animationError = ref('')
// 兼容旧场景与开发热更新:补齐动画列表和粒子系统归属。
watchEffect(() => {
if (!Array.isArray(store.timeline.animations) || !store.timeline.animations.length) store.timeline.animations = ['animation']
if (!store.timeline.animations.includes(store.timeline.animation)) store.timeline.animation = store.timeline.animations[0]
for (const system of store.systems) {
if (!system.animation || !store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
}
})
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
function toggleAnimationMenu() {
if (!activeParticleSystem.value) return
animationMenuOpen.value = !animationMenuOpen.value
animationNameDraft.value = activeParticleSystem.value.animation
animationError.value = ''
}
function selectOwnerAnimation(animation: string) {
const system = activeParticleSystem.value
if (!system) return
store.setSystemAnimation(system.id, animation)
animationNameDraft.value = animation
animationError.value = ''
}
function addAnimation() {
const name = store.addAnimation()
animationNameDraft.value = name
animationError.value = ''
}
function renameCurrentAnimation() {
const current = activeParticleSystem.value?.animation || store.timeline.animation
const next = animationNameDraft.value.trim()
if (!next) { animationError.value = '动画名称不能为空'; return }
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = '动画名称不能重复'; return }
if (!store.renameAnimation(current, next)) { animationError.value = '无法修改动画名称'; return }
animationNameDraft.value = next
animationError.value = ''
}
function deleteCurrentAnimation() {
const current = activeParticleSystem.value?.animation || store.timeline.animation
if (!store.removeAnimation(current)) { animationError.value = '至少需要保留一个动画'; return }
const next = activeParticleSystem.value?.animation || store.timeline.animation
animationNameDraft.value = next
animationError.value = ''
}
function onDocumentPointerDown(event: PointerEvent) {
if (!animationMenuRef.value?.contains(event.target as Node)) animationMenuOpen.value = false
}
onMounted(() => document.addEventListener('pointerdown', onDocumentPointerDown))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPointerDown))
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
@@ -106,6 +203,20 @@ const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames
const spineLanes = computed(() => store.spines.filter((spine) =>
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
))
const previewSpine = computed(() => {
const active = store.activeObjectType === 'spine'
? store.spines.find((spine) => spine.id === store.activeObjectId && spine.animations.length)
: null
return active || store.spines.find((spine) => spine.animations.length) || null
})
function onSpinePreviewAnimationChange(event: Event) {
const spine = previewSpine.value
if (!spine) return
spine.selectedAnimation = (event.target as HTMLSelectElement).value
store.recalcTotalFrames()
store.timeline.frame = 0
}
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() {
@@ -286,14 +397,47 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.tl-controls { display: flex; align-items: center; gap: 8px; }
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.tl-frame { font-size: 12px; color: #aab; }
.tl-frame { display: inline-block; flex: 0 0 92px; width: 92px; box-sizing: border-box; font-size: 12px; color: #aab; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; white-space: nowrap; }
.tl-frame b { display: inline; color: inherit; font-weight: 400; }
.tl-legend { display: flex; align-items: center; gap: 4px; color: #778198; font-size: 10px; }
.tl-legend i { display: inline-block; width: 10px; height: 7px; border-radius: 2px; }
.legend-emission { margin-left: 4px; background: #2f8b62; }
.legend-life { margin-left: 5px; background: #a54551; }
.legend-spine { margin-left: 5px; background: #7448b8; }
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
.tl-owner-animation { position: relative; }
.tl-animation-trigger {
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 116px; height: 25px; padding: 0 7px;
overflow: hidden; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; cursor: pointer;
}
.tl-animation-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
.tl-animation-menu {
position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; width: 226px; padding: 7px;
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
}
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
.tl-animation-option {
display: flex; align-items: center; justify-content: space-between; width: 100%; height: 27px; padding: 0 7px;
border: 0; border-radius: 5px; background: transparent; color: #b8c1d5; text-align: left; cursor: pointer;
}
.tl-animation-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-animation-option:hover { background: #252e42; }
.tl-animation-option.on { background: #34335d; color: #fff; }
.tl-animation-option > i { color: #8d8aff; font-style: normal; }
.tl-animation-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #2b3346; }
.tl-animation-name { min-width: 0; height: 27px; padding: 0 7px; box-sizing: border-box; border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2; outline: none; }
.tl-animation-name:focus { border-color: #6865d8; }
.tl-animation-editor button, .tl-animation-actions button { height: 27px; padding: 0 8px; border: 1px solid #3c4861; border-radius: 5px; background: #29344a; color: #cbd3e3; cursor: pointer; }
.tl-animation-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 6px; }
.tl-animation-actions button:last-child { border-color: #5b3942; background: #3b282f; color: #e2a8b2; }
.tl-animation-actions button:disabled { opacity: .4; cursor: not-allowed; }
.tl-animation-error { margin-top: 5px; color: #ef8997; font-size: 10px; }
.tl-animation-hint { margin-top: 5px; color: #69758d; font-size: 9px; text-align: center; }
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
.tl-sel:disabled { opacity: .5; cursor: not-allowed; }
.tl-spine-preview .tl-sel { max-width: 190px; }
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
.tl-viewport { position: relative; flex: 1; overflow: hidden; background: #0d0d14; border: 1px solid #2a2a3c; border-radius: 6px; cursor: ew-resize; }
.tl-ticks { position: absolute; top: 0; left: 0; right: 0; height: 18px; }
+456
View File
@@ -0,0 +1,456 @@
# 网页粒子系统 · 阶段八
> 阶段定位:**Spine 骨骼选择、粒子独立骨骼跟随、对象显隐与所属动画管理定版**
> 完成日期:2026-08-29
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
---
## 一、阶段结论
阶段八在阶段七 Spine 资源加载和统一时间轴的基础上,完成了骨骼选择到粒子发射器跟随的完整工作流。粒子系统现在可以分别绑定不同 Spine 对象中的不同骨骼,绑定关系彼此独立,并具备目标失效后的自动回退机制。
本阶段同时把场景对象显隐改为对象级控制,并为后续导出建立了粒子系统“所属动画”数据模型。
本阶段主要完成:
1. 将界面中的“骨架”术语统一为 `Spine`
2. 为 Spine 层级树增加骨骼选择、清除选择、搜索和折叠操作;
3. 在画布中显示当前选中骨骼的位置;
4. 实现粒子发射器跟随 Spine 骨骼;
5. 支持位移、旋转、缩放偏移以及本地/世界空间;
6. 修复 Spine 旋转和缩放被错误传递给粒子的问题;
7. 让多个粒子系统的 Spine 与骨骼绑定彼此独立;
8. 实现目标 Spine 或骨骼失效后的自动回退;
9. 为场景中的粒子、碰撞体、路径和 Spine 增加独立显隐按钮;
10. 移除系统设置中的全局 Spine 资源显示开关;
11. 为时间轴增加 Spine 预览动画快捷选择;
12. 将时间轴“动画”升级为粒子系统“所属动画”管理;
13. 支持所属动画新增、重命名、删除和最少一个动画保护;
14. 移除画布右上角重复的秒数显示;
15. 完成 Spine 4.2 测试资源下的交互与生产构建验证。
---
## 二、Spine 术语与对象界面
### 2.1 术语统一
面向用户的场景对象术语统一使用 `Spine`,不再把资源对象简称为“骨架”。
主要界面名称包括:
- `Spine`
- `Spine 属性`
- `Spine 名称`
- `Spine 资源`
- `Spine 动画`
- `启用 Spine` 相关提示;
- 场景对象类型 `Spine`
“骨骼”一词只用于 Spine 资源内部真实的 bone 层级和粒子系统生成的骨骼数据,避免与整个 Spine 场景对象混淆。
### 2.2 画布时间文字清理
画布右上角原有的 `t=0.00s` 文字只是当前时间的重复调试显示。当前帧、总帧数和播放状态已经由底部时间轴完整表达,因此本阶段将该文字移除。
---
## 三、Spine 骨骼层级树
### 3.1 骨骼选择
Spine 资源加载成功后,层级树中的每个骨骼都可以点击选择:
- 当前骨骼使用高亮行显示;
- 默认不选择任何骨骼;
- 同一时间只保留一个层级树骨骼作为“当前选中骨骼”;
- 再次点击同一骨骼可以取消选择;
- 选择另一个 Spine 中的骨骼时,会清除之前的临时层级树选择。
层级树标题旁显示自适应宽度的选择按钮:
```text
未选择时:当前骨骼:未选择
已选择时:清除选中骨骼:boneName
```
点击已选择状态的按钮可以清除层级树选择。按钮宽度会根据骨骼名称和可用空间自动调整,名称过长时使用省略显示。
### 3.2 名称搜索
层级树增加骨骼名称搜索框:
- 输入名称片段即可过滤骨骼;
- 搜索不区分大小写;
- 支持一键清空搜索内容;
- 没有匹配结果时显示空状态提示;
- 搜索只改变显示结果,不修改骨骼数据或当前绑定。
### 3.3 折叠与展开
保留并完善阶段七的层级操作:
- 单独折叠或展开父骨骼;
- 全部折叠;
- 全部展开;
- 搜索状态下直接展示名称匹配结果;
- 长列表在固定区域内滚动。
### 3.4 画布骨骼点预览
在层级树选择骨骼后,画布会立即绘制该骨骼的世界位置点,不需要先开启粒子跟随。
该预览用于确认:
- 是否选择了正确骨骼;
- 骨骼当前动画位置是否正确;
- Spine 对象自身位移、旋转和缩放后的世界位置是否正确;
- 粒子发射器后续应绑定的位置。
清除层级树选择后,单纯的选择预览点会消失;已经绑定到粒子系统的骨骼目标仍按绑定关系保留。
---
## 四、粒子发射器骨骼跟随
### 4.1 跟随模式
开启“发射器跟随”后,跟随模式包括:
| 模式 | 行为 |
|---|---|
| 无 | 不使用路径或骨骼跟随 |
| 跟随路径 | 使用场景路径对象驱动发射器 |
| 选中的骨骼 | 把当前粒子系统绑定到层级树当前选中的 Spine 骨骼 |
只有层级树存在有效选中骨骼时,才可以新建骨骼绑定。已经完成骨骼绑定的粒子系统会继续保留该模式,即使之后清除了层级树临时选择。
### 4.2 一次绑定原则
层级树的“当前选中骨骼”只是一个待绑定目标。选择“选中的骨骼”时,会把以下信息一次性写入当前粒子系统:
```text
emitterFollowSpineId
emitterFollowBoneName
```
绑定完成后,后续在层级树中选择其他骨骼不会自动覆盖已有绑定。
面板显示的是当前粒子系统实际保存的目标:
```text
跟随目标:SpineSkeleton1 / root
```
如果层级树当前选择与已绑定目标不同,面板显示“重新绑定当前选中骨骼”按钮。只有主动点击该按钮,当前粒子系统才会改绑到新目标。
### 4.3 多粒子系统独立绑定
每个粒子系统独立保存自己的 Spine ID 和骨骼名称,因此支持:
- 粒子系统 A 跟随 Spine A 的骨骼 `hand_l`
- 粒子系统 B 跟随 Spine A 的骨骼 `hand_r`
- 粒子系统 C 跟随 Spine B 的骨骼 `weapon`
- 各粒子系统使用不同的偏移和空间模式;
- 修改其中一个粒子系统的目标不会影响其他系统。
运行时逐个读取粒子系统自身保存的目标,并从对应 Spine 运行时对象中查询骨骼世界位置,不再使用全局骨骼目标覆盖全部粒子系统。
### 4.4 参数
骨骼绑定成功后显示:
| 参数 | 默认值 | 说明 |
|---|---:|---|
| 跟随空间 | 世界空间 | 控制已出生粒子是否继续随目标移动 |
| 位移偏移 X / Y | 0 / 0 | 在骨骼世界位置上叠加位置偏移 |
| 旋转偏移 | 0° | 使用粒子系统自身旋转基础上的附加角度 |
| 缩放偏移 X / Y | 1 / 1 | 使用粒子系统自身缩放基础上的倍率 |
绑定没有成功时不会再出现只有“选中的骨骼”下拉值、下方却完全空白的状态。面板会提示尚未绑定有效骨骼,并在存在层级树选择时提供明确的绑定按钮。
### 4.5 本地空间与世界空间
| 空间 | 行为 |
|---|---|
| 本地空间 | 整个粒子发射器随骨骼位置移动,已出生粒子继续随发射器移动 |
| 世界空间 | 只移动新粒子的生成位置,已经出生的粒子保留在生成时的世界位置 |
两种空间只改变粒子位置的跟随判定,不会因为切换空间而改变粒子本身的尺寸。
### 4.6 Spine 变换继承规则
骨骼跟随只读取目标骨骼的世界位置,不继承以下变换:
- Spine 对象的旋转;
- Spine 对象的缩放;
- Spine 骨骼自身的旋转;
- Spine 骨骼自身的缩放。
粒子的旋转和缩放始终来自粒子系统自己的根变换及骨骼跟随偏移参数。因此在世界空间与本地空间之间切换时,粒子大小不会因 Spine 或骨骼缩放而变化。
### 4.7 目标失效回退
每个粒子系统持续校验自己保存的目标。以下情况会使绑定失效:
- 目标 Spine 对象被删除;
- 目标 Spine 资源被清空;
- 重新加载资源后原骨骼名称不存在;
- 配置中的 Spine ID 或骨骼名称无效。
失效后只重置受影响的粒子系统:
```text
emitterFollowMode = none
emitterFollowSpineId = 0
emitterFollowBoneName = ''
```
单纯清除层级树的“当前选中骨骼”不会解除已有绑定,因为骨骼本身仍然存在。
---
## 五、场景对象独立显隐
### 5.1 对象级小眼睛
场景对象列表中的每个对象右侧增加小眼睛按钮:
- 粒子系统;
- 碰撞体;
- 路径;
- Spine。
点击后只切换该对象的 `visible` 状态。新建对象默认显示;旧场景或热更新对象缺少该字段时自动补为显示。
### 5.2 显隐与启用分离
`visible` 只控制编辑器画布显示,不代替对象原有的 `enabled` 或功能开关。
隐藏对象时:
- 不绘制对象主体;
- 不绘制该对象的变换工具;
- 不绘制其发射点、路径锚点、碰撞体轮廓或选择辅助图形;
- 隐藏粒子系统时同时隐藏粒子骨骼调试线;
- 隐藏 Spine 时隐藏 Spine 资源和选择预览点。
隐藏对象不会:
- 停止粒子模拟;
- 关闭碰撞体的实际碰撞;
- 使路径跟随失效;
- 破坏粒子系统保存的骨骼绑定;
- 修改时间轴时长或导出配置。
因此“小眼睛”是编辑器可见性控制,“启用”仍是对象功能状态,两者语义独立。
### 5.3 移除全局 Spine 显示开关
系统设置中的“启用 Spine 资源显示”已移除。每个 Spine 对象使用自己的小眼睛控制显示,不再通过一个全局开关同时隐藏全部 Spine。
该变更替代了阶段七文档中的全局 Spine 显示设置设计。
---
## 六、时间轴 Spine 预览动画
时间轴控制栏新增“Spine 预览动画”下拉框,与 Spine 属性面板中的动画选择并存。
规则如下:
- 当前选中 Spine 有动画时,优先控制当前 Spine;
- 当前对象不是 Spine 时,使用场景中第一个包含动画的 Spine;
- 下拉项显示动画名称和真实时长;
- 切换后同步更新 Spine 属性面板;
- 切换动画后重新计算时间轴总长度并回到第 0 帧;
- 没有可用 Spine 动画时下拉框置灰。
该功能只提供快捷预览入口,不改变 Spine 动画自身非循环、由编辑器统一时间轴驱动的规则。
---
## 七、粒子系统所属动画
### 7.1 数据语义
时间轴原有的“动画”字段改为“所属动画”。它表示当前粒子系统在后续导出结构中归属于哪个动画,而不是当前 Spine 的预览动画。
每个粒子系统新增独立的动画归属字段:
```text
ParticleSystem.animation
```
项目时间轴状态保存可用动画名称列表:
```text
TimelineState.animations
```
默认状态为:
```text
动画列表:['animation']
新粒子系统所属动画:animation
```
### 7.2 动画管理
点击“所属动画”下拉按钮后可以:
- 查看全部动画名称;
- 修改当前粒子系统所属动画;
- 新增动画;
- 重命名当前动画;
- 删除当前动画。
新增动画自动使用不重复的英文名称:
```text
animation2
animation3
...
```
新增后,当前粒子系统自动归入新动画。
### 7.3 重命名与删除规则
动画名称:
- 不能为空;
- 不能与已有动画重名;
- 最长输入限制为 64 个字符;
- 重命名后,所有引用旧名称的粒子系统同步更新。
删除动画时:
- 如果仍有其他动画,引用被删除动画的粒子系统自动归入相邻的剩余动画;
- 当前选择同步切换到回退动画;
- 动画列表至少保留一个项目;
- 只剩一个动画时删除按钮禁用。
### 7.4 多粒子系统
不同粒子系统可以归属不同动画,例如:
```text
ParticleSystem1 -> idle
ParticleSystem2 -> attack
ParticleSystem3 -> hit
```
切换场景对象中的粒子系统后,时间轴“所属动画”会显示该系统自己的归属,不会覆盖其他粒子系统。
当前阶段只建立动画归属关系,尚未按所属动画过滤时间轴轨道或执行最终导出。
---
## 八、数据与兼容性
### 8.1 新增字段
场景对象新增:
```text
ParticleSystem.visible
ParticleSystem.animation
CollisionBody.visible
ScenePath.visible
SpineSceneObject.visible
```
时间轴新增:
```text
TimelineState.animations
```
粒子发射器沿用并完善:
```text
emitterFollowSpineId
emitterFollowBoneName
emitterFollowBoneOffsetX
emitterFollowBoneOffsetY
emitterFollowBoneRotation
emitterFollowBoneScaleX
emitterFollowBoneScaleY
```
### 8.2 旧数据迁移
开发热更新或旧场景缺少字段时:
- 场景对象默认补为显示;
- 动画列表默认补为 `['animation']`
- 粒子系统缺少或引用无效所属动画时归入 `animation`
- 骨骼跟随字段通过 `ensureEmitterConfig()` 补齐;
- 骨骼目标不存在时自动回到无跟随模式。
系统设置中的旧 `showSpineResources` 字段不再参与界面和运行时显示判断。
---
## 九、验证结果
本阶段使用 `public/spine/` 中的 Spine 4.2 测试资源完成验证:
- `bingo.json`
- `bingo.atlas`
- `bingo.png`
- `bingo_2.png`
已验证:
- Spine 骨骼名称搜索、折叠、选择和清除;
- 层级树选择后立即绘制骨骼位置点;
- 选择 `root` 后,骨骼跟随空间和偏移参数正常显示;
- 层级树改选 `zong` 后,已有 `root` 绑定保持不变;
- 点击重新绑定后,当前粒子系统单独切换为 `zong`
- 多粒子系统配置独立保存 Spine ID 和骨骼名称;
- 骨骼目标失效检测及无跟随回退逻辑;
- 本地空间和世界空间不继承 Spine/骨骼旋转与缩放;
- 粒子、碰撞体、路径和 Spine 小眼睛状态切换;
- 全局 Spine 显示设置已经移除;
- Spine 预览动画下拉框与属性面板同步;
- 所属动画默认 `animation`
- 所属动画新增、重命名和删除;
- 删除最后一个动画被禁止;
- 两个粒子系统分别保存 `animation``animation2`
- 页面交互过程中无控制台错误;
- `npm run build` 生产构建通过;
- `git diff --check` 通过。
构建仍存在 Vite 主产物超过 500 kB 的体积提示,不影响构建结果和本阶段功能。
---
## 十、当前限制
1. Spine 资源仍通过本地文件选择器加载,刷新后需要重新选择;
2. Spine 皮肤、插槽、约束和动画混合尚未开放;
3. 骨骼跟随当前只使用骨骼位置,不提供继承骨骼旋转或缩放的可选模式;
4. 骨骼跟随目标使用 Spine ID 和骨骼名称,工程保存与导入格式尚未落地;
5. 所属动画当前只保存分组关系,尚未影响时间轴轨道筛选、播放范围或导出结果;
6. 场景对象小眼睛状态尚未纳入持久化工程文件;
7. Spine 预览动画当前一次控制一个预览对象,尚未提供多 Spine 联动选择界面;
8. 主包仍包含 Spine 运行时和主要编辑器模块,尚未进行动态拆包。
---
## 十一、阶段九建议
1. 建立正式场景工程保存格式,持久化对象显隐、所属动画和骨骼绑定;
2. 实现项目打开、保存、另存为和自动恢复;
3. 把 Spine、Atlas 和纹理复制到工程资源目录并建立相对路径引用;
4. 实现按“所属动画”筛选时间轴轨道和播放范围;
5. 设计导出数据结构,输出动画列表、粒子系统归属和 Spine 骨骼绑定;
6. 为骨骼跟随增加可选的旋转继承、缩放继承和轴向过滤;
7. 增加 Spine 皮肤、插槽和动画混合控制;
8. 建立多 Spine、多粒子系统、多骨骼绑定的自动回归测试;
9. 对 Spine 面板和曲线编辑器进行动态加载,降低主包体积;
10. 补充资源丢失、骨骼改名和动画删除后的导入迁移提示。