代码拆分
This commit is contained in:
1 parent
8bf16fe09f
commit
ee72d09b23
20 files changed
+2760
-2450
No files matched your search
Vendored
+1239
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-CxN-rEmg.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-CxN-rEmg.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-5UjzhKXC.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-5UjzhKXC.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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
.help-overlay[data-v-12773018]{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-12773018]{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-12773018]{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-12773018]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-12773018]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-12773018]{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-12773018]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-12773018]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-12773018]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-12773018]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-12773018]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-12773018]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-12773018]{color:#dce4f7}.help-grid[data-v-12773018]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-12773018]{display:grid;gap:0;margin:0}.help-list>div[data-v-12773018]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-12773018]:last-child{border-bottom:0}.help-list dt[data-v-12773018]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-12773018]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-12773018]{display:grid;gap:7px}.shortcut-list>div[data-v-12773018]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-12773018]{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-12773018]{color:#65728b;font-size:10px}.shortcut-list em[data-v-12773018]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-12773018]{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-12773018]{margin-top:12px}.help-export p[data-v-12773018]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-12773018]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-12773018]{margin-top:12px}.help-module-head[data-v-12773018]{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-12773018]:hover{color:#fff}.help-module-head .mtitle[data-v-12773018]{flex:1;min-width:0}.help-module-head .caret[data-v-12773018]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-12773018]{color:#fff}.help-module.open .help-module-head .caret[data-v-12773018]{color:#9fb2ff}.help-module .mnum[data-v-12773018]{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-12773018]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-12773018]{color:#b8c2ff}.help-module .connector-intro[data-v-12773018]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-12773018]{color:#dce4f7}.help-module .dl-link[data-v-12773018]{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-12773018]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-12773018]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-12773018]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-12773018]{padding:66px 12px 14px}.help-grid[data-v-12773018]{grid-template-columns:1fr}}.topbar[data-v-f1257357]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
.help-overlay[data-v-12773018]{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-12773018]{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-12773018]{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-12773018]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-12773018]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-12773018]{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-12773018]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-12773018]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-12773018]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-12773018]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-12773018]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-12773018]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-12773018]{color:#dce4f7}.help-grid[data-v-12773018]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-12773018]{display:grid;gap:0;margin:0}.help-list>div[data-v-12773018]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-12773018]:last-child{border-bottom:0}.help-list dt[data-v-12773018]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-12773018]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-12773018]{display:grid;gap:7px}.shortcut-list>div[data-v-12773018]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-12773018]{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-12773018]{color:#65728b;font-size:10px}.shortcut-list em[data-v-12773018]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-12773018]{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-12773018]{margin-top:12px}.help-export p[data-v-12773018]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-12773018]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-12773018]{margin-top:12px}.help-module-head[data-v-12773018]{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-12773018]:hover{color:#fff}.help-module-head .mtitle[data-v-12773018]{flex:1;min-width:0}.help-module-head .caret[data-v-12773018]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-12773018]{color:#fff}.help-module.open .help-module-head .caret[data-v-12773018]{color:#9fb2ff}.help-module .mnum[data-v-12773018]{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-12773018]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-12773018]{color:#b8c2ff}.help-module .connector-intro[data-v-12773018]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-12773018]{color:#dce4f7}.help-module .dl-link[data-v-12773018]{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-12773018]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-12773018]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-12773018]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-12773018]{padding:66px 12px 14px}.help-grid[data-v-12773018]{grid-template-columns:1fr}}.stage-wrap[data-v-659e6fc2]{position:relative;width:100%;height:100%;overfloLine truncated
|
|
||||||
Vendored
-1239
File diff suppressed because it is too large.
Load diff
@@ -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-CxN-rEmg.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-CxN-rEmg.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-Cr68vmVa.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-Cr68vmVa.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-5UjzhKXC.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-5UjzhKXC.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-BJfIcye4.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-BJfIcye4.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;
|
||||||
|
|
||||||
Vendored
+2
-2
@@ -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-CxN-rEmg.js"></script>
|
<script type="module" crossorigin src="/assets/index-5UjzhKXC.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-ClCjv-tT.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CbfRekOe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+45
-398
@@ -1,10 +1,12 @@
|
|||||||
import { Container, Sprite, Texture, IPointData, SimpleMesh, Graphics } from 'pixi.js'
|
import { Container, Sprite, Graphics } from 'pixi.js'
|
||||||
import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmissionAngleMode, type EmitterShapeSample } from './emitterShape'
|
import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmitterShapeSample } from './emitterShape'
|
||||||
|
import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail'
|
||||||
|
import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources'
|
||||||
|
|
||||||
import type { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState } from './particleTypes'
|
import type { SceneCollider, EmitterConfig, ParticleState } 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, cubicValue, curveAt, mulberry32, hexToNumber, blendRgb, gradientColorAt, hexToRgb, type SimulationTransform } from './particleMath'
|
import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt } from './particleMath'
|
||||||
|
|
||||||
// 保留原有入口,现有调用方无需随内部拆分修改。
|
// 保留原有入口,现有调用方无需随内部拆分修改。
|
||||||
export type * from './particleTypes'
|
export type * from './particleTypes'
|
||||||
@@ -33,18 +35,8 @@ interface Particle {
|
|||||||
attractionStartWorldX: number
|
attractionStartWorldX: number
|
||||||
attractionStartWorldY: number
|
attractionStartWorldY: number
|
||||||
attractionCurveSide: number
|
attractionCurveSide: number
|
||||||
trailMesh: SimpleMesh | null
|
/** 拖尾运行状态(实现见 particleTrail.ts) */
|
||||||
trailMeshSignature: string
|
trail: ParticleTrailState
|
||||||
trailHistory: { x: number; y: number }[]
|
|
||||||
trailBones: TrailBoneState[]
|
|
||||||
trailSkinWeights: { boneA: number; boneB: number; weight: number }[]
|
|
||||||
trailCurrentWidth: number
|
|
||||||
trailCurrentAlpha: number
|
|
||||||
trailCurrentColor: number
|
|
||||||
trailResourceId: number
|
|
||||||
trailEnabled: boolean
|
|
||||||
trailSampledLength: number
|
|
||||||
trailSampledWidth: number
|
|
||||||
resourceId: number
|
resourceId: number
|
||||||
/** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */
|
/** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */
|
||||||
colorSample: number
|
colorSample: number
|
||||||
@@ -52,7 +44,6 @@ interface Particle {
|
|||||||
collisionPaused: boolean
|
collisionPaused: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class ParticleEmitter extends Container {
|
export class ParticleEmitter extends Container {
|
||||||
private pool: Particle[] = []
|
private pool: Particle[] = []
|
||||||
private cfg: EmitterConfig
|
private cfg: EmitterConfig
|
||||||
@@ -64,13 +55,13 @@ export class ParticleEmitter extends Container {
|
|||||||
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
|
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
|
||||||
private _attractionEnabledAt = 0
|
private _attractionEnabledAt = 0
|
||||||
private _attractionWasEnabled = false
|
private _attractionWasEnabled = false
|
||||||
private _emitterPos: (() => IPointData) | null = null // root 世界坐标(0,0)
|
|
||||||
/** 活动粒子状态快照(供骨架 / 导出) */
|
/** 活动粒子状态快照(供骨架 / 导出) */
|
||||||
states: ParticleState[] = []
|
states: ParticleState[] = []
|
||||||
/** 确定性随机(种子>0 时可复现) */
|
/** 确定性随机(种子>0 时可复现) */
|
||||||
private rng: () => number = Math.random
|
private rng: () => number = Math.random
|
||||||
private _lastSeed = -1
|
private _lastSeed = -1
|
||||||
private trailDebugGfx = new Graphics()
|
private trailDebugGfx = new Graphics()
|
||||||
|
private trail: TrailController
|
||||||
private colliders: SceneCollider[] = []
|
private colliders: SceneCollider[] = []
|
||||||
private followSpawnX = 0
|
private followSpawnX = 0
|
||||||
private followSpawnY = 0
|
private followSpawnY = 0
|
||||||
@@ -83,6 +74,7 @@ export class ParticleEmitter extends Container {
|
|||||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||||
super()
|
super()
|
||||||
this.cfg = ensureEmitterConfig(config)
|
this.cfg = ensureEmitterConfig(config)
|
||||||
|
this.trail = new TrailController(this)
|
||||||
this._ensurePool(maxParticles)
|
this._ensurePool(maxParticles)
|
||||||
this.addChild(this.trailDebugGfx)
|
this.addChild(this.trailDebugGfx)
|
||||||
}
|
}
|
||||||
@@ -123,18 +115,7 @@ export class ParticleEmitter extends Container {
|
|||||||
attractionStartWorldX: 0,
|
attractionStartWorldX: 0,
|
||||||
attractionStartWorldY: 0,
|
attractionStartWorldY: 0,
|
||||||
attractionCurveSide: 1,
|
attractionCurveSide: 1,
|
||||||
trailMesh: null,
|
trail: createTrailState(),
|
||||||
trailMeshSignature: '',
|
|
||||||
trailHistory: [],
|
|
||||||
trailBones: [],
|
|
||||||
trailSkinWeights: [],
|
|
||||||
trailCurrentWidth: 0,
|
|
||||||
trailCurrentAlpha: 0,
|
|
||||||
trailCurrentColor: 0xffffff,
|
|
||||||
trailResourceId: 1,
|
|
||||||
trailEnabled: false,
|
|
||||||
trailSampledLength: 100,
|
|
||||||
trailSampledWidth: 100,
|
|
||||||
resourceId: 1,
|
resourceId: 1,
|
||||||
colorSample: 0xffffff,
|
colorSample: 0xffffff,
|
||||||
imageFrameIndex: 0,
|
imageFrameIndex: 0,
|
||||||
@@ -143,7 +124,6 @@ export class ParticleEmitter extends Container {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
|
|
||||||
setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) {
|
setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) {
|
||||||
this.spawnPathSampler = sampler
|
this.spawnPathSampler = sampler
|
||||||
this.spawnPathClosed = closed
|
this.spawnPathClosed = closed
|
||||||
@@ -360,9 +340,9 @@ export class ParticleEmitter extends Container {
|
|||||||
const s = p.sprite
|
const s = p.sprite
|
||||||
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
|
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
|
||||||
if (resource) {
|
if (resource) {
|
||||||
const frameState = this.imageFrameState(resource, p.maxLife - p.life)
|
const frameState = imageFrameState(resource, p.maxLife - p.life)
|
||||||
p.imageFrameIndex = frameState.index
|
p.imageFrameIndex = frameState.index
|
||||||
s.texture = this.imageFrameTexture(resource, p.imageFrameIndex)
|
s.texture = imageFrameTexture(resource, cfg.texture, p.imageFrameIndex)
|
||||||
s.visible = frameState.visible
|
s.visible = frameState.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +392,7 @@ export class ParticleEmitter extends Container {
|
|||||||
: hexToNumber(colorStart)
|
: hexToNumber(colorStart)
|
||||||
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.updateTrail(p, renderX, renderY, t, s)
|
const trailState = this.trail.update(p.trail, cfg, p.boneName, renderX, renderY, t, s)
|
||||||
const world = localToWorldMath(cfg, renderX, renderY, this.simulationTransform)
|
const world = localToWorldMath(cfg, renderX, renderY, this.simulationTransform)
|
||||||
const rootRotation = this.simulationTransform?.rotation ?? cfg.rootRotation
|
const rootRotation = this.simulationTransform?.rotation ?? cfg.rootRotation
|
||||||
const rootScaleX = Math.abs(this.simulationTransform?.scaleX ?? cfg.rootScaleX)
|
const rootScaleX = Math.abs(this.simulationTransform?.scaleX ?? cfg.rootScaleX)
|
||||||
@@ -432,265 +412,9 @@ export class ParticleEmitter extends Container {
|
|||||||
return states
|
return states
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据网格行列生成拓扑,并为每个顶点绑定相邻两根拖尾骨骼。 */
|
|
||||||
private ensureTrailMesh(p: Particle) {
|
|
||||||
const cfg = this.cfg
|
|
||||||
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
|
|
||||||
const activeTrailTexture = trailResource?.texture || Texture.EMPTY
|
|
||||||
const textureRotation = Number(trailResource?.rotation) || 0
|
|
||||||
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
|
||||||
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
|
||||||
const bones = Math.max(2, Math.min(16, Math.round(trailResource?.boneCount ?? 3)))
|
|
||||||
const signature = `${rows}:${cols}:${bones}:${trailResource?.id ?? 0}:${textureRotation}`
|
|
||||||
if (p.trailMesh && p.trailMeshSignature === signature) {
|
|
||||||
if (p.trailMesh.texture !== activeTrailTexture) p.trailMesh.texture = activeTrailTexture
|
|
||||||
return p.trailMesh
|
|
||||||
}
|
|
||||||
if (p.trailMesh) {
|
|
||||||
this.removeChild(p.trailMesh)
|
|
||||||
p.trailMesh.destroy()
|
|
||||||
}
|
|
||||||
// 拓扑或骨骼数量变化后丢弃旧轨迹,避免新权重继续套用旧骨骼位置产生偏移。
|
|
||||||
if (p.trailMeshSignature && p.trailMeshSignature !== signature) {
|
|
||||||
p.trailHistory.length = 0
|
|
||||||
p.trailBones.length = 0
|
|
||||||
}
|
|
||||||
const vertices = new Float32Array(rows * cols * 2)
|
|
||||||
const uvs = new Float32Array(rows * cols * 2)
|
|
||||||
const indices: number[] = []
|
|
||||||
const weights: { boneA: number; boneB: number; weight: number }[] = []
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
const columnT = col / (cols - 1)
|
|
||||||
const bonePosition = columnT * (bones - 1)
|
|
||||||
const boneA = Math.min(bones - 1, Math.floor(bonePosition))
|
|
||||||
const boneB = Math.min(bones - 1, boneA + 1)
|
|
||||||
const weight = bonePosition - boneA
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const vertex = col * rows + row
|
|
||||||
// 骨骼链第 0 列是粒子头部;拖尾图片左侧为头。资源旋转绕 UV 中心重新绑定。
|
|
||||||
const sourceU = columnT
|
|
||||||
const sourceV = row / (rows - 1)
|
|
||||||
const angle = textureRotation * Math.PI / 180
|
|
||||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
|
||||||
uvs[vertex * 2] = 0.5 + cos * (sourceU - 0.5) - sin * (sourceV - 0.5)
|
|
||||||
uvs[vertex * 2 + 1] = 0.5 + sin * (sourceU - 0.5) + cos * (sourceV - 0.5)
|
|
||||||
weights.push({ boneA, boneB, weight })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let col = 0; col < cols - 1; col++) {
|
|
||||||
for (let row = 0; row < rows - 1; row++) {
|
|
||||||
const a = col * rows + row
|
|
||||||
const b = (col + 1) * rows + row
|
|
||||||
const c = a + 1
|
|
||||||
const d = b + 1
|
|
||||||
indices.push(a, b, c, b, d, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mesh = new SimpleMesh(activeTrailTexture, vertices as any, uvs as any, new Uint16Array(indices) as any)
|
|
||||||
mesh.visible = false
|
|
||||||
mesh.blendMode = BLEND_MAP.normal as any
|
|
||||||
// 拖尾网格必须在粒子图片下方绘制。
|
|
||||||
this.addChildAt(mesh, Math.max(0, this.getChildIndex(p.sprite)))
|
|
||||||
p.trailMesh = mesh
|
|
||||||
p.trailMeshSignature = signature
|
|
||||||
p.trailSkinWeights = weights
|
|
||||||
return mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendTrailPoint(p: Particle, x: number, y: number, configuredLength: number) {
|
|
||||||
const first = p.trailHistory[0]
|
|
||||||
if (!first || Math.hypot(first.x - x, first.y - y) > 0.05) p.trailHistory.unshift({ x, y })
|
|
||||||
else { first.x = x; first.y = y }
|
|
||||||
// 保留略长于最大配置长度的轨迹,生命周期曲线从短变长时不会突然断裂。
|
|
||||||
const keepDistance = Math.max(1, configuredLength) * 1.5
|
|
||||||
let distance = 0
|
|
||||||
let keepCount = p.trailHistory.length
|
|
||||||
for (let i = 1; i < p.trailHistory.length; i++) {
|
|
||||||
distance += Math.hypot(p.trailHistory[i].x - p.trailHistory[i - 1].x, p.trailHistory[i].y - p.trailHistory[i - 1].y)
|
|
||||||
if (distance >= keepDistance || i >= 239) { keepCount = i + 1; break }
|
|
||||||
}
|
|
||||||
if (p.trailHistory.length > keepCount) p.trailHistory.length = keepCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 沿历史轨迹等距生成同级拖尾骨骼;第 0 根骨骼位于粒子头部。 */
|
|
||||||
private sampleTrailBones(p: Particle, length: number, boneCount: number) {
|
|
||||||
const count = Math.max(2, Math.min(16, Math.round(boneCount)))
|
|
||||||
const history = p.trailHistory
|
|
||||||
const result: TrailBoneState[] = []
|
|
||||||
if (!history.length) return result
|
|
||||||
let availableLength = 0
|
|
||||||
for (let index = 1; index < history.length; index++) {
|
|
||||||
availableLength += Math.hypot(history[index].x - history[index - 1].x, history[index].y - history[index - 1].y)
|
|
||||||
}
|
|
||||||
// 轨迹不足完整长度时,不把后方骨骼堆叠在最旧采样点;所有骨骼始终在
|
|
||||||
// 当前有效轨迹内等距插值,并随可用长度增加逐渐展开到配置长度。
|
|
||||||
const sampledLength = Math.min(Math.max(0, length), availableLength)
|
|
||||||
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
|
||||||
const targetDistance = sampledLength * boneIndex / (count - 1)
|
|
||||||
let traversed = 0
|
|
||||||
let point = history[history.length - 1]
|
|
||||||
for (let i = 1; i < history.length; i++) {
|
|
||||||
const previous = history[i - 1]
|
|
||||||
const next = history[i]
|
|
||||||
const segment = Math.hypot(next.x - previous.x, next.y - previous.y)
|
|
||||||
if (traversed + segment >= targetDistance && segment > 0.0001) {
|
|
||||||
const amount = (targetDistance - traversed) / segment
|
|
||||||
point = { x: lerp(previous.x, next.x, amount), y: lerp(previous.y, next.y, amount) }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
traversed += segment
|
|
||||||
}
|
|
||||||
result.push({ boneName: `${p.boneName}_trail_${boneIndex}`, x: point.x, y: point.y, rotation: 0 })
|
|
||||||
}
|
|
||||||
for (let i = 0; i < result.length; i++) {
|
|
||||||
const next = result[Math.min(result.length - 1, i + 1)]
|
|
||||||
const previous = result[Math.max(0, i - 1)]
|
|
||||||
const dx = i < result.length - 1 ? next.x - result[i].x : result[i].x - previous.x
|
|
||||||
const dy = i < result.length - 1 ? next.y - result[i].y : result[i].y - previous.y
|
|
||||||
result[i].rotation = Math.atan2(dy, dx) * 180 / Math.PI
|
|
||||||
}
|
|
||||||
if (sampledLength + 0.0001 < Math.max(0, length) && result.length > 1) {
|
|
||||||
// 拖尾仍在展开阶段时,位置已经在当前有效轨迹中等距插值;旋转也在
|
|
||||||
// 头尾切线之间使用最短角度插值,避免同级骨骼驱动的网格发生方向折跳。
|
|
||||||
const startRotation = result[0].rotation
|
|
||||||
const endRotation = result[result.length - 1].rotation
|
|
||||||
const shortestDelta = ((endRotation - startRotation + 540) % 360) - 180
|
|
||||||
for (let index = 0; index < result.length; index++) {
|
|
||||||
result[index].rotation = startRotation + shortestDelta * index / (result.length - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 由骨骼位置和顶点权重完成 CPU 蒙皮,更新 Pixi 网格。 */
|
|
||||||
private skinTrailMesh(p: Particle, state: TrailState) {
|
|
||||||
const mesh = this.ensureTrailMesh(p)
|
|
||||||
const cfg = this.cfg
|
|
||||||
const trailResource = cfg.trailResources.find((resource) => resource.id === state.resourceId) || cfg.trailResources[0]
|
|
||||||
if (!trailResource?.texture || state.bones.length < 2) { mesh.visible = false; return }
|
|
||||||
p.trailResourceId = trailResource.id
|
|
||||||
if (mesh.texture !== trailResource.texture) mesh.texture = trailResource.texture
|
|
||||||
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
|
||||||
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
|
||||||
const vertices = mesh.vertices as unknown as Float32Array
|
|
||||||
const centers: { x: number; y: number }[] = []
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
const skin = p.trailSkinWeights[col * rows]
|
|
||||||
const a = state.bones[Math.min(state.bones.length - 1, skin.boneA)]
|
|
||||||
const b = state.bones[Math.min(state.bones.length - 1, skin.boneB)]
|
|
||||||
centers.push({ x: lerp(a.x, b.x, skin.weight), y: lerp(a.y, b.y, skin.weight) })
|
|
||||||
}
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
const before = centers[Math.max(0, col - 1)]
|
|
||||||
const after = centers[Math.min(cols - 1, col + 1)]
|
|
||||||
const dx = after.x - before.x
|
|
||||||
const dy = after.y - before.y
|
|
||||||
const tangentLength = Math.max(0.0001, Math.hypot(dx, dy))
|
|
||||||
const normalX = -dy / tangentLength
|
|
||||||
const normalY = dx / tangentLength
|
|
||||||
const columnT = col / (cols - 1)
|
|
||||||
const shape = trailResource.shapeEnabled ? Math.max(0, curveAt(trailResource.shapeCurve, columnT)) : 1
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const rowT = row / (rows - 1) - 0.5
|
|
||||||
const offset = rowT * state.width * shape
|
|
||||||
const vertex = (col * rows + row) * 2
|
|
||||||
vertices[vertex] = centers[col].x + normalX * offset
|
|
||||||
vertices[vertex + 1] = centers[col].y + normalY * offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mesh.tint = state.colorHex
|
|
||||||
mesh.alpha = state.alpha
|
|
||||||
mesh.blendMode = BLEND_MAP[trailResource.blend] as any
|
|
||||||
mesh.visible = Math.hypot(centers[0].x - centers[cols - 1].x, centers[0].y - centers[cols - 1].y) > 0.5 && state.alpha > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateTrail(p: Particle, renderX: number, renderY: number, lifeT: number, sprite: Sprite): TrailState | undefined {
|
|
||||||
const cfg = this.cfg
|
|
||||||
if (!cfg.trail || !p.trailEnabled || p.trailSampledLength <= 0 || p.trailSampledWidth <= 0) {
|
|
||||||
p.trailHistory.length = 0
|
|
||||||
p.trailBones.length = 0
|
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
// 先确认骨骼/网格签名;发生结构变化时会在记录新轨迹前完成清理和重新绑定。
|
|
||||||
this.ensureTrailMesh(p)
|
|
||||||
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
|
|
||||||
this.appendTrailPoint(p, renderX, renderY, p.trailSampledLength)
|
|
||||||
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
|
|
||||||
const length = Math.max(0, p.trailSampledLength) * lengthMultiplier
|
|
||||||
p.trailBones = this.sampleTrailBones(p, length, trailResource?.boneCount ?? 3)
|
|
||||||
// 首骨骼必须与粒子最终渲染位置完全一致,避免采样/重绑时出现亚像素脱节。
|
|
||||||
if (p.trailBones[0]) {
|
|
||||||
p.trailBones[0].x = renderX
|
|
||||||
p.trailBones[0].y = renderY
|
|
||||||
}
|
|
||||||
const particleWidthScale = trailResource?.widthMode === 'particle'
|
|
||||||
? (Math.abs(sprite.scale.x) + Math.abs(sprite.scale.y)) * 0.5
|
|
||||||
: 1
|
|
||||||
p.trailCurrentWidth = Math.max(0, p.trailSampledWidth) * particleWidthScale
|
|
||||||
p.trailCurrentAlpha = trailResource?.alphaMode === 'particle'
|
|
||||||
? sprite.alpha
|
|
||||||
: trailResource?.alphaMode === 'curve'
|
|
||||||
? curveAt(trailResource.alphaCurve, lifeT)
|
|
||||||
: trailResource?.alpha ?? 1
|
|
||||||
p.trailCurrentColor = trailResource?.colorMode === 'particle'
|
|
||||||
? sprite.tint as number
|
|
||||||
: trailResource?.colorMode === 'curve'
|
|
||||||
? gradientColorAt(trailResource.colorGradient, lifeT)
|
|
||||||
: hexToNumber(trailResource?.color || '#ffffff')
|
|
||||||
const state: TrailState = {
|
|
||||||
bones: p.trailBones.map((bone) => ({ ...bone })),
|
|
||||||
// 拖尾是一张固定拓扑的加权 Mesh,所有绑定骨骼必须成组存活。
|
|
||||||
// 历史长度不足时所有骨骼会在当前有效轨迹内等距插值,而不是延迟出生。
|
|
||||||
activeBoneCount: p.trailBones.length,
|
|
||||||
width: p.trailCurrentWidth,
|
|
||||||
alpha: Math.min(1, Math.max(0, p.trailCurrentAlpha)),
|
|
||||||
colorHex: p.trailCurrentColor,
|
|
||||||
resourceId: p.trailResourceId,
|
|
||||||
}
|
|
||||||
this.skinTrailMesh(p, state)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */
|
/** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */
|
||||||
drawTrailMeshDebug(visible: boolean) {
|
drawTrailMeshDebug(visible: boolean) {
|
||||||
const g = this.trailDebugGfx
|
this.trail.drawDebug(this.trailDebugGfx, visible, this.cfg, this.pool.map((p) => p.trail))
|
||||||
g.clear()
|
|
||||||
if (!visible || !this.cfg.trail) return
|
|
||||||
const rows = Math.max(2, Math.min(16, Math.round(this.cfg.trailGridRows)))
|
|
||||||
const cols = Math.max(2, Math.min(32, Math.round(this.cfg.trailGridCols)))
|
|
||||||
g.lineStyle(1, 0x39d7ff, 0.72)
|
|
||||||
for (const p of this.pool) {
|
|
||||||
const mesh = p.trailMesh
|
|
||||||
if (!mesh?.visible) continue
|
|
||||||
const vertices = mesh.vertices as unknown as Float32Array
|
|
||||||
const point = (col: number, row: number) => {
|
|
||||||
const index = (col * rows + row) * 2
|
|
||||||
return { x: vertices[index], y: vertices[index + 1] }
|
|
||||||
}
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
const current = point(col, row)
|
|
||||||
if (col === 0) g.moveTo(current.x, current.y)
|
|
||||||
else g.lineTo(current.x, current.y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const current = point(col, row)
|
|
||||||
if (row === 0) g.moveTo(current.x, current.y)
|
|
||||||
else g.lineTo(current.x, current.y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.beginFill(0x8cecff, 0.9)
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const current = point(col, row)
|
|
||||||
g.drawCircle(current.x, current.y, 1.6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.endFill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitBurst() {
|
private emitBurst() {
|
||||||
@@ -743,11 +467,11 @@ export class ParticleEmitter extends Container {
|
|||||||
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 dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng)
|
||||||
const p = this.pool[idx]
|
const p = this.pool[idx]
|
||||||
const resource = this.pickImageResource()
|
const resource = pickImageResource(cfg, this.rng)
|
||||||
const spawnSerial = this._spawnSerial++
|
const spawnSerial = this._spawnSerial++
|
||||||
p.spawnId = spawnSerial
|
p.spawnId = spawnSerial
|
||||||
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
||||||
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
|
const trailResource = pickTrailResource(cfg, seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
|
||||||
// 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。
|
// 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。
|
||||||
p.noisePhaseX = seededUnit(cfg.seed, spawnSerial, 0x68bc21eb) * Math.PI * 2
|
p.noisePhaseX = seededUnit(cfg.seed, spawnSerial, 0x68bc21eb) * Math.PI * 2
|
||||||
p.noisePhaseY = seededUnit(cfg.seed, spawnSerial, 0x02e5be93) * Math.PI * 2
|
p.noisePhaseY = seededUnit(cfg.seed, spawnSerial, 0x02e5be93) * Math.PI * 2
|
||||||
@@ -761,32 +485,33 @@ export class ParticleEmitter extends Container {
|
|||||||
p.attractionElapsed = 0
|
p.attractionElapsed = 0
|
||||||
p.collisionPaused = false
|
p.collisionPaused = false
|
||||||
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
|
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
|
||||||
p.trailHistory.length = 0
|
p.trail.history.length = 0
|
||||||
p.trailBones.length = 0
|
p.trail.bones.length = 0
|
||||||
p.trailCurrentWidth = 0
|
p.trail.currentWidth = 0
|
||||||
p.trailCurrentAlpha = 0
|
p.trail.currentAlpha = 0
|
||||||
p.trailCurrentColor = 0xffffff
|
p.trail.currentColor = 0xffffff
|
||||||
p.trailResourceId = trailResource.id
|
p.trail.resourceId = trailResource.id
|
||||||
const ratio = Math.min(100, Math.max(0, Number(trailResource.trailRatio) || 0))
|
const ratio = Math.min(100, Math.max(0, Number(trailResource.trailRatio) || 0))
|
||||||
const estimatedCount = cfg.mode === 'burst'
|
const estimatedCount = cfg.mode === 'burst'
|
||||||
? Math.max(0, Math.round(cfg.burstCount))
|
? Math.max(0, Math.round(cfg.burstCount))
|
||||||
: Math.max(0, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
|
: Math.max(0, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration))))
|
||||||
const forceSingleTrail = ratio > 0 && estimatedCount === 1 && spawnSerial === 0
|
const forceSingleTrail = ratio > 0 && estimatedCount === 1 && spawnSerial === 0
|
||||||
const ratioSample = seededUnit(cfg.seed, spawnSerial, 0x27d4eb2d) * 100
|
const ratioSample = seededUnit(cfg.seed, spawnSerial, 0x27d4eb2d) * 100
|
||||||
|
|
||||||
const sampleRange = (mode: 'fixed' | 'random', fixed: number, min: number, max: number, salt: number) => {
|
const sampleRange = (mode: 'fixed' | 'random', fixed: number, min: number, max: number, salt: number) => {
|
||||||
if (mode !== 'random') return Math.max(0, Number(fixed) || 0)
|
if (mode !== 'random') return Math.max(0, Number(fixed) || 0)
|
||||||
const low = Math.max(0, Math.min(Number(min) || 0, Number(max) || 0))
|
const low = Math.max(0, Math.min(Number(min) || 0, Number(max) || 0))
|
||||||
const high = Math.max(0, Math.max(Number(min) || 0, Number(max) || 0))
|
const high = Math.max(0, Math.max(Number(min) || 0, Number(max) || 0))
|
||||||
return lerp(low, high, seededUnit(cfg.seed, spawnSerial, salt))
|
return lerp(low, high, seededUnit(cfg.seed, spawnSerial, salt))
|
||||||
}
|
}
|
||||||
p.trailSampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1)
|
p.trail.sampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1)
|
||||||
p.trailSampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c)
|
p.trail.sampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c)
|
||||||
p.trailEnabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trailSampledLength > 0 && p.trailSampledWidth > 0
|
p.trail.enabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trail.sampledLength > 0 && p.trail.sampledWidth > 0
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
if (p.trail.mesh) p.trail.mesh.visible = false
|
||||||
p.active = true; p.life = life; p.maxLife = life
|
p.active = true; p.life = life; p.maxLife = life
|
||||||
p.x = ox; p.y = oy
|
p.x = ox; p.y = oy
|
||||||
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
|
// 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。
|
||||||
if (p.trailEnabled) p.trailHistory.push({ x: ox, y: oy })
|
if (p.trail.enabled) p.trail.history.push({ x: ox, y: oy })
|
||||||
p.vx = Math.cos(dirAng) * speed
|
p.vx = Math.cos(dirAng) * speed
|
||||||
p.vy = Math.sin(dirAng) * speed
|
p.vy = Math.sin(dirAng) * speed
|
||||||
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
|
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
|
||||||
@@ -814,98 +539,27 @@ 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)
|
||||||
const initialFrame = this.imageFrameState(resource, 0)
|
const initialFrame = imageFrameState(resource, 0)
|
||||||
p.imageFrameIndex = initialFrame.index
|
p.imageFrameIndex = initialFrame.index
|
||||||
p.sprite.texture = this.imageFrameTexture(resource, 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.colorSample
|
||||||
p.sprite.visible = true
|
p.sprite.visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickImageResource(): ParticleImageResource {
|
|
||||||
const resources = this.cfg.imageResources.filter((resource) => resource.texture || resource.sequenceFrames.some((frame) => frame.texture))
|
|
||||||
if (!resources.length) return {
|
|
||||||
id: 1, texture: this.cfg.texture, textureName: 'star', textureFolder: '',
|
|
||||||
previewUrl: '/images/star.png', imageMode: 'fixed', sequencePlayback: 'loop-forward', sequenceFps: 30, sequenceFrames: [],
|
|
||||||
weight: 100, locked: false, scale: 1, anchorX: 0.5, anchorY: 0.5,
|
|
||||||
colorMode: 'fixed', colorStart: '#ffffff', colorEnd: '#ff4d4d', blend: 'normal',
|
|
||||||
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
|
|
||||||
independentAlpha: 'off', alpha: 1, alphaCurveMode: '跟随导出',
|
|
||||||
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
|
|
||||||
}
|
|
||||||
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
|
|
||||||
if (total <= 0) return resources[Math.floor(this.rng() * resources.length)]
|
|
||||||
let cursor = this.rng() * total
|
|
||||||
for (const resource of resources) {
|
|
||||||
cursor -= Math.max(0, resource.weight)
|
|
||||||
if (cursor <= 0) return resource
|
|
||||||
}
|
|
||||||
return resources[resources.length - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
private imageFrameState(resource: ParticleImageResource, age: number) {
|
|
||||||
if (resource.imageMode !== 'sequence' || resource.sequenceFrames.length <= 1) return { index: 0, visible: true }
|
|
||||||
const fps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
|
|
||||||
const rawIndex = Math.max(0, Math.floor(Math.max(0, age) * fps))
|
|
||||||
const lastIndex = resource.sequenceFrames.length - 1
|
|
||||||
if (resource.sequencePlayback === 'once-hide') {
|
|
||||||
return { index: Math.min(lastIndex, rawIndex), visible: rawIndex <= lastIndex }
|
|
||||||
}
|
|
||||||
if (resource.sequencePlayback === 'once-hold') return { index: Math.min(lastIndex, rawIndex), visible: true }
|
|
||||||
if (resource.sequencePlayback === 'loop-reverse') return { index: lastIndex - rawIndex % resource.sequenceFrames.length, visible: true }
|
|
||||||
return { index: rawIndex % resource.sequenceFrames.length, visible: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
private imageFrameTexture(resource: ParticleImageResource, frameIndex: number) {
|
|
||||||
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
|
|
||||||
return resource.sequenceFrames[Math.max(0, Math.min(resource.sequenceFrames.length - 1, frameIndex))]?.texture
|
|
||||||
|| resource.texture
|
|
||||||
|| this.cfg.texture
|
|
||||||
}
|
|
||||||
return resource.texture || this.cfg.texture
|
|
||||||
}
|
|
||||||
|
|
||||||
private pickTrailResource(sample: number): TrailImageResource {
|
|
||||||
const resources = this.cfg.trailResources.filter((resource) => resource.texture)
|
|
||||||
if (!resources.length) return this.cfg.trailResources[0] || {
|
|
||||||
id: 1, texture: null, textureName: 'trail', previewUrl: '/images/trail.png', weight: 100, locked: false,
|
|
||||||
rotation: 0, colorMode: 'particle', color: '#ffffff',
|
|
||||||
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
|
||||||
blend: 'normal',
|
|
||||||
alphaMode: 'particle', alpha: 1, alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
|
||||||
boneCount: 3, trailRatio: 100,
|
|
||||||
lengthMode: 'fixed', length: 100, lengthMin: 100, lengthMax: 100,
|
|
||||||
widthSizeMode: 'fixed', width: 100, widthMin: 100, widthMax: 100, widthMode: 'particle',
|
|
||||||
lifeLengthEnabled: false, lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
|
||||||
shapeEnabled: false, shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
|
||||||
}
|
|
||||||
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
|
|
||||||
if (total <= 0) return resources[Math.min(resources.length - 1, Math.floor(sample * resources.length))]
|
|
||||||
let cursor = sample * total
|
|
||||||
for (const resource of resources) {
|
|
||||||
cursor -= Math.max(0, resource.weight)
|
|
||||||
if (cursor <= 0) return resource
|
|
||||||
}
|
|
||||||
return resources[resources.length - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
private kill(i: number) {
|
private kill(i: number) {
|
||||||
const p = this.pool[i]
|
const p = this.pool[i]
|
||||||
p.active = false
|
p.active = false
|
||||||
p.sprite.visible = false
|
p.sprite.visible = false
|
||||||
p.trailHistory.length = 0
|
resetTrailState(p.trail)
|
||||||
p.trailBones.length = 0
|
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
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
|
||||||
p.trailHistory.length = 0
|
resetTrailState(p.trail)
|
||||||
p.trailBones.length = 0
|
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,17 +580,17 @@ export class ParticleEmitter extends Container {
|
|||||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||||
...this.captureWorldTransform(p),
|
...this.captureWorldTransform(p),
|
||||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: p.sprite.visible, active: true,
|
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: p.sprite.visible, active: true,
|
||||||
trailState: this.cfg.trail && p.trailBones.length
|
trailState: this.cfg.trail && p.trail.bones.length
|
||||||
? {
|
? {
|
||||||
bones: p.trailBones.map((bone) => {
|
bones: p.trail.bones.map((bone) => {
|
||||||
const point = localToWorldMath(this.cfg, bone.x, bone.y, this.simulationTransform)
|
const point = localToWorldMath(this.cfg, bone.x, bone.y, this.simulationTransform)
|
||||||
return { ...bone, worldX: point.x, worldY: point.y, worldRotation: (this.simulationTransform?.rotation ?? this.cfg.rootRotation) - bone.rotation }
|
return { ...bone, worldX: point.x, worldY: point.y, worldRotation: (this.simulationTransform?.rotation ?? this.cfg.rootRotation) - bone.rotation }
|
||||||
}),
|
}),
|
||||||
activeBoneCount: p.trailBones.length,
|
activeBoneCount: p.trail.bones.length,
|
||||||
width: p.trailCurrentWidth,
|
width: p.trail.currentWidth,
|
||||||
alpha: p.trailCurrentAlpha,
|
alpha: p.trail.currentAlpha,
|
||||||
colorHex: p.trailCurrentColor,
|
colorHex: p.trail.currentColor,
|
||||||
resourceId: p.trailResourceId,
|
resourceId: p.trail.resourceId,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
@@ -965,7 +619,7 @@ export class ParticleEmitter extends Container {
|
|||||||
if (!st) {
|
if (!st) {
|
||||||
p.active = false
|
p.active = false
|
||||||
p.sprite.visible = false
|
p.sprite.visible = false
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
if (p.trail.mesh) p.trail.mesh.visible = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.active = st.active
|
p.active = st.active
|
||||||
@@ -980,18 +634,14 @@ export class ParticleEmitter extends Container {
|
|||||||
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
|
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
|
||||||
if (resource) {
|
if (resource) {
|
||||||
p.imageFrameIndex = st.imageFrameIndex ?? 0
|
p.imageFrameIndex = st.imageFrameIndex ?? 0
|
||||||
s.texture = this.imageFrameTexture(resource, p.imageFrameIndex)
|
s.texture = imageFrameTexture(resource, this.cfg.texture, p.imageFrameIndex)
|
||||||
s.anchor.set(resource.anchorX, resource.anchorY)
|
s.anchor.set(resource.anchorX, resource.anchorY)
|
||||||
s.blendMode = BLEND_MAP[resource.blend] as any
|
s.blendMode = BLEND_MAP[resource.blend] as any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.cfg.trail && st.trailState) {
|
if (this.cfg.trail && st.trailState) {
|
||||||
p.trailResourceId = st.trailState.resourceId ?? this.cfg.trailResources[0]?.id ?? 1
|
this.trail.applyState(p.trail, this.cfg, p.sprite, st.trailState)
|
||||||
p.trailBones = st.trailState.bones.map((bone) => ({ ...bone }))
|
} else if (p.trail.mesh) p.trail.mesh.visible = false
|
||||||
p.trailCurrentWidth = st.trailState.width
|
|
||||||
p.trailCurrentAlpha = st.trailState.alpha
|
|
||||||
this.skinTrailMesh(p, st.trailState)
|
|
||||||
} else if (p.trailMesh) p.trailMesh.visible = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,9 +650,7 @@ export class ParticleEmitter extends Container {
|
|||||||
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
|
||||||
p.trailHistory.length = 0
|
resetTrailState(p.trail)
|
||||||
p.trailBones.length = 0
|
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
|
||||||
}
|
}
|
||||||
this.accumulator = 0
|
this.accumulator = 0
|
||||||
this.burstTimer = 0
|
this.burstTimer = 0
|
||||||
@@ -1030,4 +678,3 @@ export class ParticleEmitter extends Container {
|
|||||||
this.rng = mulberry32(this.cfg.seed >>> 0)
|
this.rng = mulberry32(this.cfg.seed >>> 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Texture } from 'pixi.js'
|
||||||
|
import type { EmitterConfig, ParticleImageResource, TrailImageResource } from './particleTypes'
|
||||||
|
|
||||||
|
/** 粒子图片资源的出厂默认值(无可用资源时的兜底)。 */
|
||||||
|
export function defaultImageResource(texture: Texture<any> | null): ParticleImageResource {
|
||||||
|
return {
|
||||||
|
id: 1, texture, textureName: 'star', textureFolder: '',
|
||||||
|
previewUrl: '/images/star.png', imageMode: 'fixed', sequencePlayback: 'loop-forward', sequenceFps: 30, sequenceFrames: [],
|
||||||
|
weight: 100, locked: false, scale: 1, anchorX: 0.5, anchorY: 0.5,
|
||||||
|
colorMode: 'fixed', colorStart: '#ffffff', colorEnd: '#ff4d4d', blend: 'normal',
|
||||||
|
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
|
||||||
|
independentAlpha: 'off', alpha: 1, alphaCurveMode: '跟随导出',
|
||||||
|
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖尾资源的出厂默认值(无可用资源时的兜底)。 */
|
||||||
|
export function defaultTrailResource(): TrailImageResource {
|
||||||
|
return {
|
||||||
|
id: 1, texture: null, textureName: 'trail', previewUrl: '/images/trail.png', weight: 100, locked: false,
|
||||||
|
rotation: 0, colorMode: 'particle', color: '#ffffff',
|
||||||
|
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||||
|
blend: 'normal',
|
||||||
|
alphaMode: 'particle', alpha: 1, alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
boneCount: 3, trailRatio: 100,
|
||||||
|
lengthMode: 'fixed', length: 100, lengthMin: 100, lengthMax: 100,
|
||||||
|
widthSizeMode: 'fixed', width: 100, widthMin: 100, widthMax: 100, widthMode: 'particle',
|
||||||
|
lifeLengthEnabled: false, lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
shapeEnabled: false, shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按权重随机挑选一张可用图片资源。
|
||||||
|
* 注意:内部对 rng 的调用次数与原实现严格一致,保证确定性随机序列不漂移。
|
||||||
|
*/
|
||||||
|
export function pickImageResource(cfg: EmitterConfig, rng: () => number): ParticleImageResource {
|
||||||
|
const resources = cfg.imageResources.filter((resource) => resource.texture || resource.sequenceFrames.some((frame) => frame.texture))
|
||||||
|
if (!resources.length) return defaultImageResource(cfg.texture)
|
||||||
|
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
|
||||||
|
if (total <= 0) return resources[Math.floor(rng() * resources.length)]
|
||||||
|
let cursor = rng() * total
|
||||||
|
for (const resource of resources) {
|
||||||
|
cursor -= Math.max(0, resource.weight)
|
||||||
|
if (cursor <= 0) return resource
|
||||||
|
}
|
||||||
|
return resources[resources.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按权重挑选拖尾资源;使用外部传入的确定性采样值,不消耗粒子随机序列。 */
|
||||||
|
export function pickTrailResource(cfg: EmitterConfig, sample: number): TrailImageResource {
|
||||||
|
const resources = cfg.trailResources.filter((resource) => resource.texture)
|
||||||
|
if (!resources.length) return cfg.trailResources[0] || defaultTrailResource()
|
||||||
|
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
|
||||||
|
if (total <= 0) return resources[Math.min(resources.length - 1, Math.floor(sample * resources.length))]
|
||||||
|
let cursor = sample * total
|
||||||
|
for (const resource of resources) {
|
||||||
|
cursor -= Math.max(0, resource.weight)
|
||||||
|
if (cursor <= 0) return resource
|
||||||
|
}
|
||||||
|
return resources[resources.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列帧在粒子年龄处的帧号与可见性。 */
|
||||||
|
export function imageFrameState(resource: ParticleImageResource, age: number) {
|
||||||
|
if (resource.imageMode !== 'sequence' || resource.sequenceFrames.length <= 1) return { index: 0, visible: true }
|
||||||
|
const fps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
|
||||||
|
const rawIndex = Math.max(0, Math.floor(Math.max(0, age) * fps))
|
||||||
|
const lastIndex = resource.sequenceFrames.length - 1
|
||||||
|
if (resource.sequencePlayback === 'once-hide') {
|
||||||
|
return { index: Math.min(lastIndex, rawIndex), visible: rawIndex <= lastIndex }
|
||||||
|
}
|
||||||
|
if (resource.sequencePlayback === 'once-hold') return { index: Math.min(lastIndex, rawIndex), visible: true }
|
||||||
|
if (resource.sequencePlayback === 'loop-reverse') return { index: lastIndex - rawIndex % resource.sequenceFrames.length, visible: true }
|
||||||
|
return { index: rawIndex % resource.sequenceFrames.length, visible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取指定帧的纹理;单图模式直接返回主纹理,帧号越界时收敛到最近一帧。 */
|
||||||
|
export function imageFrameTexture(resource: ParticleImageResource, fallbackTexture: Texture<any>, frameIndex: number) {
|
||||||
|
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
|
||||||
|
return resource.sequenceFrames[Math.max(0, Math.min(resource.sequenceFrames.length - 1, frameIndex))]?.texture
|
||||||
|
|| resource.texture
|
||||||
|
|| fallbackTexture
|
||||||
|
}
|
||||||
|
return resource.texture || fallbackTexture
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { Graphics, SimpleMesh, Texture } from 'pixi.js'
|
||||||
|
import type { Container, Sprite } from 'pixi.js'
|
||||||
|
import type { EmitterConfig, TrailBoneState, TrailState } from './particleTypes'
|
||||||
|
import { BLEND_MAP } from './particleConstants'
|
||||||
|
import { lerp, curveAt, gradientColorAt, hexToNumber } from './particleMath'
|
||||||
|
|
||||||
|
/** 每颗粒子携带的拖尾运行状态(从 Particle 上的 12 个散落字段收拢而来)。 */
|
||||||
|
export interface ParticleTrailState {
|
||||||
|
/** 所属粒子精灵与骨骼名(由发射器在更新时回填,网格插入 z 序需要精灵)。 */
|
||||||
|
sprite: Sprite | null
|
||||||
|
boneName: string
|
||||||
|
mesh: SimpleMesh | null
|
||||||
|
meshSignature: string
|
||||||
|
history: { x: number; y: number }[]
|
||||||
|
bones: TrailBoneState[]
|
||||||
|
skinWeights: { boneA: number; boneB: number; weight: number }[]
|
||||||
|
currentWidth: number
|
||||||
|
currentAlpha: number
|
||||||
|
currentColor: number
|
||||||
|
resourceId: number
|
||||||
|
enabled: boolean
|
||||||
|
sampledLength: number
|
||||||
|
sampledWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrailState(): ParticleTrailState {
|
||||||
|
return {
|
||||||
|
sprite: null,
|
||||||
|
boneName: '',
|
||||||
|
mesh: null,
|
||||||
|
meshSignature: '',
|
||||||
|
history: [],
|
||||||
|
bones: [],
|
||||||
|
skinWeights: [],
|
||||||
|
currentWidth: 0,
|
||||||
|
currentAlpha: 0,
|
||||||
|
currentColor: 0xffffff,
|
||||||
|
resourceId: 1,
|
||||||
|
enabled: false,
|
||||||
|
sampledLength: 100,
|
||||||
|
sampledWidth: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空轨迹与骨骼(保留网格本身,只隐藏)。 */
|
||||||
|
export function resetTrailState(state: ParticleTrailState) {
|
||||||
|
state.history.length = 0
|
||||||
|
state.bones.length = 0
|
||||||
|
if (state.mesh) state.mesh.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖尾子系统:负责网格拓扑、轨迹采样、CPU 蒙皮与调试绘制。
|
||||||
|
* 一个发射器内所有粒子共用一个控制器;网格必须插入到对应粒子精灵之下绘制。
|
||||||
|
*/
|
||||||
|
export class TrailController {
|
||||||
|
private container: Container
|
||||||
|
|
||||||
|
constructor(container: Container) {
|
||||||
|
this.container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据网格行列生成拓扑,并为每个顶点绑定相邻两根拖尾骨骼。 */
|
||||||
|
private ensureMesh(p: ParticleTrailState, cfg: EmitterConfig, sprite: Sprite) {
|
||||||
|
const trailResource = cfg.trailResources.find((resource) => resource.id === p.resourceId) || cfg.trailResources[0]
|
||||||
|
const activeTrailTexture = trailResource?.texture || Texture.EMPTY
|
||||||
|
const textureRotation = Number(trailResource?.rotation) || 0
|
||||||
|
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
||||||
|
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
||||||
|
const bones = Math.max(2, Math.min(16, Math.round(trailResource?.boneCount ?? 3)))
|
||||||
|
const signature = `${rows}:${cols}:${bones}:${trailResource?.id ?? 0}:${textureRotation}`
|
||||||
|
if (p.mesh && p.meshSignature === signature) {
|
||||||
|
if (p.mesh.texture !== activeTrailTexture) p.mesh.texture = activeTrailTexture
|
||||||
|
return p.mesh
|
||||||
|
}
|
||||||
|
if (p.mesh) {
|
||||||
|
this.container.removeChild(p.mesh)
|
||||||
|
p.mesh.destroy()
|
||||||
|
}
|
||||||
|
// 拓扑或骨骼数量变化后丢弃旧轨迹,避免新权重继续套用旧骨骼位置产生偏移。
|
||||||
|
if (p.meshSignature && p.meshSignature !== signature) {
|
||||||
|
p.history.length = 0
|
||||||
|
p.bones.length = 0
|
||||||
|
}
|
||||||
|
const vertices = new Float32Array(rows * cols * 2)
|
||||||
|
const uvs = new Float32Array(rows * cols * 2)
|
||||||
|
const indices: number[] = []
|
||||||
|
const weights: { boneA: number; boneB: number; weight: number }[] = []
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const columnT = col / (cols - 1)
|
||||||
|
const bonePosition = columnT * (bones - 1)
|
||||||
|
const boneA = Math.min(bones - 1, Math.floor(bonePosition))
|
||||||
|
const boneB = Math.min(bones - 1, boneA + 1)
|
||||||
|
const weight = bonePosition - boneA
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const vertex = col * rows + row
|
||||||
|
// 骨骼链第 0 列是粒子头部;拖尾图片左侧为头。资源旋转绕 UV 中心重新绑定。
|
||||||
|
const sourceU = columnT
|
||||||
|
const sourceV = row / (rows - 1)
|
||||||
|
const angle = textureRotation * Math.PI / 180
|
||||||
|
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||||
|
uvs[vertex * 2] = 0.5 + cos * (sourceU - 0.5) - sin * (sourceV - 0.5)
|
||||||
|
uvs[vertex * 2 + 1] = 0.5 + sin * (sourceU - 0.5) + cos * (sourceV - 0.5)
|
||||||
|
weights.push({ boneA, boneB, weight })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let col = 0; col < cols - 1; col++) {
|
||||||
|
for (let row = 0; row < rows - 1; row++) {
|
||||||
|
const a = col * rows + row
|
||||||
|
const b = (col + 1) * rows + row
|
||||||
|
const c = a + 1
|
||||||
|
const d = b + 1
|
||||||
|
indices.push(a, b, c, b, d, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mesh = new SimpleMesh(activeTrailTexture, vertices as any, uvs as any, new Uint16Array(indices) as any)
|
||||||
|
mesh.visible = false
|
||||||
|
mesh.blendMode = BLEND_MAP.normal as any
|
||||||
|
// 拖尾网格必须在粒子图片下方绘制。
|
||||||
|
this.container.addChildAt(mesh, Math.max(0, this.container.getChildIndex(sprite)))
|
||||||
|
p.mesh = mesh
|
||||||
|
p.meshSignature = signature
|
||||||
|
p.skinWeights = weights
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendPoint(p: ParticleTrailState, x: number, y: number, configuredLength: number) {
|
||||||
|
const first = p.history[0]
|
||||||
|
if (!first || Math.hypot(first.x - x, first.y - y) > 0.05) p.history.unshift({ x, y })
|
||||||
|
else { first.x = x; first.y = y }
|
||||||
|
// 保留略长于最大配置长度的轨迹,生命周期曲线从短变长时不会突然断裂。
|
||||||
|
const keepDistance = Math.max(1, configuredLength) * 1.5
|
||||||
|
let distance = 0
|
||||||
|
let keepCount = p.history.length
|
||||||
|
for (let i = 1; i < p.history.length; i++) {
|
||||||
|
distance += Math.hypot(p.history[i].x - p.history[i - 1].x, p.history[i].y - p.history[i - 1].y)
|
||||||
|
if (distance >= keepDistance || i >= 239) { keepCount = i + 1; break }
|
||||||
|
}
|
||||||
|
if (p.history.length > keepCount) p.history.length = keepCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 沿历史轨迹等距生成同级拖尾骨骼;第 0 根骨骼位于粒子头部。 */
|
||||||
|
private sampleBones(p: ParticleTrailState, length: number, boneCount: number) {
|
||||||
|
const count = Math.max(2, Math.min(16, Math.round(boneCount)))
|
||||||
|
const history = p.history
|
||||||
|
const result: TrailBoneState[] = []
|
||||||
|
if (!history.length) return result
|
||||||
|
let availableLength = 0
|
||||||
|
for (let index = 1; index < history.length; index++) {
|
||||||
|
availableLength += Math.hypot(history[index].x - history[index - 1].x, history[index].y - history[index - 1].y)
|
||||||
|
}
|
||||||
|
// 轨迹不足完整长度时,不把后方骨骼堆叠在最旧采样点;所有骨骼始终在
|
||||||
|
// 当前有效轨迹内等距插值,并随可用长度增加逐渐展开到配置长度。
|
||||||
|
const sampledLength = Math.min(Math.max(0, length), availableLength)
|
||||||
|
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
||||||
|
const targetDistance = sampledLength * boneIndex / (count - 1)
|
||||||
|
let traversed = 0
|
||||||
|
let point = history[history.length - 1]
|
||||||
|
for (let i = 1; i < history.length; i++) {
|
||||||
|
const previous = history[i - 1]
|
||||||
|
const next = history[i]
|
||||||
|
const segment = Math.hypot(next.x - previous.x, next.y - previous.y)
|
||||||
|
if (traversed + segment >= targetDistance && segment > 0.0001) {
|
||||||
|
const amount = (targetDistance - traversed) / segment
|
||||||
|
point = { x: lerp(previous.x, next.x, amount), y: lerp(previous.y, next.y, amount) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
traversed += segment
|
||||||
|
}
|
||||||
|
result.push({ boneName: `${p.boneName}_trail_${boneIndex}`, x: point.x, y: point.y, rotation: 0 })
|
||||||
|
}
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
const next = result[Math.min(result.length - 1, i + 1)]
|
||||||
|
const previous = result[Math.max(0, i - 1)]
|
||||||
|
const dx = i < result.length - 1 ? next.x - result[i].x : result[i].x - previous.x
|
||||||
|
const dy = i < result.length - 1 ? next.y - result[i].y : result[i].y - previous.y
|
||||||
|
result[i].rotation = Math.atan2(dy, dx) * 180 / Math.PI
|
||||||
|
}
|
||||||
|
if (sampledLength + 0.0001 < Math.max(0, length) && result.length > 1) {
|
||||||
|
// 拖尾仍在展开阶段时,位置已经在当前有效轨迹中等距插值;旋转也在
|
||||||
|
// 头尾切线之间使用最短角度插值,避免同级骨骼驱动的网格发生方向折跳。
|
||||||
|
const startRotation = result[0].rotation
|
||||||
|
const endRotation = result[result.length - 1].rotation
|
||||||
|
const shortestDelta = ((endRotation - startRotation + 540) % 360) - 180
|
||||||
|
for (let index = 0; index < result.length; index++) {
|
||||||
|
result[index].rotation = startRotation + shortestDelta * index / (result.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由骨骼位置和顶点权重完成 CPU 蒙皮,更新 Pixi 网格。 */
|
||||||
|
private skinMesh(p: ParticleTrailState, cfg: EmitterConfig, state: TrailState) {
|
||||||
|
if (!p.sprite) return
|
||||||
|
const mesh = this.ensureMesh(p, cfg, p.sprite)
|
||||||
|
const trailResource = cfg.trailResources.find((resource) => resource.id === state.resourceId) || cfg.trailResources[0]
|
||||||
|
if (!trailResource?.texture || state.bones.length < 2) { mesh.visible = false; return }
|
||||||
|
p.resourceId = trailResource.id
|
||||||
|
if (mesh.texture !== trailResource.texture) mesh.texture = trailResource.texture
|
||||||
|
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
||||||
|
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
||||||
|
const vertices = mesh.vertices as unknown as Float32Array
|
||||||
|
const centers: { x: number; y: number }[] = []
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const skin = p.skinWeights[col * rows]
|
||||||
|
const a = state.bones[Math.min(state.bones.length - 1, skin.boneA)]
|
||||||
|
const b = state.bones[Math.min(state.bones.length - 1, skin.boneB)]
|
||||||
|
centers.push({ x: lerp(a.x, b.x, skin.weight), y: lerp(a.y, b.y, skin.weight) })
|
||||||
|
}
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const before = centers[Math.max(0, col - 1)]
|
||||||
|
const after = centers[Math.min(cols - 1, col + 1)]
|
||||||
|
const dx = after.x - before.x
|
||||||
|
const dy = after.y - before.y
|
||||||
|
const tangentLength = Math.max(0.0001, Math.hypot(dx, dy))
|
||||||
|
const normalX = -dy / tangentLength
|
||||||
|
const normalY = dx / tangentLength
|
||||||
|
const columnT = col / (cols - 1)
|
||||||
|
const shape = trailResource.shapeEnabled ? Math.max(0, curveAt(trailResource.shapeCurve, columnT)) : 1
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const rowT = row / (rows - 1) - 0.5
|
||||||
|
const offset = rowT * state.width * shape
|
||||||
|
const vertex = (col * rows + row) * 2
|
||||||
|
vertices[vertex] = centers[col].x + normalX * offset
|
||||||
|
vertices[vertex + 1] = centers[col].y + normalY * offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mesh.tint = state.colorHex
|
||||||
|
mesh.alpha = state.alpha
|
||||||
|
mesh.blendMode = BLEND_MAP[trailResource.blend] as any
|
||||||
|
mesh.visible = Math.hypot(centers[0].x - centers[cols - 1].x, centers[0].y - centers[cols - 1].y) > 0.5 && state.alpha > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每帧更新拖尾;返回用于录制/导出的 TrailState,拖尾关闭时返回 undefined。 */
|
||||||
|
update(
|
||||||
|
p: ParticleTrailState,
|
||||||
|
cfg: EmitterConfig,
|
||||||
|
boneName: string,
|
||||||
|
renderX: number,
|
||||||
|
renderY: number,
|
||||||
|
lifeT: number,
|
||||||
|
sprite: Sprite,
|
||||||
|
): TrailState | undefined {
|
||||||
|
p.sprite = sprite
|
||||||
|
p.boneName = boneName
|
||||||
|
if (!cfg.trail || !p.enabled || p.sampledLength <= 0 || p.sampledWidth <= 0) {
|
||||||
|
resetTrailState(p)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// 先确认骨骼/网格签名;发生结构变化时会在记录新轨迹前完成清理和重新绑定。
|
||||||
|
this.ensureMesh(p, cfg, sprite)
|
||||||
|
const trailResource = cfg.trailResources.find((resource) => resource.id === p.resourceId) || cfg.trailResources[0]
|
||||||
|
this.appendPoint(p, renderX, renderY, p.sampledLength)
|
||||||
|
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
|
||||||
|
const length = Math.max(0, p.sampledLength) * lengthMultiplier
|
||||||
|
p.bones = this.sampleBones(p, length, trailResource?.boneCount ?? 3)
|
||||||
|
// 首骨骼必须与粒子最终渲染位置完全一致,避免采样/重绑时出现亚像素脱节。
|
||||||
|
if (p.bones[0]) {
|
||||||
|
p.bones[0].x = renderX
|
||||||
|
p.bones[0].y = renderY
|
||||||
|
}
|
||||||
|
const particleWidthScale = trailResource?.widthMode === 'particle'
|
||||||
|
? (Math.abs(sprite.scale.x) + Math.abs(sprite.scale.y)) * 0.5
|
||||||
|
: 1
|
||||||
|
p.currentWidth = Math.max(0, p.sampledWidth) * particleWidthScale
|
||||||
|
p.currentAlpha = trailResource?.alphaMode === 'particle'
|
||||||
|
? sprite.alpha
|
||||||
|
: trailResource?.alphaMode === 'curve'
|
||||||
|
? curveAt(trailResource.alphaCurve, lifeT)
|
||||||
|
: trailResource?.alpha ?? 1
|
||||||
|
p.currentColor = trailResource?.colorMode === 'particle'
|
||||||
|
? sprite.tint as number
|
||||||
|
: trailResource?.colorMode === 'curve'
|
||||||
|
? gradientColorAt(trailResource.colorGradient, lifeT)
|
||||||
|
: hexToNumber(trailResource?.color || '#ffffff')
|
||||||
|
const state: TrailState = {
|
||||||
|
bones: p.bones.map((bone) => ({ ...bone })),
|
||||||
|
// 拖尾是一张固定拓扑的加权 Mesh,所有绑定骨骼必须成组存活。
|
||||||
|
// 历史长度不足时所有骨骼会在当前有效轨迹内等距插值,而不是延迟出生。
|
||||||
|
activeBoneCount: p.bones.length,
|
||||||
|
width: p.currentWidth,
|
||||||
|
alpha: Math.min(1, Math.max(0, p.currentAlpha)),
|
||||||
|
colorHex: p.currentColor,
|
||||||
|
resourceId: p.resourceId,
|
||||||
|
}
|
||||||
|
this.skinMesh(p, cfg, state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回放:把录制的 TrailState 还原到网格(非活动拖尾隐藏)。 */
|
||||||
|
applyState(p: ParticleTrailState, cfg: EmitterConfig, sprite: Sprite, state: TrailState) {
|
||||||
|
p.sprite = sprite
|
||||||
|
p.resourceId = state.resourceId ?? cfg.trailResources[0]?.id ?? 1
|
||||||
|
p.bones = state.bones.map((bone) => ({ ...bone }))
|
||||||
|
p.currentWidth = state.width
|
||||||
|
p.currentAlpha = state.alpha
|
||||||
|
this.skinMesh(p, cfg, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */
|
||||||
|
drawDebug(g: Graphics, visible: boolean, cfg: EmitterConfig, particles: ParticleTrailState[]) {
|
||||||
|
g.clear()
|
||||||
|
if (!visible || !cfg.trail) return
|
||||||
|
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
|
||||||
|
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
|
||||||
|
g.lineStyle(1, 0x39d7ff, 0.72)
|
||||||
|
for (const p of particles) {
|
||||||
|
const mesh = p.mesh
|
||||||
|
if (!mesh?.visible) continue
|
||||||
|
const vertices = mesh.vertices as unknown as Float32Array
|
||||||
|
const point = (col: number, row: number) => {
|
||||||
|
const index = (col * rows + row) * 2
|
||||||
|
return { x: vertices[index], y: vertices[index + 1] }
|
||||||
|
}
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const current = point(col, row)
|
||||||
|
if (col === 0) g.moveTo(current.x, current.y)
|
||||||
|
else g.lineTo(current.x, current.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const current = point(col, row)
|
||||||
|
if (row === 0) g.moveTo(current.x, current.y)
|
||||||
|
else g.lineTo(current.x, current.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.beginFill(0x8cecff, 0.9)
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
const current = point(col, row)
|
||||||
|
g.drawCircle(current.x, current.y, 1.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.endFill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-808
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useParticleStore } from '../store/particleStore'
|
||||||
|
import { setUndoLimit } from '../editor/editorHistory'
|
||||||
|
|
||||||
|
defineProps<{ show: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
|
const store = useParticleStore()
|
||||||
|
|
||||||
|
function onUndoLimitChange(event: Event) {
|
||||||
|
setUndoLimit(store, Number((event.target as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoneDebugSizes() {
|
||||||
|
store.settings.bonePointDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
|
||||||
|
store.settings.boneAxisDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="show" class="settings-panel">
|
||||||
|
<div class="sp-head">系统设置 <button class="sp-close" @click="emit('close')">✕</button></div>
|
||||||
|
<div class="sp-section-title">界面布局</div>
|
||||||
|
<label class="sp-switch-row">
|
||||||
|
<span>参数面板位置</span>
|
||||||
|
<span class="sp-switch-control">
|
||||||
|
<input type="checkbox" v-model="store.settings.parameterPanelOnRight" aria-label="参数面板显示在右侧" />
|
||||||
|
<span class="sp-switch-ui" aria-hidden="true"></span>
|
||||||
|
<b>{{ store.settings.parameterPanelOnRight ? '右侧' : '左侧' }}</b>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="sp-divider"></div>
|
||||||
|
<div class="sp-section-title">操作历史</div>
|
||||||
|
<label class="sp-row"><span>最大撤回步数</span>
|
||||||
|
<input
|
||||||
|
class="sp-num sp-undo-limit"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
v-model.number="store.settings.undoLimit"
|
||||||
|
aria-label="最大撤回步数"
|
||||||
|
@change="onUndoLimitChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="sp-divider"></div>
|
||||||
|
<div class="sp-section-title">画布辅助显示</div>
|
||||||
|
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showEmitter" />发射点</label>
|
||||||
|
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showBones" />骨骼连线</label>
|
||||||
|
<template v-if="store.settings.showBones">
|
||||||
|
<label class="sp-row"><span>骨骼点绘制大小</span>
|
||||||
|
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.bonePointDrawSize" aria-label="骨骼点绘制大小" @change="normalizeBoneDebugSizes" />
|
||||||
|
</label>
|
||||||
|
<label class="sp-row"><span>坐标轴绘制大小</span>
|
||||||
|
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.boneAxisDrawSize" aria-label="坐标轴绘制大小" @change="normalizeBoneDebugSizes" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showSkinMesh" />蒙皮网格</label>
|
||||||
|
<div class="sp-divider"></div>
|
||||||
|
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||||
|
<template v-if="store.settings.tickEnabled">
|
||||||
|
<label class="sp-row"><span>刻度文字大小</span>
|
||||||
|
<input type="range" min="8" max="40" step="1" v-model.number="store.settings.axisFontSize" />
|
||||||
|
<input type="number" min="8" max="40" v-model.number="store.settings.axisFontSize" class="sp-num" />
|
||||||
|
</label>
|
||||||
|
<label class="sp-row"><span>刻度线粗细</span>
|
||||||
|
<input type="range" min="1" max="5" step="0.5" v-model.number="store.settings.tickWidth" />
|
||||||
|
<input type="number" min="1" max="5" v-model.number="store.settings.tickWidth" class="sp-num" />
|
||||||
|
</label>
|
||||||
|
<label class="sp-row"><span>坐标轴颜色</span>
|
||||||
|
<input type="color" v-model="store.settings.axisColor" class="sp-colr" />
|
||||||
|
<span class="sp-hex">{{ store.settings.axisColor }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />显示刻度数值</label>
|
||||||
|
</template>
|
||||||
|
<div class="sp-cache-note">整个系统设置会自动保存到当前浏览器</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-panel {
|
||||||
|
position: absolute; top: 52px; left: 10px; width: 240px; z-index: 10;
|
||||||
|
background: rgba(20,20,32,0.97); border: 1px solid #2a2a3c; border-radius: 10px; padding: 10px 12px;
|
||||||
|
font-size: 12px; color: #aab; box-shadow: 0 6px 20px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
|
||||||
|
.sp-section-title { margin: 2px 0 8px; color: #8a93bb; font-size: 11px; }
|
||||||
|
.sp-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #aab; }
|
||||||
|
.sp-switch-control { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.sp-switch-control input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
|
.sp-switch-ui { position: relative; width: 34px; height: 18px; border-radius: 999px; background: #39445a; cursor: pointer; transition: background .16s ease; }
|
||||||
|
.sp-switch-ui::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #f4f6fb; transition: transform .16s ease; }
|
||||||
|
.sp-switch-control input:checked + .sp-switch-ui { background: #655df0; }
|
||||||
|
.sp-switch-control input:checked + .sp-switch-ui::after { transform: translateX(16px); }
|
||||||
|
.sp-switch-control input:focus-visible + .sp-switch-ui { outline: 2px solid #8b86ff; outline-offset: 2px; }
|
||||||
|
.sp-switch-control b { min-width: 24px; color: #8a93bb; font-size: 11px; font-weight: 500; }
|
||||||
|
.sp-cache-note { margin-top: 10px; padding-top: 9px; border-top: 1px solid #2a2a3c; color: #647089; font-size: 10px; text-align: center; }
|
||||||
|
.sp-divider { height: 1px; margin: 10px 0; background: #2a2a3c; }
|
||||||
|
.sp-close { background: #2e3a55; border: none; color: #cdf; border-radius: 4px; width: 18px; height: 18px; cursor: pointer; font-size: 11px; }
|
||||||
|
.sp-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||||
|
.sp-row > span:first-child { width: 76px; flex-shrink: 0; color: #8a93bb; }
|
||||||
|
.sp-row input[type=range] { flex: 1; background: #1a1a28; accent-color: #3a5a8c; }
|
||||||
|
.sp-num { width: 52px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; padding: 2px 4px; }
|
||||||
|
.sp-undo-limit { margin-left: auto; width: 64px; height: 26px; box-sizing: border-box; }
|
||||||
|
.sp-colr { width: 26px; height: 22px; border: none; background: none; cursor: pointer; }
|
||||||
|
.sp-hex { font-size: 11px; color: #889; font-variant-numeric: tabular-nums; }
|
||||||
|
.sp-chk { display: flex; align-items: center; gap: 6px; margin: 8px 0; color: #aab; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useParticleStore } from '../store/particleStore'
|
||||||
|
import { zoomIn, zoomOut, fitView, resetView, toggleGrid, type StageContext } from './stage/viewControls'
|
||||||
|
import { undoLastOperation, editorHistoryState } from '../editor/editorHistory'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
ctx: StageContext
|
||||||
|
activeTool: '' | 'translate' | 'rotate' | 'scale'
|
||||||
|
showSettings: boolean
|
||||||
|
showHelp: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:activeTool', v: '' | 'translate' | 'rotate' | 'scale'): void
|
||||||
|
(e: 'update:showSettings', v: boolean): void
|
||||||
|
(e: 'update:showHelp', v: boolean): void
|
||||||
|
(e: 'clear-background', resetControls: boolean): void
|
||||||
|
(e: 'background-file', event: Event): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useParticleStore()
|
||||||
|
const showBackgroundMenu = ref(false)
|
||||||
|
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const backgroundFileName = computed(() => store.settings.backgroundImageName)
|
||||||
|
|
||||||
|
function toggleTool(t: '' | 'translate' | 'rotate' | 'scale') {
|
||||||
|
emit('update:activeTool', props.activeTool === t ? '' : t)
|
||||||
|
}
|
||||||
|
function toggleBackgroundMenu() {
|
||||||
|
showBackgroundMenu.value = !showBackgroundMenu.value
|
||||||
|
if (showBackgroundMenu.value) emit('update:showSettings', false)
|
||||||
|
}
|
||||||
|
function toggleSettings() {
|
||||||
|
const next = !props.showSettings
|
||||||
|
emit('update:showSettings', next)
|
||||||
|
// 打开系统设置时关闭背景图面板,保持两个弹出层互斥(与原 Stage.vue 行为一致)。
|
||||||
|
if (next) showBackgroundMenu.value = false
|
||||||
|
}
|
||||||
|
function openHelp() {
|
||||||
|
emit('update:showHelp', true)
|
||||||
|
showBackgroundMenu.value = false
|
||||||
|
emit('update:showSettings', false)
|
||||||
|
}
|
||||||
|
function onBackgroundFileChange(event: Event) {
|
||||||
|
// 上传处理委托给父组件(需要访问 BackgroundLayer 同步纹理)
|
||||||
|
emit('background-file', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBackground(resetControls = true) {
|
||||||
|
// 文件输入框在本组件内,清空其值以便用户能再次选择同一张图片触发 change。
|
||||||
|
if (backgroundFileInput.value) backgroundFileInput.value.value = ''
|
||||||
|
emit('clear-background', resetControls)
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
undoLastOperation()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="topbar">
|
||||||
|
<button class="settings-btn" @click="toggleSettings" title="系统设置">系统设置</button>
|
||||||
|
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" title="撤回上一步 (Ctrl+Z)">↶</button>
|
||||||
|
|
||||||
|
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||||
|
<div class="tool-group">
|
||||||
|
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="toggleTool('')" title="拖动画布 (Q)">✋</button>
|
||||||
|
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="toggleTool('translate')" title="位移 (W)">✥</button>
|
||||||
|
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="toggleTool('rotate')" title="旋转 (E)">⟳</button>
|
||||||
|
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="toggleTool('scale')" title="缩放 (R)">⤢</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具按钮组(参考画布工具栏) -->
|
||||||
|
<div class="tool-group">
|
||||||
|
<button class="tool-btn" @click="zoomIn(ctx)" title="放大">+</button>
|
||||||
|
<button class="tool-btn" @click="zoomOut(ctx)" title="缩小">-</button>
|
||||||
|
<button class="tool-btn" @click="fitView(ctx)" title="适配画布">⤢</button>
|
||||||
|
<button class="tool-btn" :class="{ on: store.settings.showGrid }" @click="toggleGrid(ctx)" title="切换网格">▦</button>
|
||||||
|
<button class="tool-btn" @click="resetView(ctx)" title="重置视图">⟳</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="background-menu" @click.stop>
|
||||||
|
<button
|
||||||
|
class="background-btn"
|
||||||
|
:class="{ on: showBackgroundMenu }"
|
||||||
|
title="背景图显示设置"
|
||||||
|
@click="toggleBackgroundMenu"
|
||||||
|
>背景图</button>
|
||||||
|
<div v-if="showBackgroundMenu" class="background-panel">
|
||||||
|
<button class="background-upload" @click="backgroundFileInput?.click()">
|
||||||
|
{{ backgroundFileName ? '更换图像' : '上传图像' }}
|
||||||
|
</button>
|
||||||
|
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">清除背景</button>
|
||||||
|
<input
|
||||||
|
ref="backgroundFileInput"
|
||||||
|
class="background-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
@change="onBackgroundFileChange"
|
||||||
|
/>
|
||||||
|
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || '未上传图像' }}</div>
|
||||||
|
<div class="background-position-row">
|
||||||
|
<label><span>X</span><input v-model.number="store.settings.backgroundX" type="number" step="1" aria-label="背景图 X 偏移" /></label>
|
||||||
|
<label><span>Y</span><input v-model.number="store.settings.backgroundY" type="number" step="1" aria-label="背景图 Y 偏移" /></label>
|
||||||
|
</div>
|
||||||
|
<label class="background-opacity-row">
|
||||||
|
<span>透明度</span>
|
||||||
|
<input v-model.number="store.settings.backgroundOpacity" type="range" min="0" max="1" step="0.01" aria-label="背景图透明度" />
|
||||||
|
<b>{{ Math.round(store.settings.backgroundOpacity * 100) }}%</b>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="help-btn" :class="{ on: showHelp }" title="查看使用说明" @click="openHelp">使用说明</button>
|
||||||
|
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topbar {
|
||||||
|
position: absolute; top: 10px; left: 10px; right: 10px; display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 12px; background: rgba(20,20,32,0.85); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
font-size: 12px; color: #aab; z-index: 5; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.topbar input[type=range] { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; }
|
||||||
|
/* 工具按钮组 */
|
||||||
|
.tool-group { display: flex; gap: 6px; margin-left: 6px; padding-left: 12px; border-left: 1px solid #2a2a3c; }
|
||||||
|
.tool-btn {
|
||||||
|
width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px;
|
||||||
|
color: #aab; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.tool-btn:hover { background: #2b3a5c; color: #fff; }
|
||||||
|
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||||
|
.background-menu { position: relative; flex: 0 0 auto; }
|
||||||
|
.background-btn {
|
||||||
|
height: 28px; padding: 0 11px; border: 1px solid #3a4963; border-radius: 6px;
|
||||||
|
background: #202a3b; color: #cbd7ed; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.background-btn:hover, .background-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
|
||||||
|
.background-panel {
|
||||||
|
position: absolute; top: calc(100% + 9px); right: 0; width: 248px; box-sizing: border-box; padding: 10px;
|
||||||
|
border: 1px solid #343b50; border-radius: 9px; background: rgba(20,20,32,.98); color: #aeb8cc;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.48); z-index: 12;
|
||||||
|
}
|
||||||
|
.background-upload {
|
||||||
|
width: 100%; height: 30px; border: 1px solid #5652b5; border-radius: 6px;
|
||||||
|
background: #4540a0; color: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
|
.background-upload:hover { border-color: #8982ff; background: #554dc2; }
|
||||||
|
.background-clear {
|
||||||
|
width: 100%; height: 28px; margin-top: 6px; border: 1px solid #70404a; border-radius: 6px;
|
||||||
|
background: #512a33; color: #efb5bd; cursor: pointer;
|
||||||
|
}
|
||||||
|
.background-clear:hover:not(:disabled) { border-color: #b65b6a; background: #6a303b; color: #fff; }
|
||||||
|
.background-clear:disabled { opacity: .42; cursor: not-allowed; }
|
||||||
|
.background-file-input { display: none; }
|
||||||
|
.background-file-name { margin: 7px 2px 9px; overflow: hidden; color: #737f98; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.background-position-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.background-position-row label { display: flex; align-items: center; gap: 5px; color: #8995ad; font-size: 11px; }
|
||||||
|
.background-position-row input {
|
||||||
|
width: 0; min-width: 0; flex: 1; box-sizing: border-box; height: 28px; padding: 0 6px;
|
||||||
|
border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2;
|
||||||
|
}
|
||||||
|
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
|
||||||
|
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
|
||||||
|
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.help-btn {
|
||||||
|
height: 28px; margin-left: auto; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||||
|
background: #202a3b; color: #cbd7ed; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-btn:hover, .help-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
|
||||||
|
.zoom-pct { margin-left: 0; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||||
|
.settings-btn {
|
||||||
|
height: 28px; padding: 0 12px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||||
|
background: #202a3b; color: #cdf; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-btn:hover { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||||
|
.undo-btn {
|
||||||
|
width: 30px; height: 28px; flex: 0 0 30px; border: 1px solid #3a4963; border-radius: 6px;
|
||||||
|
background: #202a3b; color: #cdd8ed; font-size: 18px; line-height: 1; cursor: pointer;
|
||||||
|
}
|
||||||
|
.undo-btn:hover:not(:disabled) { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||||
|
.undo-btn:disabled { opacity: .38; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Sprite, Texture } from 'pixi.js'
|
||||||
|
import type { Container } from 'pixi.js'
|
||||||
|
import { fileToDataUrl } from '../../spine/spineSourceBundle'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景图层:精灵创建、纹理异步加载(带版本号防竞态)、清除与上传处理。
|
||||||
|
* 纯 store 绑定 + 精灵操作,无模拟逻辑。
|
||||||
|
*/
|
||||||
|
export class BackgroundLayer {
|
||||||
|
private sprite: Sprite | null = null
|
||||||
|
private texture: Texture | null = null
|
||||||
|
private loadVersion = 0
|
||||||
|
|
||||||
|
/** 在 world 容器内创建背景精灵(最底层)。 */
|
||||||
|
createSprite(world: Container) {
|
||||||
|
const sprite = new Sprite(Texture.EMPTY)
|
||||||
|
sprite.anchor.set(0.5)
|
||||||
|
sprite.visible = false
|
||||||
|
world.addChild(sprite)
|
||||||
|
this.sprite = sprite
|
||||||
|
return sprite
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每帧应用位置与透明度设置。 */
|
||||||
|
applyTransform(backgroundX: number, backgroundY: number, backgroundOpacity: number) {
|
||||||
|
if (!this.sprite) return
|
||||||
|
this.sprite.position.set(Number(backgroundX) || 0, -(Number(backgroundY) || 0))
|
||||||
|
this.sprite.alpha = Math.min(1, Math.max(0, Number(backgroundOpacity) || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异步把 dataUrl 加载为纹理;旧请求返回时作废。 */
|
||||||
|
async syncTexture(dataUrl: string) {
|
||||||
|
const version = ++this.loadVersion
|
||||||
|
if (!dataUrl) {
|
||||||
|
if (this.sprite) {
|
||||||
|
this.sprite.texture = Texture.EMPTY
|
||||||
|
this.sprite.visible = false
|
||||||
|
}
|
||||||
|
if (this.texture) this.texture.destroy(true)
|
||||||
|
this.texture = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const texture = await Texture.fromURL(dataUrl)
|
||||||
|
if (version !== this.loadVersion || !this.sprite) {
|
||||||
|
texture.destroy(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previous = this.texture
|
||||||
|
this.texture = texture
|
||||||
|
this.sprite.texture = texture
|
||||||
|
this.sprite.visible = true
|
||||||
|
if (previous) previous.destroy(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('背景图恢复失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除背景纹理与偏移/透明度设置(供工具栏"清除背景"按钮使用)。 */
|
||||||
|
static clearStoreBackground(store: { settings: { backgroundImageData: string; backgroundImageName: string; backgroundX: number; backgroundY: number; backgroundOpacity: number } }, resetControls: boolean) {
|
||||||
|
store.settings.backgroundImageData = ''
|
||||||
|
store.settings.backgroundImageName = ''
|
||||||
|
if (resetControls) {
|
||||||
|
store.settings.backgroundX = 0
|
||||||
|
store.settings.backgroundY = 0
|
||||||
|
store.settings.backgroundOpacity = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把上传文件写进 store(dataUrl + 文件名)。 */
|
||||||
|
static async handleFileChange(store: { settings: { backgroundImageData: string; backgroundImageName: string } }, event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
store.settings.backgroundImageData = await fileToDataUrl(file)
|
||||||
|
store.settings.backgroundImageName = file.name
|
||||||
|
} catch (error) {
|
||||||
|
console.error('背景图加载失败', error)
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卸载时释放纹理。 */
|
||||||
|
destroy() {
|
||||||
|
this.loadVersion++
|
||||||
|
if (this.texture) this.texture.destroy(true)
|
||||||
|
this.texture = null
|
||||||
|
this.sprite = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Texture, type Container } 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'
|
||||||
|
|
||||||
|
type EditorStore = ReturnType<typeof useParticleStore>
|
||||||
|
|
||||||
|
/** 发射器容器世界点 → 发射器局部坐标(数学坐标系)。 */
|
||||||
|
export function worldPointToEmitterLocal(
|
||||||
|
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
scaleX = Math.max(0.0001, Math.abs(config.rootScaleX)),
|
||||||
|
scaleY = Math.max(0.0001, Math.abs(config.rootScaleY)),
|
||||||
|
) {
|
||||||
|
const angle = -config.rootRotation * Math.PI / 180
|
||||||
|
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||||
|
const screenX = worldX - config.centerX
|
||||||
|
const screenY = -worldY + config.centerY
|
||||||
|
return { x: (cos * screenX + sin * screenY) / scaleX, y: (-sin * screenX + cos * screenY) / scaleY }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采样出生路径并把世界点/切线角转换为发射器局部坐标。 */
|
||||||
|
export function sampleSpawnPathForEmitter(
|
||||||
|
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||||
|
path: ScenePath,
|
||||||
|
progress: number,
|
||||||
|
) {
|
||||||
|
const sample = sampleScenePath(path, progress)
|
||||||
|
if (!sample) return null
|
||||||
|
const local = worldPointToEmitterLocal(config, sample.x, sample.y)
|
||||||
|
const radians = sample.angle * Math.PI / 180
|
||||||
|
const tangentWorld = worldPointToEmitterLocal(config, sample.x + Math.cos(radians), sample.y + Math.sin(radians))
|
||||||
|
const angle = Math.atan2(tangentWorld.y - local.y, tangentWorld.x - local.x) * 180 / Math.PI
|
||||||
|
return { ...local, angle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每帧预览碰撞体(含路径跟随),供粒子碰撞解算与绘制使用。 */
|
||||||
|
export function buildPreviewColliders(store: EditorStore, timeSeconds: number): CollisionBody[] {
|
||||||
|
return store.colliders.map((collider) => {
|
||||||
|
if (!collider.followPath || !collider.followPathId) return collider
|
||||||
|
const path = store.paths.find((item) => item.id === collider.followPathId && item.enabled)
|
||||||
|
if (!path?.points.length) return collider
|
||||||
|
const duration = Math.max(0.05, Number(collider.followDuration) || 2)
|
||||||
|
const normalized = Math.min(1, Math.max(0, timeSeconds / duration))
|
||||||
|
const curve = Array.isArray(collider.followCurve) ? collider.followCurve : [{ x: 0, y: 0 }, { x: 1, y: 1 }]
|
||||||
|
const curveMode = collider.followCurveMode === '跟随导出'
|
||||||
|
? (store.settings.exportSettings.keyframeCurve === 'bezier' ? 'bezier' : 'linear')
|
||||||
|
: collider.followCurveMode === '贝塞尔' ? 'bezier' : 'linear'
|
||||||
|
const progress = Math.min(1, Math.max(0, followCurveValue(curve, normalized, curveMode)))
|
||||||
|
const sample = sampleScenePath(path, progress)
|
||||||
|
return sample
|
||||||
|
? {
|
||||||
|
...collider,
|
||||||
|
x: sample.x,
|
||||||
|
y: sample.y,
|
||||||
|
rotation: collider.rotation + (collider.followPathRotation ? sample.angle : 0),
|
||||||
|
}
|
||||||
|
: collider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发射器集合:按 store.systems 增删 ParticleEmitter,并在纹理就绪后补挂默认贴图。 */
|
||||||
|
export class EmitterSync {
|
||||||
|
readonly emitters = new Map<number, ParticleEmitter>()
|
||||||
|
private configs = new Map<number, object>()
|
||||||
|
|
||||||
|
sync(
|
||||||
|
store: EditorStore,
|
||||||
|
world: Container,
|
||||||
|
dotTex: Texture<any> | null,
|
||||||
|
trailTex: Texture<any> | 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.destroy({ children: true })
|
||||||
|
this.emitters.delete(id)
|
||||||
|
this.configs.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sys of store.systems) {
|
||||||
|
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
|
||||||
|
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
|
||||||
|
const cfg = ensureEmitterConfig(sys.config as any)
|
||||||
|
if (trailTex) {
|
||||||
|
for (const resource of cfg.trailResources) {
|
||||||
|
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dotTex) {
|
||||||
|
for (const resource of cfg.imageResources) {
|
||||||
|
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.emitters.has(sys.id)) continue
|
||||||
|
const em = new ParticleEmitter(sys.config as any)
|
||||||
|
world.addChild(em)
|
||||||
|
this.emitters.set(sys.id, em)
|
||||||
|
this.configs.set(sys.id, sys.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卸载时销毁全部发射器。 */
|
||||||
|
destroy(world: Container) {
|
||||||
|
for (const [id, em] of this.emitters) {
|
||||||
|
world.removeChild(em)
|
||||||
|
em.destroy({ children: true })
|
||||||
|
this.emitters.delete(id)
|
||||||
|
this.configs.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { SceneTransformTarget } from './stageTypes'
|
||||||
|
import { installPointerDrag } from './pointerDrag'
|
||||||
|
import { pointerToWorldMath, type StageContext } from './viewControls'
|
||||||
|
|
||||||
|
/** 场景对象命中交互:碰撞体拖拽、力场/吸附点拖拽、根变换 gizmo 三工具手势。 */
|
||||||
|
export class ObjectInteractions {
|
||||||
|
constructor(private ctx: StageContext, private getTool: () => '' | 'translate' | 'rotate' | 'scale') {}
|
||||||
|
|
||||||
|
/** 命中当前选中碰撞体时拖拽其中心;返回 true 表示事件已被接管。 */
|
||||||
|
onColliderPointerDown(e: PointerEvent): boolean {
|
||||||
|
const store = this.ctx.store
|
||||||
|
if (store.activeObjectType !== 'collision') return false
|
||||||
|
const collider = store.colliders.find((item) => item.id === store.activeObjectId)
|
||||||
|
if (!collider?.enabled || collider.visible === false) return false
|
||||||
|
const pointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||||
|
const angle = collider.rotation * Math.PI / 180
|
||||||
|
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||||
|
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
|
||||||
|
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
|
||||||
|
const dx = pointer.x - collider.x, dy = pointer.y - collider.y
|
||||||
|
const localX = (cos * dx + sin * dy) / scaleX, localY = (-sin * dx + cos * dy) / scaleY
|
||||||
|
const hit = collider.shape === 'circle'
|
||||||
|
? Math.hypot(localX, localY) <= collider.radius
|
||||||
|
: collider.shape === 'ellipse'
|
||||||
|
? Math.hypot(localX / Math.max(1, collider.radiusX), localY / Math.max(1, collider.radiusY)) <= 1
|
||||||
|
: Math.abs(localX) <= collider.width * 0.5 && Math.abs(localY) <= collider.height * 0.5
|
||||||
|
if (!hit) return false
|
||||||
|
const startX = collider.x, startY = collider.y, start = pointer
|
||||||
|
installPointerDrag((event) => {
|
||||||
|
const next = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
collider.x = startX + next.x - start.x
|
||||||
|
collider.y = startY + next.y - start.y
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||||
|
onForceFieldDown(e: PointerEvent): boolean {
|
||||||
|
const sys = this.ctx.store.systems.find((item) => item.id === this.ctx.store.activeId)
|
||||||
|
if (sys?.visible === false) return false
|
||||||
|
const cfg = sys?.config
|
||||||
|
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
|
||||||
|
const startPointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||||
|
const distance = Math.hypot(startPointer.x - cfg.forceCenterX, startPointer.y - cfg.forceCenterY)
|
||||||
|
if (distance > cfg.forceRadius) return false
|
||||||
|
const startX = cfg.forceCenterX
|
||||||
|
const startY = cfg.forceCenterY
|
||||||
|
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = 'grabbing'
|
||||||
|
const move = (event: PointerEvent) => {
|
||||||
|
const pointer = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
cfg.forceCenterX = startX + pointer.x - startPointer.x
|
||||||
|
cfg.forceCenterY = startY + pointer.y - startPointer.y
|
||||||
|
}
|
||||||
|
const up = () => {
|
||||||
|
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = ''
|
||||||
|
window.removeEventListener('pointermove', move)
|
||||||
|
window.removeEventListener('pointerup', up)
|
||||||
|
window.removeEventListener('pointercancel', up)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move)
|
||||||
|
window.addEventListener('pointerup', up)
|
||||||
|
window.addEventListener('pointercancel', up)
|
||||||
|
e.preventDefault()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
|
||||||
|
onAttractionDown(e: PointerEvent): boolean {
|
||||||
|
const sys = this.ctx.store.systems.find((item) => item.id === this.ctx.store.activeId)
|
||||||
|
if (sys?.visible === false) return false
|
||||||
|
const cfg = sys?.config
|
||||||
|
if (!cfg?.attraction) return false
|
||||||
|
const startPointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||||
|
const hitRadius = Math.max(cfg.attractionRadius, 12 / Math.max(0.0001, this.ctx.store.editor.viewScale))
|
||||||
|
const distance = Math.hypot(startPointer.x - cfg.attractionCenterX, startPointer.y - cfg.attractionCenterY)
|
||||||
|
if (distance > hitRadius) return false
|
||||||
|
const startX = cfg.attractionCenterX
|
||||||
|
const startY = cfg.attractionCenterY
|
||||||
|
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = 'grabbing'
|
||||||
|
const move = (event: PointerEvent) => {
|
||||||
|
const pointer = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
cfg.attractionCenterX = startX + pointer.x - startPointer.x
|
||||||
|
cfg.attractionCenterY = startY + pointer.y - startPointer.y
|
||||||
|
}
|
||||||
|
const up = () => {
|
||||||
|
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = ''
|
||||||
|
window.removeEventListener('pointermove', move)
|
||||||
|
window.removeEventListener('pointerup', up)
|
||||||
|
window.removeEventListener('pointercancel', up)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move)
|
||||||
|
window.addEventListener('pointerup', up)
|
||||||
|
window.addEventListener('pointercancel', up)
|
||||||
|
e.preventDefault()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依据层级树当前选中对象生成 gizmo 目标(读写其 x/y/rotation/scale)。 */
|
||||||
|
selectedTransformTarget(): SceneTransformTarget | null {
|
||||||
|
const store = this.ctx.store
|
||||||
|
const id = store.activeObjectId
|
||||||
|
if (store.activeObjectType === 'particle') {
|
||||||
|
const system = store.systems.find((item) => item.id === id)
|
||||||
|
if (!system || system.visible === false) return null
|
||||||
|
const config = system.config
|
||||||
|
return {
|
||||||
|
x: config.centerX, y: config.centerY, rotation: config.rootRotation,
|
||||||
|
scaleX: config.rootScaleX ?? 1, scaleY: config.rootScaleY ?? 1,
|
||||||
|
setPosition: (x, y) => { config.centerX = x; config.centerY = y },
|
||||||
|
setRotation: (rotation) => { config.rootRotation = rotation },
|
||||||
|
setScale: (scaleX, scaleY) => { config.rootScaleX = scaleX; config.rootScaleY = scaleY },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const collection = store.activeObjectType === 'collision'
|
||||||
|
? store.colliders
|
||||||
|
: store.activeObjectType === 'path'
|
||||||
|
? store.paths
|
||||||
|
: store.spines
|
||||||
|
const object = collection.find((item) => item.id === id) as any
|
||||||
|
if (!object || object.visible === false) return null
|
||||||
|
return {
|
||||||
|
x: Number(object.x) || 0, y: Number(object.y) || 0, rotation: Number(object.rotation) || 0,
|
||||||
|
scaleX: Math.max(0.05, Number(object.scaleX) || 1), scaleY: Math.max(0.05, Number(object.scaleY) || 1),
|
||||||
|
setPosition: (x, y) => { object.x = x; object.y = y },
|
||||||
|
setRotation: (rotation) => { object.rotation = rotation },
|
||||||
|
setScale: (scaleX, scaleY) => { object.scaleX = scaleX; object.scaleY = scaleY },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场景对象 gizmo 拖拽:作用于场景树当前选中对象。 */
|
||||||
|
onGizmoDown(e: PointerEvent) {
|
||||||
|
const target = this.selectedTransformTarget()
|
||||||
|
if (!target) return
|
||||||
|
const app = this.ctx.app()!
|
||||||
|
const store = this.ctx.store
|
||||||
|
const sc = store.editor.viewScale
|
||||||
|
const rect = this.ctx.holder.value!.getBoundingClientRect()
|
||||||
|
const cx = rect.left + app.screen.width / 2 + store.editor.viewX + target.x * sc
|
||||||
|
const cy = rect.top + app.screen.height * 0.5 + store.editor.viewY + (-target.y) * sc
|
||||||
|
const startX = e.clientX, startY = e.clientY
|
||||||
|
const tool = this.getTool()
|
||||||
|
// 初始基准(按下时快照,后续基于快照 + 相对按下点的总增量,避免累积误差/抖动)
|
||||||
|
const sCx = target.x, sCy = target.y
|
||||||
|
const sRot = target.rotation
|
||||||
|
const sSx = target.scaleX, sSy = target.scaleY
|
||||||
|
const startDist = Math.hypot(startX - cx, startY - cy) || 1
|
||||||
|
const startAngle = Math.atan2(startY - cy, startX - cx)
|
||||||
|
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const dx = (ev.clientX - startX) / sc
|
||||||
|
const dy = (ev.clientY - startY) / sc
|
||||||
|
if (tool === 'translate') {
|
||||||
|
// 位移:按下基准 + 总位移(离手快照,不累积)
|
||||||
|
target.setPosition(sCx + dx, sCy - dy)
|
||||||
|
} else if (tool === 'rotate') {
|
||||||
|
const a = Math.atan2(ev.clientY - cy, ev.clientX - cx)
|
||||||
|
// 跟手:拖拽环动方向 = 旋转方向(逆时针环动→逆时针,顺时针环动→顺时针)。
|
||||||
|
// 渲染层 em.rotation=-rootRotation(屏幕坐标),故这里对屏幕 atan2 增量取反,使 rootRotation 在数学坐标下与拖拽环动同向。
|
||||||
|
target.setRotation(sRot - (a - startAngle) * 180 / Math.PI)
|
||||||
|
} else if (tool === 'scale') {
|
||||||
|
const nd = Math.hypot(ev.clientX - cx, ev.clientY - cy)
|
||||||
|
const f = nd / startDist
|
||||||
|
target.setScale(Math.max(0.05, sSx * f), Math.max(0.05, sSy * f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installPointerDrag(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { worldToPathLocal } from './stageGeometry'
|
||||||
|
import { installPointerDrag } from './pointerDrag'
|
||||||
|
import { pointerToWorldMath, type StageContext } from './viewControls'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径节点编辑:双击增删锚点、单击选中/多选/反选、框选、锚点与贝塞尔手柄拖拽。
|
||||||
|
* 与原 Stage.vue 实现逐行为等价,仅把函数移到模块并显式传入共享状态。
|
||||||
|
*/
|
||||||
|
export class PathEditing {
|
||||||
|
/** 多选节点集合(选中层级树路径对象时启用)。 */
|
||||||
|
readonly selectedNodes = new Set<number>()
|
||||||
|
/** 双击后抑制紧随其后的又一次双击(单击建锚已处理过)。 */
|
||||||
|
private suppressNextDoubleClick = false
|
||||||
|
|
||||||
|
constructor(private ctx: StageContext) {}
|
||||||
|
|
||||||
|
resetSelection() {
|
||||||
|
this.selectedNodes.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
activePath() {
|
||||||
|
const store = this.ctx.store
|
||||||
|
if (store.activeObjectType !== 'path') return null
|
||||||
|
const path = store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||||
|
return path?.visible === false ? null : path
|
||||||
|
}
|
||||||
|
|
||||||
|
private pathInsertIndex(path: NonNullable<ReturnType<PathEditing['activePath']>>, x: number, y: number) {
|
||||||
|
if (path.points.length < 2) return path.points.length
|
||||||
|
const segments = path.closed ? path.points.length : path.points.length - 1
|
||||||
|
let bestIndex = path.points.length
|
||||||
|
let bestDistance = Infinity
|
||||||
|
for (let index = 0; index < segments; index++) {
|
||||||
|
const a = path.points[index], b = path.points[(index + 1) % path.points.length]
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y
|
||||||
|
const lengthSq = Math.max(0.0001, dx * dx + dy * dy)
|
||||||
|
const amount = Math.min(1, Math.max(0, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq))
|
||||||
|
const px = a.x + dx * amount, py = a.y + dy * amount
|
||||||
|
const distance = Math.hypot(x - px, y - py)
|
||||||
|
if (distance < bestDistance) { bestDistance = distance; bestIndex = index + 1 }
|
||||||
|
}
|
||||||
|
return bestIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 双击:删除命中的锚点,否则在最近线段处插入新锚点。 */
|
||||||
|
onStageDoubleClick(e: MouseEvent) {
|
||||||
|
const path = this.activePath()
|
||||||
|
if (!path || !path.enabled) return
|
||||||
|
if (this.suppressNextDoubleClick) { this.suppressNextDoubleClick = false; return }
|
||||||
|
const pointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||||
|
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||||
|
const threshold = 9 / Math.max(0.01, this.ctx.store.editor.viewScale)
|
||||||
|
const anchorIndex = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||||
|
if (anchorIndex >= 0) {
|
||||||
|
path.points.splice(anchorIndex, 1)
|
||||||
|
this.selectedNodes.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const insertIndex = this.pathInsertIndex(path, local.x, local.y)
|
||||||
|
path.points.splice(insertIndex, 0, { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||||
|
this.selectedNodes.clear()
|
||||||
|
this.selectedNodes.add(insertIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单击路径:返回 true 表示事件已被路径编辑接管。 */
|
||||||
|
onPointerDown(e: PointerEvent): boolean {
|
||||||
|
const path = this.activePath()
|
||||||
|
if (!path || !path.enabled) return false
|
||||||
|
const pointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||||
|
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||||
|
const threshold = 9 / Math.max(0.01, this.ctx.store.editor.viewScale)
|
||||||
|
if (e.detail >= 2) {
|
||||||
|
const existing = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||||
|
if (existing >= 0) return true
|
||||||
|
const anchor = { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 }
|
||||||
|
const insertIndex = this.pathInsertIndex(path, local.x, local.y)
|
||||||
|
path.points.splice(insertIndex, 0, anchor)
|
||||||
|
this.selectedNodes.clear()
|
||||||
|
this.selectedNodes.add(insertIndex)
|
||||||
|
this.suppressNextDoubleClick = true
|
||||||
|
installPointerDrag((event) => {
|
||||||
|
const worldPoint = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||||
|
anchor.outX = next.x - anchor.x
|
||||||
|
anchor.outY = next.y - anchor.y
|
||||||
|
anchor.inX = -anchor.outX
|
||||||
|
anchor.inY = -anchor.outY
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let handleHit: { index: number; side: 'in' | 'out' } | null = null
|
||||||
|
for (const index of this.selectedNodes) {
|
||||||
|
const point = path.points[index]
|
||||||
|
if (!point) continue
|
||||||
|
if (Math.hypot(point.x + point.inX - local.x, point.y + point.inY - local.y) <= threshold) handleHit = { index, side: 'in' }
|
||||||
|
if (Math.hypot(point.x + point.outX - local.x, point.y + point.outY - local.y) <= threshold) handleHit = { index, side: 'out' }
|
||||||
|
}
|
||||||
|
if (handleHit) {
|
||||||
|
const point = path.points[handleHit.index]
|
||||||
|
const oppositeLength = handleHit.side === 'in' ? Math.hypot(point.outX, point.outY) : Math.hypot(point.inX, point.inY)
|
||||||
|
const move = (event: PointerEvent) => {
|
||||||
|
const worldPoint = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||||
|
const hx = next.x - point.x, hy = next.y - point.y
|
||||||
|
if (handleHit!.side === 'in') { point.inX = hx; point.inY = hy } else { point.outX = hx; point.outY = hy }
|
||||||
|
if (!event.altKey) {
|
||||||
|
const length = Math.max(0.0001, Math.hypot(hx, hy))
|
||||||
|
const mirrorLength = event.shiftKey ? length : oppositeLength
|
||||||
|
if (handleHit!.side === 'in') { point.outX = -hx / length * mirrorLength; point.outY = -hy / length * mirrorLength }
|
||||||
|
else { point.inX = -hx / length * mirrorLength; point.inY = -hy / length * mirrorLength }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installPointerDrag(move)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const anchorIndex = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||||
|
if (anchorIndex >= 0) {
|
||||||
|
if (e.altKey) { this.selectedNodes.delete(anchorIndex); return true }
|
||||||
|
if (e.ctrlKey || e.metaKey) this.selectedNodes.add(anchorIndex)
|
||||||
|
else if (!this.selectedNodes.has(anchorIndex)) { this.selectedNodes.clear(); this.selectedNodes.add(anchorIndex) }
|
||||||
|
const start = local
|
||||||
|
const origins = [...this.selectedNodes].map((index) => ({ index, x: path.points[index].x, y: path.points[index].y }))
|
||||||
|
const move = (event: PointerEvent) => {
|
||||||
|
const worldPoint = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||||
|
for (const origin of origins) {
|
||||||
|
path.points[origin.index].x = origin.x + next.x - start.x
|
||||||
|
path.points[origin.index].y = origin.y + next.y - start.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installPointerDrag(move)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (e.altKey && !e.ctrlKey && !e.metaKey) { this.selectedNodes.clear(); return true }
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||||
|
const start = local
|
||||||
|
const subtract = e.altKey
|
||||||
|
const up = (event: PointerEvent) => {
|
||||||
|
const endWorld = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||||
|
const end = worldToPathLocal(path, endWorld.x, endWorld.y)
|
||||||
|
const minX = Math.min(start.x, end.x), maxX = Math.max(start.x, end.x)
|
||||||
|
const minY = Math.min(start.y, end.y), maxY = Math.max(start.y, end.y)
|
||||||
|
path.points.forEach((point, index) => {
|
||||||
|
if (point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY) {
|
||||||
|
if (subtract) this.selectedNodes.delete(index); else this.selectedNodes.add(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.removeEventListener('pointerup', up)
|
||||||
|
window.removeEventListener('pointercancel', up)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerup', up)
|
||||||
|
window.addEventListener('pointercancel', up)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 统一的指针拖拽安装器:move 挂到 pointermove,pointerup/pointercancel 时自动清理。
|
||||||
|
* 原先散落在 Stage.vue 各拖拽手势中的重复样板收拢于此。
|
||||||
|
*/
|
||||||
|
export function installPointerDrag(move: (event: PointerEvent) => void) {
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move)
|
||||||
|
window.removeEventListener('pointerup', up)
|
||||||
|
window.removeEventListener('pointercancel', up)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move)
|
||||||
|
window.addEventListener('pointerup', up)
|
||||||
|
window.addEventListener('pointercancel', up)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Texture } from 'pixi.js'
|
||||||
|
import { ensureEmitterConfig } from '../../core/particleEmitter'
|
||||||
|
import type { useParticleStore } from '../../store/particleStore'
|
||||||
|
|
||||||
|
type EditorStore = ReturnType<typeof useParticleStore>
|
||||||
|
|
||||||
|
/** 加载内置粒子点与拖尾纹理(仅加载一次)。 */
|
||||||
|
export async function loadDefaultTextures() {
|
||||||
|
const [particleTexture, trailTexture] = await Promise.all([
|
||||||
|
Texture.fromURL('/images/star.png'),
|
||||||
|
Texture.fromURL('/images/trail.png'),
|
||||||
|
])
|
||||||
|
return { dotTex: particleTexture, trailTex: trailTexture }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把默认贴图补挂到尚未绑定纹理的系统与资源上(挂载时执行一次)。 */
|
||||||
|
export function attachDefaultTextures(store: EditorStore, dotTex: Texture<any>, trailTex: Texture<any> | null) {
|
||||||
|
for (const sys of store.systems) {
|
||||||
|
sys.config.texture = dotTex
|
||||||
|
const cfg = ensureEmitterConfig(sys.config as any)
|
||||||
|
if (trailTex) {
|
||||||
|
for (const resource of cfg.trailResources) {
|
||||||
|
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const resource of cfg.imageResources) {
|
||||||
|
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Application } from 'pixi.js'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { useParticleStore } from '../../store/particleStore'
|
||||||
|
import { installPointerDrag } from './pointerDrag'
|
||||||
|
|
||||||
|
type EditorStore = ReturnType<typeof useParticleStore>
|
||||||
|
|
||||||
|
/** Stage 各交互/渲染模块共享的运行时引用。 */
|
||||||
|
export interface StageContext {
|
||||||
|
store: EditorStore
|
||||||
|
/** 画布容器 DOM(Pixi canvas 的父元素) */
|
||||||
|
holder: Ref<HTMLElement | null>
|
||||||
|
/** Pixi Application(初始化前为 null) */
|
||||||
|
app: () => Application | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 屏幕像素坐标 → 世界数学坐标(Y 向上)。 */
|
||||||
|
export function pointerToWorldMath(ctx: StageContext, clientX: number, clientY: number) {
|
||||||
|
const holder = ctx.holder.value!
|
||||||
|
const app = ctx.app()!
|
||||||
|
const rect = holder.getBoundingClientRect()
|
||||||
|
const scale = Math.max(0.0001, ctx.store.editor.viewScale)
|
||||||
|
const screenX = clientX - rect.left
|
||||||
|
const screenY = clientY - rect.top
|
||||||
|
return {
|
||||||
|
x: (screenX - app.screen.width / 2 - ctx.store.editor.viewX) / scale,
|
||||||
|
y: -(screenY - app.screen.height * 0.5 - ctx.store.editor.viewY) / scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚轮缩放画布(viewScale),基于鼠标所在位置缩放更平滑。 */
|
||||||
|
export function onWheel(ctx: StageContext, e: WheelEvent) {
|
||||||
|
const holder = ctx.holder.value!
|
||||||
|
const app = ctx.app()!
|
||||||
|
const store = ctx.store
|
||||||
|
const rect = holder.getBoundingClientRect()
|
||||||
|
const mx = e.clientX - rect.left
|
||||||
|
const my = e.clientY - rect.top
|
||||||
|
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
|
||||||
|
const old = store.editor.viewScale
|
||||||
|
const nn = Math.max(0.01, old * factor)
|
||||||
|
// 保持鼠标下的世界点不动:调整 viewX/viewY
|
||||||
|
const cx = app.screen.width / 2
|
||||||
|
const cy = app.screen.height * 0.5
|
||||||
|
const oldVX = store.editor.viewX
|
||||||
|
const oldVY = store.editor.viewY
|
||||||
|
// 鼠标处 world 坐标(之前),需扣除当前平移偏移
|
||||||
|
const wx = (mx - cx - oldVX) / old
|
||||||
|
const wy = (my - cy - oldVY) / old
|
||||||
|
store.editor.viewX = mx - cx - wx * nn
|
||||||
|
store.editor.viewY = my - cy - wy * nn
|
||||||
|
store.editor.viewScale = nn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 鼠标左键长按拖动画布(平移)。 */
|
||||||
|
export function panView(ctx: StageContext, startX: number, startY: number) {
|
||||||
|
const store = ctx.store
|
||||||
|
const vx0 = store.editor.viewX
|
||||||
|
const vy0 = store.editor.viewY
|
||||||
|
installPointerDrag((ev) => {
|
||||||
|
store.editor.viewX = vx0 + (ev.clientX - startX)
|
||||||
|
store.editor.viewY = vy0 + (ev.clientY - startY)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewScaleBy(ctx: StageContext, factor: number) {
|
||||||
|
const nn = Math.max(0.01, ctx.store.editor.viewScale * factor)
|
||||||
|
ctx.store.editor.viewScale = nn
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomIn(ctx: StageContext) { viewScaleBy(ctx, 1.2) }
|
||||||
|
export function zoomOut(ctx: StageContext) { viewScaleBy(ctx, 1 / 1.2) }
|
||||||
|
|
||||||
|
export function fitView(ctx: StageContext) {
|
||||||
|
ctx.store.editor.viewScale = 1.0
|
||||||
|
ctx.store.editor.viewX = 0
|
||||||
|
ctx.store.editor.viewY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetView(ctx: StageContext) {
|
||||||
|
ctx.store.editor.viewScale = 1.0
|
||||||
|
ctx.store.editor.viewX = 0
|
||||||
|
ctx.store.editor.viewY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGrid(ctx: StageContext) {
|
||||||
|
ctx.store.settings.showGrid = !ctx.store.settings.showGrid
|
||||||
|
}
|
||||||
Reference in new issue
Block a user