子发射器

This commit is contained in:
tianmo committed 2026-10-22 16:49:53 +08:00
1 parent d4ebb18777
commit d224939efd
33 files changed
+5690 -1355

No files matched your search

+1542
View File
File diff suppressed because it is too large. Load diff
File renamed without changes.
+6 -2
View File
@@ -20,7 +20,11 @@
"file": "5.贴图轴点.json" "file": "5.贴图轴点.json"
}, },
{ {
"name": "6.SpineBoy", "name": "6.子发射器",
"file": "6.SpineBoy.json" "file": "6.子发射器.json"
},
{
"name": "7.SpineBoy",
"file": "7.SpineBoy.json"
} }
] ]
@@ -1,4 +1,4 @@
var Ae=Object.defineProperty;var Be=(m,o,e)=>o in m?Ae(m,o,{enumerable:!0,configurable:!0,writable:!0,value:e}):m[o]=e;var a=(m,o,e)=>Be(m,typeof o!="symbol"?o+"":o,e);import{T as Fe,a as he,b as F,S as ue,M as pe,c as oe,W as ne,B as H,d as z,L as Te,E as O,e as ce,p as q,f as Ee,g as fe,A as Q,h as me,i as De,j as V,s as re,G as we,k as $,l as Y,m as Le,P as Ie,n as Re,o as Ne,C as X,q as be,r as _e,t as Ue,u as Ye,v as Xe,w as ee,x as j,y as ke,z as Ve,D as J,F as ge,V as Ge,H as ae,I as We,J as $e,U as xe,K as E,R as ve,N as ye,O as Pe,Q as Oe,X as He,Y as ze,Z as qe,_ as Ke}from"./index-BDM5NpkZ.js";import{$ as bt,a0 as kt,a1 as vt,a2 as yt,a3 as Pt,a4 as St,a5 as Mt,a6 as At,a7 as Bt,a8 as Ft,a9 as Et,aa as Dt,ab as Lt,ac as It,ad as Rt,ae as Nt,af as _t,ag as Ut,ah as Yt,ai as Xt,aj as Vt,ak as Gt,al as Wt,am as $t,an as Ot,ao as Ht,ap as zt,aq as qt,ar as Kt,as as Zt,at as Jt,au as Qt,av as jt,aw as es,ax as ts,ay as ss,az as os,aA as ns,aB as as,aC as rs,aD as is,aE as ls,aF as hs,aG as cs,aH as ds,aI as us,aJ as ps,aK as fs,aL as ms,aM as gs,aN as xs,aO as Cs,aP as Ts,aQ as ws,aR as bs,aS as ks,aT as vs,aU as ys,aV as Ps,aW as Ss,aX as Ms,aY as As,aZ as Bs,a_ as Fs,a$ as Es,b0 as Ds,b1 as Ls,b2 as Is,b3 as Rs,b4 as Ns,b5 as _s,b6 as Us,b7 as Ys,b8 as Xs,b9 as Vs,ba as Gs,bb as Ws,bc as $s,bd as Os,be as Hs,bf as zs,bg as qs,bh as Ks,bi as Zs,bj as Js,bk as Qs,bl as js,bm as eo,bn as to,bo as so,bp as oo,bq as no,br as ao,bs as ro,bt as io,bu as lo,bv as ho,bw as co,bx as uo,by as po,bz as fo,bA as mo,bB as go,bC as xo,bD as Co,bE as To,bF as wo,bG as bo,bH as ko,bI as vo,bJ as yo,bK as Po,bL as So,bM as Mo,bN as Ao,bO as Bo,bP as Fo,bQ as Eo,bR as Do,bS as Lo,bT as Io,bU as Ro,bV as No,bW as _o,bX as Uo,bY as Yo,bZ as Xo,b_ as Vo,b$ as Go,c0 as Wo,c1 as $o,c2 as Oo,c3 as Ho}from"./index-BDM5NpkZ.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-BS4GpQRH.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-BS4GpQRH.js";if(typeof window<"u"&&window.PIXI){const m=window.require;window.require=o=>{if(m)return m(o);if(o.startsWith("@pixi/"))return window.PIXI}}const _=class _ extends Fe{constructor(e){super(e.resource.source);a(this,"texture");this.texture=he.from(e)}static from(e){return _.textureMap.has(e)?_.textureMap.get(e):new _(e)}setFilters(e,t){this.texture.baseTexture.scaleMode=_.toPixiTextureFilter(e),this.texture.baseTexture.mipmap=_.toPixiMipMap(e)}setWraps(e,t){this.texture.baseTexture.wrapMode=_.toPixiTextureWrap(e)}dispose(){this.texture.destroy()}static toPixiTextureFilter(e){switch(e){case F.Nearest:case F.MipMapNearestLinear:case F.MipMapNearestNearest:return ue.NEAREST;case F.Linear:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return ue.LINEAR;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiMipMap(e){switch(e){case F.Nearest:case F.Linear:return pe.OFF;case F.MipMapNearestLinear:case F.MipMapNearestNearest:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return pe.ON;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiTextureWrap(e){switch(e){case oe.ClampToEdge:return ne.CLAMP;case oe.MirroredRepeat:return ne.MIRRORED_REPEAT;case oe.Repeat:return ne.REPEAT;default:throw new Error(`Unknown texture wrap: ${String(e)}`)}}static toPixiBlending(e){switch(e){case H.Normal:return z.NORMAL;case H.Additive:return z.ADD;case H.Multiply:return z.MULTIPLY;case H.Screen:return z.SCREEN;default:throw new Error(`Unknown blendMode: ${String(e)}`)}}};a(_,"textureMap",new Map);let G=_;const K="spineTextureAtlasLoader",et={extension:O.Asset,resolver:{test:m=>V(m,".atlas"),parse:m=>{var e,t;const o=m.split(".");return{resolution:parseFloat(((t=(e=re.RETINA_PREFIX)==null?void 0:e.exec(m))==null?void 0:t[1])??"1"),format:o[o.length-2],src:m}}},loader:{name:K,extension:{type:O.LoadParser,priority:Te.Normal,name:K},test(m){return V(m,".atlas")},async load(m){const o=await re.ADAPTER.fetch(m);if(!o.ok)throw new Error(`[${K}] Failed to fetch ${m}: ${o.status} ${o.statusText}`);return await o.text()},testParse(m,o){const e=V(o.src,".atlas"),t=typeof m=="string",s=o.loadParser===K;return Promise.resolve((e||s)&&t)},unload(m){m.dispose()},async parse(m,o,e){var p;const t=o.data||{};let s=q.dirname(o.src);s&&s.lastIndexOf("/")!==s.length-1&&(s+="/");const n=new Ee(m);if(t.images instanceof fe||typeof t.images=="string"){const h=t.images;t.images={},t.images[n.pages[0].name]=h}const r=[];let l=!0;for(const h of e.parsers)if(h.name==="loadTextures"){l=(p=h.config)==null?void 0:p.preferCreateImageBitmap;break}Q.setPreferences({preferCreateImageBitmap:!1});for(const h of n.pages){const u=h.name,c=t!=null&&t.images?t.images[u]:void 0;if(c instanceof fe)h.setTexture(G.from(c));else{const i=c??q.normalize([...s.split(q.sep),u].join(q.sep)),g={src:De(i,o.src),data:{...t.imageMetadLine truncated var Ae=Object.defineProperty;var Be=(m,o,e)=>o in m?Ae(m,o,{enumerable:!0,configurable:!0,writable:!0,value:e}):m[o]=e;var a=(m,o,e)=>Be(m,typeof o!="symbol"?o+"":o,e);import{T as Fe,a as he,b as F,S as ue,M as pe,c as oe,W as ne,B as H,d as z,L as Te,E as O,e as ce,p as q,f as Ee,g as fe,A as Q,h as me,i as De,j as V,s as re,G as we,k as $,l as Y,m as Le,P as Ie,n as Re,o as Ne,C as X,q as be,r as _e,t as Ue,u as Ye,v as Xe,w as ee,x as j,y as ke,z as Ve,D as J,F as ge,V as Ge,H as ae,I as We,J as $e,U as xe,K as E,R as ve,N as ye,O as Pe,Q as Oe,X as He,Y as ze,Z as qe,_ as Ke}from"./index-CPXvsWyv.js";import{$ as bt,a0 as kt,a1 as vt,a2 as yt,a3 as Pt,a4 as St,a5 as Mt,a6 as At,a7 as Bt,a8 as Ft,a9 as Et,aa as Dt,ab as Lt,ac as It,ad as Rt,ae as Nt,af as _t,ag as Ut,ah as Yt,ai as Xt,aj as Vt,ak as Gt,al as Wt,am as $t,an as Ot,ao as Ht,ap as zt,aq as qt,ar as Kt,as as Zt,at as Jt,au as Qt,av as jt,aw as es,ax as ts,ay as ss,az as os,aA as ns,aB as as,aC as rs,aD as is,aE as ls,aF as hs,aG as cs,aH as ds,aI as us,aJ as ps,aK as fs,aL as ms,aM as gs,aN as xs,aO as Cs,aP as Ts,aQ as ws,aR as bs,aS as ks,aT as vs,aU as ys,aV as Ps,aW as Ss,aX as Ms,aY as As,aZ as Bs,a_ as Fs,a$ as Es,b0 as Ds,b1 as Ls,b2 as Is,b3 as Rs,b4 as Ns,b5 as _s,b6 as Us,b7 as Ys,b8 as Xs,b9 as Vs,ba as Gs,bb as Ws,bc as $s,bd as Os,be as Hs,bf as zs,bg as qs,bh as Ks,bi as Zs,bj as Js,bk as Qs,bl as js,bm as eo,bn as to,bo as so,bp as oo,bq as no,br as ao,bs as ro,bt as io,bu as lo,bv as ho,bw as co,bx as uo,by as po,bz as fo,bA as mo,bB as go,bC as xo,bD as Co,bE as To,bF as wo,bG as bo,bH as ko,bI as vo,bJ as yo,bK as Po,bL as So,bM as Mo,bN as Ao,bO as Bo,bP as Fo,bQ as Eo,bR as Do,bS as Lo,bT as Io,bU as Ro,bV as No,bW as _o,bX as Uo,bY as Yo,bZ as Xo,b_ as Vo,b$ as Go,c0 as Wo,c1 as $o,c2 as Oo,c3 as Ho}from"./index-CPXvsWyv.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-CT90mgj8.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-CT90mgj8.js";if(typeof window<"u"&&window.PIXI){const m=window.require;window.require=o=>{if(m)return m(o);if(o.startsWith("@pixi/"))return window.PIXI}}const _=class _ extends Fe{constructor(e){super(e.resource.source);a(this,"texture");this.texture=he.from(e)}static from(e){return _.textureMap.has(e)?_.textureMap.get(e):new _(e)}setFilters(e,t){this.texture.baseTexture.scaleMode=_.toPixiTextureFilter(e),this.texture.baseTexture.mipmap=_.toPixiMipMap(e)}setWraps(e,t){this.texture.baseTexture.wrapMode=_.toPixiTextureWrap(e)}dispose(){this.texture.destroy()}static toPixiTextureFilter(e){switch(e){case F.Nearest:case F.MipMapNearestLinear:case F.MipMapNearestNearest:return ue.NEAREST;case F.Linear:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return ue.LINEAR;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiMipMap(e){switch(e){case F.Nearest:case F.Linear:return pe.OFF;case F.MipMapNearestLinear:case F.MipMapNearestNearest:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return pe.ON;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiTextureWrap(e){switch(e){case oe.ClampToEdge:return ne.CLAMP;case oe.MirroredRepeat:return ne.MIRRORED_REPEAT;case oe.Repeat:return ne.REPEAT;default:throw new Error(`Unknown texture wrap: ${String(e)}`)}}static toPixiBlending(e){switch(e){case H.Normal:return z.NORMAL;case H.Additive:return z.ADD;case H.Multiply:return z.MULTIPLY;case H.Screen:return z.SCREEN;default:throw new Error(`Unknown blendMode: ${String(e)}`)}}};a(_,"textureMap",new Map);let G=_;const K="spineTextureAtlasLoader",et={extension:O.Asset,resolver:{test:m=>V(m,".atlas"),parse:m=>{var e,t;const o=m.split(".");return{resolution:parseFloat(((t=(e=re.RETINA_PREFIX)==null?void 0:e.exec(m))==null?void 0:t[1])??"1"),format:o[o.length-2],src:m}}},loader:{name:K,extension:{type:O.LoadParser,priority:Te.Normal,name:K},test(m){return V(m,".atlas")},async load(m){const o=await re.ADAPTER.fetch(m);if(!o.ok)throw new Error(`[${K}] Failed to fetch ${m}: ${o.status} ${o.statusText}`);return await o.text()},testParse(m,o){const e=V(o.src,".atlas"),t=typeof m=="string",s=o.loadParser===K;return Promise.resolve((e||s)&&t)},unload(m){m.dispose()},async parse(m,o,e){var p;const t=o.data||{};let s=q.dirname(o.src);s&&s.lastIndexOf("/")!==s.length-1&&(s+="/");const n=new Ee(m);if(t.images instanceof fe||typeof t.images=="string"){const h=t.images;t.images={},t.images[n.pages[0].name]=h}const r=[];let l=!0;for(const h of e.parsers)if(h.name==="loadTextures"){l=(p=h.config)==null?void 0:p.preferCreateImageBitmap;break}Q.setPreferences({preferCreateImageBitmap:!1});for(const h of n.pages){const u=h.name,c=t!=null&&t.images?t.images[u]:void 0;if(c instanceof fe)h.setTexture(G.from(c));else{const i=c??q.normalize([...s.split(q.sep),u].join(q.sep)),g={src:De(i,o.src),data:{...t.imageMetadLine truncated
attribute vec2 aVertexPosition; attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord; attribute vec2 aTextureCoord;
-1239
View File
File diff suppressed because it is too large. Load diff
+1239
View File
File diff suppressed because it is too large. Load diff
@@ -1,2 +1,2 @@
var ft=Object.defineProperty;var gt=(W,t,e)=>t in W?ft(W,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):W[t]=e;var T=(W,t,e)=>gt(W,typeof t!="symbol"?t+"":t,e);import{f as ht,R as pt,N as dt,ac as ut,Z as mt,b4 as yt,O as rt,b5 as at,U as D,B as Tt}from"./index-BDM5NpkZ.js";import{$ as Rt,a0 as Yt,a1 as Ft,a2 as Et,y as _t,a3 as Ot,a4 as kt,a5 as Xt,a6 as Bt,a7 as Nt,a8 as Dt,a9 as $t,aa as zt,ab as Wt,ad as qt,H as Gt,ae as Jt,af as Ht,ag as Zt,ah as Kt,ai as Qt,aj as jt,ak as te,al as ee,am as se,an as ne,ao as ie,ap as oe,aq as re,ar as ae,as as le,at as ce,au as he,av as pe,aw as de,ax as ue,ay as fe,az as ge,aA as me,aB as ye,aC as Te,aD as be,aE as we,aF as xe,aG as Ae,aH as Pe,aI as Ie,aJ as Ce,aK as Ve,aL as Le,aM as Se,aN as Me,aO as ve,aP as Ue,aQ as Re,aR as Ye,aS as Fe,aT as Ee,x as _e,aU as Oe,aV as ke,aW as Xe,aX as Be,aY as Ne,aZ as De,a_ as $e,a$ as ze,b0 as We,b1 as qe,b2 as Ge,b3 as Je,b6 as He,b7 as Ze,b8 as Ke,b9 as Qe,ba as je,bb as ts,bc as es,bd as ss,be as ns,bf as is,bg as os,bh as rs,bi as as,bj as ls,bk as cs,bl as hs,bm as ps,bn as ds,bo as us,bp as fs,bq as gs,br as ms,bs as ys,bt as Ts,bu as bs,w as ws,X as xs,J as As,Y as Ps,z as Is,bv as Cs,bw as Vs,bx as Ls,by as Ss,bz as Ms,bA as vs,bB as Us,bC as Rs,bD as Ys,bE as Fs,bF as Es,bG as _s,T as Os,bH as ks,bI as Xs,b as Bs,bJ as Ns,c as Ds,bK as $s,bL as zs,bM as Ws,bN as qs,bO as Gs,bP as Js,bQ as Hs,bR as Zs,bS as Ks,bT as Qs,bU as js,bV as tn,bW as en,bX as sn,bY as nn,bZ as on,b_ as rn,V as an,b$ as ln,c0 as cn,c1 as hn,c2 as pn,c3 as dn}from"./index-BDM5NpkZ.js";class It{constructor(t){T(this,"skeletonData");T(this,"animationToMixTime",{});T(this,"defaultMix",0);if(!t)throw new Error("skeletonData cannot be null.");this.skeletonData=t}setMix(t,e,n){return typeof t=="string"?this.setMix1(t,e,n):this.setMix2(t,e,n)}setMix1(t,e,n){const s=this.skeletonData.findAnimation(t);if(!s)throw new Error(`Animation not found: ${t}`);const i=this.skeletonData.findAnimation(e);if(!i)throw new Error(`Animation not found: ${e}`);this.setMix2(s,i,n)}setMix2(t,e,n){if(!t)throw new Error("from cannot be null.");if(!e)throw new Error("to cannot be null.");const s=`${t.name}.${e.name}`;this.animationToMixTime[s]=n}getMix(t,e){const n=`${t.name}.${e.name}`,s=this.animationToMixTime[n];return s===void 0?this.defaultMix:s}}class Ct{constructor(t,e="",n=new bt,s=new lt){T(this,"textureLoader");T(this,"pathPrefix");T(this,"downloader");T(this,"cache");T(this,"errors",{});T(this,"toLoad",0);T(this,"loaded",0);T(this,"texturePmaInfo",{});this.textureLoader=t,this.pathPrefix=e,this.downloader=n,this.cache=s}start(t){return this.toLoad++,this.pathPrefix+t}success(t,e,n){this.toLoad--,this.loaded++,this.cache.assets[e]=n,this.cache.assetsRefCount[e]=(this.cache.assetsRefCount[e]||0)+1,t&&t(e,n)}error(t,e,n){this.toLoad--,this.loaded++,this.errors[e]=n,t&&t(e,n)}loadAll(){return new Promise((e,n)=>{const s=()=>{if(this.isLoadingComplete()){this.hasErrors()?n(this.errors):e(this);return}requestAnimationFrame(s)};requestAnimationFrame(s)})}setRawDataURI(t,e){this.downloader.rawDataUris[this.pathPrefix+t]=e}loadBinary(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadBinary(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load binary ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}loadText(t,e=()=>{},n=()=>{}){t=this.start(t),this.downloader.downloadText(t,s=>{this.success(e,t,s)},(s,i)=>{this.error(n,t,`Couldn't load text ${t}: status ${s}, ${i}`)})}loadJson(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadJson(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load JSON ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}reuseAssets(t,e=()=>{},n=()=>{}){const s=this.cache.getAsset(t),i=s!==void 0;return i&&(this.cache.assetsLoaded[t]=s.then(o=>(o=o instanceof Image||o instanceof ImageBitmap?this.textureLoader(o):o,this.success(e,t,o),o)).catch(o=>{this.error(n,t,o)})),i}loadTexture(t,e=()=>{},n=()=>{}){if(t=this.start(t),this.reuseAssets(t,e,n))return;const s=this.texturePmaInfo[t];this.cache.assetsLoaded[t]=new Promise((i,o)=>{if(!!!(typeof window<"u"&&typeof navigator<"u"&&window.document))fetch(t,{mode:"cors"}).then(c=>{if(c.ok)return c.blob();const a=`Couldn't load image: ${t}`;this.error(n,t,`Couldn't load image: ${t}`),o(a)}).then(c=>c?createImageBitmap(c,{premultiplyAlpha:"none",colorSpaceConversion:"none"}):null).then(c=>{if(c){const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)}});else{const c=new Image;c.crossOrigin="anonymous",c.onload=()=>{const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)},c.onerror=()=>{const a=`Couldn't load image: ${t}`;this.error(n,t,a),o(a)},this.downloader.rawDataUris[t]&&(t=this.downloader.rawDataUris[t]),c.src=t}})}loadTextureAtlas(t,e=()=>{},n=()=>{},s){const i=t.lastIndexOf("/"),o=i>=0?t.substring(0,i+1)Line truncated var ft=Object.defineProperty;var gt=(W,t,e)=>t in W?ft(W,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):W[t]=e;var T=(W,t,e)=>gt(W,typeof t!="symbol"?t+"":t,e);import{f as ht,R as pt,N as dt,ac as ut,Z as mt,b4 as yt,O as rt,b5 as at,U as D,B as Tt}from"./index-CPXvsWyv.js";import{$ as Rt,a0 as Yt,a1 as Ft,a2 as Et,y as _t,a3 as Ot,a4 as kt,a5 as Xt,a6 as Bt,a7 as Nt,a8 as Dt,a9 as $t,aa as zt,ab as Wt,ad as qt,H as Gt,ae as Jt,af as Ht,ag as Zt,ah as Kt,ai as Qt,aj as jt,ak as te,al as ee,am as se,an as ne,ao as ie,ap as oe,aq as re,ar as ae,as as le,at as ce,au as he,av as pe,aw as de,ax as ue,ay as fe,az as ge,aA as me,aB as ye,aC as Te,aD as be,aE as we,aF as xe,aG as Ae,aH as Pe,aI as Ie,aJ as Ce,aK as Ve,aL as Le,aM as Se,aN as Me,aO as ve,aP as Ue,aQ as Re,aR as Ye,aS as Fe,aT as Ee,x as _e,aU as Oe,aV as ke,aW as Xe,aX as Be,aY as Ne,aZ as De,a_ as $e,a$ as ze,b0 as We,b1 as qe,b2 as Ge,b3 as Je,b6 as He,b7 as Ze,b8 as Ke,b9 as Qe,ba as je,bb as ts,bc as es,bd as ss,be as ns,bf as is,bg as os,bh as rs,bi as as,bj as ls,bk as cs,bl as hs,bm as ps,bn as ds,bo as us,bp as fs,bq as gs,br as ms,bs as ys,bt as Ts,bu as bs,w as ws,X as xs,J as As,Y as Ps,z as Is,bv as Cs,bw as Vs,bx as Ls,by as Ss,bz as Ms,bA as vs,bB as Us,bC as Rs,bD as Ys,bE as Fs,bF as Es,bG as _s,T as Os,bH as ks,bI as Xs,b as Bs,bJ as Ns,c as Ds,bK as $s,bL as zs,bM as Ws,bN as qs,bO as Gs,bP as Js,bQ as Hs,bR as Zs,bS as Ks,bT as Qs,bU as js,bV as tn,bW as en,bX as sn,bY as nn,bZ as on,b_ as rn,V as an,b$ as ln,c0 as cn,c1 as hn,c2 as pn,c3 as dn}from"./index-CPXvsWyv.js";class It{constructor(t){T(this,"skeletonData");T(this,"animationToMixTime",{});T(this,"defaultMix",0);if(!t)throw new Error("skeletonData cannot be null.");this.skeletonData=t}setMix(t,e,n){return typeof t=="string"?this.setMix1(t,e,n):this.setMix2(t,e,n)}setMix1(t,e,n){const s=this.skeletonData.findAnimation(t);if(!s)throw new Error(`Animation not found: ${t}`);const i=this.skeletonData.findAnimation(e);if(!i)throw new Error(`Animation not found: ${e}`);this.setMix2(s,i,n)}setMix2(t,e,n){if(!t)throw new Error("from cannot be null.");if(!e)throw new Error("to cannot be null.");const s=`${t.name}.${e.name}`;this.animationToMixTime[s]=n}getMix(t,e){const n=`${t.name}.${e.name}`,s=this.animationToMixTime[n];return s===void 0?this.defaultMix:s}}class Ct{constructor(t,e="",n=new bt,s=new lt){T(this,"textureLoader");T(this,"pathPrefix");T(this,"downloader");T(this,"cache");T(this,"errors",{});T(this,"toLoad",0);T(this,"loaded",0);T(this,"texturePmaInfo",{});this.textureLoader=t,this.pathPrefix=e,this.downloader=n,this.cache=s}start(t){return this.toLoad++,this.pathPrefix+t}success(t,e,n){this.toLoad--,this.loaded++,this.cache.assets[e]=n,this.cache.assetsRefCount[e]=(this.cache.assetsRefCount[e]||0)+1,t&&t(e,n)}error(t,e,n){this.toLoad--,this.loaded++,this.errors[e]=n,t&&t(e,n)}loadAll(){return new Promise((e,n)=>{const s=()=>{if(this.isLoadingComplete()){this.hasErrors()?n(this.errors):e(this);return}requestAnimationFrame(s)};requestAnimationFrame(s)})}setRawDataURI(t,e){this.downloader.rawDataUris[this.pathPrefix+t]=e}loadBinary(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadBinary(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load binary ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}loadText(t,e=()=>{},n=()=>{}){t=this.start(t),this.downloader.downloadText(t,s=>{this.success(e,t,s)},(s,i)=>{this.error(n,t,`Couldn't load text ${t}: status ${s}, ${i}`)})}loadJson(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadJson(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load JSON ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}reuseAssets(t,e=()=>{},n=()=>{}){const s=this.cache.getAsset(t),i=s!==void 0;return i&&(this.cache.assetsLoaded[t]=s.then(o=>(o=o instanceof Image||o instanceof ImageBitmap?this.textureLoader(o):o,this.success(e,t,o),o)).catch(o=>{this.error(n,t,o)})),i}loadTexture(t,e=()=>{},n=()=>{}){if(t=this.start(t),this.reuseAssets(t,e,n))return;const s=this.texturePmaInfo[t];this.cache.assetsLoaded[t]=new Promise((i,o)=>{if(!!!(typeof window<"u"&&typeof navigator<"u"&&window.document))fetch(t,{mode:"cors"}).then(c=>{if(c.ok)return c.blob();const a=`Couldn't load image: ${t}`;this.error(n,t,`Couldn't load image: ${t}`),o(a)}).then(c=>c?createImageBitmap(c,{premultiplyAlpha:"none",colorSpaceConversion:"none"}):null).then(c=>{if(c){const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)}});else{const c=new Image;c.crossOrigin="anonymous",c.onload=()=>{const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)},c.onerror=()=>{const a=`Couldn't load image: ${t}`;this.error(n,t,a),o(a)},this.downloader.rawDataUris[t]&&(t=this.downloader.rawDataUris[t]),c.src=t}})}loadTextureAtlas(t,e=()=>{},n=()=>{},s){const i=t.lastIndexOf("/"),o=i>=0?t.substring(0,i+1)Line truncated
${n}`:""}`)}remove(t){t=this.pathPrefix+t;const e=this.cache.assets[t];return e.dispose&&e.dispose(),delete this.cache.assets[t],delete this.cache.assetsRefCount[t],delete this.cache.assetsLoaded[t],e}removeAll(){for(const t in this.cache.assets){const e=this.cache.assets[t];e.dispose&&e.dispose()}this.cache.assets={},this.cache.assetsLoaded={},this.cache.assetsRefCount={}}isLoadingComplete(){return this.toLoad===0}getToLoad(){return this.toLoad}getLoaded(){return this.loaded}dispose(){this.removeAll()}disposeAsset(t){const e=this.cache.assets[t];if(e instanceof ht){e.dispose();return}this.disposeAssetInternal(t)}hasErrors(){return Object.keys(this.errors).length>0}getErrors(){return this.errors}disposeAssetInternal(t){if(this.cache.assetsRefCount[t]>0&&--this.cache.assetsRefCount[t]===0)return this.remove(t)}createTextureAtlas(t,e,n,s){const i=new ht(t);i.dispose=()=>{var o;if(!(this.cache.assetsRefCount[n]<=0)){this.disposeAssetInternal(n);for(const r of i.pages)(o=r.texture)==null||o.dispose()}};for(const o of i.pages){const r=this.texturePath(e,o.name,s);this.texturePmaInfo[this.pathPrefix+r]=o.pma}return i}createTexture(t,e,n){const s=this.textureLoader(n,e),i=s.dispose.bind(s);return s.dispose=()=>{this.disposeAssetInternal(t)&&i()},s}texturePath(t,e,n){return n?n[e]:t+e}}const nt=class nt{constructor(){T(this,"assets",{});T(this,"assetsRefCount",{});T(this,"assetsLoaded",{})}static getCache(t){const e=nt.AVAILABLE_CACHES.get(t);if(e)return e;const n=new nt;return nt.AVAILABLE_CACHES.set(t,n),n}async addAsset(t,e){return this.assetsLoaded[t]=Promise.resolve(e),this.assets[t]=e,e}getAsset(t){return this.assetsLoaded[t]}};T(nt,"AVAILABLE_CACHES",new Map);let lt=nt;class bt{constructor(){T(this,"callbacks",{});T(this,"rawDataUris",{})}dataUriToString(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");return e!==-1?(e+=7,atob(t.substr(e))):t.substr(t.indexOf(",")+1)}base64ToUint8Array(t){var e=window.atob(t),n=e.length,s=new Uint8Array(n);for(let i=0;i<n;i++)s[i]=e.charCodeAt(i);return s}dataUriToUint8Array(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");if(e===-1)throw new Error("Not a binary data URI.");return e+=7,this.base64ToUint8Array(t.substr(e))}downloadText(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToString(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.overrideMimeType("text/html"),i.open("GET",s||t,!0);const o=()=>{this.finish(t,i.status,i.responseText)};i.onload=o,i.onerror=o,i.send()}downloadJson(t,e,n){this.downloadText(t,s=>{e(JSON.parse(s))},n)}downloadBinary(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToUint8Array(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.open("GET",s||t,!0),i.responseType="arraybuffer";const o=()=>{this.finish(t,i.status,i.response)};i.onload=()=>{i.status===200||i.status===0?this.finish(t,200,new Uint8Array(i.response)):o()},i.onerror=o,i.send()}start(t,e,n){let s=this.callbacks[t];try{if(s)return!0;this.callbacks[t]=s=[]}finally{s.push(e,n)}}finish(t,e,n){const s=this.callbacks[t];if(delete this.callbacks[t],e===200||e===0)for(let i=0,o=s.length;i<o;i+=2)s[i](n);else for(let i=1,o=s.length;i<o;i+=2)s[i](e,n)}}class Vt{constructor(t,e=!1){T(this,"atlas");T(this,"allowMissingRegions");this.atlas=t,this.allowMissingRegions=e}findRegions(t,e,n){const s=n.regions;for(let i=0,o=s.length;i<o;i++)s[i]=this.findRegion(t,n.getPath(e,i))}findRegion(t,e){const n=this.atlas.findRegion(e);if(!n&&!this.allowMissingRegions)throw new Error(`Region not found in atlas: ${e} (attachment: ${t})`);return n}newRegionAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new pt(n,i)}newMeshAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new dt(n,i)}newBoundingBoxAttachment(t,e,n){return new ut(n)}newPathAttachment(t,e,n){return new mt(n)}newPointAttachment(t,e,n){return new yt(n)}newClippingAttachment(t,e,n){return new rt(n)}}class Lt{constructor(){T(this,"minX",0);T(this,"minY",0);T(this,"maxX",0);T(this,"maxY",0);T(this,"boundingBoxes",[]);T(this,"polygons",[]);T(this,"polygonPool",new at(()=>D.newFloatArray(16)))}update(t,e){if(!t)throw new Error("skeleton cannot be null.");const n=this.boundingBoxes,s=this.polygons,i=this.polygonPool,o=t.slots,r=o.length;n.length=0,i.freeAll(s),s.length=0;for(let l=0;l<r;l++){const c=o[l];if(!c.bone.active)continue;const a=c.appliedPose.attachment;if(a instanceof ut){n.push(a);let h=i.obtain();h.length!==a.worldVerticesLength&&(h=D.newFloatArray(a.worldVerticesLength)),s.push(h),a.computeWorldVertices(t,c,0,a.worldVerticesLength,h,0,2)}}e?this.aabbCompute():(this.minX=Number.POSITIVE_INFINITY,this.minY=Number.POSITIVE_INFINITY,this.maxX=Number.NEGATIVE_INFINITY,this.maxY=Number.NEGATIVE_INFINITY)}aabbCompute(){let t=Number.POSITIVE_INFLine truncated ${n}`:""}`)}remove(t){t=this.pathPrefix+t;const e=this.cache.assets[t];return e.dispose&&e.dispose(),delete this.cache.assets[t],delete this.cache.assetsRefCount[t],delete this.cache.assetsLoaded[t],e}removeAll(){for(const t in this.cache.assets){const e=this.cache.assets[t];e.dispose&&e.dispose()}this.cache.assets={},this.cache.assetsLoaded={},this.cache.assetsRefCount={}}isLoadingComplete(){return this.toLoad===0}getToLoad(){return this.toLoad}getLoaded(){return this.loaded}dispose(){this.removeAll()}disposeAsset(t){const e=this.cache.assets[t];if(e instanceof ht){e.dispose();return}this.disposeAssetInternal(t)}hasErrors(){return Object.keys(this.errors).length>0}getErrors(){return this.errors}disposeAssetInternal(t){if(this.cache.assetsRefCount[t]>0&&--this.cache.assetsRefCount[t]===0)return this.remove(t)}createTextureAtlas(t,e,n,s){const i=new ht(t);i.dispose=()=>{var o;if(!(this.cache.assetsRefCount[n]<=0)){this.disposeAssetInternal(n);for(const r of i.pages)(o=r.texture)==null||o.dispose()}};for(const o of i.pages){const r=this.texturePath(e,o.name,s);this.texturePmaInfo[this.pathPrefix+r]=o.pma}return i}createTexture(t,e,n){const s=this.textureLoader(n,e),i=s.dispose.bind(s);return s.dispose=()=>{this.disposeAssetInternal(t)&&i()},s}texturePath(t,e,n){return n?n[e]:t+e}}const nt=class nt{constructor(){T(this,"assets",{});T(this,"assetsRefCount",{});T(this,"assetsLoaded",{})}static getCache(t){const e=nt.AVAILABLE_CACHES.get(t);if(e)return e;const n=new nt;return nt.AVAILABLE_CACHES.set(t,n),n}async addAsset(t,e){return this.assetsLoaded[t]=Promise.resolve(e),this.assets[t]=e,e}getAsset(t){return this.assetsLoaded[t]}};T(nt,"AVAILABLE_CACHES",new Map);let lt=nt;class bt{constructor(){T(this,"callbacks",{});T(this,"rawDataUris",{})}dataUriToString(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");return e!==-1?(e+=7,atob(t.substr(e))):t.substr(t.indexOf(",")+1)}base64ToUint8Array(t){var e=window.atob(t),n=e.length,s=new Uint8Array(n);for(let i=0;i<n;i++)s[i]=e.charCodeAt(i);return s}dataUriToUint8Array(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");if(e===-1)throw new Error("Not a binary data URI.");return e+=7,this.base64ToUint8Array(t.substr(e))}downloadText(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToString(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.overrideMimeType("text/html"),i.open("GET",s||t,!0);const o=()=>{this.finish(t,i.status,i.responseText)};i.onload=o,i.onerror=o,i.send()}downloadJson(t,e,n){this.downloadText(t,s=>{e(JSON.parse(s))},n)}downloadBinary(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToUint8Array(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.open("GET",s||t,!0),i.responseType="arraybuffer";const o=()=>{this.finish(t,i.status,i.response)};i.onload=()=>{i.status===200||i.status===0?this.finish(t,200,new Uint8Array(i.response)):o()},i.onerror=o,i.send()}start(t,e,n){let s=this.callbacks[t];try{if(s)return!0;this.callbacks[t]=s=[]}finally{s.push(e,n)}}finish(t,e,n){const s=this.callbacks[t];if(delete this.callbacks[t],e===200||e===0)for(let i=0,o=s.length;i<o;i+=2)s[i](n);else for(let i=1,o=s.length;i<o;i+=2)s[i](e,n)}}class Vt{constructor(t,e=!1){T(this,"atlas");T(this,"allowMissingRegions");this.atlas=t,this.allowMissingRegions=e}findRegions(t,e,n){const s=n.regions;for(let i=0,o=s.length;i<o;i++)s[i]=this.findRegion(t,n.getPath(e,i))}findRegion(t,e){const n=this.atlas.findRegion(e);if(!n&&!this.allowMissingRegions)throw new Error(`Region not found in atlas: ${e} (attachment: ${t})`);return n}newRegionAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new pt(n,i)}newMeshAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new dt(n,i)}newBoundingBoxAttachment(t,e,n){return new ut(n)}newPathAttachment(t,e,n){return new mt(n)}newPointAttachment(t,e,n){return new yt(n)}newClippingAttachment(t,e,n){return new rt(n)}}class Lt{constructor(){T(this,"minX",0);T(this,"minY",0);T(this,"maxX",0);T(this,"maxY",0);T(this,"boundingBoxes",[]);T(this,"polygons",[]);T(this,"polygonPool",new at(()=>D.newFloatArray(16)))}update(t,e){if(!t)throw new Error("skeleton cannot be null.");const n=this.boundingBoxes,s=this.polygons,i=this.polygonPool,o=t.slots,r=o.length;n.length=0,i.freeAll(s),s.length=0;for(let l=0;l<r;l++){const c=o[l];if(!c.bone.active)continue;const a=c.appliedPose.attachment;if(a instanceof ut){n.push(a);let h=i.obtain();h.length!==a.worldVerticesLength&&(h=D.newFloatArray(a.worldVerticesLength)),s.push(h),a.computeWorldVertices(t,c,0,a.worldVerticesLength,h,0,2)}}e?this.aabbCompute():(this.minX=Number.POSITIVE_INFINITY,this.minY=Number.POSITIVE_INFINITY,this.maxX=Number.NEGATIVE_INFINITY,this.maxY=Number.NEGATIVE_INFINITY)}aabbCompute(){let t=Number.POSITIVE_INFLine truncated
+1
View File
@@ -0,0 +1 @@
.help-overlay[data-v-78204b9d]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:72px 24px 28px;box-sizing:border-box;background:#05080fb8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.help-dialog[data-v-78204b9d]{width:min(820px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #3a4864;border-radius:14px;background:#141a27;color:#b8c3d8;box-shadow:0 22px 70px #0000009e}.help-head[data-v-78204b9d]{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px 20px 15px;border-bottom:1px solid #2b354a;background:linear-gradient(135deg,#202942,#181e2d)}.help-head h2[data-v-78204b9d]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-78204b9d]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-78204b9d]{width:28px;height:28px;flex:0 0 28px;border:1px solid #43516b;border-radius:6px;background:#273249;color:#ccd7ed;cursor:pointer}.help-close[data-v-78204b9d]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-78204b9d]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-78204b9d]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-78204b9d]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-78204b9d]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-78204b9d]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-78204b9d]{color:#dce4f7}.help-grid[data-v-78204b9d]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-78204b9d]{display:grid;gap:0;margin:0}.help-list>div[data-v-78204b9d]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-78204b9d]:last-child{border-bottom:0}.help-list dt[data-v-78204b9d]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-78204b9d]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-78204b9d]{display:grid;gap:7px}.shortcut-list>div[data-v-78204b9d]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-78204b9d]{min-width:24px;box-sizing:border-box;padding:3px 6px;border:1px solid #465470;border-bottom-width:2px;border-radius:5px;background:#101623;color:#e0e7f6;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center}.shortcut-list span[data-v-78204b9d]{color:#65728b;font-size:10px}.shortcut-list em[data-v-78204b9d]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-78204b9d]{margin:11px 0 0;padding-top:9px;border-top:1px solid #2a3549;color:#697791;font-size:10px;line-height:1.55}.help-export[data-v-78204b9d]{margin-top:12px}.help-export p[data-v-78204b9d]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-78204b9d]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-78204b9d]{margin-top:12px}.help-module-head[data-v-78204b9d]{display:flex;align-items:center;gap:8px;width:100%;padding:0;border:0;background:transparent;color:#9fb2ff;font:inherit;font-size:13px;font-weight:700;text-align:left;cursor:pointer}.help-module-head[data-v-78204b9d]:hover{color:#fff}.help-module-head .mtitle[data-v-78204b9d]{flex:1;min-width:0}.help-module-head .caret[data-v-78204b9d]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-78204b9d]{color:#fff}.help-module.open .help-module-head .caret[data-v-78204b9d]{color:#9fb2ff}.help-module .mnum[data-v-78204b9d]{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#4a53b8,#6a5fd0);color:#fff;font-size:12px;font-weight:700}.help-module.help-connector[data-v-78204b9d]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-78204b9d]{color:#b8c2ff}.help-module .connector-intro[data-v-78204b9d]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-78204b9d]{color:#dce4f7}.help-module .dl-link[data-v-78204b9d]{display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;margin:0 10px 6px 0;padding:6px 12px;border:1px solid #5652b5;border-radius:6px;background:#4540a0;color:#fff;font-size:12px;font-weight:700;text-decoration:none}.help-module .dl-link[data-v-78204b9d]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-78204b9d]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-78204b9d]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-78204b9d]{padding:66px 12px 14px}.help-grid[data-v-78204b9d]{grid-template-columns:1fr}}.topbar[data-v-4749834a]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
-1
View File
@@ -1 +0,0 @@
.help-overlay[data-v-78204b9d]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:72px 24px 28px;box-sizing:border-box;background:#05080fb8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.help-dialog[data-v-78204b9d]{width:min(820px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #3a4864;border-radius:14px;background:#141a27;color:#b8c3d8;box-shadow:0 22px 70px #0000009e}.help-head[data-v-78204b9d]{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px 20px 15px;border-bottom:1px solid #2b354a;background:linear-gradient(135deg,#202942,#181e2d)}.help-head h2[data-v-78204b9d]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-78204b9d]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-78204b9d]{width:28px;height:28px;flex:0 0 28px;border:1px solid #43516b;border-radius:6px;background:#273249;color:#ccd7ed;cursor:pointer}.help-close[data-v-78204b9d]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-78204b9d]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-78204b9d]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-78204b9d]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-78204b9d]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-78204b9d]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-78204b9d]{color:#dce4f7}.help-grid[data-v-78204b9d]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-78204b9d]{display:grid;gap:0;margin:0}.help-list>div[data-v-78204b9d]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-78204b9d]:last-child{border-bottom:0}.help-list dt[data-v-78204b9d]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-78204b9d]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-78204b9d]{display:grid;gap:7px}.shortcut-list>div[data-v-78204b9d]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-78204b9d]{min-width:24px;box-sizing:border-box;padding:3px 6px;border:1px solid #465470;border-bottom-width:2px;border-radius:5px;background:#101623;color:#e0e7f6;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center}.shortcut-list span[data-v-78204b9d]{color:#65728b;font-size:10px}.shortcut-list em[data-v-78204b9d]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-78204b9d]{margin:11px 0 0;padding-top:9px;border-top:1px solid #2a3549;color:#697791;font-size:10px;line-height:1.55}.help-export[data-v-78204b9d]{margin-top:12px}.help-export p[data-v-78204b9d]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-78204b9d]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-78204b9d]{margin-top:12px}.help-module-head[data-v-78204b9d]{display:flex;align-items:center;gap:8px;width:100%;padding:0;border:0;background:transparent;color:#9fb2ff;font:inherit;font-size:13px;font-weight:700;text-align:left;cursor:pointer}.help-module-head[data-v-78204b9d]:hover{color:#fff}.help-module-head .mtitle[data-v-78204b9d]{flex:1;min-width:0}.help-module-head .caret[data-v-78204b9d]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-78204b9d]{color:#fff}.help-module.open .help-module-head .caret[data-v-78204b9d]{color:#9fb2ff}.help-module .mnum[data-v-78204b9d]{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#4a53b8,#6a5fd0);color:#fff;font-size:12px;font-weight:700}.help-module.help-connector[data-v-78204b9d]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-78204b9d]{color:#b8c2ff}.help-module .connector-intro[data-v-78204b9d]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-78204b9d]{color:#dce4f7}.help-module .dl-link[data-v-78204b9d]{display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;margin:0 10px 6px 0;padding:6px 12px;border:1px solid #5652b5;border-radius:6px;background:#4540a0;color:#fff;font-size:12px;font-weight:700;text-decoration:none}.help-module .dl-link[data-v-78204b9d]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-78204b9d]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-78204b9d]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-78204b9d]{padding:66px 12px 14px}.help-grid[data-v-78204b9d]{grid-template-columns:1fr}}.topbar[data-v-4749834a]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
+2 -2
View File
@@ -8,8 +8,8 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; } html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; } #app { display: flex; flex-direction: column; height: 100vh; }
</style> </style>
<script type="module" crossorigin src="/assets/index-BDM5NpkZ.js"></script> <script type="module" crossorigin src="/assets/index-CPXvsWyv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eyCkSEtn.css"> <link rel="stylesheet" crossorigin href="/assets/index-DvMPoOSF.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+2
View File
@@ -15,6 +15,8 @@
"test:spine-json": "node scripts/verify-spine-json.mjs", "test:spine-json": "node scripts/verify-spine-json.mjs",
"test:spine-naming": "node scripts/verify-spine-naming.mjs", "test:spine-naming": "node scripts/verify-spine-naming.mjs",
"test:render-layer": "node scripts/verify-render-layer.mjs", "test:render-layer": "node scripts/verify-render-layer.mjs",
"test:particle-events": "node scripts/verify-particle-events.mjs",
"test:sub-emitter-timeline": "node scripts/verify-sub-emitter-timeline.mjs",
"test:connector": "node scripts/verify-spine-connector.mjs", "test:connector": "node scripts/verify-spine-connector.mjs",
"test:connector:spine38-path": "node scripts/verify-spine38-connector-path.mjs", "test:connector:spine38-path": "node scripts/verify-spine38-connector-path.mjs",
"test:connector:sea": "node scripts/verify-connector-sea.mjs", "test:connector:sea": "node scripts/verify-connector-sea.mjs",
File diff suppressed because it is too large. Load diff
File renamed without changes.
+6 -2
View File
@@ -20,7 +20,11 @@
"file": "5.贴图轴点.json" "file": "5.贴图轴点.json"
}, },
{ {
"name": "6.SpineBoy", "name": "6.子发射器",
"file": "6.SpineBoy.json" "file": "6.子发射器.json"
},
{
"name": "7.SpineBoy",
"file": "7.SpineBoy.json"
} }
] ]
+21
View File
@@ -0,0 +1,21 @@
class Vec { constructor(){ this.x=0; this.y=0 } set(x,y){ this.x=x; this.y=(y===undefined?x:y); return this } }
export class Texture { constructor(){ this.width=1; this.height=1 } }
Texture.EMPTY = new Texture()
export class Container {
constructor(){ this.children=[]; this.parent=null; this.position=new Vec(); this.scale=new Vec(); this.scale.x=1; this.scale.y=1; this.anchor=new Vec(); this.rotation=0; this.visible=true; this.renderable=true; this.alpha=1 }
addChild(child){ this.children.push(child); child.parent=this; return child }
addChildAt(child,index){ this.children.splice(index,0,child); child.parent=this; return child }
removeChild(child){ const index=this.children.indexOf(child); if(index>=0) this.children.splice(index,1); return child }
removeFromParent(){ if(this.parent) this.parent.removeChild(this) }
getChildIndex(child){ return this.children.indexOf(child) }
destroy(){}
}
export class Sprite extends Container {
constructor(texture){ super(); this.texture=texture; this.blendMode=0; this.tint=0xffffff; this.x=0; this.y=0 }
}
export class Graphics extends Container {
clear(){} lineStyle(){} moveTo(){} lineTo(){} beginFill(){} endFill(){} drawCircle(){} drawPolygon(){} drawRect(){} bezierCurveTo(){}
}
export class SimpleMesh extends Container {
constructor(texture,vertices,uvs,indices){ super(); this.texture=texture; this.vertices=vertices; this.uvs=uvs; this.indices=indices; this.blendMode=0; this.tint=0xffffff }
}
+174
View File
@@ -0,0 +1,174 @@
import { build } from 'esbuild'
const bundled = await build({
bundle: true,
entryPoints: ['src/core/particleEmitter.ts'],
format: 'esm',
platform: 'node',
write: false,
alias: { 'pixi.js': './scripts/pixi-stub.mjs' },
})
const { ParticleEmitter, defaultConfig } = await import(
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}`
)
const { Texture } = await import('./pixi-stub.mjs')
const subBundled = await build({
bundle: true,
entryPoints: ['src/core/subEmitterIds.ts'],
format: 'esm',
platform: 'node',
write: false,
})
const { collectSubEmitterIds, collectEmitterAncestors } = await import(
`data:text/javascript;base64,${Buffer.from(subBundled.outputFiles[0].text).toString('base64')}`
)
function makeConfig(overrides = {}) {
const config = defaultConfig()
config.texture = new Texture()
for (const resource of config.imageResources) resource.texture = new Texture()
for (const resource of config.trailResources) resource.texture = new Texture()
Object.assign(config, {
mode: 'burst',
burstCount: 1,
burstLoop: false,
delay: 0,
duration: 1,
lifeMode: 'fixed',
lifeMin: 0.1,
lifeMax: 0.1,
speedMin: 0,
speedMax: 0,
shape: 'point',
collision: false,
eventEnabled: false,
}, overrides)
return config
}
const step = 1 / 30
const emitter = new ParticleEmitter(makeConfig())
const events = []
emitter.onParticleEvent = (event) => events.push(event)
emitter.update(step, 'force')
if (!events.some((event) => event.kind === 'birth')) throw new Error('未收到出生事件')
for (let i = 0; i < 8; i++) emitter.update(step)
if (!events.some((event) => event.kind === 'death')) throw new Error('未收到死亡事件')
const target = new ParticleEmitter(makeConfig({ lifeMin: 1, lifeMax: 1 }))
if (!target.spawnEventParticle(12, 34, 1)) throw new Error('事件粒子生成失败')
if (target.activeCount !== 1) throw new Error('事件粒子未进入目标粒子池')
if (target.spawnEventParticle(0, 0, 3)) throw new Error('事件链深度上限未生效')
const inheritConfig = makeConfig({
burstCount: 0,
lifeMin: 1,
lifeMax: 1,
scaleMode: 'fixed',
scaleMin: 2,
scaleMax: 2,
initialRotationMode: 'fixed',
initialRotationMin: 0,
initialRotationMax: 0,
})
inheritConfig.imageResources[0].colorMode = 'fixed'
inheritConfig.imageResources[0].colorStart = '#102030'
const inheritTarget = new ParticleEmitter(inheritConfig)
inheritTarget.spawnEventParticle(0, 0, 1, {
attributes: ['color', 'scale', 'rotation', 'life'],
colorMode: 'add',
data: { colorHex: 0x010203, scaleX: 1.5, scaleY: 1.5, rotation: 90, life: 0.5 },
})
inheritTarget.update(0)
const inherited = inheritTarget.capture()[0]
if (!inherited) throw new Error('继承属性的子发射器粒子未生成')
if (inherited.colorHex !== 0x112233) throw new Error(`颜色继承应相加,实际 ${inherited.colorHex.toString(16)}`)
if (Math.abs(inherited.scaleX - 3) > 1e-6 || Math.abs(inherited.scaleY - 3) > 1e-6) throw new Error('大小继承应乘以目标整体缩放')
if (Math.abs(inherited.rotation - 90) > 1e-6) throw new Error('旋转继承应作为整体方向偏移')
for (let i = 0; i < 12; i++) inheritTarget.update(step)
if (inheritTarget.activeCount !== 1) throw new Error('生命周期继承过短')
for (let i = 0; i < 6; i++) inheritTarget.update(step)
if (inheritTarget.activeCount !== 0) throw new Error('生命周期继承未生效')
const replaceColorTarget = new ParticleEmitter(makeConfig({ burstCount: 0, lifeMin: 1, lifeMax: 1 }))
replaceColorTarget.spawnEventParticle(0, 0, 1, {
attributes: ['color'],
colorMode: 'replace',
data: { colorHex: 0x445566, scaleX: 1, scaleY: 1, rotation: 0, life: 1 },
})
replaceColorTarget.update(0)
if (replaceColorTarget.capture()[0]?.colorHex !== 0x445566) throw new Error('颜色覆盖继承失败')
// 子发射器爆发模式:一次事件应按“单次数量”生成多颗粒子。
const burstTarget = new ParticleEmitter(makeConfig({ burstCount: 10 }))
if (!burstTarget.spawnEventParticle(0, 0, 1)) throw new Error('子发射器爆发事件生成失败')
if (burstTarget.activeCount !== 10) throw new Error(`子发射器应按单次数量生成 10 颗,实际 ${burstTarget.activeCount} 颗`)
// 子发射器 delay:事件粒子应延迟发射,而不是立即生成。
const delayedTarget = new ParticleEmitter(makeConfig({ delay: 0.2, burstCount: 2, lifeMin: 1, lifeMax: 1 }))
delayedTarget.setAutoEmission(false)
if (!delayedTarget.spawnEventParticle(0, 0, 1)) throw new Error('延迟子发射器事件未入队')
if (delayedTarget.activeCount !== 0) throw new Error('子发射器 delay 未生效,粒子立即生成了')
delayedTarget.update(0.1)
if (delayedTarget.activeCount !== 0) throw new Error('子发射器延迟未到时间就发射了')
delayedTarget.update(0.1)
if (delayedTarget.activeCount !== 2) throw new Error(`子发射器延迟后应生成 2 颗,实际 ${delayedTarget.activeCount} 颗`)
// 子发射器:被引用后不自动发射,只接收事件粒子;移除引用后恢复普通系统。
const subEmitter = new ParticleEmitter(makeConfig({ burstCount: 5 }))
subEmitter.setAutoEmission(false)
subEmitter.update(step, 'force')
if (subEmitter.activeCount !== 0) throw new Error('子发射器不应自动发射')
if (!subEmitter.spawnEventParticle(0, 0, 1)) throw new Error('子发射器无法接收事件粒子')
subEmitter.setAutoEmission(true)
subEmitter.update(step, 'force')
if (subEmitter.activeCount <= 1) throw new Error('移除引用后应恢复自动发射')
const subIds = collectSubEmitterIds([
{ id: 1, config: { events: [{ targetSystemId: 2 }, { targetSystemId: 1 }] } },
{ id: 2, config: {} },
{ id: 3, config: {} },
])
if (!subIds.has(2) || subIds.has(1) || subIds.has(3)) throw new Error('子发射器引用集合计算错误')
const chainSystems = [
{ id: 1, config: { events: [{ targetSystemId: 2 }] } },
{ id: 2, config: { events: [{ targetSystemId: 3 }] } },
{ id: 3, config: {} },
]
const ancestors = collectEmitterAncestors(chainSystems, 3)
if (!ancestors.has(1) || !ancestors.has(2) || ancestors.has(3)) {
throw new Error('子发射器上游父系统计算错误,无法阻止循环依赖')
}
const collider = {
id: 1, shape: 'circle', enabled: true,
x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
radius: 50, radiusX: 50, radiusY: 50, width: 100, height: 100, sides: 6,
response: 'physics', elasticity: 1, friction: 0,
}
const collisionEmitter = new ParticleEmitter(makeConfig({ collision: true, lifeMin: 1, lifeMax: 1 }))
collisionEmitter.setColliders(Array.from({ length: 4 }, (_, index) => ({
...collider,
id: index + 1,
radius: 50 + index * 10,
})))
collisionEmitter.setAutoEmission(false)
collisionEmitter.spawnEventParticle(0, 0, 1)
const collisionEvents = []
collisionEmitter.onParticleEvent = (event) => collisionEvents.push(event)
collisionEmitter.update(step, 'force')
if (!collisionEvents.some((event) => event.kind === 'collision')) throw new Error('未收到碰撞事件')
const collisionCountsBySpawn = new Map()
for (const event of collisionEvents) {
if (event.kind !== 'collision') continue
const counts = collisionCountsBySpawn.get(event.spawnId) || []
counts.push(event.collisionCount)
collisionCountsBySpawn.set(event.spawnId, counts)
}
if (![...collisionCountsBySpawn.values()].some((counts) => counts[0] === 1 && counts.at(-1) === 4)) {
throw new Error('同一粒子的碰撞次数未正确累计')
}
console.log('粒子事件校验通过:出生 / 死亡 / 碰撞 / 子发射器生成与禁用自动发射 / 属性继承 / 深度上限')
+87
View File
@@ -0,0 +1,87 @@
import { build } from 'esbuild'
const source = `
import { createPinia, setActivePinia } from 'pinia'
import { useParticleStore } from './src/store/particleStore.ts'
import { subEmitterTriggerTimings } from './src/core/subEmitterTiming.ts'
import { collectSubEmitterIds } from './src/core/subEmitterIds.ts'
setActivePinia(createPinia())
const store = useParticleStore()
const parent = store.addSystem()
const child = store.addSystem()
const assert = (condition, message) => { if (!condition) throw new Error(message) }
const setLife = (system, life) => {
system.config.lifeMode = 'fixed'
system.config.lifeMin = life
system.config.lifeMax = life
}
parent.config.mode = 'stream'
parent.config.streamBehavior = 'normal'
parent.config.delay = 0
parent.config.duration = 1
setLife(parent, 2)
parent.config.eventEnabled = true
parent.config.events = [{
id: 1, event: 'death', targetSystemId: child.id,
probability: 1, collisionLimit: 1, inherit: [], colorInheritMode: 'add',
}]
// 子发射器自身的 10s 发射时长不应算入时间轴。
child.config.mode = 'stream'
child.config.streamBehavior = 'normal'
child.config.delay = 0
child.config.duration = 10
setLife(child, 1.5)
// 死亡触发:父粒子最晚 3s 死亡,子发射器再存活 1.5s → 4.5s。
let frames = store.recalcTotalFrames()
assert(frames === Math.ceil(4.5 * 30) + 1, '死亡子发射器时间轴错误:' + frames)
const deathTiming = subEmitterTriggerTimings(store.systems, 30).get(child.id)
assert(deathTiming && Math.abs(deathTiming.firstTrigger - 2) < 1e-6, '死亡子发射器起点应为第一次死亡 2s')
assert(deathTiming && Math.abs(deathTiming.lastTrigger - 3) < 1e-6, '死亡子发射器最后触发应为 3s')
assert(deathTiming && Math.abs(deathTiming.end - 4.5) < 1e-6, '死亡子发射器终点应为 4.5s')
// 子发射器自身 delay 在父事件之后继续延后发射。
child.config.delay = 0.2
frames = store.recalcTotalFrames()
assert(frames === Math.ceil(4.7 * 30) + 1, '子发射器延迟未计入时间轴:' + frames)
child.config.delay = 0
// 出生触发:子发射器与父粒子同时开始,取较长者 2s → 3s。
parent.config.events[0].event = 'birth'
frames = store.recalcTotalFrames()
assert(frames === Math.ceil(3 * 30) + 1, '出生子发射器时间轴错误:' + frames)
const birthTiming = subEmitterTriggerTimings(store.systems, 30).get(child.id)
assert(birthTiming && Math.abs(birthTiming.firstTrigger - 0) < 1e-6, '出生子发射器应从父粒子首次出生开始')
assert(birthTiming && Math.abs(birthTiming.lastTrigger - 1) < 1e-6, '出生子发射器最后触发应为发射结束 1s')
assert(birthTiming && Math.abs(birthTiming.end - 2.5) < 1e-6, '出生子发射器终点应为 2.5s')
// 关闭“开启子发射器”后,保留的规则不应继续把目标标记为子发射器。
parent.config.eventEnabled = false
assert(!collectSubEmitterIds(store.systems).has(child.id), '关闭子发射器后目标仍被当作子发射器')
assert(!subEmitterTriggerTimings(store.systems, 30).has(child.id), '关闭子发射器后仍有时间轴触发区间')
frames = store.recalcTotalFrames()
assert(frames === Math.ceil(11.5 * 30) + 1, '关闭子发射器后目标未恢复独立时长')
// 移除引用后子发射器恢复正常,自身的 10s 发射时长重新计入。
parent.config.eventEnabled = true
parent.config.events = []
frames = store.recalcTotalFrames()
assert(frames === Math.ceil(11.5 * 30) + 1, '移除引用后时间轴错误:' + frames)
console.log('子发射器时间轴校验通过:死亡/出生触发与移除引用后的总长度')
`
const result = await build({
stdin: { contents: source, resolveDir: process.cwd(), sourcefile: 'verify-sub-emitter-timeline.ts', loader: 'ts' },
bundle: true,
platform: 'node',
format: 'esm',
write: false,
logLevel: 'silent',
})
const code = Buffer.from(result.outputFiles[0].contents).toString('base64')
await import(`data:text/javascript;base64,${code}`)
+5 -1
View File
@@ -6,7 +6,7 @@ const bundled = await build({
contents: [ contents: [
"export { bakeLoopFrames, closeLoopFrameSeam } from './src/core/loopFrameBaker.ts'", "export { bakeLoopFrames, closeLoopFrameSeam } from './src/core/loopFrameBaker.ts'",
"export { loopPlaybackRange, loopSegmentTiming } from './src/core/loopSegmentTiming.ts'", "export { loopPlaybackRange, loopSegmentTiming } from './src/core/loopSegmentTiming.ts'",
"export { normalizePlaybackRange, resizePlaybackRange } from './src/timeline/playbackRange.ts'", "export { normalizePlaybackRange, resizePlaybackRange, resolvePlaybackRange } from './src/timeline/playbackRange.ts'",
].join('\n'), ].join('\n'),
resolveDir: process.cwd(), resolveDir: process.cwd(),
sourcefile: 'timeline-test-entry.ts', sourcefile: 'timeline-test-entry.ts',
@@ -52,6 +52,10 @@ const normalized = api.normalizePlaybackRange(10, 10, 100)
if (normalized.start !== 10 || normalized.end !== 11) throw new Error('播放范围必须至少包含两个帧位置') if (normalized.start !== 10 || normalized.end !== 11) throw new Error('播放范围必须至少包含两个帧位置')
const resized = api.resizePlaybackRange({ start: 5, end: 59 }, 60, 90) const resized = api.resizePlaybackRange({ start: 5, end: 59 }, 60, 90)
if (resized.start !== 5 || resized.end !== 89) throw new Error('全范围播放没有随总时长扩展') if (resized.start !== 5 || resized.end !== 89) throw new Error('全范围播放没有随总时长扩展')
const abOff = api.resolvePlaybackRange(false, 5, 54, 139)
if (abOff.start !== 0 || abOff.end !== 138) throw new Error('AB 关闭时应播放完整时间轴')
const abOn = api.resolvePlaybackRange(true, 5, 54, 139)
if (abOn.start !== 5 || abOn.end !== 54) throw new Error('AB 开启时应播放用户输入区间')
function state(x) { function state(x) {
return [{ return [{
+34
View File
@@ -232,6 +232,8 @@ export function defaultConfig(): EmitterConfig {
collision: false, collision: false,
collisionColliderIds: [], collisionColliderIds: [],
pathFollow: false, pathFollow: false,
eventEnabled: false,
events: [],
emitterFollow: false, emitterFollow: false,
emitterFollowMode: 'none', emitterFollowMode: 'none',
emitterFollowPathId: 0, emitterFollowPathId: 0,
@@ -306,6 +308,38 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
if (typeof config.renderLayerSlotName !== 'string') config.renderLayerSlotName = '' if (typeof config.renderLayerSlotName !== 'string') config.renderLayerSlotName = ''
if (config.renderLayerSlotPosition !== 'above' && config.renderLayerSlotPosition !== 'below') config.renderLayerSlotPosition = 'above' if (config.renderLayerSlotPosition !== 'above' && config.renderLayerSlotPosition !== 'below') config.renderLayerSlotPosition = 'above'
if (typeof config.renderLayerFollowAttachment !== 'boolean') config.renderLayerFollowAttachment = false if (typeof config.renderLayerFollowAttachment !== 'boolean') config.renderLayerFollowAttachment = false
if (typeof config.eventEnabled !== 'boolean') config.eventEnabled = false
if (!Array.isArray(config.events)) config.events = []
for (const rule of config.events) {
rule.id = Math.max(1, Math.round(Number(rule.id) || 1))
if (rule.event !== 'birth' && rule.event !== 'death' && rule.event !== 'collision') rule.event = 'birth'
rule.targetSystemId = Math.max(0, Math.round(Number(rule.targetSystemId) || 0))
const probability = Number(rule.probability)
rule.probability = Math.min(1, Math.max(0, Number.isFinite(probability) ? probability : 1))
const collisionLimit = Math.round(Number(rule.collisionLimit))
rule.collisionLimit = Math.min(4, Math.max(1, Number.isFinite(collisionLimit) ? collisionLimit : 1))
// 旧版本 inherit 是单字符串;统一迁移为可多选的属性数组。
const legacyInherit = rule.inherit as unknown
const allowedInherit = ['color', 'scale', 'rotation', 'life']
const inheritList = Array.isArray(legacyInherit)
? legacyInherit
: legacyInherit === 'all'
? [...allowedInherit]
: allowedInherit.includes(String(legacyInherit))
? [String(legacyInherit)]
: []
const normalizedInherit = [...new Set(inheritList.filter((name) => allowedInherit.includes(String(name))))] as typeof rule.inherit
// 只有值确实变化时才写回;ensureEmitterConfig 每帧都会调用,重复赋新数组会触发无限响应式更新。
const currentInherit = Array.isArray(legacyInherit) ? legacyInherit : null
if (
!currentInherit
|| currentInherit.length !== normalizedInherit.length
|| normalizedInherit.some((name, index) => name !== currentInherit[index])
) {
rule.inherit = normalizedInherit
}
if (rule.colorInheritMode !== 'add' && rule.colorInheritMode !== 'replace') rule.colorInheritMode = 'add'
}
config.innerRadius = Math.max(0, Number(config.innerRadius) || 0) config.innerRadius = Math.max(0, Number(config.innerRadius) || 0)
config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0) config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0)
config.innerRectW = Math.max(0, Number(config.innerRectW) || 0) config.innerRectW = Math.max(0, Number(config.innerRectW) || 0)
+189 -14
View File
@@ -3,10 +3,10 @@ import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAng
import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail' import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail'
import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources' import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources'
import type { SceneCollider, EmitterConfig, ParticleState } from './particleTypes' import type { SceneCollider, EmitterConfig, ParticleState, ParticleInheritAttribute } from './particleTypes'
import { defaultConfig, ensureEmitterConfig } from './particleConfig' import { defaultConfig, ensureEmitterConfig } from './particleConfig'
import { BLEND_MAP, MAX_PARTICLES } from './particleConstants' import { BLEND_MAP, MAX_PARTICLES } from './particleConstants'
import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration } from './particleMath' import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration, addRgbColors } from './particleMath'
// 保留原有入口,现有调用方无需随内部拆分修改。 // 保留原有入口,现有调用方无需随内部拆分修改。
export type * from './particleTypes' export type * from './particleTypes'
@@ -40,8 +40,54 @@ interface Particle {
resourceId: number resourceId: number
/** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */ /** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */
colorSample: number colorSample: number
/** 父粒子颜色,逐通道相加到粒子自身颜色。 */
colorAdd: number
/** 父粒子颜色覆盖;null 表示不覆盖。 */
colorOverride: number | null
/** 当前渲染缩放,用于事件继承。 */
currentScaleX: number
currentScaleY: number
imageFrameIndex: number imageFrameIndex: number
collisionPaused: boolean collisionPaused: boolean
/** 当前粒子累计发生的碰撞次数。 */
collisionCount: number
/** 由事件链生成的层级;0 = 正常发射。 */
eventDepth: number
}
export type ParticleEventKind = 'birth' | 'death' | 'collision'
export interface ParticleEventInfo {
kind: ParticleEventKind
/** 事件位置的数学世界坐标。 */
worldX: number
worldY: number
rotation: number
spawnId: number
/** 0 = 正常发射;数值越大表示事件链越深。 */
depth: number
colorHex: number
scaleX: number
scaleY: number
maxLife: number
collisionCount: number
normalX?: number
normalY?: number
}
/** 子发射器从父粒子继承的数据。 */
export interface ParticleInheritData {
colorHex: number
scaleX: number
scaleY: number
rotation: number
life: number
}
export interface ParticleInheritOptions {
attributes: ParticleInheritAttribute[]
colorMode: 'add' | 'replace'
data: ParticleInheritData
} }
export class ParticleEmitter extends Container { export class ParticleEmitter extends Container {
@@ -55,8 +101,21 @@ export class ParticleEmitter extends Container {
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */ /** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
private _attractionEnabledAt = 0 private _attractionEnabledAt = 0
private _attractionWasEnabled = false private _attractionWasEnabled = false
/** 子发射器关闭自动发射,只接收父粒子事件生成的粒子。 */
private autoEmission = true
/** 事件粒子延迟队列:子发射器 delay 表示在父事件之后再延迟多久发射。 */
private pendingEventSpawns: Array<{
worldX: number
worldY: number
depth: number
inherit?: ParticleInheritOptions
count: number
remaining: number
}> = []
/** 活动粒子状态快照(供骨架 / 导出) */ /** 活动粒子状态快照(供骨架 / 导出) */
states: ParticleState[] = [] states: ParticleState[] = []
/** 粒子出生/死亡/碰撞事件回调(由 EmitterSync 接入跨系统事件)。 */
onParticleEvent: ((event: ParticleEventInfo) => void) | null = null
/** 确定性随机(种子>0 时可复现) */ /** 确定性随机(种子>0 时可复现) */
private rng: () => number = Math.random private rng: () => number = Math.random
private _lastSeed = -1 private _lastSeed = -1
@@ -118,8 +177,14 @@ export class ParticleEmitter extends Container {
trail: createTrailState(), trail: createTrailState(),
resourceId: 1, resourceId: 1,
colorSample: 0xffffff, colorSample: 0xffffff,
colorAdd: 0,
colorOverride: null,
currentScaleX: 1,
currentScaleY: 1,
imageFrameIndex: 0, imageFrameIndex: 0,
collisionPaused: false, collisionPaused: false,
collisionCount: 0,
eventDepth: 0,
}) })
} }
} }
@@ -129,6 +194,7 @@ export class ParticleEmitter extends Container {
this.spawnPathClosed = closed this.spawnPathClosed = closed
} }
setColliders(colliders: SceneCollider[]) { this.colliders = colliders } setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
setAutoEmission(enabled: boolean) { this.autoEmission = enabled }
setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) { setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) {
this.followSpawnX = x this.followSpawnX = x
this.followSpawnY = y this.followSpawnY = y
@@ -147,6 +213,8 @@ export class ParticleEmitter extends Container {
if (!collider.enabled) continue if (!collider.enabled) continue
const hit = resolveColliderPoint(collider, world.x, world.y) const hit = resolveColliderPoint(collider, world.x, world.y)
if (!hit) continue if (!hit) continue
p.collisionCount++
this.emitWorldEvent('collision', p, hit.x, hit.y, { normalX: hit.nx, normalY: hit.ny })
if (collider.response === 'destroy') { if (collider.response === 'destroy') {
this.kill(particleIndex) this.kill(particleIndex)
return true return true
@@ -203,7 +271,7 @@ export class ParticleEmitter extends Container {
// 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。 // 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。
const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration))) const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)))
// 发射(仅在窗口内且未超时长) // 发射(仅在窗口内且未超时长)
if (inWindow && withinDuration) { if (this.autoEmission && inWindow && withinDuration) {
if (cfg.mode === 'stream') { if (cfg.mode === 'stream') {
this.accumulator += dt * cfg.rate this.accumulator += dt * cfg.rate
while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() } while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() }
@@ -219,6 +287,24 @@ export class ParticleEmitter extends Container {
} }
} }
// 处理子发射器的延迟发射队列;新粒子在本帧就会参与后续更新与录制。
if (this.pendingEventSpawns.length && dt > 0) {
const ready: typeof this.pendingEventSpawns = []
const waiting: typeof this.pendingEventSpawns = []
for (const pending of this.pendingEventSpawns) {
pending.remaining -= dt
if (pending.remaining <= 0) ready.push(pending)
else waiting.push(pending)
}
this.pendingEventSpawns = waiting
for (const pending of ready) {
const local = worldMathToLocal(this.cfg, pending.worldX, pending.worldY, this.simulationTransform)
for (let index = 0; index < pending.count; index++) {
if (!this.spawnOne({ x: local.x, y: local.y }, pending.depth, pending.inherit)) break
}
}
}
const states: ParticleState[] = [] const states: ParticleState[] = []
for (let i = 0; i < this.pool.length; i++) { for (let i = 0; i < this.pool.length; i++) {
const p = this.pool[i] const p = this.pool[i]
@@ -385,6 +471,8 @@ export class ParticleEmitter extends Container {
: scaleMulX : scaleMulX
} }
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1)) s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
p.currentScaleX = s.scale.x
p.currentScaleY = s.scale.y
const baseAlpha = cfg.alphaMode === 'curve' const baseAlpha = cfg.alphaMode === 'curve'
? curveAt(cfg.alphaCurve, t, this.resolveCurveMode(cfg.curveMode)) * cfg.alphaScale ? curveAt(cfg.alphaCurve, t, this.resolveCurveMode(cfg.curveMode)) * cfg.alphaScale
: cfg.alpha * cfg.alphaScale : cfg.alpha * cfg.alphaScale
@@ -395,11 +483,13 @@ export class ParticleEmitter extends Container {
? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode)) ? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode))
: baseAlpha, 1) : baseAlpha, 1)
const colorStart = resource?.colorStart || '#ffffff' const colorStart = resource?.colorStart || '#ffffff'
const rgb = resource?.colorMode === 'lifetime' const baseRgb = resource?.colorMode === 'lifetime'
? gradientColorAt(resource.colorGradient, t) ? gradientColorAt(resource.colorGradient, t)
: resource?.colorMode === 'random' : resource?.colorMode === 'random'
? p.colorSample ? p.colorSample
: hexToNumber(colorStart) : hexToNumber(colorStart)
// 继承颜色:按配置逐通道相加,或直接覆盖目标系统颜色。
const rgb = p.colorOverride ?? addRgbColors(baseRgb, p.colorAdd)
s.tint = rgb s.tint = rgb
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
const trailState = this.trail.update(p.trail, cfg, p.boneName, renderX, renderY, t, s) const trailState = this.trail.update(p.trail, cfg, p.boneName, renderX, renderY, t, s)
@@ -467,15 +557,70 @@ export class ParticleEmitter extends Container {
} }
} }
private spawnOne() { /** 由跨系统事件触发:在给定的数学世界坐标处生成一颗目标系统的粒子。 */
spawnEventParticle(worldX: number, worldY: number, depth = 1, inherit?: ParticleInheritOptions, count?: number): boolean {
if (depth > 2) return false
// 子发射器按自己的发射模式决定每次事件生成多少颗:爆发=单次数量,持续=1。
const requested = count ?? (this.cfg.mode === 'burst' ? this.cfg.burstCount : 1)
const total = Math.max(1, Math.min(MAX_PARTICLES, Math.round(Number(requested) || 1)))
// 子发射器 delay 表示在父事件之后再延迟多久发射。
const delay = Math.max(0, Number(this.cfg.delay) || 0)
if (delay > 0) {
this.pendingEventSpawns.push({ worldX, worldY, depth, inherit, count: total, remaining: delay })
return true
}
const local = worldMathToLocal(this.cfg, worldX, worldY, this.simulationTransform)
let spawned = false
for (let index = 0; index < total; index++) {
if (!this.spawnOne({ x: local.x, y: local.y }, depth, inherit)) break
spawned = true
}
return spawned
}
private emitWorldEvent(
kind: ParticleEventKind,
particle: Particle,
worldX: number,
worldY: number,
extra?: { normalX?: number; normalY?: number },
) {
if (!this.onParticleEvent) return
this.onParticleEvent({
kind,
worldX,
worldY,
rotation: particle.rotation,
spawnId: particle.spawnId,
depth: particle.eventDepth,
colorHex: particle.sprite.tint as number,
scaleX: particle.currentScaleX,
scaleY: particle.currentScaleY,
maxLife: particle.maxLife,
collisionCount: particle.collisionCount,
...extra,
})
}
private emitLocalEvent(kind: ParticleEventKind, particle: Particle, x: number, y: number) {
if (!this.onParticleEvent) return
const world = localToWorldMath(this.cfg, x, y, this.simulationTransform)
this.emitWorldEvent(kind, particle, world.x, world.y)
}
private spawnOne(originOverride?: { x: number; y: number }, eventDepth = 0, inherit?: ParticleInheritOptions): boolean {
const cfg = this.cfg const cfg = this.cfg
let idx = this.pool.findIndex((p) => !p.active) let idx = this.pool.findIndex((p) => !p.active)
if (idx === -1) return // 池满 if (idx === -1) return false // 池满
const origin = this.spawnOrigin() const origin = originOverride
? { x: originOverride.x, y: originOverride.y, radialAngle: 0 }
: this.spawnOrigin()
const { x: ox, y: oy } = origin const { x: ox, y: oy } = origin
const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng()) const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng())
const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng()) const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng) // 继承旋转作为整个子发射系统的方向偏移:旋转发射方向与粒子初始朝向。
const rotationOffset = inherit?.attributes.includes('rotation') ? inherit.data.rotation : 0
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng) + rotationOffset * Math.PI / 180
const p = this.pool[idx] const p = this.pool[idx]
const resource = pickImageResource(cfg, this.rng) const resource = pickImageResource(cfg, this.rng)
const spawnSerial = this._spawnSerial++ const spawnSerial = this._spawnSerial++
@@ -494,6 +639,8 @@ export class ParticleEmitter extends Container {
p.attractionActive = false p.attractionActive = false
p.attractionElapsed = 0 p.attractionElapsed = 0
p.collisionPaused = false p.collisionPaused = false
p.collisionCount = 0
p.eventDepth = eventDepth
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1 p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
p.trail.history.length = 0 p.trail.history.length = 0
p.trail.bones.length = 0 p.trail.bones.length = 0
@@ -531,7 +678,7 @@ export class ParticleEmitter extends Container {
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
p.rotation = cfg.initialRotationMode === 'direction' p.rotation = cfg.initialRotationMode === 'direction'
? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin ? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin
: initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng()) : initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng()) + rotationOffset
p.rotationSpeed = cfg.rotationOverLifeEnabled p.rotationSpeed = cfg.rotationOverLifeEnabled
? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng()) ? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng())
: 0 : 0
@@ -549,23 +696,49 @@ export class ParticleEmitter extends Container {
p.colorSample = resource.colorMode === 'random' p.colorSample = resource.colorMode === 'random'
? gradientColorAt(resource.colorGradient, seededUnit(cfg.seed, spawnSerial, 0x9e3779b9)) ? gradientColorAt(resource.colorGradient, seededUnit(cfg.seed, spawnSerial, 0x9e3779b9))
: hexToNumber(resource.colorStart) : hexToNumber(resource.colorStart)
p.colorAdd = 0
p.colorOverride = null
// 子发射器继承父粒子属性:颜色 / 大小 / 旋转 / 生命周期。
if (inherit) {
const attributes = inherit.attributes
if (attributes.includes('color')) {
if (inherit.colorMode === 'replace') p.colorOverride = inherit.data.colorHex
else p.colorAdd = inherit.data.colorHex
}
if (attributes.includes('scale')) {
// 大小作为整个子发射系统的缩放:目标粒子自身缩放 × 父粒子当前缩放。
p.scaleStartX *= inherit.data.scaleX
p.scaleStartY *= inherit.data.scaleY
}
// 旋转已在上面作为整体方向偏移应用,不再覆盖粒子自身旋转。
if (attributes.includes('life')) {
p.life = Math.max(0.0001, inherit.data.life)
p.maxLife = p.life
}
}
p.currentScaleX = p.scaleStartX
p.currentScaleY = p.scaleStartY
const initialFrame = imageFrameState(resource, 0) const initialFrame = imageFrameState(resource, 0)
p.imageFrameIndex = initialFrame.index p.imageFrameIndex = initialFrame.index
p.sprite.texture = imageFrameTexture(resource, cfg.texture, initialFrame.index) p.sprite.texture = imageFrameTexture(resource, cfg.texture, initialFrame.index)
p.sprite.anchor.set(resource.anchorX, resource.anchorY) p.sprite.anchor.set(resource.anchorX, resource.anchorY)
p.sprite.blendMode = BLEND_MAP[resource.blend] as any p.sprite.blendMode = BLEND_MAP[resource.blend] as any
p.sprite.tint = p.colorSample p.sprite.tint = p.colorOverride ?? addRgbColors(p.colorSample, p.colorAdd)
p.sprite.visible = true p.sprite.visible = true
this.emitLocalEvent('birth', p, p.x, p.y)
return true
} }
private kill(i: number) { private kill(i: number) {
const p = this.pool[i] const p = this.pool[i]
if (p.active) this.emitLocalEvent('death', p, p.x, p.y)
p.active = false p.active = false
p.sprite.visible = false p.sprite.visible = false
resetTrailState(p.trail) resetTrailState(p.trail)
} }
clear() { clear() {
this.pendingEventSpawns.length = 0
for (const p of this.pool) { for (const p of this.pool) {
p.active = false p.active = false
p.sprite.visible = false p.sprite.visible = false
@@ -657,6 +830,7 @@ export class ParticleEmitter extends Container {
/** 清空所有粒子并重置发射计时 */ /** 清空所有粒子并重置发射计时 */
reset() { reset() {
this.pendingEventSpawns.length = 0
for (const p of this.pool) { for (const p of this.pool) {
p.active = false p.active = false
p.sprite.visible = false p.sprite.visible = false
@@ -678,6 +852,7 @@ export class ParticleEmitter extends Container {
* 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。 * 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。
*/ */
restartEmissionCycle() { restartEmissionCycle() {
this.pendingEventSpawns.length = 0
this.accumulator = 0 this.accumulator = 0
this.burstTimer = 0 this.burstTimer = 0
this._spawnSerial = 0 this._spawnSerial = 0
+8
View File
@@ -247,3 +247,11 @@ export function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(h, 16) const n = parseInt(h, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255] return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
} }
/** 颜色相加:逐通道相加并钳制到 0~255。 */
export function addRgbColors(base: number, add: number): number {
const r = Math.min(255, ((base >> 16) & 255) + ((add >> 16) & 255))
const g = Math.min(255, ((base >> 8) & 255) + ((add >> 8) & 255))
const b = Math.min(255, (base & 255) + (add & 255))
return (r << 16) | (g << 8) | b
}
+25
View File
@@ -16,6 +16,28 @@ export type FieldFalloff = 'constant' | 'linear' | 'inverse'
/** 粒子系统在 Spine 绘制顺序中的位置(仅影响编辑器预览)。 */ /** 粒子系统在 Spine 绘制顺序中的位置(仅影响编辑器预览)。 */
export type RenderLayerMode = 'top' | 'slot' export type RenderLayerMode = 'top' | 'slot'
export type RenderLayerPosition = 'above' | 'below' export type RenderLayerPosition = 'above' | 'below'
/** 粒子事件类型。 */
export type ParticleEventType = 'birth' | 'death' | 'collision'
/** 子发射器可继承的单个属性(可多选)。 */
export type ParticleInheritAttribute = 'color' | 'scale' | 'rotation' | 'life'
/** 颜色继承方式:逐通道相加或直接覆盖。 */
export type ParticleColorInheritMode = 'add' | 'replace'
/** 一条事件规则:某个粒子事件以一定概率触发另一个粒子系统。 */
export interface ParticleEventRule {
id: number
event: ParticleEventType
/** 目标粒子系统 id;0 表示未选择。 */
targetSystemId: number
/** 每个粒子事件触发概率 0~1。 */
probability: number
/** 碰撞事件对同一父粒子的最大触发次数。 */
collisionLimit: number
/** 子发射器继承父粒子的属性;空数组 = 无。 */
inherit: ParticleInheritAttribute[]
/** 继承颜色时采用相加还是覆盖。 */
colorInheritMode: ParticleColorInheritMode
}
export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number } export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export interface ColorGradientStop { position: number; color: string } export interface ColorGradientStop { position: number; color: string }
@@ -352,6 +374,9 @@ export interface EmitterConfig {
collisionColliderIds: number[] collisionColliderIds: number[]
/** 开启路径跟随(预留) */ /** 开启路径跟随(预留) */
pathFollow: boolean pathFollow: boolean
/** 开启事件(具体事件类型与行为后续接入)。 */
eventEnabled: boolean
events: ParticleEventRule[]
emitterFollow: boolean emitterFollow: boolean
emitterFollowMode: 'none' | 'path' | 'bone' emitterFollowMode: 'none' | 'path' | 'bone'
emitterFollowPathId: number emitterFollowPathId: number
+50
View File
@@ -0,0 +1,50 @@
interface SubEmitterSource {
id: number
config: { eventEnabled?: boolean; events?: Array<{ targetSystemId: number }> }
}
/**
* 被任一子发射器规则引用的粒子系统属于父系统的一部分:
* 不在场景对象列表中单独显示,也不自动发射,只由父粒子事件驱动。
*/
export function collectSubEmitterIds(systems: readonly SubEmitterSource[]): Set<number> {
const ids = new Set<number>()
for (const system of systems) {
if (system.config?.eventEnabled === false) continue
const rules = system.config?.events
if (!Array.isArray(rules)) continue
for (const rule of rules) {
if (rule.targetSystemId > 0 && rule.targetSystemId !== system.id) ids.add(rule.targetSystemId)
}
}
return ids
}
/**
* 收集某个粒子系统的全部上游父系统(沿子发射器引用向上遍历)。
* 添加子发射器目标时必须排除这些系统,否则会形成循环依赖。
*/
export function collectEmitterAncestors(systems: readonly SubEmitterSource[], systemId: number): Set<number> {
const parentsOf = new Map<number, number[]>()
for (const system of systems) {
if (system.config?.eventEnabled === false) continue
const rules = system.config?.events
if (!Array.isArray(rules)) continue
for (const rule of rules) {
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id) continue
const parents = parentsOf.get(rule.targetSystemId)
if (parents) parents.push(system.id)
else parentsOf.set(rule.targetSystemId, [system.id])
}
}
const ancestors = new Set<number>()
const queue = [...(parentsOf.get(systemId) || [])]
while (queue.length) {
const current = queue.pop()!
if (ancestors.has(current)) continue
ancestors.add(current)
queue.push(...(parentsOf.get(current) || []))
}
ancestors.delete(systemId)
return ancestors
}
+142
View File
@@ -0,0 +1,142 @@
import { loopSegmentTiming } from './loopSegmentTiming'
export interface SubEmitterRuleSource {
event: 'birth' | 'death' | 'collision'
targetSystemId: number
}
export interface SubEmitterTimingSource {
id: number
config: {
mode: 'stream' | 'burst'
streamBehavior: 'normal' | 'loop'
delay: number
duration: number
loopDurationFrames: number
lifeMin: number
lifeMax: number
lifeMode: string
eventEnabled: boolean
events?: SubEmitterRuleSource[]
}
}
export interface SubEmitterTriggerTiming {
/** 第一次被父粒子事件触发的时刻(秒)。 */
firstTrigger: number
/** 最后一次可能被触发的时刻(秒)。 */
lastTrigger: number
/** 最后一颗粒子消失的时刻(秒)。 */
end: number
}
const numberOrZero = (value: unknown) => Math.max(0, Number(value) || 0)
/**
* 计算子发射器在时间轴上的实际活动区间:
* 起点取第一次可能被触发的时刻,终点取最后一次触发 + 子粒子最大生命。
* 触发时机:出生=父粒子出生时;死亡=最早/最晚死亡时;碰撞=出生到死亡之间。
*/
export function subEmitterTriggerTimings(
systems: readonly SubEmitterTimingSource[],
fps: number,
): Map<number, SubEmitterTriggerTiming> {
const safeFps = Math.max(1, fps)
const byId = new Map(systems.map((system) => [system.id, system]))
const subIds = new Set<number>()
for (const system of systems) {
if (!system.config.eventEnabled) continue
for (const rule of system.config.events || []) {
if (rule.targetSystemId > 0 && rule.targetSystemId !== system.id && byId.has(rule.targetSystemId)) {
subIds.add(rule.targetSystemId)
}
}
}
const parentsOf = new Map<number, Array<{ parent: SubEmitterTimingSource; rule: SubEmitterRuleSource }>>()
for (const system of systems) {
if (!system.config.eventEnabled) continue
for (const rule of system.config.events || []) {
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id || !byId.has(rule.targetSystemId)) continue
const parents = parentsOf.get(rule.targetSystemId)
if (parents) parents.push({ parent: system, rule })
else parentsOf.set(rule.targetSystemId, [{ parent: system, rule }])
}
}
const emissionStart = (config: SubEmitterTimingSource['config']) => numberOrZero(config.delay)
const delayOf = (config: SubEmitterTimingSource['config']) => numberOrZero(config.delay)
const emissionEnd = (config: SubEmitterTimingSource['config']) => {
const delay = emissionStart(config)
if (config.mode !== 'stream') return delay
if (config.streamBehavior === 'loop') {
return delay + loopSegmentTiming(config as any, safeFps).total / safeFps
}
return delay + Math.max(1 / safeFps, Number(config.duration) || 0)
}
const lifeMinOf = (config: SubEmitterTimingSource['config']) => {
const min = numberOrZero(config.lifeMin)
const max = numberOrZero(config.lifeMax)
return config.lifeMode === 'fixed' ? min : Math.min(min, max)
}
const lifeMaxOf = (config: SubEmitterTimingSource['config']) => {
const min = numberOrZero(config.lifeMin)
const max = numberOrZero(config.lifeMax)
return config.lifeMode === 'fixed' ? min : Math.max(min, max)
}
const firstCache = new Map<number, number>()
const lastCache = new Map<number, number>()
const firstVisiting = new Set<number>()
const lastVisiting = new Set<number>()
const firstTrigger = (system: SubEmitterTimingSource): number => {
const cached = firstCache.get(system.id)
if (cached != null) return cached
if (firstVisiting.has(system.id)) return emissionStart(system.config)
firstVisiting.add(system.id)
let first = Infinity
for (const { parent, rule } of parentsOf.get(system.id) || []) {
const parentFirst = subIds.has(parent.id)
? firstTrigger(parent) + delayOf(parent.config)
: emissionStart(parent.config)
const offset = rule.event === 'death' ? lifeMinOf(parent.config) : 0
first = Math.min(first, parentFirst + offset)
}
if (!Number.isFinite(first)) first = emissionStart(system.config)
firstVisiting.delete(system.id)
firstCache.set(system.id, first)
return first
}
const lastTrigger = (system: SubEmitterTimingSource): number => {
const cached = lastCache.get(system.id)
if (cached != null) return cached
if (lastVisiting.has(system.id)) return emissionEnd(system.config)
lastVisiting.add(system.id)
let last = -Infinity
for (const { parent, rule } of parentsOf.get(system.id) || []) {
const parentLast = subIds.has(parent.id)
? lastTrigger(parent) + delayOf(parent.config)
: emissionEnd(parent.config)
const offset = rule.event === 'birth' ? 0 : lifeMaxOf(parent.config)
last = Math.max(last, parentLast + offset)
}
if (!Number.isFinite(last)) last = emissionEnd(system.config)
lastVisiting.delete(system.id)
lastCache.set(system.id, last)
return last
}
const result = new Map<number, SubEmitterTriggerTiming>()
for (const id of subIds) {
const system = byId.get(id)
if (!system) continue
const first = firstTrigger(system)
const last = lastTrigger(system)
result.set(id, {
firstTrigger: first,
lastTrigger: last,
end: Math.max(first, last) + lifeMaxOf(system.config),
})
}
return result
}
+50 -1
View File
@@ -16,6 +16,8 @@ import { restoreSpineSource } from '../spine/spineSourceBundle'
import { registerSpineAsset, releaseSpineAsset } from '../spine/spineAssetRegistry' import { registerSpineAsset, releaseSpineAsset } from '../spine/spineAssetRegistry'
import { cloneEditorValue } from './editorClone' import { cloneEditorValue } from './editorClone'
import { ensureSpineExportSettings } from '../export/spineExportSettings' import { ensureSpineExportSettings } from '../export/spineExportSettings'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { lastFrameOfDuration } from '../timeline/playbackRange'
type EditorStore = ReturnType<typeof useParticleStore> type EditorStore = ReturnType<typeof useParticleStore>
@@ -43,6 +45,10 @@ export interface EditorConfigFile {
loop: boolean loop: boolean
playbackStart?: number playbackStart?: number
playbackEnd?: number playbackEnd?: number
/** B 是否跟随时间轴总长,而不是用户自定义的循环末帧。 */
playbackEndAuto?: boolean
/** 是否启用独立的 A/B 区间播放。 */
abLoop?: boolean
animations: string[] animations: string[]
} }
selection: { selection: {
@@ -85,6 +91,7 @@ function withoutLegacyColliderTags(collider: CollisionBody): CollisionBody {
export function createEditorConfig(store: EditorStore): EditorConfigFile { export function createEditorConfig(store: EditorStore): EditorConfigFile {
const settings = captureProjectSettings(store.settings) const settings = captureProjectSettings(store.settings)
const playbackEndAuto = store.timeline.playbackEnd >= Math.max(1, store.timeline.totalFrames - 1)
return jsonClone({ return jsonClone({
format: EDITOR_CONFIG_FORMAT, format: EDITOR_CONFIG_FORMAT,
version: EDITOR_CONFIG_VERSION, version: EDITOR_CONFIG_VERSION,
@@ -96,6 +103,8 @@ export function createEditorConfig(store: EditorStore): EditorConfigFile {
loop: store.timeline.loop, loop: store.timeline.loop,
playbackStart: store.timeline.playbackStart, playbackStart: store.timeline.playbackStart,
playbackEnd: store.timeline.playbackEnd, playbackEnd: store.timeline.playbackEnd,
playbackEndAuto,
abLoop: store.timeline.abLoop,
animations: [...store.timeline.animations], animations: [...store.timeline.animations],
}, },
selection: { selection: {
@@ -111,6 +120,41 @@ export function createEditorConfig(store: EditorStore): EditorConfigFile {
}) })
} }
/** v1 配置没有 playbackEndAuto,用旧版“各系统独立计时”的末帧判断 B 是否为自动全范围。 */
function legacyPlaybackLastFrame(
systems: ParticleSystem[],
colliders: CollisionBody[],
spines: SpineSceneObject[],
fps: number,
) {
const safeFps = Math.max(1, fps)
let maxSec = 0
for (const system of systems) {
const c = system.config
const lifeMin = Math.max(0, Number(c.lifeMin) || 0)
const lifeMax = Math.max(0, Number(c.lifeMax) || 0)
const life = c.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
const emissionDuration = c.mode === 'stream'
? c.streamBehavior === 'loop'
? loopSegmentTiming(c, safeFps).total / safeFps
: Math.max(1 / safeFps, Number(c.duration) || 0)
: 0
let end = Math.max(0, Number(c.delay) || 0) + emissionDuration + life
if (c.mode === 'stream' && c.streamBehavior === 'loop') {
end = Math.max(0, Number(c.delay) || 0) + loopSegmentTiming(c, safeFps).total / safeFps
}
maxSec = Math.max(maxSec, end)
}
for (const spine of spines) {
const animation = spine.animations.find((item) => item.name === spine.selectedAnimation)
if (animation) maxSec = Math.max(maxSec, Math.max(0, Number(spine.timeOffset) || 0) + Math.max(0, animation.duration || 0))
}
for (const collider of colliders) {
if (collider.followPath && collider.followPathId) maxSec = Math.max(maxSec, Math.max(0, collider.followDuration) || 0)
}
return lastFrameOfDuration(maxSec, safeFps)
}
function downloadName() { function downloadName() {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-') const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-')
return `SpineParticle-${stamp}.json` return `SpineParticle-${stamp}.json`
@@ -247,6 +291,10 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
store.settings.exportSettings = ensureSpineExportSettings(config.settings?.exportSettings) store.settings.exportSettings = ensureSpineExportSettings(config.settings?.exportSettings)
store.settings.undoLimit = Math.min(200, Math.max(1, Math.round(Number(store.settings.undoLimit) || 20))) store.settings.undoLimit = Math.min(200, Math.max(1, Math.round(Number(store.settings.undoLimit) || 20)))
store.timeline.fps = Math.max(1, Math.round(Number(config.timeline?.fps) || 30)) store.timeline.fps = Math.max(1, Math.round(Number(config.timeline?.fps) || 30))
const savedPlaybackEnd = Math.max(1, Math.floor(Number(config.timeline?.playbackEnd) || Number.MAX_SAFE_INTEGER))
const playbackEndAuto = config.timeline?.playbackEndAuto
?? savedPlaybackEnd >= legacyPlaybackLastFrame(systems, colliders, spines, store.timeline.fps)
store.timeline.abLoop = config.timeline?.abLoop === true
store.timeline.loop = config.timeline?.loop !== false store.timeline.loop = config.timeline?.loop !== false
store.timeline.animations = Array.isArray(config.timeline?.animations) && config.timeline.animations.length store.timeline.animations = Array.isArray(config.timeline?.animations) && config.timeline.animations.length
? [...new Set(config.timeline.animations.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim()))] ? [...new Set(config.timeline.animations.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim()))]
@@ -279,7 +327,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
store.syncSceneSequences() store.syncSceneSequences()
repairSelection(store, config.selection) repairSelection(store, config.selection)
store.timeline.playbackStart = Math.max(0, Math.floor(Number(config.timeline?.playbackStart) || 0)) store.timeline.playbackStart = Math.max(0, Math.floor(Number(config.timeline?.playbackStart) || 0))
store.timeline.playbackEnd = Math.max(1, Math.floor(Number(config.timeline?.playbackEnd) || Number.MAX_SAFE_INTEGER)) store.timeline.playbackEnd = savedPlaybackEnd
store.timeline.frame = 0 store.timeline.frame = 0
store.timeline.recorded = false store.timeline.recorded = false
store.timeline.playing = true store.timeline.playing = true
@@ -287,6 +335,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
if (!store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0] if (!store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
} }
store.recalcTotalFrames() store.recalcTotalFrames()
if (playbackEndAuto) store.timeline.playbackEnd = store.timeline.totalFrames - 1
store.timeline.frame = store.timeline.playbackStart store.timeline.frame = store.timeline.playbackStart
return config return config
} }
+97 -26
View File
@@ -1,11 +1,12 @@
import { defineStore } from 'pinia' import { acceptHMRUpdate, defineStore } from 'pinia'
import { EmitterConfig, defaultConfig, ensureEmitterConfig, ParticleState, type SceneCollider } from '../core/particleEmitter' import { EmitterConfig, defaultConfig, ensureEmitterConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
import type { SpineSceneObject } from '../spine/spineTypes' import type { SpineSceneObject } from '../spine/spineTypes'
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry' import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
import { cloneEditorValue } from '../editor/editorClone' import { cloneEditorValue } from '../editor/editorClone'
import { defaultSpineExportSettings, type SpineExportSettings } from '../export/spineExportSettings' import { defaultSpineExportSettings, type SpineExportSettings } from '../export/spineExportSettings'
import { loopSegmentTiming } from '../core/loopSegmentTiming' import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { lastFrameOfDuration, normalizePlaybackRange, resizePlaybackRange } from '../timeline/playbackRange' import { collectSubEmitterIds } from '../core/subEmitterIds'
import { lastFrameOfDuration, resizePlaybackRange, resolvePlaybackRange } from '../timeline/playbackRange'
export interface ParticleSystem { export interface ParticleSystem {
id: number id: number
@@ -98,6 +99,8 @@ export interface TimelineState {
/** 自定义播放范围,首尾帧均包含。 */ /** 自定义播放范围,首尾帧均包含。 */
playbackStart: number playbackStart: number
playbackEnd: number playbackEnd: number
/** 是否启用独立的 A/B 区间播放;关闭时播放完整时间轴。 */
abLoop: boolean
/** 帧率 */ /** 帧率 */
fps: number fps: number
/** 是否已录制 */ /** 是否已录制 */
@@ -133,7 +136,7 @@ export const useParticleStore = defineStore('particle', {
activeObjectType: 'particle' as SceneObjectType, activeObjectType: 'particle' as SceneObjectType,
activeObjectId: 0, activeObjectId: 0,
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。 // 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, playbackStart: 0, playbackEnd: 59, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState, timeline: { playing: true, loop: true, abLoop: false, frame: 0, totalFrames: 60, playbackStart: 0, playbackEnd: 59, fps: 30, recorded: false, animation: 'animation', animations: ['animation'] } as TimelineState,
settings: { settings: {
parameterPanelOnRight: true, parameterPanelOnRight: true,
tickEnabled: false, tickEnabled: false,
@@ -158,6 +161,12 @@ export const useParticleStore = defineStore('particle', {
}), }),
getters: { getters: {
activeSystemCount: (s) => s.systems.length, activeSystemCount: (s) => s.systems.length,
playbackRange: (s) => resolvePlaybackRange(
s.timeline.abLoop,
s.timeline.playbackStart,
s.timeline.playbackEnd,
s.timeline.totalFrames,
),
}, },
actions: { actions: {
syncSceneSequences() { syncSceneSequences() {
@@ -447,7 +456,7 @@ export const useParticleStore = defineStore('particle', {
this.recalcTotalFrames() this.recalcTotalFrames()
}, },
play() { play() {
const range = normalizePlaybackRange(this.timeline.playbackStart, this.timeline.playbackEnd, this.timeline.totalFrames) const range = this.playbackRange
if (this.timeline.frame < range.start || this.timeline.frame >= range.end) this.timeline.frame = range.start if (this.timeline.frame < range.start || this.timeline.frame >= range.end) this.timeline.frame = range.start
this.timeline.playing = true this.timeline.playing = true
}, },
@@ -456,57 +465,114 @@ export const useParticleStore = defineStore('particle', {
if (this.timeline.playing) this.pause() if (this.timeline.playing) this.pause()
else this.play() else this.play()
}, },
toStart() { this.timeline.frame = this.timeline.playbackStart }, toStart() { this.timeline.frame = this.playbackRange.start },
previousFrame() { previousFrame() {
this.timeline.playing = false this.timeline.playing = false
this.timeline.frame = Math.max(this.timeline.playbackStart, this.timeline.frame - 1) this.timeline.frame = Math.max(this.playbackRange.start, this.timeline.frame - 1)
}, },
nextFrame() { nextFrame() {
this.timeline.playing = false this.timeline.playing = false
this.timeline.frame = Math.min(this.timeline.playbackEnd, this.timeline.frame + 1) this.timeline.frame = Math.min(this.playbackRange.end, this.timeline.frame + 1)
}, },
toEnd() { toEnd() {
this.timeline.playing = false this.timeline.playing = false
this.timeline.frame = this.timeline.playbackEnd this.timeline.frame = this.playbackRange.end
}, },
toggleLoop() { this.timeline.loop = !this.timeline.loop }, toggleLoop() { this.timeline.loop = !this.timeline.loop },
toggleABLoop() {
this.timeline.abLoop = !this.timeline.abLoop
const range = this.playbackRange
if (this.timeline.frame < range.start || this.timeline.frame > range.end) this.timeline.frame = range.start
},
setFrame(f: number) { setFrame(f: number) {
this.timeline.frame = Math.max(0, Math.min(f, this.timeline.totalFrames - 1)) this.timeline.frame = Math.max(0, Math.min(f, this.timeline.totalFrames - 1))
}, },
setPlaybackStart(value: number) { setPlaybackStart(value: number) {
const range = normalizePlaybackRange(value, this.timeline.playbackEnd, this.timeline.totalFrames) const range = resolvePlaybackRange(true, value, this.timeline.playbackEnd, this.timeline.totalFrames)
this.timeline.playbackStart = range.start this.timeline.playbackStart = range.start
this.timeline.playbackEnd = range.end this.timeline.playbackEnd = range.end
if (this.timeline.frame < range.start) this.timeline.frame = range.start if (this.timeline.abLoop && this.timeline.frame < range.start) this.timeline.frame = range.start
}, },
setPlaybackEnd(value: number) { setPlaybackEnd(value: number) {
const range = normalizePlaybackRange(this.timeline.playbackStart, value, this.timeline.totalFrames) const range = resolvePlaybackRange(true, this.timeline.playbackStart, value, this.timeline.totalFrames)
this.timeline.playbackStart = range.start this.timeline.playbackStart = range.start
this.timeline.playbackEnd = range.end this.timeline.playbackEnd = range.end
if (this.timeline.frame > range.end) this.timeline.frame = range.end if (this.timeline.abLoop && this.timeline.frame > range.end) this.timeline.frame = range.end
}, },
/** 依据所有粒子系统的"最晚消失时间"重算总帧数。 /** 依据所有粒子系统的"最晚消失时间"重算总帧数。
* 固定生命周期使用 lifeMin;随机生命周期使用上下限中的较大值。 * 固定生命周期使用 lifeMin;随机生命周期使用上下限中的较大值。
* 单系统总时长 = delay + 持续模式 duration(爆发为 0)+ 最大粒子生命, * 单系统总时长 = delay + 持续模式 duration(爆发为 0)+ 最大粒子生命,
* 并递归计入子发射器链(出生立即触发、死亡/碰撞在最晚生命结束时触发)。
* 多系统取最大;转帧 = ceil(sec * fps)。 */ * 多系统取最大;转帧 = ceil(sec * fps)。 */
recalcTotalFrames() { recalcTotalFrames() {
const fps = this.timeline.fps || 30 const fps = Math.max(1, this.timeline.fps || 30)
let maxSec = 0 const particleLifeOf = (c: any) => {
for (const sys of this.systems) {
const c = sys.config
const lifeMin = Math.max(0, Number(c.lifeMin) || 0) const lifeMin = Math.max(0, Number(c.lifeMin) || 0)
const lifeMax = Math.max(0, Number(c.lifeMax) || 0) const lifeMax = Math.max(0, Number(c.lifeMax) || 0)
const particleLife = c.lifeMode === 'fixed' return c.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
? lifeMin }
: Math.max(lifeMin, lifeMax) const byId = new Map(this.systems.map((system) => [system.id, system]))
const emissionDuration = c.mode === 'stream' const tailCache = new Map<number, number>()
? c.streamBehavior === 'loop' const visiting = new Set<number>()
? Math.max(1 / fps, Math.ceil(Math.max(1, Number(c.loopDurationFrames) || 1)) / fps) /** 从一颗粒子出生算起,它及其子发射器链最晚持续多久。 */
const effectTail = (system: any): number => {
const cached = tailCache.get(system.id)
if (cached != null) return cached
if (visiting.has(system.id)) return 0
visiting.add(system.id)
const config = system.config
const life = particleLifeOf(config)
let tail = life
if (config.eventEnabled) {
for (const rule of config.events || []) {
const target = byId.get(rule.targetSystemId)
if (!target || target.id === system.id) continue
const childTail = effectTail(target)
// 出生时立即触发;死亡/碰撞最晚发生在父粒子生命结束时。
const offset = rule.event === 'birth' ? 0 : life
// 子发射器自身的 delay 表示父事件之后再延迟多久发射。
const targetDelay = Math.max(0, Number(target.config.delay) || 0)
tail = Math.max(tail, offset + targetDelay + childTail)
}
}
visiting.delete(system.id)
tailCache.set(system.id, tail)
return tail
}
const subEmitterIds = collectSubEmitterIds(this.systems)
let maxSec = 0
for (const system of this.systems) {
// 子发射器不独立发射,时长通过父系统的 effectTail 计算。
if (subEmitterIds.has(system.id)) continue
const c = system.config
const delay = Math.max(0, Number(c.delay) || 0)
const isLoop = c.mode === 'stream' && c.streamBehavior === 'loop'
let end: number
if (c.mode === 'stream') {
const emissionDuration = isLoop
? loopSegmentTiming(c, fps).total / fps
: Math.max(1 / fps, Number(c.duration) || 0) : Math.max(1 / fps, Number(c.duration) || 0)
: 0 end = delay + emissionDuration
let end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife if (isLoop) {
if (c.mode === 'stream' && c.streamBehavior === 'loop') { // 循环结束段已包含父系统自身消亡,只追加子发射器链的尾部时长。
end = Math.max(0, Number(c.delay) || 0) + loopSegmentTiming(c, fps).total / fps let childTail = 0
if (c.eventEnabled) {
const life = particleLifeOf(c)
for (const rule of c.events || []) {
const target = byId.get(rule.targetSystemId)
if (!target || target.id === system.id) continue
const targetDelay = Math.max(0, Number(target.config.delay) || 0)
childTail = Math.max(childTail, (rule.event === 'birth' ? 0 : life) + targetDelay + effectTail(target))
}
}
end += childTail
} else {
end += effectTail(system)
}
} else {
// 爆发:delay 时刻触发一次,之后只计算生命与子发射器链。
end = delay + effectTail(system)
} }
if (end > maxSec) maxSec = end if (end > maxSec) maxSec = end
} }
@@ -541,3 +607,8 @@ export const useParticleStore = defineStore('particle', {
}, },
}, },
}) })
// 开发环境接受 Pinia store 热更新,避免动作改动后仍执行旧引用(保留当前场景状态)。
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useParticleStore, import.meta.hot))
}
+6
View File
@@ -29,6 +29,12 @@ export function normalizePlaybackRange(start: unknown, end: unknown, totalFrames
return { start: safeStart, end: safeEnd } return { start: safeStart, end: safeEnd }
} }
/** AB 关闭时始终使用完整时间轴,A/B 只是保留给下次开启。 */
export function resolvePlaybackRange(abLoop: boolean, start: unknown, end: unknown, totalFrames: number) {
const last = Math.max(1, Math.floor(totalFrames) - 1)
return normalizePlaybackRange(abLoop ? start : 0, abLoop ? end : last, totalFrames)
}
export function resizePlaybackRange(range: PlaybackRange, previousTotal: number, nextTotal: number) { export function resizePlaybackRange(range: PlaybackRange, previousTotal: number, nextTotal: number) {
const previousLast = Math.max(1, previousTotal - 1) const previousLast = Math.max(1, previousTotal - 1)
const nextLast = Math.max(1, nextTotal - 1) const nextLast = Math.max(1, nextTotal - 1)
+42 -23
View File
@@ -37,6 +37,7 @@ import { Application, Container, Graphics, type Text, type Texture } from 'pixi.
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter' import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { bakeLoopFrames } from '../core/loopFrameBaker' import { bakeLoopFrames } from '../core/loopFrameBaker'
import { loopPlaybackRange } from '../core/loopSegmentTiming' import { loopPlaybackRange } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore' import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer' import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { installEditorHistory, undoLastOperation } from '../editor/editorHistory' import { installEditorHistory, undoLastOperation } from '../editor/editorHistory'
@@ -220,12 +221,21 @@ function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps:
*/ */
function ensureFramesBaked(fps: number) { function ensureFramesBaked(fps: number) {
const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2) const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2)
const subEmitterIds = collectSubEmitterIds(store.systems)
let complete = true let complete = true
for (const system of store.systems) { // 烘焙预热会大量出生/死亡粒子,期间暂停跨系统事件,避免注入预热粒子。
if ((system.frames?.length || 0) >= totalFrames) continue emitterSync.suspendEvents(true)
const emitter = emitterSync.emitters.get(system.id) try {
if (!emitter) { complete = false; continue } for (const system of store.systems) {
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames) // 子发射器由父粒子事件驱动,不能脱离父系统单独烘焙。
if (subEmitterIds.has(system.id)) continue
if ((system.frames?.length || 0) >= totalFrames) continue
const emitter = emitterSync.emitters.get(system.id)
if (!emitter) { complete = false; continue }
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames)
}
} finally {
emitterSync.suspendEvents(false)
} }
if (complete) store.timeline.recorded = true if (complete) store.timeline.recorded = true
} }
@@ -351,6 +361,7 @@ function loop() {
appendStateBones(collected, state, emitter.position.x, emitter.position.y) appendStateBones(collected, state, emitter.position.x, emitter.position.y)
} }
const tl = store.timeline const tl = store.timeline
const playbackRange = store.playbackRange
const fps = tl.fps const fps = tl.fps
const frameDuration = 1 / Math.max(1, fps) const frameDuration = 1 / Math.max(1, fps)
let displayFrame = timelineDisplayFrame let displayFrame = timelineDisplayFrame
@@ -376,18 +387,26 @@ function loop() {
total = 0 total = 0
if (!tl.recorded) { if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。 // 录制阶段:固定步长模拟并记录当前帧。
for (const sys of store.systems) { emitterSync.suspendEvents(true)
const emitter = emitterSync.emitters.get(sys.id) try {
if (!emitter) continue const subEmitterIds = collectSubEmitterIds(store.systems)
const isLoopStream = sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop' for (const sys of store.systems) {
// 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始 const emitter = emitterSync.emitters.get(sys.id)
// (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙, if (!emitter) continue
// 导致整段回放都没有可用帧。 const isLoopStream = !subEmitterIds.has(sys.id)
if (isLoopStream) { && sys.config.mode === 'stream'
if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames) && sys.config.streamBehavior === 'loop'
} else if (tl.frame === 0) { // 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始
emitter.reset() // (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙,
// 导致整段回放都没有可用帧。
if (isLoopStream) {
if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
} else if (tl.frame === 0) {
emitter.reset()
}
} }
} finally {
emitterSync.suspendEvents(false)
} }
for (const [systemId, em] of emitterSync.emitters) { for (const [systemId, em] of emitterSync.emitters) {
const system = store.systems.find((item) => item.id === systemId) const system = store.systems.find((item) => item.id === systemId)
@@ -412,7 +431,7 @@ function loop() {
} }
tl.frame++ tl.frame++
if (tl.frame >= tl.totalFrames) { if (tl.frame >= tl.totalFrames) {
tl.frame = tl.playbackStart tl.frame = playbackRange.start
tl.recorded = true tl.recorded = true
} }
} else { } else {
@@ -435,13 +454,13 @@ function loop() {
: null : null
// 烘焙数据保留“末帧 = 首帧”以便导出闭环。预览精确循环段时,末帧只作为 // 烘焙数据保留“末帧 = 首帧”以便导出闭环。预览精确循环段时,末帧只作为
// 数学边界,不再与下一轮首帧连续显示两次,避免衔接处停顿一帧。 // 数学边界,不再与下一轮首帧连续显示两次,避免衔接处停顿一帧。
const closesActiveLoop = tl.loop const closesActiveLoop = tl.loop && tl.abLoop
&& activeLoopRange?.start === tl.playbackStart && activeLoopRange?.start === playbackRange.start
&& activeLoopRange.end === tl.playbackEnd && activeLoopRange.end === playbackRange.end
const playbackLastFrame = closesActiveLoop ? tl.playbackEnd - 1 : tl.playbackEnd const playbackLastFrame = closesActiveLoop ? playbackRange.end - 1 : playbackRange.end
if (tl.frame > playbackLastFrame) { if (tl.frame > playbackLastFrame) {
if (tl.loop) tl.frame = tl.playbackStart if (tl.loop) tl.frame = playbackRange.start
else { tl.frame = tl.playbackEnd; tl.playing = false; break } else { tl.frame = playbackRange.end; tl.playing = false; break }
} }
} }
} }
+65 -29
View File
@@ -9,10 +9,17 @@
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button> <button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button> <button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
<button class="tl-btn" @click="store.toEnd()" 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> <button class="tl-btn" :class="{ on: store.timeline.loop }" title="持续循环播放" aria-label="持续循环播放" @click="store.toggleLoop()">⟳</button>
<label class="tl-range" title="自定义播放起始帧"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" aria-label="播放起始帧" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label> <div class="tl-ab-loop">
<label class="tl-range" title="自定义播放结束帧"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" aria-label="播放结束帧" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label> <button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" title="独立 A/B 区间循环" @click="store.toggleABLoop()">
<button v-if="activeLoopPlaybackRange" class="tl-loop-range" :title="`设为闭合自循环范围 ${activeLoopPlaybackRange.start}–${activeLoopPlaybackRange.end}f`" @click="useActiveLoopPlaybackRange">循环段</button> <span>AB 循环</span><i class="tl-switch"><b></b></i>
</button>
<div v-if="store.timeline.abLoop" class="tl-ab-popover" @pointerdown.stop>
<div class="tl-ab-title">AB 循环区间</div>
<label class="tl-range" title="自定义播放起始帧"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" aria-label="播放起始帧" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
<label class="tl-range" title="自定义播放结束帧"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" aria-label="播放结束帧" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
</div>
</div>
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ Math.max(0, store.timeline.totalFrames - 1) }}</b></span> <span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ Math.max(0, store.timeline.totalFrames - 1) }}</b></span>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span> <span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
<div ref="animationMenuRef" class="tl-anim tl-owner-animation"> <div ref="animationMenuRef" class="tl-anim tl-owner-animation">
@@ -84,23 +91,24 @@
<!-- 每系统一条轨道(粒子条) --> <!-- 每系统一条轨道(粒子条) -->
<div class="tl-lanes"> <div class="tl-lanes">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane"> <div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico">●</span> <span class="lane-ico" :class="{ 'sub-lane-ico': isSubEmitter(sys) }" :style="{ left: startMarkerPx(sys) + 'px' }">●</span>
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 --> <!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
<div <div
class="lane-bar" class="lane-bar"
:class="{ 'sub-emitter-bar': isSubEmitter(sys) }"
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }" :style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
@pointerdown.stop="onBarDown($event, sys)" @pointerdown.stop="onBarDown($event, sys)"
> >
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span> <span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
<span class="bar-life" :class="{ 'loop-life': isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span> <span class="bar-life" :class="{ 'loop-life': !isSubEmitter(sys) && isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<template v-if="isLoopStream(sys)"> <template v-if="!isSubEmitter(sys) && isLoopStream(sys)">
<span v-if="loopStartWidthPx(sys)" class="bar-loop-transition bar-loop-start" :style="{ width: loopStartWidthPx(sys) + 'px' }"></span> <span v-if="loopStartWidthPx(sys)" class="bar-loop-transition bar-loop-start" :style="{ width: loopStartWidthPx(sys) + 'px' }"></span>
<span class="bar-emission bar-loop" :style="{ left: loopStartWidthPx(sys) + 'px', width: emissionWidthPx(sys) + 'px' }"> <span class="bar-emission bar-loop" :style="{ left: loopStartWidthPx(sys) + 'px', width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" title="拖动循环段时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span> <span class="bar-handle" title="拖动循环段时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span> </span>
<span v-if="loopEndWidthPx(sys)" class="bar-loop-transition bar-loop-end" :style="{ left: (loopStartWidthPx(sys) + emissionWidthPx(sys)) + 'px', width: loopEndWidthPx(sys) + 'px' }"></span> <span v-if="loopEndWidthPx(sys)" class="bar-loop-transition bar-loop-end" :style="{ left: (loopStartWidthPx(sys) + emissionWidthPx(sys)) + 'px', width: loopEndWidthPx(sys) + 'px' }"></span>
</template> </template>
<span v-else-if="sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }"> <span v-else-if="!isSubEmitter(sys) && sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span> <span class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span> </span>
</div> </div>
@@ -127,7 +135,9 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
import { useParticleStore } from '../store/particleStore' import { useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes' import type { SpineSceneObject } from '../spine/spineTypes'
import { loopPlaybackRange, loopSegmentTiming } from '../core/loopSegmentTiming' import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
type SysLike = { id: number; config: any } type SysLike = { id: number; config: any }
@@ -148,12 +158,6 @@ watchEffect(() => {
}) })
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null) const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
const activeLoopPlaybackRange = computed(() => {
const system = activeParticleSystem.value
if (!system || !isLoopStream(system)) return null
return loopPlaybackRange(system.config, fps.value)
})
function toggleAnimationMenu() { function toggleAnimationMenu() {
if (!activeParticleSystem.value) return if (!activeParticleSystem.value) return
animationMenuOpen.value = !animationMenuOpen.value animationMenuOpen.value = !animationMenuOpen.value
@@ -202,8 +206,8 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPoin
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。 // 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30)) const fps = computed(() => Math.max(1, store.timeline.fps || 30))
/** 视口当前能显示的帧数(滚轮缩放它)。默认显示 0~120f,起点恒为第 0 帧。 */ /** 视口当前能显示的帧数(滚轮缩放它),默认至少显示完整总时长。 */
const viewFrames = ref(120) const viewFrames = ref(Math.max(120, store.timeline.totalFrames))
// 拖动期间只更新本地预览值,松手后再一次性写回配置。 // 拖动期间只更新本地预览值,松手后再一次性写回配置。
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。 // 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
const draftDelays = ref<Record<number, number>>({}) const draftDelays = ref<Record<number, number>>({})
@@ -217,6 +221,10 @@ watch(() => store.timeline.playbackStart, (value) => { playbackStartDraft.value
watch(() => store.timeline.playbackEnd, (value) => { playbackEndDraft.value = String(value) }) watch(() => store.timeline.playbackEnd, (value) => { playbackEndDraft.value = String(value) })
const totalFrames = computed(() => store.timeline.totalFrames) const totalFrames = computed(() => store.timeline.totalFrames)
// 子发射器延长时间轴后自动扩大预览范围;用户手动放大后保持其缩放。
watch(totalFrames, (value) => {
if (value > viewFrames.value) viewFrames.value = Math.min(2000, value)
})
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中')) const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
const spineLanes = computed(() => store.spines.filter((spine) => const spineLanes = computed(() => store.spines.filter((spine) =>
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0), spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
@@ -246,14 +254,6 @@ function commitPlaybackEnd() {
playbackEndDraft.value = String(store.timeline.playbackEnd) playbackEndDraft.value = String(store.timeline.playbackEnd)
} }
function useActiveLoopPlaybackRange() {
const range = activeLoopPlaybackRange.value
if (!range) return
// 先扩展结束端,再写入起始端,避免旧范围较小时对新起点产生临时钳制。
store.setPlaybackEnd(range.end)
store.setPlaybackStart(range.start)
}
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧) // 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() { function pxPerF() {
const vw = vpRef.value?.clientWidth || 600 const vw = vpRef.value?.clientWidth || 600
@@ -278,8 +278,27 @@ const ticks = computed(() => {
const playPx = computed(() => store.timeline.frame / pxPerF()) const playPx = computed(() => store.timeline.frame / pxPerF())
const subEmitterIds = computed(() => collectSubEmitterIds(store.systems))
const subEmitterTiming = computed(() => subEmitterTriggerTimings(store.systems as any, fps.value))
function isSubEmitter(sys: SysLike) {
return subEmitterIds.value.has(sys.id)
}
// 圆点标记系统启动时刻;子发射器的 delay 只移动时间块,不移动这个标记。
function startMarkerPx(sys: SysLike) {
const start = isSubEmitter(sys) ? subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0 : 0
return (start * fps.value) / pxPerF() + 4
}
function barLeftPx(sys: SysLike) { function barLeftPx(sys: SysLike) {
const s = (sys.config as any) const s = (sys.config as any)
if (isSubEmitter(sys)) {
// 子发射器从第一次父事件 + 自身延迟开始;拖左最多到事件发生位置。
const firstTrigger = subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return ((firstTrigger + delay) * fps.value) / pxPerF()
}
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟 // 左端 = 延迟发射时长(delay);左端之前是空白=延迟
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0) const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return (delay * fps.value) / pxPerF() return (delay * fps.value) / pxPerF()
@@ -292,11 +311,18 @@ function maxParticleLife(sys: SysLike) {
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax) return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
} }
function barWidthPx(sys: SysLike) { function barWidthPx(sys: SysLike): number {
if (isSubEmitter(sys)) {
const timing = subEmitterTiming.value.get(sys.id)
const start = timing?.firstTrigger ?? 0
const end = timing?.end ?? start
return (Math.max(0, end - start) * fps.value) / pxPerF()
}
return lifeWidthPx(sys) return lifeWidthPx(sys)
} }
function emissionWidthPx(sys: SysLike) { function emissionWidthPx(sys: SysLike) {
if (isSubEmitter(sys)) return 0
if ((sys.config as any).mode !== 'stream') return 0 if ((sys.config as any).mode !== 'stream') return 0
if ((sys.config as any).streamBehavior === 'loop') { if ((sys.config as any).streamBehavior === 'loop') {
const config = sys.config as any const config = sys.config as any
@@ -307,7 +333,8 @@ function emissionWidthPx(sys: SysLike) {
return (duration * fps.value) / pxPerF() return (duration * fps.value) / pxPerF()
} }
function lifeWidthPx(sys: SysLike) { function lifeWidthPx(sys: SysLike): number {
if (isSubEmitter(sys)) return barWidthPx(sys)
const config = sys.config as any const config = sys.config as any
if (config.mode === 'stream' && config.streamBehavior === 'loop') { if (config.mode === 'stream' && config.streamBehavior === 'loop') {
return loopStartWidthPx(sys) + emissionWidthPx(sys) + loopEndWidthPx(sys) return loopStartWidthPx(sys) + emissionWidthPx(sys) + loopEndWidthPx(sys)
@@ -468,11 +495,18 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.tl-controls { display: flex; align-items: center; gap: 8px; } .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 { 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-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.tl-ab-loop { position: relative; }
.tl-ab-toggle { display: flex; height: 26px; align-items: center; gap: 7px; padding: 0 7px; border: 1px solid #343a51; border-radius: 6px; background: #1e1e2e; color: #8f98ad; font-size: 10px; cursor: pointer; }
.tl-ab-toggle.on { border-color: #716eff; background: #2e2d50; color: #eef; }
.tl-switch { position: relative; width: 22px; height: 12px; border-radius: 99px; background: #343a4b; }
.tl-switch b { position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #8992a5; transition: transform .15s ease, background .15s ease; }
.tl-ab-toggle.on .tl-switch { background: #716eff; }
.tl-ab-toggle.on .tl-switch b { transform: translateX(10px); background: #fff; }
.tl-ab-popover { position: absolute; z-index: 40; left: 0; bottom: calc(100% + 8px); display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #4b4c73; border-radius: 8px; background: #171724; box-shadow: 0 10px 26px rgba(0,0,0,.5); }
.tl-ab-title { color: #b7bdd2; font-size: 10px; white-space: nowrap; }
.tl-range { display: flex; height: 25px; align-items: center; gap: 3px; color: #778198; font-size: 10px; } .tl-range { display: flex; height: 25px; align-items: center; gap: 3px; color: #778198; font-size: 10px; }
.tl-range input { width: 43px; height: 25px; padding: 0 5px; box-sizing: border-box; border: 1px solid #3a3a51; border-radius: 5px; outline: none; background: #181824; color: #e1e4ed; font-size: 11px; text-align: center; } .tl-range input { width: 43px; height: 25px; padding: 0 5px; box-sizing: border-box; border: 1px solid #3a3a51; border-radius: 5px; outline: none; background: #181824; color: #e1e4ed; font-size: 11px; text-align: center; }
.tl-range input:focus { border-color: #716eff; } .tl-range input:focus { border-color: #716eff; }
.tl-loop-range { height: 25px; padding: 0 7px; border: 1px solid #4e5680; border-radius: 5px; background: #252943; color: #bfc8e8; font-size: 10px; cursor: pointer; white-space: nowrap; }
.tl-loop-range:hover { border-color: #716eff; color: #fff; }
.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 { 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-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 { display: flex; align-items: center; gap: 4px; color: #778198; font-size: 10px; }
@@ -523,10 +557,12 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; } .tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; } .tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; } .lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
.lane-ico.sub-lane-ico { color: #7f8999; }
.lane-bar { position: absolute; top: 4px; height: 18px; display: flex; align-items: stretch; cursor: grab; box-sizing: border-box; } .lane-bar { position: absolute; top: 4px; height: 18px; display: flex; align-items: stretch; cursor: grab; box-sizing: border-box; }
.lane-bar:active { cursor: grabbing; } .lane-bar:active { cursor: grabbing; }
.lane-name { position: absolute; left: 14px; right: 5px; top: 2px; z-index: 2; pointer-events: none; font-size: 11px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lane-name { position: absolute; left: 14px; right: 5px; top: 2px; z-index: 2; pointer-events: none; font-size: 11px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-life { position: absolute; inset: 0 auto 0 0; z-index: 0; min-width: 1px; border: 1px solid #c05a66; border-radius: 5px; background: #a54551; box-sizing: border-box; } .bar-life { position: absolute; inset: 0 auto 0 0; z-index: 0; min-width: 1px; border: 1px solid #c05a66; border-radius: 5px; background: #a54551; box-sizing: border-box; }
.sub-emitter-bar .bar-life { border-color: #727d8f; background: #596274; }
.bar-life.loop-life { border-color: #667085; background: #242b39; } .bar-life.loop-life { border-color: #667085; background: #242b39; }
.bar-emission { position: absolute; inset: 0 auto 0 0; z-index: 1; min-width: 1px; border: 1px solid #45a97d; border-radius: 5px 0 0 5px; background: #2f8b62; box-sizing: border-box; } .bar-emission { position: absolute; inset: 0 auto 0 0; z-index: 1; min-width: 1px; border: 1px solid #45a97d; border-radius: 5px 0 0 5px; background: #2f8b62; box-sizing: border-box; }
.bar-emission.bar-loop { border-radius: 0; } .bar-emission.bar-loop { border-radius: 0; }
@@ -6,6 +6,9 @@
<button class="group-reset" title="重置修改器参数" @click.stop="resetModule('mods')">重置</button> <button class="group-reset" title="重置修改器参数" @click.stop="resetModule('mods')">重置</button>
</div> </div>
<div class="group-body" v-show="!collapsed.mods"> <div class="group-body" v-show="!collapsed.mods">
<div class="modifier-item damping-item">
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
<div class="modifier-item"> <div class="modifier-item">
<label class="switch-row modifier-switch"> <label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.noise" /> <input type="checkbox" v-model="sys.config.noise" />
@@ -345,12 +348,83 @@
<div class="modifier-hint">只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞</div> <div class="modifier-hint">只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞</div>
</div> </div>
</div> </div>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" /> <div class="modifier-item events-item">
<label class="switch-row modifier-switch">
<input v-model="sys.config.eventEnabled" type="checkbox" />
<span class="switch-ui"></span><span>开启子发射器</span>
</label>
<div v-if="sys.config.eventEnabled" class="event-rules">
<div v-for="(rule, index) in sys.config.events" :key="rule.id" class="event-card">
<div class="event-selects">
<select v-model="rule.event" class="inp event-select" title="选择触发时机">
<option value="birth">出生</option>
<option value="death">死亡</option>
<option value="collision">碰撞</option>
</select>
<select v-model.number="rule.targetSystemId" class="inp event-target" title="选择目标粒子系统">
<option :value="0">无(粒子系统)</option>
<option v-for="system in eventTargetSystems" :key="system.id" :value="system.id">
{{ system.config.name || `ParticleSystem${system.id}` }}
</option>
</select>
<button type="button" class="event-remove" title="删除该子发射器" @click="removeEventRule(index)">✕</button>
</div>
<label class="event-probability">
<span>发射概率</span>
<input v-model.number="rule.probability" type="number" min="0" max="1" step="0.01" class="inp" />
</label>
<label v-if="rule.event === 'collision'" class="event-probability event-collision-limit">
<span>碰撞次数限制</span>
<input :value="rule.collisionLimit ?? 1" type="number" min="1" max="4" step="1" class="inp" @input="setCollisionLimit(rule, $event)" />
</label>
<div class="event-probability event-inherit">
<span>继承</span>
<button type="button" class="inherit-trigger" @pointerdown.stop @click.stop="toggleInheritMenu(rule, $event)">
<span class="inherit-summary">{{ inheritSummary(rule) }}</span><i>⌄</i>
</button>
</div>
<label v-if="hasInherit(rule, 'color')" class="event-probability event-color-mode">
<span>颜色继承方式</span>
<select v-model="rule.colorInheritMode" class="inp">
<option value="add">相加</option>
<option value="replace">覆盖</option>
</select>
</label>
</div>
<button type="button" class="event-add" @click="addEventRule">+ 新增子发射器</button>
</div>
</div>
</div> </div>
</div> </div>
<Teleport to="body">
<div
v-if="inheritMenu && activeInheritRule"
class="inherit-menu"
:style="{ left: `${inheritMenu.left}px`, top: `${inheritMenu.top}px`, width: `${inheritMenu.width}px` }"
@pointerdown.stop
>
<button type="button" class="inherit-option" @click="selectInheritNone(activeInheritRule)">
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 0 ? '✓' : '' }}</span>无(默认值)
</button>
<button type="button" class="inherit-option" @click="selectInheritAll(activeInheritRule)">
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 4 ? '✓' : '' }}</span>所有属性
</button>
<div class="inherit-divider"></div>
<button
v-for="attribute in INHERIT_ATTRIBUTES"
:key="attribute.key"
type="button"
class="inherit-option"
@click="toggleInherit(activeInheritRule, attribute.key)"
>
<span class="inherit-check">{{ hasInherit(activeInheritRule, attribute.key) ? '✓' : '' }}</span>{{ attribute.label }}
</button>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import NumSlider from '../NumSlider.vue' import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue' import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue' import ParticleAttributeControl from '../ParticleAttributeControl.vue'
@@ -358,7 +432,124 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue' import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue' import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext' import { useParticlePanelContext } from './particlePanelContext'
import { collectEmitterAncestors } from '../../core/subEmitterIds'
const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, onEmitterFollowModeChange, bindSelectedBone, resetModule, select, LINEAR_CURVE, FLAT_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } = useParticlePanelContext() const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, onEmitterFollowModeChange, bindSelectedBone, resetModule, select, LINEAR_CURVE, FLAT_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } = useParticlePanelContext()
// 不能指向自身或任何上游父系统,避免循环依赖。
const eventTargetSystems = computed(() => {
const currentId = sys.value?.id
if (!currentId) return []
const ancestors = collectEmitterAncestors(store.systems, currentId)
return store.systems.filter((system) => system.id !== currentId && !ancestors.has(system.id))
})
type InheritRule = { id: number; inherit: string[] }
const INHERIT_ATTRIBUTES = [
{ key: 'color', label: '颜色' },
{ key: 'scale', label: '大小' },
{ key: 'rotation', label: '旋转' },
{ key: 'life', label: '生命周期' },
] as const
const inheritMenu = ref<{ ruleId: number; left: number; top: number; width: number } | null>(null)
const activeInheritRule = computed<InheritRule | null>(() => {
const menu = inheritMenu.value
const events = sys.value?.config.events
if (!menu || !Array.isArray(events)) return null
return events.find((rule) => rule.id === menu.ruleId) || null
})
function inheritList(rule: InheritRule) {
if (!Array.isArray(rule.inherit)) (rule as { inherit: string[] }).inherit = []
return rule.inherit
}
function inheritSummary(rule: InheritRule) {
const list = inheritList(rule)
if (!list.length) return '无(默认值)'
if (list.length === INHERIT_ATTRIBUTES.length) return '所有属性'
return INHERIT_ATTRIBUTES.filter((attribute) => list.includes(attribute.key)).map((attribute) => attribute.label).join('、')
}
function hasInherit(rule: InheritRule, key: string) {
return inheritList(rule).includes(key)
}
function toggleInherit(rule: InheritRule, key: string) {
const list = inheritList(rule)
const index = list.indexOf(key)
if (index >= 0) list.splice(index, 1)
else list.push(key)
}
function selectInheritNone(rule: InheritRule) {
inheritList(rule).splice(0)
}
function selectInheritAll(rule: InheritRule) {
inheritList(rule).splice(0, inheritList(rule).length, ...INHERIT_ATTRIBUTES.map((attribute) => attribute.key))
}
function toggleInheritMenu(rule: InheritRule, event: MouseEvent) {
if (inheritMenu.value?.ruleId === rule.id) {
inheritMenu.value = null
return
}
const trigger = event.currentTarget as HTMLElement | null
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const width = Math.max(168, rect.width)
const estimatedHeight = 212
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8))
let top = rect.bottom + 4
if (top + estimatedHeight > window.innerHeight - 8) top = Math.max(8, rect.top - estimatedHeight - 4)
inheritMenu.value = { ruleId: rule.id, left, top, width }
}
function closeInheritMenu() {
inheritMenu.value = null
}
function onInheritMenuViewportChange() {
closeInheritMenu()
}
function onInheritMenuKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') closeInheritMenu()
}
onMounted(() => {
document.addEventListener('pointerdown', closeInheritMenu)
window.addEventListener('scroll', onInheritMenuViewportChange, true)
window.addEventListener('resize', onInheritMenuViewportChange)
window.addEventListener('keydown', onInheritMenuKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeInheritMenu)
window.removeEventListener('scroll', onInheritMenuViewportChange, true)
window.removeEventListener('resize', onInheritMenuViewportChange)
window.removeEventListener('keydown', onInheritMenuKeydown)
})
function addEventRule() {
const config = sys.value?.config
if (!config) return
if (!Array.isArray(config.events)) config.events = []
const nextId = Math.max(0, ...config.events.map((rule) => rule.id)) + 1
config.events.push({ id: nextId, event: 'birth', targetSystemId: 0, probability: 1, collisionLimit: 1, inherit: [], colorInheritMode: 'add' })
}
function removeEventRule(index: number) {
sys.value?.config.events.splice(index, 1)
}
function setCollisionLimit(rule: { collisionLimit: number }, event: Event) {
const input = event.target as HTMLInputElement
const value = Math.round(Number(input.value))
rule.collisionLimit = Math.min(4, Math.max(1, Number.isFinite(value) ? value : 1))
input.value = String(rule.collisionLimit)
}
</script> </script>
<style scoped> <style scoped>
@@ -410,6 +601,34 @@ const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePic
.switch-row input:checked + .switch-ui { background: #625ff1; } .switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); } .switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.modifier-item { padding: 8px 0 10px; border-bottom: 1px solid #2a3549; } .modifier-item { padding: 8px 0 10px; border-bottom: 1px solid #2a3549; }
.events-item { margin-top: 4px; padding-bottom: 2px; border-bottom: 0; }
.event-rules { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; }
.event-card { padding: 7px; border: 1px solid #2b3a54; border-radius: 7px; background: #121a28; }
.event-selects { display: flex; align-items: center; gap: 5px; }
.event-selects .inp { height: 26px; min-height: 26px; box-sizing: border-box; font-size: 11px; }
.event-select { flex: 0 0 34%; }
.event-target { flex: 1; min-width: 0; }
.event-remove { width: 24px; height: 26px; flex: 0 0 24px; padding: 0; border: 1px solid #3a465f; border-radius: 5px; background: #1c2638; color: #8794ad; font-size: 11px; cursor: pointer; }
.event-remove:hover { border-color: #a05260; background: #3a2230; color: #ffb3c0; }
.event-probability { display: flex; align-items: center; gap: 8px; margin-top: 6px; color: #8d9ab5; font-size: 10px; }
.event-probability > span { flex: 0 0 56px; }
.event-probability .inp { flex: 1; height: 26px; box-sizing: border-box; font-size: 11px; }
.event-inherit { margin-top: 4px; }
.event-color-mode { margin-top: 4px; }
.event-color-mode > span { flex: 0 0 84px; }
.event-color-mode .inp { height: 26px; min-height: 26px; box-sizing: border-box; font-size: 11px; }
.event-collision-limit > span { flex-basis: 72px; }
.inherit-trigger { flex: 1; min-width: 0; height: 26px; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 0 7px; border: 1px solid #34435e; border-radius: 4px; background: #101827; color: #cdd6e8; font-size: 11px; text-align: left; cursor: pointer; }
.inherit-trigger:hover { border-color: #5d5aa8; }
.inherit-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inherit-trigger i { color: #7f8db0; font-style: normal; font-size: 10px; }
.inherit-menu { position: fixed; z-index: 9999; padding: 5px; border: 1px solid #3b3f4a; border-radius: 6px; background: #232427; box-shadow: 0 12px 30px rgba(0, 0, 0, .55); }
.inherit-option { display: flex; align-items: center; gap: 6px; width: 100%; height: 26px; padding: 0 7px; border: 0; border-radius: 4px; background: transparent; color: #c9ccd3; font-size: 12px; text-align: left; cursor: pointer; }
.inherit-option:hover { background: #3a5a8c; color: #fff; }
.inherit-check { width: 14px; flex: 0 0 14px; color: #d8dbe2; font-size: 12px; }
.inherit-divider { height: 1px; margin: 4px; background: #35373d; }
.event-add { height: 28px; border: 1px dashed #536079; border-radius: 6px; background: transparent; color: #9aa7c2; font-size: 11px; cursor: pointer; }
.event-add:hover { border-color: #7774ff; background: #232a45; color: #fff; }
.modifier-switch { font-weight: 600; } .modifier-switch { font-weight: 600; }
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; } .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; } .modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
@@ -14,19 +14,21 @@
<div class="group-body" v-show="!collapsed.scene"> <div class="group-body" v-show="!collapsed.scene">
<div class="tree scene-tree"> <div class="tree scene-tree">
<div <div
v-for="(system, i) in store.systems" v-for="entry in displaySystems"
:key="`particle-${system.id}`" :key="`particle-${entry.system.id}`"
class="tree-item scene-object-item" class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'particle' && system.id === store.activeObjectId }" :class="{ on: activeObjectType === 'particle' && entry.system.id === store.activeObjectId, 'scene-object-child': entry.depth > 0 }"
@click="select(i)" :style="entry.depth > 0 ? { paddingLeft: `${entry.depth * 18}px` } : undefined"
@click="store.selectSystem(entry.system.id)"
> >
<span class="tdot">●</span> <span v-if="entry.depth > 0" class="sub-branch">└</span>
<span class="tlabel">{{ system.config.name || 'ParticleSystem' + (i + 1) }}</span> <span v-else class="tdot">●</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"> <span class="tlabel">{{ entry.system.config.name || 'ParticleSystem' }}</span>
<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 class="scene-object-eye" :class="{ off: entry.system.visible === false }" :title="entry.system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="entry.system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="entry.system.visible = entry.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="entry.system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
</button> </button>
<span class="scene-object-kind">粒子</span> <span class="scene-object-kind" :class="{ 'sub-emitter-kind': entry.depth > 0 }">{{ entry.depth > 0 ? '子发射器' : '粒子' }}</span>
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(system.id)">✕</button> <button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(entry.system.id)">✕</button>
</div> </div>
<div <div
v-for="collider in store.colliders" v-for="collider in store.colliders"
@@ -182,6 +184,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import NumSlider from '../NumSlider.vue' import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue' import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue' import ParticleAttributeControl from '../ParticleAttributeControl.vue'
@@ -189,7 +192,44 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue' import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue' import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext' import { useParticlePanelContext } from './particlePanelContext'
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext() const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
type DisplaySystem = { system: (typeof store.systems)[number]; depth: number }
/** 子发射器嵌在父系统下方显示,方便直接选中;移除引用后自动回到普通行。 */
const displaySystems = computed<DisplaySystem[]>(() => {
const byId = new Map(store.systems.map((system) => [system.id, system]))
const parentOf = new Map<number, number>()
for (const system of store.systems) {
if (system.config.eventEnabled === false) continue
for (const rule of system.config.events || []) {
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id) continue
if (!byId.has(rule.targetSystemId) || parentOf.has(rule.targetSystemId)) continue
parentOf.set(rule.targetSystemId, system.id)
}
}
const children = new Map<number, number[]>()
for (const [child, parent] of parentOf) {
const list = children.get(parent)
if (list) list.push(child)
else children.set(parent, [child])
}
const result: DisplaySystem[] = []
const visited = new Set<number>()
const walk = (system: (typeof store.systems)[number], depth: number) => {
if (visited.has(system.id)) return
visited.add(system.id)
result.push({ system, depth })
for (const childId of children.get(system.id) || []) {
const child = byId.get(childId)
if (child) walk(child, depth + 1)
}
}
for (const system of store.systems) if (!parentOf.has(system.id)) walk(system, 0)
// 环形引用兜底:保证所有系统仍然可见、可选中。
for (const system of store.systems) if (!visited.has(system.id)) walk(system, 0)
return result
})
</script> </script>
<style scoped> <style scoped>
@@ -219,11 +259,14 @@ const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, a
.tree-item .tdot.collision-dot { color: #ff7070; } .tree-item .tdot.collision-dot { color: #ff7070; }
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; } .tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; } .tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
.tree-item .sub-branch { color: #5f7090; font-size: 12px; }
.tree-item .tlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
.scene-object-item { min-height: 28px; cursor: pointer; } .scene-object-item { min-height: 28px; cursor: pointer; }
.scene-object-item:hover { background: #202b40; } .scene-object-item:hover { background: #202b40; }
.scene-object-child { background: rgba(30, 42, 62, .35); }
.scene-object-kind { color: #75829c; font-size: 10px; } .scene-object-kind { color: #75829c; font-size: 10px; }
.scene-object-kind.sub-emitter-kind { color: #8f8cff; }
.scene-object-eye { .scene-object-eye {
width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px; width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px;
background: transparent; color: #9aa6bf; cursor: pointer; background: transparent; color: #9aa6bf; cursor: pointer;
@@ -24,6 +24,7 @@ import {
type SpineConnectorHealth, type SpineConnectorHealth,
} from '../../export/spineConnectorClient' } from '../../export/spineConnectorClient'
import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming' import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming'
import { collectEmitterAncestors } from '../../core/subEmitterIds'
import { LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } from './particlePanelCurves' import { LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } from './particlePanelCurves'
import { useParticleExport } from './useParticleExport' import { useParticleExport } from './useParticleExport'
@@ -239,6 +240,7 @@ watchEffect(() => {
// 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。 // 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。
watchEffect(() => { watchEffect(() => {
const colliderIds = new Set(store.colliders.map((collider) => collider.id)) const colliderIds = new Set(store.colliders.map((collider) => collider.id))
const systemIds = new Set(store.systems.map((system) => system.id))
for (const system of store.systems) { for (const system of store.systems) {
const config = ensureEmitterConfig(system.config as any) const config = ensureEmitterConfig(system.config as any)
const validCollisionIds = config.collisionColliderIds.filter((id) => colliderIds.has(id)) const validCollisionIds = config.collisionColliderIds.filter((id) => colliderIds.has(id))
@@ -247,6 +249,16 @@ watchEffect(() => {
config.spawnPathId = 0 config.spawnPathId = 0
if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed' if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed'
} }
// 目标被删除、指向自身或指向上游父系统(循环依赖)时回退为“无(粒子系统)”。
const ancestors = collectEmitterAncestors(store.systems, system.id)
for (const rule of config.events || []) {
if (
rule.targetSystemId > 0
&& (rule.targetSystemId === system.id || ancestors.has(rule.targetSystemId) || !systemIds.has(rule.targetSystemId))
) {
rule.targetSystemId = 0
}
}
if (config.emitterFollowMode !== 'bone') continue if (config.emitterFollowMode !== 'bone') continue
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId) const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName) const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName)
@@ -451,6 +463,7 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode', 'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode',
'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve', 'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve',
'trailGridRows', 'trailGridCols', 'collision', 'collisionColliderIds', 'pathFollow', 'trailGridRows', 'trailGridCols', 'collision', 'collisionColliderIds', 'pathFollow',
'eventEnabled', 'events',
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId', 'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId',
'emitterFollowSpineId', 'emitterFollowBoneName', 'emitterFollowSpineId', 'emitterFollowBoneName',
'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation', 'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation',
+66 -1
View File
@@ -1,7 +1,9 @@
import { Container, Texture } from 'pixi.js' import { Container, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig } from '../../core/particleEmitter' import { ParticleEmitter, ensureEmitterConfig, type ParticleEventInfo } from '../../core/particleEmitter'
import { sampleScenePath } from '../../core/scenePathSampler' import { sampleScenePath } from '../../core/scenePathSampler'
import type { EmitterConfig } from '../../core/particleTypes' import type { EmitterConfig } from '../../core/particleTypes'
import { seededUnit } from '../../core/particleMath'
import { collectSubEmitterIds } from '../../core/subEmitterIds'
import { followCurveValue } from './stageGeometry' import { followCurveValue } from './stageGeometry'
import type { useParticleStore, CollisionBody } from '../../store/particleStore' import type { useParticleStore, CollisionBody } from '../../store/particleStore'
import type { ScenePath } from '../../store/particleStore' import type { ScenePath } from '../../store/particleStore'
@@ -69,6 +71,9 @@ export function buildPreviewColliders(store: EditorStore, timeSeconds: number):
export class EmitterSync { export class EmitterSync {
readonly emitters = new Map<number, ParticleEmitter>() readonly emitters = new Map<number, ParticleEmitter>()
private configs = new Map<number, object>() private configs = new Map<number, object>()
private store: EditorStore | null = null
private eventsSuspended = false
private subEmitterState = new Map<number, boolean>()
/** 渲染层级:一个插槽对应一个 wrapper(同插槽多系统共用),内部 compensator 抵消插槽骨骼变换。 */ /** 渲染层级:一个插槽对应一个 wrapper(同插槽多系统共用),内部 compensator 抵消插槽骨骼变换。 */
private slotGroups = new Map<string, { private slotGroups = new Map<string, {
key: string key: string
@@ -87,6 +92,7 @@ export class EmitterSync {
trailTex: Texture<any> | null, trailTex: Texture<any> | null,
spineRuntime: SpineRuntimeLayer | null = null, spineRuntime: SpineRuntimeLayer | null = null,
) { ) {
this.store = store
for (const [id, em] of this.emitters) { for (const [id, em] of this.emitters) {
const system = store.systems.find((item) => item.id === id) const system = store.systems.find((item) => item.id === id)
if (!system || this.configs.get(id) !== system.config) { if (!system || this.configs.get(id) !== system.config) {
@@ -112,10 +118,26 @@ export class EmitterSync {
} }
if (this.emitters.has(sys.id)) continue if (this.emitters.has(sys.id)) continue
const em = new ParticleEmitter(sys.config as any) const em = new ParticleEmitter(sys.config as any)
em.onParticleEvent = (event) => this.handleParticleEvent(sys.id, event)
world.addChild(em) world.addChild(em)
this.emitters.set(sys.id, em) this.emitters.set(sys.id, em)
this.configs.set(sys.id, sys.config) this.configs.set(sys.id, sys.config)
} }
// 兼容 HMR/旧实例:确保每个发射器都接上事件回调。
const subEmitterIds = collectSubEmitterIds(store.systems)
for (const sys of store.systems) {
const em = this.emitters.get(sys.id)
if (!em) continue
if (!em.onParticleEvent) em.onParticleEvent = (event) => this.handleParticleEvent(sys.id, event)
// 子发射器不自动发射,只由父系统事件驱动。
const isSubEmitter = subEmitterIds.has(sys.id)
if (this.subEmitterState.get(sys.id) !== isSubEmitter) {
// 刚被设为子发射器时清掉它此前自动发射留下的粒子。
if (isSubEmitter) em.clear()
this.subEmitterState.set(sys.id, isSubEmitter)
}
em.setAutoEmission(!isSubEmitter)
}
const activeGroups = new Set<string>() const activeGroups = new Set<string>()
for (const sys of store.systems) { for (const sys of store.systems) {
@@ -193,6 +215,49 @@ export class EmitterSync {
} }
} }
/** 循环烘焙期间暂停跨系统事件,避免预热过程向目标系统注入粒子。 */
suspendEvents(value: boolean) {
this.eventsSuspended = value
}
private handleParticleEvent(sourceId: number, event: ParticleEventInfo) {
if (this.eventsSuspended) return
const store = this.store
if (!store) return
const source = store.systems.find((system) => system.id === sourceId)
const config = source?.config
if (!config?.eventEnabled || !Array.isArray(config.events) || !config.events.length) return
// 事件链最多两级,防止跨系统互相触发形成死循环。
if (event.depth >= 2) return
for (const rule of config.events) {
if (rule.event !== event.kind) continue
if (rule.event === 'collision' && event.collisionCount > rule.collisionLimit) continue
if (rule.targetSystemId <= 0 || rule.targetSystemId === sourceId) continue
const probability = Math.min(1, Math.max(0, Number(rule.probability) || 0))
if (probability <= 0) continue
const salt = (Math.imul(rule.id + 1, 0x9e3779b1) ^ Math.imul(event.depth + 1, 0x85ebca6b)) >>> 0
const roll = seededUnit(config.seed >>> 0, event.spawnId + 1, salt)
if (roll >= probability) continue
const target = this.emitters.get(rule.targetSystemId)
if (!target) continue
const inheritAttributes = Array.isArray(rule.inherit) ? rule.inherit : []
const inherit = inheritAttributes.length
? {
attributes: inheritAttributes,
colorMode: rule.colorInheritMode === 'replace' ? 'replace' as const : 'add' as const,
data: {
colorHex: event.colorHex,
scaleX: event.scaleX,
scaleY: event.scaleY,
rotation: event.rotation,
life: event.maxLife,
},
}
: undefined
target.spawnEventParticle(event.worldX, event.worldY, event.depth + 1, inherit)
}
}
/** 卸载时销毁全部发射器。 */ /** 卸载时销毁全部发射器。 */
destroy(world: Container) { destroy(world: Container) {
for (const group of this.slotGroups.values()) { for (const group of this.slotGroups.values()) {