渲染顺序功能

This commit is contained in:
tianmo committed 2026-10-22 11:16:50 +08:00
1 parent aabf4ff693
commit c041503004
22 files changed
+1904 -1291

No files matched your search

@@ -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-CBAQrWSZ.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-CBAQrWSZ.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-CjEJ0XJ5.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-CjEJ0XJ5.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
+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,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-CBAQrWSZ.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-CBAQrWSZ.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-3evwjaUy.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-3evwjaUy.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-CjEJ0XJ5.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-CjEJ0XJ5.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-Bvs6zl88.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-Bvs6zl88.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 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
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; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-CBAQrWSZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9S6kMFu.css">
<script type="module" crossorigin src="/assets/index-CjEJ0XJ5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwW3PUa0.css">
</head>
<body>
<div id="app"></div>
+1
View File
@@ -14,6 +14,7 @@
"test:timeline": "node scripts/verify-timeline.mjs",
"test:spine-json": "node scripts/verify-spine-json.mjs",
"test:spine-naming": "node scripts/verify-spine-naming.mjs",
"test:render-layer": "node scripts/verify-render-layer.mjs",
"test:connector": "node scripts/verify-spine-connector.mjs",
"test:connector:spine38-path": "node scripts/verify-spine38-connector-path.mjs",
"test:connector:sea": "node scripts/verify-connector-sea.mjs",
+73
View File
@@ -0,0 +1,73 @@
import { build } from 'esbuild'
const bundled = await build({
bundle: true,
entryPoints: ['src/views/stage/renderLayer.ts'],
format: 'esm',
platform: 'node',
write: false,
})
const { resolveRenderTarget } = await import(
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}`
)
const spines = [{
id: 7,
enabled: true,
status: 'ready',
slots: [
{ name: 'slot_back', order: 0 },
{ name: 'slot_body', order: 1 },
{ name: 'slot_hand', order: 2 },
],
}]
const base = {
renderLayerMode: 'slot',
renderLayerSpineId: 7,
renderLayerSlotName: 'slot_hand',
renderLayerSlotPosition: 'above',
renderLayerFollowAttachment: true,
}
function expect(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
}
}
expect(resolveRenderTarget(spines, { ...base, renderLayerMode: 'top' }), null, '最上层不应挂槽位')
expect(
resolveRenderTarget(spines, base),
{ spineId: 7, slotName: 'slot_hand', targetSlotName: 'slot_hand', followAttachment: true },
'图片之上应挂到目标插槽',
)
expect(
resolveRenderTarget(spines, { ...base, renderLayerSlotPosition: 'below' }),
{ spineId: 7, slotName: 'slot_body', targetSlotName: 'slot_hand', followAttachment: false },
'图片之下应挂到 drawOrder 前一个插槽',
)
expect(
resolveRenderTarget(spines, { ...base, renderLayerSlotPosition: 'below' }, ['slot_back', 'slot_hand', 'slot_body']),
{ spineId: 7, slotName: 'slot_back', targetSlotName: 'slot_hand', followAttachment: false },
'动画 drawOrder 变化时应按当前顺序取前一个插槽',
)
expect(
resolveRenderTarget(spines, { ...base, renderLayerSlotPosition: 'below' }, ['slot_hand', 'slot_body', 'slot_back']),
null,
'当前 drawOrder 中目标位于最底层时无法放在其下方',
)
expect(
resolveRenderTarget(spines, { ...base, renderLayerSlotName: 'slot_body', renderLayerSlotPosition: 'below' }, ['slot_hand', 'slot_body', 'slot_back']),
{ spineId: 7, slotName: 'slot_hand', targetSlotName: 'slot_body', followAttachment: false },
'当前 drawOrder 中应取紧邻的前一个插槽',
)
expect(
resolveRenderTarget(spines, { ...base, renderLayerSlotName: 'slot_back', renderLayerSlotPosition: 'below' }),
null,
'第一个插槽下方没有可用层级',
)
expect(resolveRenderTarget(spines, { ...base, renderLayerSlotName: 'missing' }), null, '插槽不存在时应回退')
expect(resolveRenderTarget([{ ...spines[0], enabled: false }], base), null, 'Spine 停用时应回退')
expect(resolveRenderTarget([{ ...spines[0], status: 'loading' }], base), null, 'Spine 未就绪时应回退')
console.log('渲染层级解析校验通过:最上层 / 插槽之上 / 插槽之下 / 动画 drawOrder / 失效回退')
+10
View File
@@ -65,6 +65,11 @@ export function defaultConfig(): EmitterConfig {
rootRotation: 0,
rootScaleX: 1,
rootScaleY: 1,
renderLayerMode: 'top',
renderLayerSpineId: 0,
renderLayerSlotName: '',
renderLayerSlotPosition: 'above',
renderLayerFollowAttachment: false,
emissionAngleMode: 'random',
direction: 0,
spread: 360,
@@ -296,6 +301,11 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
if (config.shape !== 'path' && (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent')) config.emissionAngleMode = 'fixed'
config.spawnPathId = Math.max(0, Math.round(Number(config.spawnPathId) || 0))
if (config.spawnPathDistribution !== 'fixed' && config.spawnPathDistribution !== 'random') config.spawnPathDistribution = 'fixed'
if (config.renderLayerMode !== 'top' && config.renderLayerMode !== 'slot') config.renderLayerMode = 'top'
config.renderLayerSpineId = Math.max(0, Math.round(Number(config.renderLayerSpineId) || 0))
if (typeof config.renderLayerSlotName !== 'string') config.renderLayerSlotName = ''
if (config.renderLayerSlotPosition !== 'above' && config.renderLayerSlotPosition !== 'below') config.renderLayerSlotPosition = 'above'
if (typeof config.renderLayerFollowAttachment !== 'boolean') config.renderLayerFollowAttachment = false
config.innerRadius = Math.max(0, Number(config.innerRadius) || 0)
config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0)
config.innerRectW = Math.max(0, Number(config.innerRectW) || 0)
+9
View File
@@ -13,6 +13,9 @@ export type AttributeMode = 'fixed' | 'random' | 'curve'
export type RotationMode = AttributeMode | 'direction'
export type ScaleAxisMode = 'uniform' | 'separate'
export type FieldFalloff = 'constant' | 'linear' | 'inverse'
/** 粒子系统在 Spine 绘制顺序中的位置(仅影响编辑器预览)。 */
export type RenderLayerMode = 'top' | 'slot'
export type RenderLayerPosition = 'above' | 'below'
export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export interface ColorGradientStop { position: number; color: string }
@@ -168,6 +171,12 @@ export interface EmitterConfig {
rootRotation: number
rootScaleX: number
rootScaleY: number
// ==== 渲染层级(仅编辑器预览;不写入 Spine 导出数据)====
renderLayerMode: RenderLayerMode
renderLayerSpineId: number
renderLayerSlotName: string
renderLayerSlotPosition: RenderLayerPosition
renderLayerFollowAttachment: boolean
// 方向
emissionAngleMode: EmissionAngleMode
direction: number
+5
View File
@@ -223,6 +223,9 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
for (const spine of spines) {
if (spine.sourceBundle == null) spine.sourceBundle = null
if (spine.dataVersion == null) spine.dataVersion = ''
if (!Array.isArray(spine.slots)) spine.slots = []
if (typeof spine.selectedBone !== 'string') spine.selectedBone = ''
if (typeof spine.selectedSlot !== 'string') spine.selectedSlot = ''
}
await hydrateParticleResources(systems)
@@ -257,6 +260,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
spine.error = spine.skeletonFileName ? '配置未包含可复用的 Spine 原始资源' : ''
spine.animations = []
spine.bones = []
spine.slots = []
spine.selectedAnimation = ''
continue
}
@@ -264,6 +268,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
spine.dataVersion = parsed.dataVersion
spine.animations = parsed.animations
spine.bones = parsed.bones
spine.slots = parsed.slots
if (!parsed.animations.some((animation) => animation.name === spine.selectedAnimation)) {
spine.selectedAnimation = parsed.animations[0]?.name || ''
}
+94 -27
View File
@@ -62,6 +62,7 @@
<div class="tree-title-row">
<div class="section-title">层级树</div>
<button
v-if="treeTab === 'bone'"
class="selected-bone"
:class="{ active: spineObject.selectedBone }"
:disabled="!spineObject.selectedBone"
@@ -71,36 +72,71 @@
<template v-if="spineObject.selectedBone">清除选中骨骼:<strong>{{ spineObject.selectedBone }}</strong></template>
<template v-else>当前骨骼:<strong>未选择</strong></template>
</button>
<button v-if="spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
<button v-else class="selected-bone" :class="{ active: spineObject.selectedSlot }" :disabled="!spineObject.selectedSlot" :title="spineObject.selectedSlot ? '点击清除插槽选择' : '当前未选择插槽'" @click="spineObject.selectedSlot = ''">
<template v-if="spineObject.selectedSlot">清除选中插槽:<strong>{{ spineObject.selectedSlot }}</strong></template>
<template v-else>当前插槽:<strong>未选择</strong></template>
</button>
<button v-if="treeTab === 'bone' && spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
</div>
<div v-if="!spineObject.bones.length" class="empty">未加载 Spine</div>
<div v-else class="bone-search">
<input v-model.trim="boneSearch" type="search" aria-label="搜索骨骼" placeholder="搜索骨骼名称…" />
<button v-if="boneSearch" title="清空搜索" aria-label="清空骨骼搜索" @click="boneSearch = ''">✕</button>
<div class="tree-tabs">
<button :class="{ on: treeTab === 'bone' }" @click="treeTab = 'bone'">骨骼</button>
<button :class="{ on: treeTab === 'slot' }" @click="treeTab = 'slot'">插槽</button>
</div>
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">未找到匹配的骨骼</div>
<div v-else-if="spineObject.bones.length" class="bone-tree">
<div
v-for="bone in visibleBones"
:key="bone.name"
class="bone"
:class="{ selected: spineObject.selectedBone === bone.name }"
:style="{ paddingLeft: `${6 + bone.depth * 16}px` }"
role="button"
tabindex="0"
@click="selectBone(bone.name)"
@keydown.enter.prevent="selectBone(bone.name)"
>
<button
v-if="parentBoneNames.has(bone.name)"
class="bone-toggle"
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
@click.stop="toggleBone(bone.name)"
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
<span v-else class="bone-leaf">•</span>
<span class="bone-name">{{ bone.name }}</span>
<template v-if="treeTab === 'bone'">
<div v-if="!spineObject.bones.length" class="empty">未加载 Spine</div>
<div v-else class="bone-search">
<input v-model.trim="boneSearch" type="search" aria-label="搜索骨骼" placeholder="搜索骨骼名称…" />
<button v-if="boneSearch" title="清空搜索" aria-label="清空骨骼搜索" @click="boneSearch = ''">✕</button>
</div>
</div>
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">未找到匹配的骨骼</div>
<div v-else-if="spineObject.bones.length" class="bone-tree">
<div
v-for="bone in visibleBones"
:key="bone.name"
class="bone"
:class="{ selected: spineObject.selectedBone === bone.name }"
:style="{ paddingLeft: `${6 + bone.depth * 16}px` }"
role="button"
tabindex="0"
@click="selectBone(bone.name)"
@keydown.enter.prevent="selectBone(bone.name)"
>
<button
v-if="parentBoneNames.has(bone.name)"
class="bone-toggle"
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
@click.stop="toggleBone(bone.name)"
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
<span v-else class="bone-leaf">•</span>
<span class="bone-name">{{ bone.name }}</span>
</div>
</div>
</template>
<template v-else>
<div v-if="!spineObject.slots.length" class="empty">未加载 Spine 插槽</div>
<div v-else class="bone-search">
<input v-model.trim="slotSearch" type="search" aria-label="搜索插槽" placeholder="搜索插槽或附件名称…" />
<button v-if="slotSearch" title="清空搜索" aria-label="清空插槽搜索" @click="slotSearch = ''">✕</button>
</div>
<div v-if="spineObject.slots.length && !visibleSlots.length" class="empty">未找到匹配的插槽</div>
<div v-else-if="spineObject.slots.length" class="bone-tree slot-tree">
<div
v-for="slot in visibleSlots"
:key="slot.name"
class="bone slot-row"
:class="{ selected: spineObject.selectedSlot === slot.name }"
role="button"
tabindex="0"
:title="`${slot.name}(骨骼:${slot.bone || '-'},附件:${slot.attachment || '空'})`"
@click="selectSlot(slot.name)"
@keydown.enter.prevent="selectSlot(slot.name)"
>
<span class="slot-order">{{ slot.order + 1 }}</span>
<span class="bone-name">{{ slot.name }}</span>
<span class="slot-attachment">{{ slot.attachment || '空' }}</span>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -121,12 +157,16 @@ const textureInput = ref<HTMLInputElement | null>(null)
const folderInput = ref<HTMLInputElement | null>(null)
const collapsedBones = ref(new Set<string>())
const boneSearch = ref('')
const treeTab = ref<'bone' | 'slot'>('bone')
const slotSearch = ref('')
const skeletonFile = ref<File | null>(null)
const atlasFile = ref<File | null>(null)
const textureFiles = ref<File[]>([])
// 兼容热更新或旧场景数据;骨骼选择默认保持为空。
if (props.spineObject.selectedBone == null) props.spineObject.selectedBone = ''
if (props.spineObject.selectedSlot == null) props.spineObject.selectedSlot = ''
if (!Array.isArray(props.spineObject.slots)) props.spineObject.slots = []
if (props.spineObject.sourceBundle == null) props.spineObject.sourceBundle = null
if (props.spineObject.dataVersion == null) props.spineObject.dataVersion = ''
@@ -147,6 +187,14 @@ const visibleBones = computed(() => props.spineObject.bones.filter((bone) => {
return true
}))
const allBonesCollapsed = computed(() => parentBoneNames.value.size > 0 && [...parentBoneNames.value].every((name) => collapsedBones.value.has(name)))
const visibleSlots = computed(() => {
const query = slotSearch.value.trim().toLocaleLowerCase()
const slots = [...props.spineObject.slots].sort((a, b) => a.order - b.order)
if (!query) return slots
return slots.filter((slot) =>
slot.name.toLocaleLowerCase().includes(query) || slot.attachment.toLocaleLowerCase().includes(query),
)
})
function selectBone(name: string) {
const shouldClear = props.spineObject.selectedBone === name
@@ -154,6 +202,12 @@ function selectBone(name: string) {
if (!shouldClear) props.spineObject.selectedBone = name
}
function selectSlot(name: string) {
const shouldClear = props.spineObject.selectedSlot === name
for (const spine of store.spines) spine.selectedSlot = ''
if (!shouldClear) props.spineObject.selectedSlot = name
}
function toggleBone(name: string) {
if (collapsedBones.value.has(name)) collapsedBones.value.delete(name)
else collapsedBones.value.add(name)
@@ -228,8 +282,11 @@ async function loadBundle() {
props.spineObject.dataVersion = parsed.dataVersion
props.spineObject.animations = parsed.animations
props.spineObject.bones = parsed.bones
props.spineObject.slots = parsed.slots
props.spineObject.selectedBone = ''
props.spineObject.selectedSlot = ''
boneSearch.value = ''
slotSearch.value = ''
collapsedBones.value = new Set()
props.spineObject.selectedAnimation = parsed.animations[0]?.name || ''
props.spineObject.assetVersion++
@@ -266,8 +323,11 @@ function clearBundle() {
props.spineObject.sourceBundle = null
props.spineObject.animations = []
props.spineObject.bones = []
props.spineObject.slots = []
props.spineObject.selectedBone = ''
props.spineObject.selectedSlot = ''
boneSearch.value = ''
slotSearch.value = ''
collapsedBones.value = new Set()
props.spineObject.selectedAnimation = ''
props.spineObject.assetVersion++
@@ -302,6 +362,10 @@ button:disabled { opacity: .55; cursor: wait; }
.empty { color: #667187; font-size: 12px; padding: 5px 0; }
.bone-tree { max-height: 240px; overflow: auto; padding: 4px 0; background: #111827; border-radius: 5px; }
.tree-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.tree-tabs { display: flex; gap: 3px; margin: 6px 0; }
.tree-tabs button { height: 24px; padding: 0 8px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #8d98ae; font-size: 10px; cursor: pointer; }
.tree-tabs button:hover { background: #273246; }
.tree-tabs button.on { border-color: #7774ff; background: #35336b; color: #fff; }
.selected-bone { width: auto; min-width: 0; max-width: 100%; height: 24px; flex: 0 1 auto; overflow: hidden; padding: 0 7px; color: #75819c; background: #182132; border: 1px solid transparent; border-radius: 5px; font-size: 10px; text-align: right; white-space: nowrap; text-overflow: ellipsis; }
.selected-bone.active { color: #aeb9d4; background: #242f44; border-color: #35445e; cursor: pointer; }
.selected-bone.active:hover { border-color: #675ec4; background: #302b57; }
@@ -320,5 +384,8 @@ button:disabled { opacity: .55; cursor: wait; }
.bone-toggle:hover { background: #26324a; }
.bone-leaf { display: inline-flex; width: 22px; justify-content: center; color: #58647d; }
.bone-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.slot-row { padding-right: 8px; }
.slot-order { width: 24px; flex: 0 0 24px; color: #66758e; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
.slot-attachment { margin-left: auto; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6d7c96; font-size: 10px; }
@media (max-width: 430px) { .grid.three { grid-template-columns: 1fr; } }
</style>
+74 -11
View File
@@ -1,4 +1,4 @@
import { Container } from 'pixi.js'
import { Container, Matrix } from 'pixi.js'
import { getSpineAsset } from './spineAssetRegistry'
import type { SpineSceneObject } from './spineTypes'
@@ -22,11 +22,79 @@ export class SpineRuntimeLayer {
readonly container = new Container()
private entries = new Map<number, RuntimeEntry>()
/** 供渲染层级使用:取某个场景 Spine 的运行时实例。 */
getSpine(objectId: number) {
return this.entries.get(objectId)?.spine || null
}
/** 当前帧的绘制顺序(会跟随动画里的 drawOrder timeline)。 */
getDrawOrder(objectId: number): string[] | null {
const spine = this.entries.get(objectId)?.spine
try {
const drawOrder = spine?.skeleton?.drawOrder
return Array.isArray(drawOrder) ? drawOrder.map((slot: any) => String(slot?.data?.name || '')) : null
} catch {
return null
}
}
/** 插槽 wrapper 相对 Spine 的局部矩阵(与 SpinePixi updateSlotObject 保持一致)。 */
getSlotLocalMatrix(objectId: number, slotName: string): Matrix | null {
const spine = this.entries.get(objectId)?.spine
const slot = spine?.skeleton?.findSlot?.(slotName)
const bone = slot?.bone
if (!bone?.active) return null
return new Matrix(bone.a, bone.c, -bone.b, -bone.d, bone.worldX, bone.worldY)
}
/**
* 插槽 wrapper 在编辑器 world 空间中的父矩阵 = 场景对象 root 变换 × 插槽骨骼矩阵。
* 粒子发射器挂进插槽后用它做逆变换补偿,保持原有的世界坐标表现。
*/
getSlotParentMatrix(
objectId: number,
slotName: string,
root: { x: number; y: number; rotation: number; scaleX: number; scaleY: number },
): Matrix | null {
const local = this.getSlotLocalMatrix(objectId, slotName)
if (!local) return null
const radians = -root.rotation * Math.PI / 180
const cos = Math.cos(radians), sin = Math.sin(radians)
const rootMatrix = new Matrix(
cos * root.scaleX, sin * root.scaleX,
-sin * root.scaleY, cos * root.scaleY,
root.x, -root.y,
)
return rootMatrix.append(local)
}
attachSlotObject(objectId: number, slotName: string, container: Container, followAttachmentTimeline: boolean) {
const spine = this.entries.get(objectId)?.spine
if (!spine?.addSlotObject) return false
try {
spine.addSlotObject(slotName, container, { followAttachmentTimeline })
return true
} catch {
return false
}
}
detachSlotObject(objectId: number, slotName: string, container: Container) {
const spine = this.entries.get(objectId)?.spine
try { spine?.removeSlotObject?.(slotName, container) } catch { /* 运行时已被释放时忽略 */ }
}
private destroyEntry(entry: RuntimeEntry) {
// 先摘掉渲染层级 wrapper,避免随 root 一起销毁编辑器里的粒子发射器。
try { entry.spine?.removeSlotObjects?.() } catch { /* ignore */ }
this.container.removeChild(entry.root)
entry.root.destroy({ children: true })
}
sync(objects: SpineSceneObject[], frame: number, fps: number) {
for (const [id, entry] of this.entries) {
if (objects.some((object) => object.id === id)) continue
this.container.removeChild(entry.root)
entry.root.destroy({ children: true })
this.destroyEntry(entry)
this.entries.delete(id)
}
@@ -34,16 +102,12 @@ export class SpineRuntimeLayer {
const asset = getSpineAsset(object.id)
let entry = this.entries.get(object.id)
if (entry && entry.assetVersion !== object.assetVersion && !asset) {
this.container.removeChild(entry.root)
entry.root.destroy({ children: true })
this.destroyEntry(entry)
this.entries.delete(object.id)
entry = undefined
}
if ((!entry || entry.assetVersion !== object.assetVersion) && asset) {
if (entry) {
this.container.removeChild(entry.root)
entry.root.destroy({ children: true })
}
if (entry) this.destroyEntry(entry)
const root = new Container()
const spine = new asset.SpineClass({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false })
if (asset.skeletonData.defaultSkin) spine.skeleton.setSkin(asset.skeletonData.defaultSkin)
@@ -115,8 +179,7 @@ export class SpineRuntimeLayer {
destroy() {
for (const entry of this.entries.values()) {
entry.root.removeFromParent()
entry.root.destroy({ children: true })
this.destroyEntry(entry)
}
this.entries.clear()
this.container.removeFromParent()
+13 -1
View File
@@ -2,7 +2,7 @@ import { Texture } from 'pixi.js'
import type { LoadedSpineAsset } from './spineAssetRegistry'
import { parseBinary42, parseJson42 } from './runtime/spineRuntime42'
import { parseBinary43, parseJson43 } from './runtime/spineRuntime43'
import type { SpineAnimationInfo, SpineBoneInfo } from './spineTypes'
import type { SpineAnimationInfo, SpineBoneInfo, SpineSlotInfo } from './spineTypes'
import { normalizeSpine38JsonFor42 } from './spine38Compatibility'
import { normalizeSpineVersion, spineRuntimeVersion, supportedSpineVersionsText, type SupportedSpineVersion } from './spineVersion'
@@ -11,6 +11,7 @@ type CompatibleSkeletonData = LoadedSpineAsset['skeletonData']
export interface ParsedSpineBundle extends LoadedSpineAsset {
animations: SpineAnimationInfo[]
bones: SpineBoneInfo[]
slots: SpineSlotInfo[]
dataVersion: SupportedSpineVersion
}
@@ -40,6 +41,16 @@ function boneMetadata(skeletonData: CompatibleSkeletonData): SpineBoneInfo[] {
})
}
/** setup 姿态下的插槽顺序(即 Spine 层级面板中的绘制顺序)。 */
function slotMetadata(skeletonData: CompatibleSkeletonData): SpineSlotInfo[] {
return skeletonData.slots.map((slot) => ({
name: slot.name,
order: Number(slot.index) || 0,
bone: slot.boneData?.name || '',
attachment: slot.attachmentName || '',
}))
}
export async function parseSpineBundle(skeletonFile: File, atlasFile: File, textureFiles: File[]): Promise<ParsedSpineBundle> {
if (!/\.(json|skel)$/i.test(skeletonFile.name)) throw new Error('Spine 数据仅支持 .json 或 .skel')
if (!/\.atlas$/i.test(atlasFile.name)) throw new Error('图集文件仅支持 .atlas')
@@ -98,6 +109,7 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
dataVersion,
animations: skeletonData.animations.map((animation) => ({ name: animation.name, duration: animation.duration })),
bones: boneMetadata(skeletonData),
slots: slotMetadata(skeletonData),
}
} catch (error) {
for (const texture of textures) texture.destroy(true)
+10
View File
@@ -12,6 +12,14 @@ export interface SpineBoneInfo {
depth: number
}
/** 插槽信息;order 为 setup 姿态下的绘制顺序(Spine drawOrder 可能被动画改变)。 */
export interface SpineSlotInfo {
name: string
order: number
bone: string
attachment: string
}
export interface SpineSceneObject {
id: number
name: string
@@ -32,8 +40,10 @@ export interface SpineSceneObject {
sourceBundle: SpineSourceBundle | null
selectedAnimation: string
selectedBone: string
selectedSlot: string
animations: SpineAnimationInfo[]
bones: SpineBoneInfo[]
slots: SpineSlotInfo[]
assetVersion: number
status: 'empty' | 'loading' | 'ready' | 'error'
error: string
+3
View File
@@ -322,8 +322,10 @@ export const useParticleStore = defineStore('particle', {
sourceBundle: null,
selectedAnimation: '',
selectedBone: '',
selectedSlot: '',
animations: [],
bones: [],
slots: [],
assetVersion: 0,
status: 'empty',
error: '',
@@ -381,6 +383,7 @@ export const useParticleStore = defineStore('particle', {
copy.name = copyName(source.name, this.spines.map((item) => item.name))
// 骨骼选择是层级树的临时操作状态,不在两个对象之间复用。
copy.selectedBone = ''
copy.selectedSlot = ''
retainSpineAsset(source.id, copy.id)
this.spines.push(copy)
this.activeObjectId = copy.id
+5 -3
View File
@@ -189,7 +189,7 @@ useEditorShortcuts({
function syncEmitters() {
if (!world) return
emitterSync.sync(store, world, dotTex, trailTex)
emitterSync.sync(store, world, dotTex, trailTex, spineRuntime)
}
/**
@@ -243,13 +243,15 @@ function loop() {
background.applyTransform(store.settings.backgroundX, store.settings.backgroundY, store.settings.backgroundOpacity)
drawAxis(store, app, axisGfx, axisLabelLayer, axisLabels)
syncEmitters()
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
const followFps = Math.max(1, store.timeline.fps)
const followFrame = store.timeline.frame
const followTime = followFrame / followFps
// 发射器读取骨骼变换前,先把 Spine 运行时同步到同一时间轴帧。
// 发射器读取骨骼变换、插槽 drawOrder 之前,先把 Spine 运行时同步到同一时间轴帧。
spineRuntime?.sync(store.spines, followFrame, followFps)
syncEmitters()
// 同步完成后计算插槽层级补偿,抵消插槽骨骼变换,保持粒子系统原有世界坐标表现。
emitterSync.updateRenderCompensation(store, spineRuntime)
previewColliders = buildPreviewColliders(store, followTime)
for (const sys of store.systems) {
const em = emitterSync.emitters.get(sys.id)
@@ -27,6 +27,59 @@
</div>
</template>
<!-- 渲染层级(仅编辑器预览,不影响导出数据) -->
<div class="look-title">渲染层级(Spine 插槽)</div>
<div class="seg">
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'top' }" @click="sys.config.renderLayerMode = 'top'">最上层</button>
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'slot' }" @click="setRenderLayerSlotMode">Spine 插槽</button>
</div>
<template v-if="sys.config.renderLayerMode === 'slot'">
<label class="row"><span>Spine</span>
<select class="inp" :value="sys.config.renderLayerSpineId" @change="selectRenderLayerSpine">
<option :value="0">{{ renderLayerReadySpines.length ? '请选择 Spine' : '无可用 Spine' }}</option>
<option v-for="spine in renderLayerReadySpines" :key="spine.id" :value="spine.id">{{ spine.name }}</option>
</select>
</label>
<!-- 没有可用的 Spine 时,插槽选择与层级说明整体隐藏。 -->
<template v-if="renderLayerSpineReady">
<div class="render-layer-current">
<span class="render-layer-text" :title="renderLayerSlotLabel">{{ renderLayerSlotLabel || '尚未选择插槽' }}</span>
<button class="mini-btn" @click="renderLayerSlotPickerOpen = !renderLayerSlotPickerOpen">{{ renderLayerSlotLabel ? '重新选择' : '选择插槽' }}</button>
<button v-if="sys.config.renderLayerSlotName" class="mini-btn ghost" @click="clearRenderLayerSlot">清除</button>
</div>
<div v-if="renderLayerSlotPickerOpen" class="slot-picker">
<input v-model.trim="renderLayerSlotSearch" class="inp" type="search" placeholder="搜索插槽或附件名称…" aria-label="搜索插槽" />
<div class="slot-list">
<button
v-for="slot in renderLayerFilteredSlots"
:key="slot.name"
class="slot-item"
:class="{ on: sys.config.renderLayerSlotName === slot.name }"
@click="pickRenderLayerSlot(slot)"
>
<span class="slot-order">{{ slot.order + 1 }}</span>
<span class="slot-name">{{ slot.name }}</span>
<span class="slot-attachment">{{ slot.attachment || '空插槽' }}</span>
</button>
<div v-if="!renderLayerFilteredSlots.length" class="modifier-hint">没有匹配的插槽</div>
</div>
</div>
<template v-if="sys.config.renderLayerSlotName">
<div class="seg">
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'above' }" @click="sys.config.renderLayerSlotPosition = 'above'">在此插槽图片之上</button>
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'below' }" @click="sys.config.renderLayerSlotPosition = 'below'">在此插槽图片之下</button>
</div>
<label class="switch-row render-layer-switch">
<input v-model="sys.config.renderLayerFollowAttachment" type="checkbox" :disabled="sys.config.renderLayerSlotPosition === 'below'" />
<span class="switch-ui"></span><span>随插槽附件显隐</span>
</label>
<div v-if="renderLayerResolvedLabel" class="modifier-hint">{{ renderLayerResolvedLabel }}</div>
</template>
<div v-if="renderLayerWarning" class="modifier-hint render-layer-warning">{{ renderLayerWarning }}</div>
<div class="modifier-hint">调整显示层级只影响编辑器预览,不影响导出数据。</div>
</template>
</template>
<!-- 图片资源 -->
<div class="look-title image-title">
<span>图片资源</span>
@@ -105,7 +158,7 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext'
const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, collapsed, toggle, resetModule, select, ALPHA_CURVE, RESOURCE_ALPHA_CURVE } = useParticlePanelContext()
const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, collapsed, toggle, resetModule, select, renderLayerSlotPickerOpen, renderLayerSlotSearch, renderLayerReadySpines, renderLayerSpineReady, renderLayerFilteredSlots, renderLayerSlotLabel, renderLayerResolvedLabel, renderLayerWarning, setRenderLayerSlotMode, selectRenderLayerSpine, pickRenderLayerSlot, clearRenderLayerSlot, ALPHA_CURVE, RESOURCE_ALPHA_CURVE } = useParticlePanelContext()
</script>
<style scoped>
@@ -154,4 +207,27 @@ const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addIm
.file-input { display: none; }
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
.render-layer-current { display: flex; align-items: center; gap: 6px; margin: 6px 0; }
.render-layer-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9aa6c2; font-size: 11px; }
.mini-btn { flex: 0 0 auto; height: 24px; padding: 0 7px; border: 1px solid #3d4a66; border-radius: 5px; background: #202a3e; color: #a9b5cf; font-size: 10px; cursor: pointer; }
.mini-btn:hover { border-color: #7774ff; color: #fff; }
.mini-btn.ghost { background: transparent; }
.slot-picker { margin: 6px 0; padding: 6px; border: 1px solid #2e3a52; border-radius: 6px; background: #111a28; }
.slot-list { max-height: 180px; overflow: auto; margin-top: 6px; }
.slot-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 4px 6px; border: 1px solid transparent; border-radius: 4px; background: transparent; color: #aab4cc; font-size: 11px; text-align: left; cursor: pointer; }
.slot-item:hover { background: #1d2a3f; }
.slot-item.on { border-color: #7774ff; background: #2b2b55; color: #fff; }
.slot-order { width: 26px; flex: 0 0 26px; color: #66758e; font-variant-numeric: tabular-nums; }
.slot-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slot-attachment { max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6d7c96; font-size: 10px; }
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
.switch-row input:checked + .switch-ui { background: #625ff1; }
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
.switch-row input:disabled + .switch-ui { opacity: .45; cursor: not-allowed; }
.render-layer-switch { margin: 6px 0; }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
.render-layer-warning { color: #d8a45e; }
</style>
+117 -1
View File
@@ -83,6 +83,104 @@ const boundSpineBone = computed(() => {
if (!spine || !spine.bones.some((bone) => bone.name === config.emitterFollowBoneName)) return null
return { spine, boneName: config.emitterFollowBoneName }
})
// ===== 渲染层级(仅编辑器预览) =====
const renderLayerSlotPickerOpen = ref(false)
const renderLayerSlotSearch = ref('')
const renderLayerReadySpines = computed(() =>
store.spines.filter((spine) => spine.enabled && spine.visible !== false && spine.status === 'ready'),
)
const renderLayerSpine = computed(() =>
store.spines.find((spine) => spine.id === sys.value?.config.renderLayerSpineId) || null,
)
const renderLayerSpineReady = computed(() => {
const spine = renderLayerSpine.value
return !!spine && spine.enabled && spine.visible !== false && spine.status === 'ready'
})
const renderLayerSlots = computed(() =>
[...(renderLayerSpine.value?.slots || [])].sort((a, b) => a.order - b.order),
)
const renderLayerFilteredSlots = computed(() => {
const query = renderLayerSlotSearch.value.trim().toLocaleLowerCase()
if (!query) return renderLayerSlots.value
return renderLayerSlots.value.filter((slot) =>
slot.name.toLocaleLowerCase().includes(query) || slot.attachment.toLocaleLowerCase().includes(query),
)
})
const renderLayerTargetSlot = computed(() =>
renderLayerSlots.value.find((slot) => slot.name === sys.value?.config.renderLayerSlotName) || null,
)
const renderLayerPreviousSlot = computed(() => {
const target = renderLayerTargetSlot.value
if (!target) return null
return renderLayerSlots.value.filter((slot) => slot.order < target.order).sort((a, b) => b.order - a.order)[0] || null
})
const renderLayerSlotLabel = computed(() => {
const config = sys.value?.config
const spine = renderLayerSpine.value
const target = renderLayerTargetSlot.value
if (!config || !spine || !target) return ''
return `${spine.name} / ${target.name} · 图片${config.renderLayerSlotPosition === 'below' ? '之下' : '之上'}`
})
const renderLayerResolvedLabel = computed(() => {
const config = sys.value?.config
const target = renderLayerTargetSlot.value
if (!config || !target) return ''
if (config.renderLayerSlotPosition === 'above') return `渲染在 ${target.name} 图片之上、其后插槽之下`
return renderLayerPreviousSlot.value
? `渲染在 ${renderLayerPreviousSlot.value.name} 与 ${target.name} 之间`
: ''
})
const renderLayerWarning = computed(() => {
const config = sys.value?.config
if (!config || config.renderLayerMode !== 'slot') return ''
if (!renderLayerReadySpines.value.length) return '没有已加载的 Spine,粒子临时显示在最上层。'
if (!config.renderLayerSpineId) return '尚未选择 Spine,粒子临时显示在最上层。'
const spine = renderLayerSpine.value
if (!spine) return '目标 Spine 不存在,粒子临时显示在最上层。'
if (!spine.enabled || spine.status !== 'ready') return '目标 Spine 未就绪,粒子临时显示在最上层。'
// 未选择插槽时面板本身已显示“尚未选择插槽”,这里不再重复提示。
if (!config.renderLayerSlotName) return ''
if (!renderLayerTargetSlot.value) return '目标插槽不存在(资源可能已更换),粒子临时显示在最上层。'
if (config.renderLayerSlotPosition === 'below' && !renderLayerPreviousSlot.value) {
return '这是第一个插槽,无法显示在其下方,粒子临时显示在最上层。'
}
return ''
})
function setRenderLayerSlotMode() {
const config = sys.value?.config
if (!config) return
config.renderLayerMode = 'slot'
if (!config.renderLayerSpineId && renderLayerReadySpines.value.length) {
config.renderLayerSpineId = renderLayerReadySpines.value[0].id
}
renderLayerSlotPickerOpen.value = !config.renderLayerSlotName
}
function selectRenderLayerSpine(event: Event) {
const config = sys.value?.config
if (!config) return
config.renderLayerSpineId = Math.max(0, Math.round(Number((event.target as HTMLSelectElement).value) || 0))
config.renderLayerSlotName = ''
renderLayerSlotPickerOpen.value = true
}
function pickRenderLayerSlot(slot: { name: string }) {
const config = sys.value?.config
if (!config) return
config.renderLayerSlotName = slot.name
renderLayerSlotPickerOpen.value = false
renderLayerSlotSearch.value = ''
}
function clearRenderLayerSlot() {
const config = sys.value?.config
if (!config) return
config.renderLayerSlotName = ''
config.renderLayerSlotPosition = 'above'
}
const collisionPickerOpen = ref(false)
const selectedCollisionBodies = computed(() => {
const ids = new Set(sys.value?.config.collisionColliderIds || [])
@@ -96,6 +194,10 @@ const availableCollisionBodies = computed(() => {
watch(() => [sys.value?.id, sys.value?.config.collision], () => {
collisionPickerOpen.value = false
})
watch(() => sys.value?.id, () => {
renderLayerSlotPickerOpen.value = false
renderLayerSlotSearch.value = ''
})
function addCollisionBody(colliderId: number) {
const config = sys.value?.config
@@ -156,6 +258,18 @@ watchEffect(() => {
}
})
// 渲染层级:仅当 Spine 对象被删除时清空绑定;资源尚未就绪时保留绑定,等加载完成后自动恢复。
watchEffect(() => {
for (const system of store.systems) {
const config = ensureEmitterConfig(system.config as any)
if (config.renderLayerMode !== 'slot' || !config.renderLayerSpineId) continue
if (!store.spines.some((spine) => spine.id === config.renderLayerSpineId)) {
config.renderLayerSpineId = 0
config.renderLayerSlotName = ''
}
}
})
// HMR 或旧场景中的碰撞体缺少路径跟随字段时即时补齐。
watchEffect(() => {
for (const collider of store.colliders) {
@@ -320,6 +434,8 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
'alpha', 'alphaEnd', 'colorStart', 'colorEnd', 'colorOverLife', 'blend',
'alphaMode', 'curveMode', 'alphaCurve', 'textureName', 'textureFolder',
'alphaScale', 'anchorX', 'anchorY', 'independentAlpha',
'renderLayerMode', 'renderLayerSpineId', 'renderLayerSlotName',
'renderLayerSlotPosition', 'renderLayerFollowAttachment',
],
mods: [
'noise', 'noiseAmt', 'noiseRange', 'noiseSpeed',
@@ -455,5 +571,5 @@ function onSpawnPathChange() {
// 重置/删除图片 → 回到默认 star.png
return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE }
return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, renderLayerSlotPickerOpen, renderLayerSlotSearch, renderLayerReadySpines, renderLayerSpineReady, renderLayerSlots, renderLayerFilteredSlots, renderLayerSlotLabel, renderLayerResolvedLabel, renderLayerWarning, setRenderLayerSlotMode, selectRenderLayerSpine, pickRenderLayerSlot, clearRenderLayerSlot, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE }
}
+99 -3
View File
@@ -1,10 +1,12 @@
import { Texture, type Container } from 'pixi.js'
import { Container, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig } from '../../core/particleEmitter'
import { sampleScenePath } from '../../core/scenePathSampler'
import type { EmitterConfig } from '../../core/particleTypes'
import { followCurveValue } from './stageGeometry'
import type { useParticleStore, CollisionBody } from '../../store/particleStore'
import type { ScenePath } from '../../store/particleStore'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import { resolveRenderTarget, type RenderTarget } from './renderLayer'
type EditorStore = ReturnType<typeof useParticleStore>
@@ -67,17 +69,28 @@ export function buildPreviewColliders(store: EditorStore, timeSeconds: number):
export class EmitterSync {
readonly emitters = new Map<number, ParticleEmitter>()
private configs = new Map<number, object>()
/** 渲染层级:一个插槽对应一个 wrapper(同插槽多系统共用),内部 compensator 抵消插槽骨骼变换。 */
private slotGroups = new Map<string, {
key: string
spineId: number
slotName: string
wrapper: Container
compensator: Container
followAttachment: boolean
attached: boolean
}>()
sync(
store: EditorStore,
world: Container,
dotTex: Texture<any> | null,
trailTex: Texture<any> | null,
spineRuntime: SpineRuntimeLayer | null = null,
) {
for (const [id, em] of this.emitters) {
const system = store.systems.find((item) => item.id === id)
if (!system || this.configs.get(id) !== system.config) {
world.removeChild(em)
em.removeFromParent()
em.destroy({ children: true })
this.emitters.delete(id)
this.configs.delete(id)
@@ -103,15 +116,98 @@ export class EmitterSync {
this.emitters.set(sys.id, em)
this.configs.set(sys.id, sys.config)
}
const activeGroups = new Set<string>()
for (const sys of store.systems) {
const em = this.emitters.get(sys.id)
if (!em) continue
const drawOrder = spineRuntime && sys.config.renderLayerMode === 'slot'
? spineRuntime.getDrawOrder(sys.config.renderLayerSpineId)
: null
const target = resolveRenderTarget(store.spines, sys.config, drawOrder)
const group = target && spineRuntime ? this.ensureSlotGroup(target, spineRuntime, sys.config) : null
if (group) {
activeGroups.add(group.key)
if (em.parent !== group.compensator) group.compensator.addChild(em)
} else if (em.parent !== world) {
world.addChild(em)
}
}
for (const [key, group] of [...this.slotGroups]) {
if (activeGroups.has(key)) continue
for (const child of [...group.compensator.children]) {
if (child instanceof ParticleEmitter && child.parent === group.compensator) world.addChild(child)
}
spineRuntime?.detachSlotObject(group.spineId, group.slotName, group.wrapper)
group.wrapper.removeFromParent()
group.wrapper.removeChild(group.compensator)
group.compensator.destroy()
group.wrapper.destroy()
this.slotGroups.delete(key)
}
}
private ensureSlotGroup(
target: RenderTarget,
spineRuntime: SpineRuntimeLayer,
config: { renderLayerFollowAttachment: boolean; renderLayerSlotPosition: string },
) {
// 以“用户配置的目标插槽 + 上/下”分组;drawOrder 变化导致实际挂载插槽改变时只重挂,不重建分组。
const key = `${target.spineId}:${target.targetSlotName}:${config.renderLayerSlotPosition}`
let group = this.slotGroups.get(key)
if (!group) {
const wrapper = new Container()
const compensator = new Container()
wrapper.addChild(compensator)
group = {
key,
spineId: target.spineId,
slotName: target.slotName,
wrapper,
compensator,
followAttachment: config.renderLayerFollowAttachment,
attached: false,
}
this.slotGroups.set(key, group)
}
// 同一插槽的多个系统共用一个 wrapper,跟随设置以首个挂载的系统为准,避免逐帧反复重挂。
if (!group.attached || !group.wrapper.parent || group.slotName !== target.slotName) {
if (group.wrapper.parent) spineRuntime.detachSlotObject(group.spineId, group.slotName, group.wrapper)
group.slotName = target.slotName
group.attached = spineRuntime.attachSlotObject(group.spineId, group.slotName, group.wrapper, target.followAttachment)
}
return group.attached ? group : null
}
/** 每帧在 Spine 同步之后调用:把插槽骨骼变换抵消掉,粒子仍保持在编辑器世界坐标中。 */
updateRenderCompensation(store: EditorStore, spineRuntime: SpineRuntimeLayer | null) {
if (!spineRuntime) return
for (const group of this.slotGroups.values()) {
if (!group.attached || !group.wrapper.parent) continue
const spine = store.spines.find((item) => item.id === group.spineId)
if (!spine) continue
const parent = spineRuntime.getSlotParentMatrix(group.spineId, group.slotName, spine)
if (!parent) continue
parent.invert()
group.compensator.transform.setFromMatrix(parent)
}
}
/** 卸载时销毁全部发射器。 */
destroy(world: Container) {
for (const group of this.slotGroups.values()) {
for (const child of [...group.compensator.children]) group.compensator.removeChild(child)
group.wrapper.removeFromParent()
group.compensator.destroy()
group.wrapper.destroy()
}
this.slotGroups.clear()
for (const [id, em] of this.emitters) {
world.removeChild(em)
em.removeFromParent()
em.destroy({ children: true })
this.emitters.delete(id)
this.configs.delete(id)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
/** 配置中的渲染层级字段(仅编辑器预览)。 */
export interface RenderLayerConfig {
renderLayerMode: string
renderLayerSpineId: number
renderLayerSlotName: string
renderLayerSlotPosition: string
renderLayerFollowAttachment: boolean
}
export interface RenderLayerSlot {
name: string
order: number
}
export interface RenderLayerSpine {
id: number
enabled: boolean
status: string
slots?: RenderLayerSlot[]
}
/** 实际要挂载的插槽;below 会解析到当前 drawOrder 中的前一个插槽。 */
export interface RenderTarget {
spineId: number
/** 实际挂载的插槽 */
slotName: string
/** 用户配置的目标插槽(用于分组与重挂判断) */
targetSlotName: string
followAttachment: boolean
}
/**
* SpinePixi 的 addSlotObject 渲染在该插槽附件之上,所以「在此插槽图片之下」
* 需要挂到当前 drawOrder 中紧邻的前一个插槽。传入 drawOrder 时按动画实时顺序解析,
* 否则回退到 setup 顺序(用于没有运行时或旧数据)。
*/
export function resolveRenderTarget(
spines: readonly RenderLayerSpine[],
config: RenderLayerConfig,
drawOrder?: readonly string[] | null,
): RenderTarget | null {
if (config.renderLayerMode !== 'slot') return null
const spine = spines.find((item) => item.id === config.renderLayerSpineId && item.enabled && item.status === 'ready')
if (!spine) return null
const slots = spine.slots || []
const target = slots.find((slot) => slot.name === config.renderLayerSlotName)
if (!target) return null
if (config.renderLayerSlotPosition === 'below') {
let attachSlotName = ''
if (drawOrder?.length) {
const index = drawOrder.indexOf(target.name)
if (index === 0) return null
if (index > 0) attachSlotName = drawOrder[index - 1]
}
if (!attachSlotName) {
const previous = slots
.filter((slot) => slot.order < target.order)
.sort((a, b) => b.order - a.order)[0]
if (!previous) return null
attachSlotName = previous.name
}
return { spineId: spine.id, slotName: attachSlotName, targetSlotName: target.name, followAttachment: false }
}
return {
spineId: spine.id,
slotName: target.name,
targetSlotName: target.name,
followAttachment: config.renderLayerFollowAttachment,
}
}