From b5ac9552a1064c094969445b660f62e33827ad1c Mon Sep 17 00:00:00 2001 From: tianmo Date: Sat, 29 Aug 2026 12:32:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/particleEmitter.ts | 40 ++- src/spine/SpineObjectPanel.vue | 61 ++++- src/spine/SpineRuntimeLayer.ts | 41 ++- src/spine/spineTypes.ts | 3 +- src/store/particleStore.ts | 71 ++++- src/views/ParticlePanel.vue | 174 +++++++++++-- src/views/Stage.vue | 143 +++++++++-- src/views/Timeline.vue | 156 ++++++++++- 网页粒子系统阶段八.md | 456 +++++++++++++++++++++++++++++++++ 9 files changed, 1064 insertions(+), 81 deletions(-) create mode 100644 网页粒子系统阶段八.md diff --git a/src/core/particleEmitter.ts b/src/core/particleEmitter.ts index 74a57cb..926e140 100644 --- a/src/core/particleEmitter.ts +++ b/src/core/particleEmitter.ts @@ -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 diff --git a/src/spine/SpineObjectPanel.vue b/src/spine/SpineObjectPanel.vue index 8decc60..1aef759 100644 --- a/src/spine/SpineObjectPanel.vue +++ b/src/spine/SpineObjectPanel.vue @@ -55,16 +55,41 @@
层级树
+
未加载 Spine
-
-
+ +
未找到匹配的骨骼
+
+
{{ bone.name }} @@ -87,10 +112,14 @@ const skeletonInput = ref(null) const atlasInput = ref(null) const textureInput = ref(null) const collapsedBones = ref(new Set()) +const boneSearch = ref('') const skeletonFile = ref(null) const atlasFile = ref(null) const textureFiles = ref([]) +// 兼容热更新或旧场景数据;骨骼选择默认保持为空。 +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; } diff --git a/src/spine/SpineRuntimeLayer.ts b/src/spine/SpineRuntimeLayer.ts index e3739a9..1303a79 100644 --- a/src/spine/SpineRuntimeLayer.ts +++ b/src/spine/SpineRuntimeLayer.ts @@ -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() - 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() diff --git a/src/spine/spineTypes.ts b/src/spine/spineTypes.ts index 7c44cb5..63a0958 100644 --- a/src/spine/spineTypes.ts +++ b/src/spine/spineTypes.ts @@ -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 } - diff --git a/src/store/particleStore.ts b/src/store/particleStore.ts index 179bff5..d13603d 100644 --- a/src/store/particleStore.ts +++ b/src/store/particleStore.ts @@ -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) diff --git a/src/views/ParticlePanel.vue b/src/views/ParticlePanel.vue index 6b7dd1d..54b6d3c 100644 --- a/src/views/ParticlePanel.vue +++ b/src/views/ParticlePanel.vue @@ -26,6 +26,9 @@ > {{ system.config.name || 'ParticleSystem' + (i + 1) }} + 粒子
@@ -38,6 +41,9 @@ > {{ collider.name }} + 碰撞
@@ -50,6 +56,9 @@ > {{ path.name }} + 路径
@@ -62,6 +71,9 @@ > {{ spine.name }} + Spine
@@ -679,33 +691,61 @@ 开启发射器跟随
- - - -
移动进度曲线 (0→1)
- -