中英双语
This commit is contained in:
1 parent
ea26af26ee
commit
b98c0333e6
50 files changed
+4082
-2049
No files matched your search
Vendored
+1
@@ -0,0 +1 @@
|
||||
.help-overlay[data-v-4bb9bc76]{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-4bb9bc76]{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-4bb9bc76]{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-4bb9bc76]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-4bb9bc76]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-4bb9bc76]{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-4bb9bc76]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-4bb9bc76]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-4bb9bc76]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-4bb9bc76]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-4bb9bc76]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-4bb9bc76]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-4bb9bc76]{color:#dce4f7}.help-grid[data-v-4bb9bc76]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-4bb9bc76]{display:grid;gap:0;margin:0}.help-list>div[data-v-4bb9bc76]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-4bb9bc76]:last-child{border-bottom:0}.help-list dt[data-v-4bb9bc76]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-4bb9bc76]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-4bb9bc76]{display:grid;gap:7px}.shortcut-list>div[data-v-4bb9bc76]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-4bb9bc76]{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-4bb9bc76]{color:#65728b;font-size:10px}.shortcut-list em[data-v-4bb9bc76]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-4bb9bc76]{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-4bb9bc76]{margin-top:12px}.help-export p[data-v-4bb9bc76]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-4bb9bc76]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-4bb9bc76]{margin-top:12px}.help-module-head[data-v-4bb9bc76]{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-4bb9bc76]:hover{color:#fff}.help-module-head .mtitle[data-v-4bb9bc76]{flex:1;min-width:0}.help-module-head .caret[data-v-4bb9bc76]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-4bb9bc76]{color:#fff}.help-module.open .help-module-head .caret[data-v-4bb9bc76]{color:#9fb2ff}.help-module .mnum[data-v-4bb9bc76]{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-4bb9bc76]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-4bb9bc76]{color:#b8c2ff}.help-module .connector-intro[data-v-4bb9bc76]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-4bb9bc76]{color:#dce4f7}.help-module .dl-link[data-v-4bb9bc76]{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-4bb9bc76]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-4bb9bc76]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-4bb9bc76]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-4bb9bc76]{padding:66px 12px 14px}.help-grid[data-v-4bb9bc76]{grid-template-columns:1fr}}.topbar[data-v-6411b316]{position:absolute;top:10px;left:10px;right:10px;dispLine 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-Bjqx1iPW.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-Bjqx1iPW.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-ChTSy5jk.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-ChTSy5jk.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-DQ5rq6hP.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-DQ5rq6hP.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-Ck8IwYQq.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-Ck8IwYQq.js";if(typeof window<"u"&&window.PIXI){const m=window.require;window.require=o=>{if(m)return m(o);if(o.startsWith("@pixi/"))return window.PIXI}}const _=class _ extends Fe{constructor(e){super(e.resource.source);a(this,"texture");this.texture=he.from(e)}static from(e){return _.textureMap.has(e)?_.textureMap.get(e):new _(e)}setFilters(e,t){this.texture.baseTexture.scaleMode=_.toPixiTextureFilter(e),this.texture.baseTexture.mipmap=_.toPixiMipMap(e)}setWraps(e,t){this.texture.baseTexture.wrapMode=_.toPixiTextureWrap(e)}dispose(){this.texture.destroy()}static toPixiTextureFilter(e){switch(e){case F.Nearest:case F.MipMapNearestLinear:case F.MipMapNearestNearest:return ue.NEAREST;case F.Linear:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return ue.LINEAR;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiMipMap(e){switch(e){case F.Nearest:case F.Linear:return pe.OFF;case F.MipMapNearestLinear:case F.MipMapNearestNearest:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return pe.ON;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiTextureWrap(e){switch(e){case oe.ClampToEdge:return ne.CLAMP;case oe.MirroredRepeat:return ne.MIRRORED_REPEAT;case oe.Repeat:return ne.REPEAT;default:throw new Error(`Unknown texture wrap: ${String(e)}`)}}static toPixiBlending(e){switch(e){case H.Normal:return z.NORMAL;case H.Additive:return z.ADD;case H.Multiply:return z.MULTIPLY;case H.Screen:return z.SCREEN;default:throw new Error(`Unknown blendMode: ${String(e)}`)}}};a(_,"textureMap",new Map);let G=_;const K="spineTextureAtlasLoader",et={extension:O.Asset,resolver:{test:m=>V(m,".atlas"),parse:m=>{var e,t;const o=m.split(".");return{resolution:parseFloat(((t=(e=re.RETINA_PREFIX)==null?void 0:e.exec(m))==null?void 0:t[1])??"1"),format:o[o.length-2],src:m}}},loader:{name:K,extension:{type:O.LoadParser,priority:Te.Normal,name:K},test(m){return V(m,".atlas")},async load(m){const o=await re.ADAPTER.fetch(m);if(!o.ok)throw new Error(`[${K}] Failed to fetch ${m}: ${o.status} ${o.statusText}`);return await o.text()},testParse(m,o){const e=V(o.src,".atlas"),t=typeof m=="string",s=o.loadParser===K;return Promise.resolve((e||s)&&t)},unload(m){m.dispose()},async parse(m,o,e){var p;const t=o.data||{};let s=q.dirname(o.src);s&&s.lastIndexOf("/")!==s.length-1&&(s+="/");const n=new Ee(m);if(t.images instanceof fe||typeof t.images=="string"){const h=t.images;t.images={},t.images[n.pages[0].name]=h}const r=[];let l=!0;for(const h of e.parsers)if(h.name==="loadTextures"){l=(p=h.config)==null?void 0:p.preferCreateImageBitmap;break}Q.setPreferences({preferCreateImageBitmap:!1});for(const h of n.pages){const u=h.name,c=t!=null&&t.images?t.images[u]:void 0;if(c instanceof fe)h.setTexture(G.from(c));else{const i=c??q.normalize([...s.split(q.sep),u].join(q.sep)),g={src:De(i,o.src),data:{...t.imageMetadLine truncated
|
||||
attribute vec2 aVertexPosition;
|
||||
attribute vec2 aTextureCoord;
|
||||
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
.help-overlay[data-v-78204b9d]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:72px 24px 28px;box-sizing:border-box;background:#05080fb8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.help-dialog[data-v-78204b9d]{width:min(820px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #3a4864;border-radius:14px;background:#141a27;color:#b8c3d8;box-shadow:0 22px 70px #0000009e}.help-head[data-v-78204b9d]{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px 20px 15px;border-bottom:1px solid #2b354a;background:linear-gradient(135deg,#202942,#181e2d)}.help-head h2[data-v-78204b9d]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-78204b9d]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-78204b9d]{width:28px;height:28px;flex:0 0 28px;border:1px solid #43516b;border-radius:6px;background:#273249;color:#ccd7ed;cursor:pointer}.help-close[data-v-78204b9d]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-78204b9d]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-78204b9d]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-78204b9d]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-78204b9d]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-78204b9d]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-78204b9d]{color:#dce4f7}.help-grid[data-v-78204b9d]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-78204b9d]{display:grid;gap:0;margin:0}.help-list>div[data-v-78204b9d]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-78204b9d]:last-child{border-bottom:0}.help-list dt[data-v-78204b9d]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-78204b9d]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-78204b9d]{display:grid;gap:7px}.shortcut-list>div[data-v-78204b9d]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-78204b9d]{min-width:24px;box-sizing:border-box;padding:3px 6px;border:1px solid #465470;border-bottom-width:2px;border-radius:5px;background:#101623;color:#e0e7f6;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center}.shortcut-list span[data-v-78204b9d]{color:#65728b;font-size:10px}.shortcut-list em[data-v-78204b9d]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-78204b9d]{margin:11px 0 0;padding-top:9px;border-top:1px solid #2a3549;color:#697791;font-size:10px;line-height:1.55}.help-export[data-v-78204b9d]{margin-top:12px}.help-export p[data-v-78204b9d]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-78204b9d]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-78204b9d]{margin-top:12px}.help-module-head[data-v-78204b9d]{display:flex;align-items:center;gap:8px;width:100%;padding:0;border:0;background:transparent;color:#9fb2ff;font:inherit;font-size:13px;font-weight:700;text-align:left;cursor:pointer}.help-module-head[data-v-78204b9d]:hover{color:#fff}.help-module-head .mtitle[data-v-78204b9d]{flex:1;min-width:0}.help-module-head .caret[data-v-78204b9d]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-78204b9d]{color:#fff}.help-module.open .help-module-head .caret[data-v-78204b9d]{color:#9fb2ff}.help-module .mnum[data-v-78204b9d]{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#4a53b8,#6a5fd0);color:#fff;font-size:12px;font-weight:700}.help-module.help-connector[data-v-78204b9d]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-78204b9d]{color:#b8c2ff}.help-module .connector-intro[data-v-78204b9d]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-78204b9d]{color:#dce4f7}.help-module .dl-link[data-v-78204b9d]{display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;margin:0 10px 6px 0;padding:6px 12px;border:1px solid #5652b5;border-radius:6px;background:#4540a0;color:#fff;font-size:12px;font-weight:700;text-decoration:none}.help-module .dl-link[data-v-78204b9d]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-78204b9d]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-78204b9d]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-78204b9d]{padding:66px 12px 14px}.help-grid[data-v-78204b9d]{grid-template-columns:1fr}}.topbar[data-v-4749834a]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
|
||||
@@ -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-Bjqx1iPW.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-Bjqx1iPW.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-DQ5rq6hP.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-DQ5rq6hP.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
|
||||
Vendored
+1257
File diff suppressed because it is too large.
Load diff
Vendored
+3
-3
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spine粒子编辑器</title>
|
||||
<title>Spine粒子编辑器-Yeomai</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-Bjqx1iPW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ChdWidWA.css">
|
||||
<script type="module" crossorigin src="/assets/index-DQ5rq6hP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BJgrTIS_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spine粒子编辑器</title>
|
||||
<title>Spine粒子编辑器-Yeomai</title>
|
||||
<style>
|
||||
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; }
|
||||
|
||||
Generated
+90
-1
@@ -14,7 +14,8 @@
|
||||
"@spine-particle/spine-pixi-v7-4.3": "file:vendor/spine/spine-pixi-v7-4.3",
|
||||
"pinia": "^4.0.3",
|
||||
"pixi.js": "^7.4.3",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.43",
|
||||
@@ -520,6 +521,67 @@
|
||||
"resolved": "vendor/spine/spine-pixi-v7",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.16.tgz",
|
||||
"integrity": "sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/devtools-types": "11.4.16",
|
||||
"@intlify/message-compiler": "11.4.16",
|
||||
"@intlify/shared": "11.4.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/devtools-types": {
|
||||
"version": "11.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.16.tgz",
|
||||
"integrity": "sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.16",
|
||||
"@intlify/shared": "11.4.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.16.tgz",
|
||||
"integrity": "sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.4.16",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.16.tgz",
|
||||
"integrity": "sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -2398,6 +2460,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.4.16",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.16.tgz",
|
||||
"integrity": "sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.16",
|
||||
"@intlify/devtools-types": "11.4.16",
|
||||
"@intlify/shared": "11.4.16",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@
|
||||
"test:render-layer": "node scripts/verify-render-layer.mjs",
|
||||
"test:particle-events": "node scripts/verify-particle-events.mjs",
|
||||
"test:sub-emitter-timeline": "node scripts/verify-sub-emitter-timeline.mjs",
|
||||
"test:i18n": "node scripts/verify-i18n.mjs",
|
||||
"test:connector": "node scripts/verify-spine-connector.mjs",
|
||||
"test:connector:spine38-path": "node scripts/verify-spine38-connector-path.mjs",
|
||||
"test:connector:sea": "node scripts/verify-connector-sea.mjs",
|
||||
@@ -30,7 +31,8 @@
|
||||
"@spine-particle/spine-pixi-v7-4.3": "file:vendor/spine/spine-pixi-v7-4.3",
|
||||
"pinia": "^4.0.3",
|
||||
"pixi.js": "^7.4.3",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.43",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const zh = JSON.parse(await readFile('src/i18n/locales/zh-CN.json', 'utf8'))
|
||||
const en = JSON.parse(await readFile('src/i18n/locales/en.json', 'utf8'))
|
||||
|
||||
function flattenKeys(value, prefix = '') {
|
||||
const keys = []
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
||||
keys.push(...flattenKeys(child, path))
|
||||
} else {
|
||||
keys.push(path)
|
||||
}
|
||||
}
|
||||
return keys.sort()
|
||||
}
|
||||
|
||||
const zhKeys = flattenKeys(zh)
|
||||
const enKeys = flattenKeys(en)
|
||||
const missingInEnglish = zhKeys.filter((key) => !enKeys.includes(key))
|
||||
const missingInChinese = enKeys.filter((key) => !zhKeys.includes(key))
|
||||
if (missingInEnglish.length || missingInChinese.length) {
|
||||
throw new Error([
|
||||
missingInEnglish.length ? `Missing in English: ${missingInEnglish.join(', ')}` : '',
|
||||
missingInChinese.length ? `Missing in Chinese: ${missingInChinese.join(', ')}` : '',
|
||||
].filter(Boolean).join('\n'))
|
||||
}
|
||||
|
||||
const bundled = await build({
|
||||
entryPoints: ['src/i18n/index.ts'],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
})
|
||||
const i18nModule = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].contents).toString('base64')}`
|
||||
)
|
||||
|
||||
const cases = [
|
||||
[null, ['zh-CN'], 'zh-CN'],
|
||||
[null, ['zh-Hant-TW'], 'zh-CN'],
|
||||
[null, ['en-US'], 'en'],
|
||||
[null, ['fr-FR'], 'en'],
|
||||
[null, ['ko-KR', 'en-US'], 'en'],
|
||||
['en', ['zh-CN'], 'en'],
|
||||
['zh-CN', ['en-US'], 'zh-CN'],
|
||||
]
|
||||
for (const [saved, browserLanguages, expected] of cases) {
|
||||
const actual = i18nModule.resolveAppLocale(saved, browserLanguages)
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Locale resolution failed for ${JSON.stringify(browserLanguages)}: expected ${expected}, got ${actual}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`i18n 校验通过:${zhKeys.length} 个消息键,中英语言包一致,自动语言与缓存优先级正确`)
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
<!-- 右列:上=画布区,下=时间轴区(高度可拖) -->
|
||||
<div class="right">
|
||||
<div class="stage-area"><Stage /></div>
|
||||
<div class="resizer-row" @pointerdown="onTimelineDrag" title="拖动调整时间轴高度"></div>
|
||||
<div class="resizer-row" @pointerdown="onTimelineDrag" :title="$t('app.resizeTimeline')"></div>
|
||||
<div class="timeline-area" :style="{ height: timelineH + 'px' }">
|
||||
<Timeline />
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cloneEditorValue } from './editorClone'
|
||||
import { ensureSpineExportSettings } from '../export/spineExportSettings'
|
||||
import { loopSegmentTiming } from '../core/loopSegmentTiming'
|
||||
import { lastFrameOfDuration } from '../timeline/playbackRange'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
@@ -179,14 +180,14 @@ export function parseEditorConfig(text: string): EditorConfigFile {
|
||||
try {
|
||||
config = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('配置文件不是有效的 JSON')
|
||||
throw new Error(translate('errors.invalidJson'))
|
||||
}
|
||||
const candidate = config as Partial<EditorConfigFile>
|
||||
if (candidate.format !== EDITOR_CONFIG_FORMAT) throw new Error('不是 SpineParticle 编辑器配置文件')
|
||||
if (candidate.version !== EDITOR_CONFIG_VERSION) throw new Error(`暂不支持配置版本:${candidate.version ?? '未知'}`)
|
||||
if (candidate.format !== EDITOR_CONFIG_FORMAT) throw new Error(translate('errors.invalidConfigFormat'))
|
||||
if (candidate.version !== EDITOR_CONFIG_VERSION) throw new Error(translate('errors.unsupportedConfigVersion', { version: candidate.version ?? translate('export.unknown') }))
|
||||
if (!candidate.scene || !Array.isArray(candidate.scene.systems) || !Array.isArray(candidate.scene.colliders)
|
||||
|| !Array.isArray(candidate.scene.paths) || !Array.isArray(candidate.scene.spines)) {
|
||||
throw new Error('配置文件缺少完整场景数据')
|
||||
throw new Error(translate('errors.missingScene'))
|
||||
}
|
||||
return candidate as EditorConfigFile
|
||||
}
|
||||
@@ -305,7 +306,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
|
||||
spine.assetVersion = Math.max(Number(spine.assetVersion) || 0, oldSpineVersions.get(spine.id) || 0) + 1
|
||||
if (!parsed) {
|
||||
spine.status = 'empty'
|
||||
spine.error = spine.skeletonFileName ? '配置未包含可复用的 Spine 原始资源' : ''
|
||||
spine.error = spine.skeletonFileName ? translate('errors.missingEmbeddedSpine') : ''
|
||||
spine.animations = []
|
||||
spine.bones = []
|
||||
spine.slots = []
|
||||
@@ -345,6 +346,6 @@ export async function loadEditorConfigText(store: EditorStore, text: string) {
|
||||
}
|
||||
|
||||
export async function loadEditorConfigFile(store: EditorStore, file: File) {
|
||||
if (!/\.json$/i.test(file.name)) throw new Error('请选择 .json 配置文件')
|
||||
if (!/\.json$/i.test(file.name)) throw new Error(translate('errors.chooseJsonConfig'))
|
||||
return loadEditorConfigText(store, await file.text())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { watch, type WatchStopHandle } from 'vue'
|
||||
import type { SettingsState, useParticleStore } from '../store/particleStore'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
@@ -79,12 +80,12 @@ function parseSystemSettingsCache(text: string): SystemSettingsFile {
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('系统设置缓存不是有效的 JSON')
|
||||
throw new Error(translate('errors.settingsInvalidJson'))
|
||||
}
|
||||
const candidate = parsed as Partial<SystemSettingsFile>
|
||||
if (candidate.format !== SYSTEM_SETTINGS_FORMAT) throw new Error('不是 Spine粒子编辑器系统设置缓存')
|
||||
if (candidate.version !== SYSTEM_SETTINGS_VERSION) throw new Error(`暂不支持系统配置版本:${candidate.version ?? '未知'}`)
|
||||
if (!candidate.settings || typeof candidate.settings !== 'object') throw new Error('系统设置缓存缺少 settings')
|
||||
if (candidate.format !== SYSTEM_SETTINGS_FORMAT) throw new Error(translate('errors.settingsFormat'))
|
||||
if (candidate.version !== SYSTEM_SETTINGS_VERSION) throw new Error(translate('errors.settingsVersion', { version: candidate.version ?? translate('export.unknown') }))
|
||||
if (!candidate.settings || typeof candidate.settings !== 'object') throw new Error(translate('errors.settingsMissing'))
|
||||
return candidate as SystemSettingsFile
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { reactive } from 'vue'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
export interface TransferProgressState {
|
||||
visible: boolean
|
||||
@@ -59,8 +60,8 @@ export function beginTransfer(title: string, detail: string) {
|
||||
update,
|
||||
/** 让初始状态先绘制出来,再执行可能阻塞主线程的序列化。 */
|
||||
paint: () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
finish: (nextDetail = '已完成') => closeAfter(false, nextDetail),
|
||||
fail: (nextDetail = '操作失败') => closeAfter(true, nextDetail),
|
||||
finish: (nextDetail = translate('transfer.completed')) => closeAfter(false, nextDetail),
|
||||
fail: (nextDetail = translate('transfer.failed')) => closeAfter(true, nextDetail),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import type { SpineExportVersion } from './spineExportSettings'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
const CONNECTOR_URL = 'http://127.0.0.1:27843'
|
||||
|
||||
@@ -64,7 +65,7 @@ function imagePath(folder: string, name: string, contentType: string) {
|
||||
async function sourceToImage(source: string, folder: string, name: string): Promise<ConnectorImage | null> {
|
||||
if (!source) return null
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) throw new Error(`无法读取导出图片:${name}`)
|
||||
if (!response.ok) throw new Error(translate('errors.readExportImageFailed', { name }))
|
||||
const blob = await response.blob()
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||
let binary = ''
|
||||
@@ -147,13 +148,13 @@ export async function convertWithSpineConnector(input: {
|
||||
request.onprogress = (event) => {
|
||||
onProgress?.({ phase: 'download', progress: event.lengthComputable && event.total > 0 ? event.loaded / event.total : null })
|
||||
}
|
||||
request.onerror = () => reject(new SpineConnectorError('未检测到本地 Spine 连接器,请先启动连接器', 'CONNECTOR_OFFLINE'))
|
||||
request.onerror = () => reject(new SpineConnectorError(translate('errors.connectorOffline'), 'CONNECTOR_OFFLINE'))
|
||||
request.onload = async () => {
|
||||
const responseBlob = request.response as Blob
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
let errorBody: ConnectorErrorBody = {}
|
||||
try { errorBody = JSON.parse(await responseBlob.text()) as ConnectorErrorBody } catch { /* 使用 HTTP 回退信息 */ }
|
||||
reject(new SpineConnectorError(errorBody.message || `Spine 连接器返回错误:HTTP ${request.status}`, errorBody.code, errorBody.pairUrl))
|
||||
reject(new SpineConnectorError(translate('errors.connectorHttpError', { status: request.status }), errorBody.code, errorBody.pairUrl))
|
||||
return
|
||||
}
|
||||
const encodedFileName = request.getResponseHeader('X-Spine-File-Name')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import { collectSpineExportImages } from './spineConnectorClient'
|
||||
import { createZipArchive } from './zipArchive'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
function decodeBase64(value: string) {
|
||||
const binary = atob(value)
|
||||
@@ -23,7 +24,7 @@ function safeFileName(value: string) {
|
||||
|
||||
export async function buildSpineImagesArchive(input: { systems: ParticleSystem[]; imagesPath: string }) {
|
||||
const images = await collectSpineExportImages(input.systems)
|
||||
if (!images.length) throw new Error('当前可见粒子系统没有可导出的图片')
|
||||
if (!images.length) throw new Error(translate('errors.noExportableImages'))
|
||||
const folder = imagesDirectory(input.imagesPath)
|
||||
const blob = createZipArchive(images.map((image) => ({
|
||||
name: `${folder}/${image.path}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, t
|
||||
import { buildWeightedTrailMesh } from './spineMeshBuilder'
|
||||
import { loopPlaybackRange, loopSegmentTiming } from '../core/loopSegmentTiming'
|
||||
import { collectSubEmitterIds } from '../core/subEmitterIds'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
type Track = { key: string; name: string; states: Array<ParticleState | null> }
|
||||
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
|
||||
@@ -241,7 +242,7 @@ function resourceAttachment(resource: ParticleImageResource, frameIndex: number,
|
||||
const file = frame?.name || resource.textureName || `image_${resource.id}`
|
||||
const texture = frame?.texture || resource.texture
|
||||
const size = textureSize(texture)
|
||||
if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: `图片“${file}”缺失或尺寸不可读,已保留路径。` })
|
||||
if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: translate('errors.missingImageWarning', { file }) })
|
||||
const width = size.width || 1, height = size.height || 1
|
||||
return {
|
||||
path: normalizePath(resource.textureFolder, file),
|
||||
@@ -346,7 +347,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
|
||||
? frameLength
|
||||
: Math.min(frameLength, particleDurationFrames(system, sourceFps))
|
||||
const frames = (system.frames || []).slice(0, sourceCount)
|
||||
if (!frames.length) warnings.push({ code: 'empty-cache', message: `“${system.config.name}”没有可用帧缓存。` })
|
||||
if (!frames.length) warnings.push({ code: 'empty-cache', message: translate('errors.emptyCacheWarning', { name: system.config.name }) })
|
||||
const prefix = uniqueName(safeName(system.config.name, `system_${system.id}`), usedNames)
|
||||
document.bones.push({ name: prefix, parent: 'root' })
|
||||
const tracks = collectTracks(frames, settings.bonePool, usedNames, prefix)
|
||||
@@ -400,7 +401,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
|
||||
const indices = trailNames.map((name) => document.bones.findIndex((bone) => bone.name === name))
|
||||
const path = normalizePath('', resource.textureName || `trail_${resource.id}`)
|
||||
;(skin[slotName] ||= {})[`trail_${resource.id}`] = buildWeightedTrailMesh(system.config, resource, indices, path)
|
||||
if (!resource.texture) warnings.push({ code: 'missing-image', message: `拖尾图片“${resource.textureName || resource.id}”缺失,已保留路径。` })
|
||||
if (!resource.texture) warnings.push({ code: 'missing-image', message: translate('errors.missingTrailImageWarning', { file: resource.textureName || resource.id }) })
|
||||
}
|
||||
}
|
||||
// Spine 按 slots 数组顺序绘制。保持每个粒子的拖尾在图片下方。
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { normalizeSpine38JsonFor42 } from '../spine/spine38Compatibility'
|
||||
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from '../spine/spineVersion'
|
||||
import type { SpineJsonMap } from './spineJsonTypes'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
class ExportValidationAttachmentLoader42 implements AttachmentLoader42 {
|
||||
newRegionAttachment(_skin: Skin42, name: string, path: string, _sequence: any) { return new RegionAttachment42(name, path) }
|
||||
@@ -61,16 +62,16 @@ function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCou
|
||||
const uvs = mesh.uvs
|
||||
const vertices = mesh.vertices
|
||||
const triangles = mesh.triangles
|
||||
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(`拖尾网格“${attachmentName}”的 UV 数据无效`)
|
||||
if (!Array.isArray(vertices) || !vertices.length) throw new Error(`拖尾网格“${attachmentName}”缺少顶点数据`)
|
||||
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(translate('errors.invalidTrailUV', { name: attachmentName }))
|
||||
if (!Array.isArray(vertices) || !vertices.length) throw new Error(translate('errors.missingTrailVertices', { name: attachmentName }))
|
||||
const vertexCount = uvs.length / 2
|
||||
if (!Array.isArray(triangles) || triangles.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的三角形索引越界`)
|
||||
throw new Error(translate('errors.trailIndexOutOfRange', { name: attachmentName }))
|
||||
}
|
||||
const hull = Number(mesh.hull) || 0
|
||||
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(`拖尾网格“${attachmentName}”的外轮廓数量无效`)
|
||||
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(translate('errors.invalidTrailHull', { name: attachmentName }))
|
||||
if (Array.isArray(mesh.edges) && mesh.edges.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount * 2 || index % 2)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的外轮廓边索引越界`)
|
||||
throw new Error(translate('errors.trailHullIndexOutOfRange', { name: attachmentName }))
|
||||
}
|
||||
// 未加权 Mesh 每个顶点只有 x/y;拖尾必须是逐顶点的骨骼权重编码。
|
||||
if (vertices.length === uvs.length) return
|
||||
@@ -78,21 +79,21 @@ function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCou
|
||||
let decodedVertices = 0
|
||||
while (offset < vertices.length) {
|
||||
const influences = vertices[offset++]
|
||||
if (!Number.isInteger(influences) || influences < 1) throw new Error(`拖尾网格“${attachmentName}”包含无效骨骼权重数量`)
|
||||
if (!Number.isInteger(influences) || influences < 1) throw new Error(translate('errors.invalidInfluenceCount', { name: attachmentName }))
|
||||
let weightSum = 0
|
||||
for (let influence = 0; influence < influences; influence++) {
|
||||
const boneIndex = vertices[offset++]
|
||||
const x = vertices[offset++]
|
||||
const y = vertices[offset++]
|
||||
const weight = vertices[offset++]
|
||||
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(`拖尾网格“${attachmentName}”引用了不存在的骨骼`)
|
||||
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(`拖尾网格“${attachmentName}”包含无效权重数据`)
|
||||
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(translate('errors.missingBoneReference', { name: attachmentName }))
|
||||
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(translate('errors.invalidWeight', { name: attachmentName }))
|
||||
weightSum += weight
|
||||
}
|
||||
if (Math.abs(weightSum - 1) > 0.001) throw new Error(`拖尾网格“${attachmentName}”的顶点权重之和不为 1`)
|
||||
if (Math.abs(weightSum - 1) > 0.001) throw new Error(translate('errors.weightSum', { name: attachmentName }))
|
||||
decodedVertices++
|
||||
}
|
||||
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(`拖尾网格“${attachmentName}”的顶点数量与 UV 不一致`)
|
||||
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(translate('errors.vertexCountMismatch', { name: attachmentName }))
|
||||
}
|
||||
|
||||
function validateEditorImportStructure(root: Record<string, any>, version: SupportedSpineVersion) {
|
||||
@@ -100,12 +101,12 @@ function validateEditorImportStructure(root: Record<string, any>, version: Suppo
|
||||
const boneByName = new Map(bones.map((bone) => [String(bone.name || ''), bone]))
|
||||
const weightedMeshSlots = new Set<string>()
|
||||
for (const bone of bones) {
|
||||
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(`骨骼“${bone.name || ''}”的 inherit 值“${bone.inherit}”不符合 Spine ${version} JSON 格式`)
|
||||
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(translate('errors.invalidInherit', { name: bone.name || '', inherit: bone.inherit, version }))
|
||||
// 拖尾骨骼命名:系统名_序号_trail_资源id_骨骼序号,必须直接挂在粒子系统骨骼下。
|
||||
if (/_trail_\d+_\d+$/.test(String(bone.name || ''))) {
|
||||
const parent = boneByName.get(String(bone.parent || ''))
|
||||
if (!parent || /_trail_\d+_\d+$/.test(String(parent.name || ''))) {
|
||||
throw new Error(`拖尾骨骼“${bone.name || ''}”必须直接挂在粒子系统骨骼下,不能组成骨骼链`)
|
||||
throw new Error(translate('errors.trailBoneParent', { name: bone.name || '' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,34 +126,34 @@ function validateEditorImportStructure(root: Record<string, any>, version: Suppo
|
||||
for (const slotName of weightedMeshSlots) {
|
||||
const attachmentKeys = animation?.slots?.[slotName]?.attachment
|
||||
if (Array.isArray(attachmentKeys) && attachmentKeys.some((key) => !key?.name)) {
|
||||
throw new Error(`加权拖尾插槽“${slotName}”不能使用空附件,未出现或死亡阶段应将对应骨骼缩放设为 0`)
|
||||
throw new Error(translate('errors.trailSlotEmpty', { name: slotName }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSpine38Structure(root: Record<string, any>) {
|
||||
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error('Spine 3.8 导出的版本标识必须为 3.8')
|
||||
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error(translate('errors.spine38Version'))
|
||||
for (const bone of root.bones || []) {
|
||||
if (bone.inherit != null) throw new Error(`Spine 3.8 骨骼“${bone.name || ''}”应使用 transform,不能使用 inherit`)
|
||||
if (bone.inherit != null) throw new Error(translate('errors.spine38Inherit', { name: bone.name || '' }))
|
||||
}
|
||||
for (const animation of Object.values(root.animations || {}) as SpineJsonMap[]) {
|
||||
for (const slot of Object.values(animation.slots || {}) as SpineJsonMap[]) {
|
||||
if (slot.rgba || slot.rgba2) throw new Error('Spine 3.8 插槽颜色时间轴必须使用 color / twoColor')
|
||||
if (slot.rgba || slot.rgba2) throw new Error(translate('errors.spine38SlotColor'))
|
||||
for (const frame of slot.attachment || []) {
|
||||
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error('Spine 3.8 附件时间轴的隐藏帧必须显式写入 name: null')
|
||||
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error(translate('errors.spine38AttachmentName'))
|
||||
}
|
||||
}
|
||||
for (const bone of Object.values(animation.bones || {}) as SpineJsonMap[]) {
|
||||
for (const frame of bone.rotate || []) {
|
||||
if (frame.value != null || frame.angle == null) throw new Error('Spine 3.8 旋转时间轴必须使用 angle')
|
||||
if (frame.value != null || frame.angle == null) throw new Error(translate('errors.spine38RotationAngle'))
|
||||
}
|
||||
}
|
||||
const stack: unknown[] = [animation]
|
||||
while (stack.length) {
|
||||
const value = stack.pop()
|
||||
if (!value || typeof value !== 'object') continue
|
||||
if (!Array.isArray(value) && Array.isArray((value as SpineJsonMap).curve)) throw new Error('Spine 3.8 不支持 4.x 数组曲线格式')
|
||||
if (!Array.isArray(value) && Array.isArray((value as SpineJsonMap).curve)) throw new Error(translate('errors.spine38Curve'))
|
||||
stack.push(...Object.values(value))
|
||||
}
|
||||
}
|
||||
@@ -165,14 +166,14 @@ function validateSpine38Structure(root: Record<string, any>) {
|
||||
export function validateSpineJson(text: string, expectedVersion?: SupportedSpineVersion) {
|
||||
const root = JSON.parse(text)
|
||||
const version = normalizeSpineVersion(root?.skeleton?.spine)
|
||||
if (!version) throw new Error(`导出版本不受支持,仅支持 Spine ${supportedSpineVersionsText()}`)
|
||||
if (expectedVersion && version !== expectedVersion) throw new Error(`导出版本不一致:设置为 Spine ${expectedVersion},JSON 标记为 Spine ${version}`)
|
||||
if (!version) throw new Error(translate('errors.unsupportedExportVersion', { versions: supportedSpineVersionsText() }))
|
||||
if (expectedVersion && version !== expectedVersion) throw new Error(translate('errors.exportVersionMismatch', { expected: expectedVersion, actual: version }))
|
||||
validateEditorImportStructure(root, version)
|
||||
if (version === '3.8') validateSpine38Structure(root)
|
||||
const runtimeRoot = version === '3.8' ? normalizeSpine38JsonFor42(JSON.parse(JSON.stringify(root))) : root
|
||||
const data = version === '4.3'
|
||||
? new SkeletonJson43(new ExportValidationAttachmentLoader43()).readSkeletonData(runtimeRoot)
|
||||
: new SkeletonJson42(new ExportValidationAttachmentLoader42()).readSkeletonData(runtimeRoot)
|
||||
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
|
||||
if (!data.bones.length) throw new Error(translate('errors.exportMissingBones'))
|
||||
return { version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { translate } from '../i18n'
|
||||
|
||||
export interface ZipArchiveFile {
|
||||
name: string
|
||||
data: Uint8Array
|
||||
@@ -17,7 +19,7 @@ function crc32(data: Uint8Array) {
|
||||
|
||||
function safeArchivePath(value: string) {
|
||||
const parts = String(value || '').replace(/\\/g, '/').split('/').filter((part) => part && part !== '.')
|
||||
if (!parts.length || parts.some((part) => part === '..')) throw new Error('ZIP 文件路径无效')
|
||||
if (!parts.length || parts.some((part) => part === '..')) throw new Error(translate('errors.invalidZipPath'))
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en.json'
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'en', label: 'English' },
|
||||
] as const
|
||||
|
||||
export type AppLocale = typeof SUPPORTED_LOCALES[number]['code']
|
||||
|
||||
const STORAGE_KEY = 'spine-particle.locale'
|
||||
|
||||
function isAppLocale(value: unknown): value is AppLocale {
|
||||
return SUPPORTED_LOCALES.some((locale) => locale.code === value)
|
||||
}
|
||||
|
||||
export function resolveAppLocale(saved: unknown, browserLanguages: readonly string[]): AppLocale {
|
||||
if (isAppLocale(saved)) return saved
|
||||
return browserLanguages.some((language) => language.toLowerCase().startsWith('zh')) ? 'zh-CN' : 'en'
|
||||
}
|
||||
|
||||
function detectInitialLocale(): AppLocale {
|
||||
let saved: string | null = null
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
saved = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Storage can be unavailable in privacy modes.
|
||||
}
|
||||
}
|
||||
const languages = typeof navigator === 'undefined'
|
||||
? []
|
||||
: navigator.languages?.length
|
||||
? [...navigator.languages]
|
||||
: [navigator.language]
|
||||
return resolveAppLocale(saved, languages)
|
||||
}
|
||||
|
||||
const initialLocale = detectInitialLocale()
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
export function setAppLocale(locale: AppLocale) {
|
||||
i18n.global.locale.value = locale
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, locale)
|
||||
} catch {
|
||||
// Keep the current session when storage is unavailable.
|
||||
}
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = locale
|
||||
document.title = i18n.global.t('app.title')
|
||||
}
|
||||
}
|
||||
|
||||
export function translate(key: string, named?: Record<string, unknown>) {
|
||||
return named ? i18n.global.t(key, named) : i18n.global.t(key)
|
||||
}
|
||||
|
||||
// Persist both automatic first-use detection and later manual changes.
|
||||
setAppLocale(initialLocale)
|
||||
@@ -0,0 +1,857 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Spine Particle Editor - Yeomai",
|
||||
"resizeTimeline": "Drag to resize the timeline",
|
||||
"systems": "Systems",
|
||||
"particlesBones": "Particles (bones)",
|
||||
"rootBone": "Root bone",
|
||||
"hudTip": "Scroll to zoom · Drag with left mouse to pan"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"zhCN": "中文",
|
||||
"en": "English"
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"reset": "Reset",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"delete": "Delete",
|
||||
"clear": "Clear",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"none": "None",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"fixed": "Fixed",
|
||||
"random": "Random",
|
||||
"curve": "Curve",
|
||||
"followExport": "Follow export",
|
||||
"linear": "Linear",
|
||||
"bezier": "Bézier",
|
||||
"above": "Above",
|
||||
"below": "Below",
|
||||
"left": "Left",
|
||||
"right": "Right",
|
||||
"seconds": "s",
|
||||
"frames": "f"
|
||||
},
|
||||
"toolbar": {
|
||||
"settings": "Settings",
|
||||
"undo": "Undo (Ctrl+Z)",
|
||||
"pan": "Pan canvas (Q)",
|
||||
"translate": "Move (W)",
|
||||
"rotate": "Rotate (E)",
|
||||
"scale": "Scale (R)",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"fitView": "Fit canvas",
|
||||
"toggleGrid": "Toggle grid",
|
||||
"resetView": "Reset view",
|
||||
"background": "Background",
|
||||
"backgroundSettings": "Background settings",
|
||||
"changeBackground": "Replace image",
|
||||
"uploadBackground": "Upload image",
|
||||
"clearBackground": "Clear background",
|
||||
"noBackground": "No image",
|
||||
"backgroundX": "Background X offset",
|
||||
"backgroundY": "Background Y offset",
|
||||
"backgroundOpacity": "Background opacity",
|
||||
"opacity": "Opacity",
|
||||
"help": "Help",
|
||||
"openHelp": "Open help"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"layout": "Layout",
|
||||
"parameterPanelPosition": "Parameter panel position",
|
||||
"panelOnRight": "Show parameter panel on the right",
|
||||
"history": "History",
|
||||
"maxUndo": "Maximum undo steps",
|
||||
"canvasGuides": "Canvas guides",
|
||||
"emitter": "Emitter point",
|
||||
"bones": "Bone links",
|
||||
"bonePointSize": "Bone point size",
|
||||
"boneAxisSize": "Axis size",
|
||||
"skinMesh": "Skin mesh",
|
||||
"enableTicks": "Show ticks",
|
||||
"tickFontSize": "Tick text size",
|
||||
"tickWidth": "Tick line width",
|
||||
"axisColor": "Axis color",
|
||||
"showAxisLabels": "Show tick values",
|
||||
"cacheNote": "Settings are saved automatically in this browser"
|
||||
},
|
||||
"config": {
|
||||
"title": "Configuration",
|
||||
"save": "Save Config",
|
||||
"saveTitle": "Save the complete editor configuration (Ctrl+S)",
|
||||
"load": "Load Config",
|
||||
"loadTitle": "Load an editor configuration file",
|
||||
"presets": "Preset list",
|
||||
"selectPreset": "Select preset",
|
||||
"noPresets": "No presets",
|
||||
"saved": "Configuration saved locally",
|
||||
"loading": "Loading configuration…",
|
||||
"loaded": "Loaded: {name}",
|
||||
"presetLoading": "Loading preset…",
|
||||
"presetLoaded": "Preset loaded: {name}",
|
||||
"presetNamingError": "{message}. Check the preset file name.",
|
||||
"presetTransferTitle": "Load Preset",
|
||||
"presetDownloading": "Downloading {file}…",
|
||||
"presetParsing": "Parsing preset configuration…",
|
||||
"presetRestoring": "Parsing and restoring preset resources…",
|
||||
"presetComplete": "Preset loaded",
|
||||
"presetListError": "Unable to read preset list",
|
||||
"presetReadError": "Unable to read preset: {file}"
|
||||
},
|
||||
"presets": {
|
||||
"particleSweep": "1. Particle Sweep",
|
||||
"barBlocks": "2. Bar Blocks",
|
||||
"forceTrail": "3. Force Field Trail",
|
||||
"lightningSequence": "4. Lightning Sequence",
|
||||
"texturePivot": "5. Texture Pivot",
|
||||
"subEmitter": "6. Sub-emitter"
|
||||
},
|
||||
"curve": {
|
||||
"bezierHint": "Drag square handles · Double-click empty space to add · Double-click a node to delete",
|
||||
"linearHint": "Double-click empty space to add · Double-click a node to delete · Drag nodes to adjust",
|
||||
"resetTitle": "Restore default curve"
|
||||
},
|
||||
"gradient": {
|
||||
"addTitle": "Double-click the color bar to add a stop",
|
||||
"color": "Stop color",
|
||||
"colorValue": "Stop color value",
|
||||
"position": "Stop position",
|
||||
"addMiddle": "Add color in the middle",
|
||||
"delete": "Delete selected stop",
|
||||
"hint": "Double-click to add · Drag a stop to move · Double-click a stop to delete"
|
||||
},
|
||||
"attributeControl": {
|
||||
"angleOffset": "Angle offset",
|
||||
"value": "Value",
|
||||
"lower": "Min",
|
||||
"upper": "Max",
|
||||
"rangeLower": "Range min",
|
||||
"rangeUpper": "Range max",
|
||||
"curveInput": "Curve input →",
|
||||
"direction": "Emission direction"
|
||||
},
|
||||
"pivot": {
|
||||
"title": "Texture Pivot",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"expand": "Open full-screen pivot editor",
|
||||
"interaction": "Click/drag to set pivot · Right-drag to pan · Scroll to zoom",
|
||||
"previewAlt": "Pivot preview",
|
||||
"noImage": "No image selected",
|
||||
"resetCenter": "Center (50%, 50%)",
|
||||
"coordinates": "Pivot coordinates (%)",
|
||||
"rotationTest": "Pivot rotation test",
|
||||
"static": "Static",
|
||||
"spin": "Spin",
|
||||
"swing": "Swing",
|
||||
"rotationAlt": "Rotation test",
|
||||
"topLeft": "Top left",
|
||||
"top": "Top",
|
||||
"topRight": "Top right",
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"bottomLeft": "Bottom left",
|
||||
"bottom": "Bottom",
|
||||
"bottomRight": "Bottom right"
|
||||
},
|
||||
"image": {
|
||||
"chooseSequence": "Choose sequence images",
|
||||
"chooseLocal": "Choose local image",
|
||||
"subfolder": "Subfolder",
|
||||
"name": "Name",
|
||||
"restore": "Restore {name}",
|
||||
"deleteResource": "Delete image resource",
|
||||
"imageMode": "Image mode",
|
||||
"fixed": "Single image",
|
||||
"sequence": "Sequence",
|
||||
"playbackMode": "Playback",
|
||||
"onceHide": "Once, hide at end",
|
||||
"onceHold": "Once, hold last frame",
|
||||
"loopForward": "Loop forward",
|
||||
"loopReverse": "Loop reverse",
|
||||
"playbackFps": "Playback FPS",
|
||||
"chooseSequenceMulti": "Choose sequence images (multiple)",
|
||||
"weight": "Weight %",
|
||||
"unlockWeight": "Unlock weight",
|
||||
"lockWeight": "Lock current weight",
|
||||
"pivot": "Texture Pivot",
|
||||
"collapsePivot": "Collapse pivot editor",
|
||||
"expandPivot": "Expand pivot editor",
|
||||
"pivotModal": "Full-screen texture pivot editor",
|
||||
"pivotModalDesc": "Click or drag on the texture to set the exact particle rotation center",
|
||||
"pivotZoomHint": "Zoom from 100% to 400% to align tips or roots precisely:",
|
||||
"confirmPivot": "Apply and save pivot",
|
||||
"noSequence": "No sequence selected",
|
||||
"firstFrame": "Frame 1",
|
||||
"firstImage": "first image",
|
||||
"lastImage": "last image",
|
||||
"pivotOn": "Enabled: X {x}% | Y {y}%",
|
||||
"pivotOff": "Disabled, using image center (50%, 50%)",
|
||||
"pivotCustom": "Custom pivot: X {x}% | Y {y}%",
|
||||
"frameCountOne": "1 frame · {name}",
|
||||
"frameCountMany": "{count} frames · {first} → {last}",
|
||||
"sequenceSelected": "{count} frames selected, playing {first} to {last}"
|
||||
},
|
||||
"timeline": {
|
||||
"play": "Play (D)",
|
||||
"pause": "Pause (D)",
|
||||
"toStart": "Go to first frame (S)",
|
||||
"previous": "Previous frame",
|
||||
"next": "Next frame",
|
||||
"toEnd": "Go to last frame",
|
||||
"continuousLoop": "Continuous loop playback",
|
||||
"abLoop": "Independent A/B range loop",
|
||||
"abLoopShort": "AB Loop",
|
||||
"abRange": "A/B loop range",
|
||||
"playbackStart": "Custom playback start frame",
|
||||
"playbackEnd": "Custom playback end frame",
|
||||
"startFrame": "Playback start frame",
|
||||
"endFrame": "Playback end frame",
|
||||
"frame": "Frame",
|
||||
"emission": "Emitting",
|
||||
"particleLife": "Particles alive",
|
||||
"spineAnimation": "Spine animation",
|
||||
"ownerAnimation": "Owner animation",
|
||||
"setOwnerAnimation": "Set the animation that owns this particle system",
|
||||
"chooseOwnerAnimation": "Choose owner animation",
|
||||
"animationName": "Animation name",
|
||||
"renameAnimation": "Rename current animation",
|
||||
"rename": "Rename",
|
||||
"newAnimation": "+ New",
|
||||
"keepOneAnimation": "At least one animation is required",
|
||||
"spinePreview": "Spine preview animation",
|
||||
"noSpineAnimation": "No Spine animation available",
|
||||
"none": "None",
|
||||
"systemName": "System {index}",
|
||||
"dragLoopDuration": "Drag to change loop duration",
|
||||
"dragEmissionDuration": "Drag to change emission duration",
|
||||
"dragSpineStart": "Drag to change animation start time",
|
||||
"noAnimationData": "No animation data",
|
||||
"animationNameEmpty": "Animation name cannot be empty",
|
||||
"animationNameDuplicate": "Animation name already exists",
|
||||
"animationRenameFailed": "Unable to rename animation",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomFit": "Fit",
|
||||
"frameStatus": "Frame {current} / {total}"
|
||||
},
|
||||
"emission": {
|
||||
"title": "Emission",
|
||||
"reset": "Reset emission settings",
|
||||
"stream": "Stream",
|
||||
"burst": "Burst",
|
||||
"normalStream": "Standard stream",
|
||||
"loopStream": "Loop stream",
|
||||
"rate": "Emission rate",
|
||||
"duration": "Emission duration",
|
||||
"loopDuration": "Emission duration (f)",
|
||||
"generateStart": "Generate loop start animation",
|
||||
"generateEnd": "Generate loop end animation",
|
||||
"customDuration": "Custom duration",
|
||||
"auto": "Auto {frames}f",
|
||||
"startLoopDuration": "Loop start duration",
|
||||
"endLoopDuration": "Loop end duration",
|
||||
"loopWarning": "Bone and path following are unavailable in loop stream mode and will be reset when entering or leaving it.",
|
||||
"burstCount": "Burst count",
|
||||
"delay": "Delay",
|
||||
"shapeTitle": "Emitter Shape",
|
||||
"shapeReset": "Reset emitter shape settings",
|
||||
"shape": "Shape",
|
||||
"point": "Point",
|
||||
"circle": "Circle",
|
||||
"rect": "Rectangle",
|
||||
"cone": "Cone",
|
||||
"path": "Path",
|
||||
"innerDiameter": "Inner diameter",
|
||||
"outerDiameter": "Outer diameter",
|
||||
"innerRadius": "Inner radius",
|
||||
"outerRadius": "Outer radius",
|
||||
"coneDirection": "Cone direction",
|
||||
"coneAngle": "Cone opening angle",
|
||||
"innerWidth": "Inner width",
|
||||
"innerHeight": "Inner height",
|
||||
"outerWidth": "Outer width",
|
||||
"outerHeight": "Outer height",
|
||||
"selectPath": "Spawn path",
|
||||
"noPath": "No path (point emission)",
|
||||
"birthDistribution": "Birth distribution",
|
||||
"evenDistribution": "Even spacing",
|
||||
"randomDistribution": "Random",
|
||||
"emissionAngle": "Emission angle",
|
||||
"radial": "Radial (outward)",
|
||||
"pathNormal": "Path normal",
|
||||
"pathTangent": "Path tangent",
|
||||
"fixedAngle": "Fixed angle",
|
||||
"baseAngle": "Base angle",
|
||||
"randomRange": "Random range",
|
||||
"showShape": "Show shape"
|
||||
},
|
||||
"attributes": {
|
||||
"title": "Particle Attributes",
|
||||
"reset": "Reset particle attribute settings",
|
||||
"initialParams": "Initial particle values",
|
||||
"initialLife": "Initial life",
|
||||
"randomSample": "Random sample →",
|
||||
"initialSpeed": "Initial speed",
|
||||
"initialScale": "Initial scale",
|
||||
"uniform": "Uniform",
|
||||
"separateXY": "Separate XY",
|
||||
"scaleX": "Scale X",
|
||||
"scaleY": "Scale Y",
|
||||
"initialRotation": "Initial rotation",
|
||||
"lifecycleTitle": "Values over particle life",
|
||||
"speedOverLife": "Speed over life",
|
||||
"speedMultiplier": "Speed multiplier",
|
||||
"lifeTo": "Lifetime →",
|
||||
"scaleOverLife": "Scale over life",
|
||||
"scaleMultiplier": "Scale multiplier",
|
||||
"multiplierX": "Multiplier X",
|
||||
"multiplierY": "Multiplier Y",
|
||||
"rotationOverLife": "Rotation over life",
|
||||
"rotationSpeed": "Rotation speed"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance & Resources",
|
||||
"reset": "Reset appearance and resource settings",
|
||||
"opacity": "Opacity",
|
||||
"curveMode": "Curve mode",
|
||||
"transparent": "0 (transparent)",
|
||||
"opaque": "1 (opaque)",
|
||||
"renderLayer": "Render layer (Spine slot)",
|
||||
"top": "Topmost",
|
||||
"spineSlot": "Spine slot",
|
||||
"selectSpine": "Select Spine",
|
||||
"noSpine": "No Spine available",
|
||||
"noSlot": "No slot selected",
|
||||
"reselect": "Reselect",
|
||||
"selectSlot": "Select slot",
|
||||
"searchSlots": "Search slots or attachments…",
|
||||
"searchSlotsAria": "Search slots",
|
||||
"emptySlot": "Empty slot",
|
||||
"noMatchingSlots": "No matching slots",
|
||||
"above": "Above slot image",
|
||||
"below": "Below slot image",
|
||||
"followAttachment": "Follow attachment visibility",
|
||||
"previewOnly": "Render layer changes only affect the editor preview and are not exported.",
|
||||
"imageResources": "Image Resources",
|
||||
"addImage": "Add image resource",
|
||||
"scale": "Scale",
|
||||
"color": "Color",
|
||||
"fixedColor": "Fixed color",
|
||||
"randomColor": "Random color",
|
||||
"lifetime": "Over lifetime",
|
||||
"normal": "Normal",
|
||||
"add": "Add",
|
||||
"multiply": "Multiply",
|
||||
"screen": "Screen",
|
||||
"fixedColorValue": "Fixed color value",
|
||||
"randomColorHint": "Each particle samples a color from the gradient at birth and keeps it for its lifetime",
|
||||
"independentAlpha": "Independent opacity",
|
||||
"off": "Off"
|
||||
},
|
||||
"renderLayer": {
|
||||
"slotLabelAbove": "{spine} / {slot} · above image",
|
||||
"slotLabelBelow": "{spine} / {slot} · below image",
|
||||
"resolvedAbove": "Render above {slot} and below the following slot",
|
||||
"resolvedBetween": "Render between {previous} and {slot}",
|
||||
"warningNoSpine": "No loaded Spine resource. Particles are temporarily rendered on top.",
|
||||
"warningNoSelection": "No Spine selected. Particles are temporarily rendered on top.",
|
||||
"warningMissingSpine": "The target Spine no longer exists. Particles are temporarily rendered on top.",
|
||||
"warningNotReady": "The target Spine is not ready. Particles are temporarily rendered on top.",
|
||||
"warningMissingSlot": "The target slot no longer exists. Particles are temporarily rendered on top.",
|
||||
"warningFirstSlot": "This is the first slot, so particles cannot render below it and are temporarily shown on top."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Settings",
|
||||
"reset": "Reset export settings",
|
||||
"imagesPath": "Image path (Spine Images Path)",
|
||||
"spineVersion": "Spine version",
|
||||
"keyframeCurve": "Keyframe interpolation",
|
||||
"linear": "Linear",
|
||||
"smoothBezier": "Smooth Bézier",
|
||||
"bezierHelp": "Smooth Bézier fits continuous motion within the error tolerance; birth, death, attachment changes, and jumps still use stepped keys.",
|
||||
"spine38Help": "Spine 3.8 does not support per-channel array Bézier curves. Export converts them to tolerance-reduced piecewise linear keys.",
|
||||
"fps": "Export FPS",
|
||||
"omitFps": "Omit fps",
|
||||
"fpsHelp": "Changing FPS preserves real animation duration. “Omit fps” only removes the Spine fps metadata.",
|
||||
"integerFrames": "Integer frame alignment",
|
||||
"bonePool": "Bone object pool",
|
||||
"excludeEmpty": "Exclude empty animations",
|
||||
"baking": "Recalculating and baking animation",
|
||||
"exportJsonTitle": "Export Spine {version} JSON",
|
||||
"preparing": "Preparing export…",
|
||||
"exportJson": "Export Spine JSON",
|
||||
"exportImagesTitle": "Download the images used by the current particle systems as a ZIP",
|
||||
"organizingImages": "Collecting images…",
|
||||
"exportImages": "Export images ZIP",
|
||||
"generatingProject": "Generating project…",
|
||||
"exportSpine": "Export .spine package",
|
||||
"authorize": "Authorize this site",
|
||||
"authorizeTitle": "Authorize this site in the local connector",
|
||||
"refreshConnector": "Check connector again",
|
||||
"refresh": "Refresh",
|
||||
"recalculating": "Recalculating particle animation…",
|
||||
"exported": "Exported Spine {version}: {systems} particle systems, {animations} animations, {bones} bones{warning}",
|
||||
"resourceWarning": "; {count} resource warnings (export continued without missing images)",
|
||||
"timeout": "Baking timed out. Ensure the page is still running and try again.",
|
||||
"preparingProject": "Collecting images and calling local Spine…",
|
||||
"projectExported": "Generated {fileName}: .spine project with {images} images, Spine {version}",
|
||||
"collectingImages": "Collecting images used by the current particle systems…",
|
||||
"imagesExported": "Generated {fileName}: {images} images in {folder}/",
|
||||
"connectorChecking": "Checking the local Spine connector…",
|
||||
"connectorOffline": "Local Spine connector is not running",
|
||||
"spineNotFound": "Connector is running, but Spine was not found",
|
||||
"notAuthorized": "Connector is running, but this site is not authorized",
|
||||
"connectorReady": "Connector available · Spine ready",
|
||||
"connectorBusy": "Recalculating and generating the Spine project",
|
||||
"connectorGenerate": "Generate a Spine {version} project with the local Spine",
|
||||
"unknown": "Unknown",
|
||||
"checking": "Checking",
|
||||
"offline": "Offline",
|
||||
"notFound": "Spine not found",
|
||||
"unauthorized": "Not authorized",
|
||||
"projectTransferTitle": "Export Spine Package",
|
||||
"projectTransferDetail": "Recalculating particle animation…",
|
||||
"projectTransferDownload": "Spine project download started",
|
||||
"generatingJson": "Generating Spine JSON…",
|
||||
"preparingProjectFiles": "Preparing project images…",
|
||||
"uploading": "Uploading data to the Spine connector…",
|
||||
"spineProcessing": "Spine is converting the project…",
|
||||
"downloadingProject": "Downloading the generated Spine project…",
|
||||
"handingToBrowser": "Starting browser download…"
|
||||
},
|
||||
"transfer": {
|
||||
"completed": "Completed",
|
||||
"failed": "Operation failed"
|
||||
},
|
||||
"scene": {
|
||||
"title": "Scene Objects",
|
||||
"addParticle": "Add particle system",
|
||||
"addParticleShort": "+ Particle",
|
||||
"addCollider": "Add collider",
|
||||
"addColliderShort": "+ Collider",
|
||||
"addPath": "Add path",
|
||||
"addPathShort": "+ Path",
|
||||
"addSpine": "Add Spine",
|
||||
"copyTitle": "Duplicate selected scene object",
|
||||
"showParticle": "Show particle system",
|
||||
"hideParticle": "Hide particle system",
|
||||
"showCollider": "Show collider",
|
||||
"hideCollider": "Hide collider",
|
||||
"showPath": "Show path",
|
||||
"hidePath": "Hide path",
|
||||
"showSpine": "Show Spine",
|
||||
"hideSpine": "Hide Spine",
|
||||
"particle": "Particle",
|
||||
"subEmitter": "Sub-emitter",
|
||||
"collision": "Collision",
|
||||
"path": "Path",
|
||||
"deleteParticle": "Delete particle system",
|
||||
"deleteCollider": "Delete collider",
|
||||
"deletePath": "Delete path",
|
||||
"deleteSpine": "Delete Spine",
|
||||
"empty": "No scene objects",
|
||||
"name": "Name",
|
||||
"positionX": "Position X",
|
||||
"positionY": "Position Y",
|
||||
"offsetX": "Offset X",
|
||||
"offsetY": "Offset Y",
|
||||
"rotation": "Rotation",
|
||||
"scaleX": "Scale X",
|
||||
"scaleY": "Scale Y",
|
||||
"seed": "Random seed",
|
||||
"seedTitle": "Controls random attributes, resource selection, and modifier sampling",
|
||||
"randomizeSeed": "Generate a new global random seed",
|
||||
"seedHint": "Controls initial random values, resource selection, and modifier sampling. The same seed reproduces the same result.",
|
||||
"colliderTitle": "Collider Properties",
|
||||
"colliderName": "Collider name",
|
||||
"type": "Type",
|
||||
"circle": "Circle",
|
||||
"ellipse": "Ellipse",
|
||||
"rect": "Rectangle",
|
||||
"polygon": "Polygon",
|
||||
"custom": "Custom",
|
||||
"enabled": "Enabled",
|
||||
"xPosition": "X position",
|
||||
"yPosition": "Y position",
|
||||
"scaleXShort": "Scale X",
|
||||
"scaleYShort": "Scale Y",
|
||||
"radius": "Radius",
|
||||
"radiusX": "X radius",
|
||||
"radiusY": "Y radius",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"sides": "Sides",
|
||||
"collisionResponse": "Collision response",
|
||||
"physics": "Physics (bounce / friction)",
|
||||
"pause": "Pause in place",
|
||||
"destroy": "Destroy immediately",
|
||||
"elasticity": "Elasticity (0-1)",
|
||||
"friction": "Friction (0-1)",
|
||||
"followPath": "Follow a path",
|
||||
"targetPath": "Target path",
|
||||
"notSelected": "(Not selected)",
|
||||
"moveDuration": "Move duration (s)",
|
||||
"followDirection": "Orient along path",
|
||||
"moveProgressCurve": "Movement progress curve",
|
||||
"moveProgressHelp": "X: elapsed / duration · Y: path progress (0 = start, 1 = end)",
|
||||
"pathTitle": "Path Properties",
|
||||
"pathName": "Path name",
|
||||
"closedPath": "Closed path",
|
||||
"pathColor": "Path color",
|
||||
"pathHelp": "Controls:",
|
||||
"pathHelpItems": [
|
||||
"Double-click empty space or a curve to add an anchor",
|
||||
"Double-click an anchor to delete it",
|
||||
"Double-click, hold on the second click, and drag to create handles",
|
||||
"Drag anchors to move them and handles to reshape the curve",
|
||||
"Shift + handle drag: symmetric handles",
|
||||
"Alt + handle drag: independent handles",
|
||||
"Ctrl + click / box select: add nodes",
|
||||
"Alt + click / box select: remove nodes",
|
||||
"Alt + click empty space: clear selection",
|
||||
"Move multiple selected anchors together"
|
||||
],
|
||||
"applyPresetShape": "Apply preset shape",
|
||||
"choosePreset": "Choose preset…",
|
||||
"square": "Square",
|
||||
"star": "Star",
|
||||
"applyPreset": "Apply preset",
|
||||
"clearAnchors": "Clear anchors",
|
||||
"panelHint": "Use the buttons above to create a scene object"
|
||||
},
|
||||
"modifiers": {
|
||||
"title": "Modifiers",
|
||||
"reset": "Reset modifiers",
|
||||
"damping": "Damping",
|
||||
"enableNoise": "Enable movement noise",
|
||||
"strength": "Strength",
|
||||
"noiseRange": "Wander range",
|
||||
"noiseSpeed": "Time speed",
|
||||
"noiseHint": "Strength controls influence · Wander range controls maximum offset · Time speed controls variation rate",
|
||||
"enableGravity": "Enable gravity",
|
||||
"gravityStrength": "Gravity strength",
|
||||
"strengthMin": "Strength min",
|
||||
"strengthMax": "Strength max",
|
||||
"direction": "Direction",
|
||||
"gravityOverLife": "Gravity over lifetime",
|
||||
"windOverLife": "Wind over lifetime",
|
||||
"axisStrength": "Y: strength 0–1",
|
||||
"directionHint": "0° right · -90° down · Negative strength reverses direction",
|
||||
"enableWind": "Enable wind",
|
||||
"windStrength": "Wind strength",
|
||||
"enableForceField": "Enable force field",
|
||||
"centerX": "Center X",
|
||||
"centerY": "Center Y",
|
||||
"forceStrength": "Force strength",
|
||||
"forceRadius": "Force radius",
|
||||
"falloffMode": "Falloff",
|
||||
"constant": "Constant",
|
||||
"linearFalloff": "Linear (zero at edge)",
|
||||
"inverseFalloff": "Inverse distance (strongest at center)",
|
||||
"forceHint": "Positive attracts · Negative repels · Drag the force circle on the canvas",
|
||||
"showShape": "Show shape",
|
||||
"enableVortex": "Enable vortex",
|
||||
"vortexHint": "Positive counter-clockwise · Negative clockwise",
|
||||
"enableAttraction": "Enable attraction",
|
||||
"attractionX": "Attraction point X",
|
||||
"attractionY": "Attraction point Y",
|
||||
"attractionRadius": "Arrival radius",
|
||||
"attractionDuration": "Attraction duration",
|
||||
"delay": "Delay",
|
||||
"delayMode": "Delay mode",
|
||||
"globalTime": "Global time",
|
||||
"particleLife": "Per particle life",
|
||||
"attractionPath": "Attraction path",
|
||||
"straight": "Straight",
|
||||
"attractionHint": "Starts attraction after the delay · Dies on reaching the red radius · Drag the attraction circle on the canvas",
|
||||
"enableTrail": "Enable trail",
|
||||
"trailImages": "Trail Images",
|
||||
"addTrailImage": "Add trail image resource",
|
||||
"trailRotation": "Rotation",
|
||||
"trailBones": "Trail bones",
|
||||
"trailRatio": "Trail ratio (%)",
|
||||
"trailLength": "Trail length",
|
||||
"randomRange": "Random range",
|
||||
"lengthRatio": "Length ratio (%)",
|
||||
"lengthMin": "Length min (%)",
|
||||
"lengthMax": "Length max (%)",
|
||||
"trailWidth": "Trail width",
|
||||
"widthRatio": "Width ratio (%)",
|
||||
"widthMin": "Width min (%)",
|
||||
"widthMax": "Width max (%)",
|
||||
"widthScale": "Width scale",
|
||||
"followParticle": "Follow particle",
|
||||
"lifeLengthCurve": "Length over lifetime",
|
||||
"shapeCurve": "Shape curve (head to tail width)",
|
||||
"colorMode": "Color mode",
|
||||
"blendMode": "Blend mode",
|
||||
"fixedColor": "Fixed color",
|
||||
"alphaMode": "Opacity mode",
|
||||
"opacity": "Opacity",
|
||||
"gridRows": "Grid rows",
|
||||
"gridCols": "Grid columns",
|
||||
"trailHint": "Each particle creates a sibling trail bone group according to the resource ratio. Random length and width are fixed at birth and reproducible with the same seed.",
|
||||
"enableFollow": "Enable emitter following",
|
||||
"followMode": "Follow mode",
|
||||
"noFollow": "None",
|
||||
"followPath": "Follow path",
|
||||
"selectedBone": "Selected bone",
|
||||
"emitterAngleMode": "Emitter angle mode",
|
||||
"fixedAngle": "Fixed angle",
|
||||
"followMoveDirection": "Follow movement direction",
|
||||
"moveProgressCurve": "Movement progress curve (0→1)",
|
||||
"moveDuration": "Move duration (s)",
|
||||
"followSpace": "Follow space",
|
||||
"localSpace": "Local space (move emitter)",
|
||||
"worldSpace": "World space (move spawn point)",
|
||||
"moveDirection": "Movement direction",
|
||||
"forward": "Forward (start → end)",
|
||||
"reverse": "Reverse (end → start)",
|
||||
"followOffset": "Start offset (0-1)",
|
||||
"followSpaceHint": "Local space moves the entire emitter. World space moves only the spawn point; existing particles stay in world position.",
|
||||
"followTarget": "Follow target: ",
|
||||
"noValidBone": "No valid bone is bound. Select a bone in the Spine hierarchy first.",
|
||||
"rebind": "Rebind",
|
||||
"bind": "Bind",
|
||||
"selectedBoneLabel": "Selected bone: ",
|
||||
"boneLocalSpace": "Local space (follow bone)",
|
||||
"boneWorldSpace": "World space (keep particle position)",
|
||||
"offsetX": "Position offset X",
|
||||
"offsetY": "Position offset Y",
|
||||
"rotationOffset": "Rotation offset",
|
||||
"scaleOffsetX": "Scale offset X",
|
||||
"scaleOffsetY": "Scale offset Y",
|
||||
"boneFollowHint": "The emitter follows only the selected Spine bone position. Particle transforms use the particle system settings and offsets above.",
|
||||
"enableCollision": "Enable collision",
|
||||
"collisionObjects": "Collision objects",
|
||||
"colliderDisabled": "{name} (disabled)",
|
||||
"clearCollider": "Remove collider {name}",
|
||||
"addCollider": "Add a scene collider",
|
||||
"noAvailableColliders": "No colliders available",
|
||||
"noCollidersSelected": "No collider selected. Current particles will not collide.",
|
||||
"available": "Available",
|
||||
"disabled": "Disabled",
|
||||
"collisionHint": "Only enabled scene colliders added here participate in this particle system's collisions.",
|
||||
"enableSubEmitter": "Enable sub-emitters",
|
||||
"triggerTiming": "Choose trigger event",
|
||||
"birth": "Birth",
|
||||
"death": "Death",
|
||||
"collision": "Collision",
|
||||
"chooseTarget": "Choose target particle system",
|
||||
"noTarget": "None (particle system)",
|
||||
"deleteSubEmitter": "Delete this sub-emitter",
|
||||
"probability": "Trigger probability",
|
||||
"collisionLimit": "Collision trigger limit",
|
||||
"inherit": "Inherit",
|
||||
"colorInheritMode": "Color inheritance",
|
||||
"add": "Add",
|
||||
"replace": "Replace",
|
||||
"addSubEmitter": "+ Add sub-emitter",
|
||||
"inheritNone": "None (default)",
|
||||
"inheritAll": "All attributes",
|
||||
"inheritColor": "Color",
|
||||
"inheritScale": "Size",
|
||||
"inheritRotation": "Rotation",
|
||||
"inheritLife": "Lifetime"
|
||||
},
|
||||
"spine": {
|
||||
"title": "Spine Properties",
|
||||
"name": "Spine name",
|
||||
"xPosition": "X position",
|
||||
"yPosition": "Y position",
|
||||
"rotation": "Rotation",
|
||||
"scaleX": "Scale X",
|
||||
"scaleY": "Scale Y",
|
||||
"startTime": "Start time",
|
||||
"resources": "Spine Resources",
|
||||
"chooseFolder": "Choose folder and load",
|
||||
"folderHint": "Reads JSON / SKEL, Atlas, and all textures from the folder. Assets can still be replaced individually below.",
|
||||
"choose": "Choose",
|
||||
"chooseMultiple": "Choose multiple",
|
||||
"loading": "Loading…",
|
||||
"load": "↧ Load",
|
||||
"clear": "Clear",
|
||||
"supportNote": "Supports Spine 3.8 / 4.0 / 4.1 / 4.2 / 4.3 JSON · Runtime loaded on demand · Fully local",
|
||||
"currentResource": "Current asset {version}",
|
||||
"animations": "Spine Animations",
|
||||
"noAnimations": "No animations available",
|
||||
"timelineAnimation": "Timeline animation",
|
||||
"no": "None",
|
||||
"hierarchy": "Hierarchy",
|
||||
"clearBoneTitle": "Click to clear bone selection",
|
||||
"noBoneTitle": "No bone selected",
|
||||
"clearBone": "Clear selected bone: {name}",
|
||||
"currentBone": "Current bone: ",
|
||||
"clearSlotTitle": "Click to clear slot selection",
|
||||
"noSlotTitle": "No slot selected",
|
||||
"clearSlot": "Clear selected slot: {name}",
|
||||
"currentSlot": "Current slot: ",
|
||||
"notSelected": "Not selected",
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all",
|
||||
"bones": "Bones",
|
||||
"slots": "Slots",
|
||||
"notLoaded": "Spine not loaded",
|
||||
"searchBones": "Search bones",
|
||||
"searchBonesPlaceholder": "Search bone names…",
|
||||
"clearSearch": "Clear search",
|
||||
"clearBoneSearch": "Clear bone search",
|
||||
"noMatchingBones": "No matching bones",
|
||||
"expandChildren": "Expand child bones",
|
||||
"collapseChildren": "Collapse child bones",
|
||||
"notLoadedSlots": "Spine slots not loaded",
|
||||
"searchSlots": "Search slots",
|
||||
"searchSlotsPlaceholder": "Search slots or attachments…",
|
||||
"clearSlotSearch": "Clear slot search",
|
||||
"noMatchingSlots": "No matching slots",
|
||||
"slotTitle": "{name} (bone: {bone}, attachment: {attachment})",
|
||||
"empty": "Empty",
|
||||
"textureSummary": "{count} textures: {names}",
|
||||
"textureImages": "texture images",
|
||||
"missingFolderAssets": "The selected folder is missing: {missing}",
|
||||
"chooseAssets": "Select Spine data, Atlas, and all texture images"
|
||||
},
|
||||
"help": {
|
||||
"title": "Help",
|
||||
"subtitle": "Quick start from creating particles to saving and exporting",
|
||||
"close": "Close help",
|
||||
"closeShort": "Close (Esc)",
|
||||
"quickStart": "Quick Start",
|
||||
"stepCreate": "Create objects: use + Particle / + Collider / + Path / + Spine in Scene Objects.",
|
||||
"stepEdit": "Edit particles: select a particle system, then adjust Emission → Emitter Shape → Particle Attributes → Appearance & Resources → Modifiers.",
|
||||
"stepPreview": "Preview: play, pause, step, or scrub on the timeline. Parameter changes are recalculated automatically.",
|
||||
"stepExport": "Save and export: Save Config keeps the project; Export Spine JSON / images ZIP / .spine package produces the deliverable.",
|
||||
"quickNote": "Reset beside each section restores that section's defaults. Settings are saved automatically in this browser.",
|
||||
"commonOps": "Common Actions",
|
||||
"eye": "Visibility",
|
||||
"eyeDesc": "Show or hide an object independently. Hidden particle systems are not exported.",
|
||||
"copyDesc": "Duplicate the selected object for parameter comparisons.",
|
||||
"backgroundDesc": "Upload a reference image and adjust its position and opacity for canvas preview only.",
|
||||
"settingsDesc": "Manage layout, canvas guides, tick display, and other editor settings.",
|
||||
"undoDesc": "Ctrl+Z undoes one step. The maximum history size is configurable.",
|
||||
"spineBoneDesc": "Search and select bones in the hierarchy for emitter following.",
|
||||
"shortcuts": "Shortcuts",
|
||||
"undoShortcut": "Undo",
|
||||
"saveShortcut": "Save config",
|
||||
"panShortcut": "Pan canvas",
|
||||
"translateShortcut": "Move tool",
|
||||
"rotateShortcut": "Rotate tool",
|
||||
"scaleShortcut": "Scale tool",
|
||||
"playShortcut": "Play / pause",
|
||||
"startShortcut": "Go to first frame",
|
||||
"shortcutNote": "Click the canvas before using Q / W / E / R. Single-key shortcuts are disabled while typing in inputs.",
|
||||
"modules": "Module Guide",
|
||||
"configTitle": "Configuration",
|
||||
"saveConfigDesc": "Save all scene objects and parameters to a project JSON file. Ctrl+S is supported.",
|
||||
"loadConfigDesc": "Load a local configuration and restore the complete project.",
|
||||
"presetDesc": "Choose a preset from the dropdown. Open the dropdown to refresh the list.",
|
||||
"configScope": "Scope",
|
||||
"configScopeDesc": "The project includes objects and parameters. Interface settings are stored separately in the browser.",
|
||||
"sceneTitle": "Scene Objects",
|
||||
"particleDesc": "The core object. Set position, offset, rotation, scale, and random seed. The same seed reproduces the same result.",
|
||||
"colliderDesc": "Circle, ellipse, rectangle, polygon, or custom shape with physics, pause, destroy, and path-following responses.",
|
||||
"pathDesc": "Bézier path with anchors, handles, closing, color, and preset shapes.",
|
||||
"spineDesc": "Load Spine data, Atlas, and textures; browse bones and slots, and use them for emitter following.",
|
||||
"sceneCommonDesc": "Objects can be hidden and duplicated independently. Hidden particle systems are not exported.",
|
||||
"emissionDesc": "Streaming, burst, delay, and three-part loop streaming with start, loop, and end sections.",
|
||||
"burstDesc": "Emit a fixed number of particles once.",
|
||||
"delayDesc": "Wait time before emission starts, shown at the left edge of the particle bar.",
|
||||
"loopNote": "Loop streaming does not support bone or path following and resets those settings when enabled.",
|
||||
"shapeDesc": "Point, circle, rectangle, cone, or path spawn areas. Circle/cone use inner and outer radii; rectangles use inner and outer dimensions.",
|
||||
"angleDesc": "Fixed, random, radial, path normal, or path tangent.",
|
||||
"initialDesc": "Life, speed, scale, and rotation support fixed, random, and curve modes. Rotation can follow emission direction.",
|
||||
"overLifeDesc": "Speed, scale, and rotation can be controlled by lifetime curves.",
|
||||
"alphaDesc": "Opacity can be fixed or curved. Curve mode can follow export, linear, or Bézier.",
|
||||
"imageDesc": "Single images or sequence frames, weighted image selection, pivots, colors, blend modes, and independent opacity.",
|
||||
"sequenceDesc": "Playback can hide after one pass, hold the last frame, loop forward, or loop in reverse.",
|
||||
"noiseDesc": "Adds movement noise with strength, range, and speed controls.",
|
||||
"gravityDesc": "Gravity, wind, and lifetime strength curves.",
|
||||
"forceDesc": "Global force field, vortex, attraction, and collision.",
|
||||
"trailDesc": "Independent trail resources with bones, length, width, shape, color, and opacity curves.",
|
||||
"followDesc": "The emitter can follow a path or a Spine bone.",
|
||||
"collisionDesc": "Choose scene colliders and particle collision response.",
|
||||
"exportDesc": "Configure image path, Spine version, keyframe curves, FPS, frame alignment, bone pool, and empty animations.",
|
||||
"exportButtonsDesc": "Export Spine JSON, an images ZIP, or a complete .spine project through the connector.",
|
||||
"connectorTitle": "Spine Connector",
|
||||
"connectorIntro": "The connector sends generated JSON and images to a locally installed and activated Spine editor and converts them into an editable .spine project.",
|
||||
"prerequisite": "Requirements",
|
||||
"prerequisiteDesc": "Install and activate Spine, then start the desktop connector.",
|
||||
"download": "Download",
|
||||
"webSide": "Website",
|
||||
"webSideDesc": "When the connector reports ready, click Export .spine package.",
|
||||
"authorization": "First authorization",
|
||||
"authorizationDesc": "Approve the website origin once. The connector stores the local allowlist.",
|
||||
"pathSetup": "Application path",
|
||||
"pathSetupDesc": "The connector detects Spine automatically. Set the application path manually if detection fails.",
|
||||
"downloadResult": "Output",
|
||||
"downloadResultDesc": "The downloaded ZIP contains the .spine project and image folder.",
|
||||
"version": "Version",
|
||||
"versionDesc": "The connector matches Spine 3.8–4.3 from the JSON version.",
|
||||
"security": "Security",
|
||||
"securityDesc": "The connector listens only on the local machine and deletes temporary conversion files.",
|
||||
"differenceTitle": "Config vs. export",
|
||||
"differenceDesc": "Save Config preserves editable objects and parameters. Export Spine JSON includes only visible particle systems and bakes collisions, following, trails, and sub-emitters into editable animation data."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "Enabling loop stream disables particle following and resets its settings. Continue?",
|
||||
"confirmLoopFollowExit": "Leaving loop stream disables particle following and resets its settings. Continue?",
|
||||
"invalidJson": "The configuration file is not valid JSON",
|
||||
"invalidConfigFormat": "This is not a SpineParticle editor configuration",
|
||||
"unsupportedConfigVersion": "Unsupported configuration version: {version}",
|
||||
"missingScene": "The configuration is missing complete scene data",
|
||||
"missingEmbeddedSpine": "The configuration does not contain reusable Spine source assets",
|
||||
"chooseJsonConfig": "Choose a .json configuration file",
|
||||
"settingsInvalidJson": "The cached settings are not valid JSON",
|
||||
"settingsFormat": "This is not a SpineParticle settings cache",
|
||||
"settingsVersion": "Unsupported settings version: {version}",
|
||||
"settingsMissing": "The settings cache is missing settings",
|
||||
"noExportableImages": "The visible particle systems have no exportable images",
|
||||
"invalidZipPath": "Invalid ZIP file path",
|
||||
"spineFileType": "Spine data must be .json or .skel",
|
||||
"atlasFileType": "Atlas files must use .atlas",
|
||||
"chooseTexture": "Choose at least one texture image",
|
||||
"binaryParseFailed": "Spine binary parsing failed (4.3: {v43}; legacy: {v42})",
|
||||
"invalidSpineJson": "Invalid Spine JSON format",
|
||||
"unsupportedSpineJsonVersion": "Unsupported Spine JSON version. Supported versions: {versions}",
|
||||
"unsupportedSpineDataVersion": "Unsupported Spine data version. Supported versions: {versions}",
|
||||
"embeddedReadFailed": "Unable to read embedded Spine file: {name}",
|
||||
"missingImageWarning": "Image “{file}” is missing or has unreadable dimensions. Its path was kept.",
|
||||
"missingTrailImageWarning": "Trail image “{file}” is missing. Its path was kept.",
|
||||
"emptyCacheWarning": "“{name}” has no baked frame cache.",
|
||||
"readExportImageFailed": "Unable to read export image: {name}",
|
||||
"connectorOffline": "Local Spine connector was not detected. Start the connector first.",
|
||||
"connectorHttpError": "Spine connector returned HTTP {status}",
|
||||
"invalidTrailUV": "Trail mesh “{name}” has invalid UV data",
|
||||
"missingTrailVertices": "Trail mesh “{name}” has no vertex data",
|
||||
"trailIndexOutOfRange": "Trail mesh “{name}” has an out-of-range triangle index",
|
||||
"invalidTrailHull": "Trail mesh “{name}” has an invalid hull size",
|
||||
"trailHullIndexOutOfRange": "Trail mesh “{name}” has an out-of-range hull edge index",
|
||||
"invalidInfluenceCount": "Trail mesh “{name}” has an invalid bone influence count",
|
||||
"missingBoneReference": "Trail mesh “{name}” references a missing bone",
|
||||
"invalidWeight": "Trail mesh “{name}” contains invalid weights",
|
||||
"weightSum": "Trail mesh “{name}” vertex weights do not sum to 1",
|
||||
"vertexCountMismatch": "Trail mesh “{name}” vertex count does not match its UV count",
|
||||
"invalidInherit": "Bone “{name}” has inherit value “{inherit}” incompatible with Spine {version} JSON",
|
||||
"trailBoneParent": "Trail bone “{name}” must be a direct child of the particle system bone, not a bone chain",
|
||||
"trailSlotEmpty": "Weighted trail slot “{name}” cannot use an empty attachment. Hide the bones with zero scale instead.",
|
||||
"spine38Version": "Spine 3.8 export must identify its version as 3.8",
|
||||
"spine38Inherit": "Spine 3.8 bone “{name}” must use transform instead of inherit",
|
||||
"spine38SlotColor": "Spine 3.8 slot color timelines must use color / twoColor",
|
||||
"spine38AttachmentName": "Spine 3.8 hidden attachment frames must explicitly write name: null",
|
||||
"spine38RotationAngle": "Spine 3.8 rotation timelines must use angle",
|
||||
"spine38Curve": "Spine 3.8 does not support Spine 4.x array curves",
|
||||
"unsupportedExportVersion": "Unsupported export version. Supported Spine versions: {versions}",
|
||||
"exportVersionMismatch": "Export version mismatch: expected Spine {expected}, JSON reports Spine {actual}",
|
||||
"exportMissingBones": "Export result contains no bones",
|
||||
"missingAtlasTexture": "Atlas texture was not selected: {name}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Spine粒子编辑器-Yeomai",
|
||||
"resizeTimeline": "拖动调整时间轴高度",
|
||||
"systems": "系统",
|
||||
"particlesBones": "粒子(骨骼)",
|
||||
"rootBone": "根骨骼",
|
||||
"hudTip": "滚轮缩放 · 左键拖动画布"
|
||||
},
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"zhCN": "中文",
|
||||
"en": "English"
|
||||
},
|
||||
"common": {
|
||||
"close": "关闭",
|
||||
"reset": "重置",
|
||||
"add": "添加",
|
||||
"remove": "移除",
|
||||
"delete": "删除",
|
||||
"clear": "清除",
|
||||
"cancel": "取消",
|
||||
"confirm": "确定",
|
||||
"none": "无",
|
||||
"enabled": "启用",
|
||||
"disabled": "禁用",
|
||||
"fixed": "固定",
|
||||
"random": "随机",
|
||||
"curve": "曲线",
|
||||
"followExport": "跟随导出",
|
||||
"linear": "线性",
|
||||
"bezier": "贝塞尔",
|
||||
"above": "之上",
|
||||
"below": "之下",
|
||||
"left": "左侧",
|
||||
"right": "右侧",
|
||||
"seconds": "秒",
|
||||
"frames": "帧"
|
||||
},
|
||||
"toolbar": {
|
||||
"settings": "系统设置",
|
||||
"undo": "撤回上一步 (Ctrl+Z)",
|
||||
"pan": "拖动画布 (Q)",
|
||||
"translate": "位移 (W)",
|
||||
"rotate": "旋转 (E)",
|
||||
"scale": "缩放 (R)",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"fitView": "适配画布",
|
||||
"toggleGrid": "切换网格",
|
||||
"resetView": "重置视图",
|
||||
"background": "背景图",
|
||||
"backgroundSettings": "背景图显示设置",
|
||||
"changeBackground": "更换图像",
|
||||
"uploadBackground": "上传图像",
|
||||
"clearBackground": "清除背景",
|
||||
"noBackground": "未上传图像",
|
||||
"backgroundX": "背景图 X 偏移",
|
||||
"backgroundY": "背景图 Y 偏移",
|
||||
"backgroundOpacity": "背景图透明度",
|
||||
"opacity": "透明度",
|
||||
"help": "使用说明",
|
||||
"openHelp": "查看使用说明"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系统设置",
|
||||
"layout": "界面布局",
|
||||
"parameterPanelPosition": "参数面板位置",
|
||||
"panelOnRight": "参数面板显示在右侧",
|
||||
"history": "操作历史",
|
||||
"maxUndo": "最大撤回步数",
|
||||
"canvasGuides": "画布辅助显示",
|
||||
"emitter": "发射点",
|
||||
"bones": "骨骼连线",
|
||||
"bonePointSize": "骨骼点绘制大小",
|
||||
"boneAxisSize": "坐标轴绘制大小",
|
||||
"skinMesh": "蒙皮网格",
|
||||
"enableTicks": "启用刻度",
|
||||
"tickFontSize": "刻度文字大小",
|
||||
"tickWidth": "刻度线粗细",
|
||||
"axisColor": "坐标轴颜色",
|
||||
"showAxisLabels": "显示刻度数值",
|
||||
"cacheNote": "整个系统设置会自动保存到当前浏览器"
|
||||
},
|
||||
"config": {
|
||||
"title": "配置管理",
|
||||
"save": "保存配置",
|
||||
"saveTitle": "保存全部编辑器配置 (Ctrl+S)",
|
||||
"load": "加载配置",
|
||||
"loadTitle": "加载本地编辑器配置",
|
||||
"presets": "预设列表",
|
||||
"selectPreset": "选择预设",
|
||||
"noPresets": "无预设文件",
|
||||
"saved": "配置已保存到本地",
|
||||
"loading": "正在加载配置…",
|
||||
"loaded": "已加载:{name}",
|
||||
"presetLoading": "正在加载预设…",
|
||||
"presetLoaded": "已加载预设:{name}",
|
||||
"presetNamingError": "{message},请检查预设文件命名",
|
||||
"presetTransferTitle": "加载预设",
|
||||
"presetDownloading": "正在下载 {file}…",
|
||||
"presetParsing": "正在解析预设配置…",
|
||||
"presetRestoring": "正在解析并恢复预设资源…",
|
||||
"presetComplete": "预设加载完成",
|
||||
"presetListError": "无法读取预设列表",
|
||||
"presetReadError": "无法读取预设:{file}"
|
||||
},
|
||||
"presets": {
|
||||
"particleSweep": "1.粒子扫过",
|
||||
"barBlocks": "2.长条方块",
|
||||
"forceTrail": "3.力场拖尾",
|
||||
"lightningSequence": "4.雷电序列帧",
|
||||
"texturePivot": "5.贴图轴点",
|
||||
"subEmitter": "6.子发射器"
|
||||
},
|
||||
"curve": {
|
||||
"bezierHint": "拖动方形手柄调整弧度 · 双击空白新建 · 双击节点删除",
|
||||
"linearHint": "双击空白新建 · 双击节点删除 · 拖动节点调整",
|
||||
"resetTitle": "恢复默认曲线"
|
||||
},
|
||||
"gradient": {
|
||||
"addTitle": "双击色带添加色标",
|
||||
"color": "色标颜色",
|
||||
"colorValue": "色标颜色值",
|
||||
"position": "色标位置",
|
||||
"addMiddle": "在中间新增颜色",
|
||||
"delete": "删除当前色标",
|
||||
"hint": "双击色带新增 · 拖动色标调整位置 · 双击色标删除"
|
||||
},
|
||||
"attributeControl": {
|
||||
"angleOffset": "角度偏移",
|
||||
"value": "数值",
|
||||
"lower": "下限",
|
||||
"upper": "上限",
|
||||
"rangeLower": "范围下限",
|
||||
"rangeUpper": "范围上限",
|
||||
"curveInput": "曲线输入 →",
|
||||
"direction": "发射方向"
|
||||
},
|
||||
"pivot": {
|
||||
"title": "贴图轴点",
|
||||
"zoomOut": "缩小",
|
||||
"zoomIn": "放大",
|
||||
"expand": "放大到全屏操作",
|
||||
"interaction": "左键点击/拖动设置轴点 · 右键拖动画布 · 滚轮缩放",
|
||||
"previewAlt": "轴点预览",
|
||||
"noImage": "未选择图片",
|
||||
"resetCenter": "复位居中 (50%, 50%)",
|
||||
"coordinates": "轴点坐标(%)",
|
||||
"rotationTest": "轴心旋转测试",
|
||||
"static": "静止",
|
||||
"spin": "自转",
|
||||
"swing": "摆动",
|
||||
"rotationAlt": "旋转测试",
|
||||
"topLeft": "左上",
|
||||
"top": "正上",
|
||||
"topRight": "右上",
|
||||
"left": "正左",
|
||||
"center": "正中",
|
||||
"right": "正右",
|
||||
"bottomLeft": "左下",
|
||||
"bottom": "正下·底部",
|
||||
"bottomRight": "右下"
|
||||
},
|
||||
"image": {
|
||||
"chooseSequence": "选择序列帧图片",
|
||||
"chooseLocal": "选择本地图片",
|
||||
"subfolder": "子文件夹",
|
||||
"name": "名称",
|
||||
"restore": "恢复 {name}",
|
||||
"deleteResource": "删除图片资源",
|
||||
"imageMode": "图片模式",
|
||||
"fixed": "固定",
|
||||
"sequence": "序列帧",
|
||||
"playbackMode": "播放模式",
|
||||
"onceHide": "单次-尾帧消失",
|
||||
"onceHold": "单次-尾帧固定",
|
||||
"loopForward": "正向循环",
|
||||
"loopReverse": "反向循环",
|
||||
"playbackFps": "播放帧率",
|
||||
"chooseSequenceMulti": "选择序列帧(多选)",
|
||||
"weight": "占比%",
|
||||
"unlockWeight": "解除占比锁定",
|
||||
"lockWeight": "锁定当前占比",
|
||||
"pivot": "贴图轴点",
|
||||
"collapsePivot": "收起轴点调整",
|
||||
"expandPivot": "展开轴点调整",
|
||||
"pivotModal": "贴图旋转轴点大图精修",
|
||||
"pivotModalDesc": "在下方贴图上任意点击或拖拽,精确设置粒子的旋转/摆动中心",
|
||||
"pivotZoomHint": "支持 100% ~ 400% 放大,精确对准尖端或根部:",
|
||||
"confirmPivot": "确定并保存轴点",
|
||||
"noSequence": "未选择序列帧",
|
||||
"firstFrame": "第 1 帧",
|
||||
"firstImage": "第一张图片",
|
||||
"lastImage": "最后一张图片",
|
||||
"pivotOn": "已开启:X {x}% | Y {y}%",
|
||||
"pivotOff": "未开启,默认围绕图片正中心 (50%, 50%)",
|
||||
"pivotCustom": "轴点已自定义:X {x}% | Y {y}%",
|
||||
"frameCountOne": "1 帧 · {name}",
|
||||
"frameCountMany": "{count} 帧 · {first} → {last}",
|
||||
"sequenceSelected": "已选择 {count} 帧,按 {first}-{last} 顺序播放"
|
||||
},
|
||||
"timeline": {
|
||||
"play": "播放 (D)",
|
||||
"pause": "暂停 (D)",
|
||||
"toStart": "回到第一帧 (S)",
|
||||
"previous": "前一帧",
|
||||
"next": "后一帧",
|
||||
"toEnd": "移动到最后一帧",
|
||||
"continuousLoop": "持续循环播放",
|
||||
"abLoop": "独立 A/B 区间循环",
|
||||
"abLoopShort": "AB 循环",
|
||||
"abRange": "AB 循环区间",
|
||||
"playbackStart": "自定义播放起始帧",
|
||||
"playbackEnd": "自定义播放结束帧",
|
||||
"startFrame": "播放起始帧",
|
||||
"endFrame": "播放结束帧",
|
||||
"frame": "帧",
|
||||
"emission": "持续发射",
|
||||
"particleLife": "粒子存在",
|
||||
"spineAnimation": "Spine 动画",
|
||||
"ownerAnimation": "所属动画",
|
||||
"setOwnerAnimation": "设置当前粒子系统所属的动画",
|
||||
"chooseOwnerAnimation": "选择所属动画",
|
||||
"animationName": "动画名称",
|
||||
"renameAnimation": "重命名当前动画",
|
||||
"rename": "重命名",
|
||||
"newAnimation": "+ 新增",
|
||||
"keepOneAnimation": "至少需要保留一个动画",
|
||||
"spinePreview": "Spine 预览动画",
|
||||
"noSpineAnimation": "无可用 Spine 动画",
|
||||
"none": "无",
|
||||
"systemName": "系统 {index}",
|
||||
"dragLoopDuration": "拖动循环段时长",
|
||||
"dragEmissionDuration": "拖动持续发射时长",
|
||||
"dragSpineStart": "拖动调整动画开始时间",
|
||||
"noAnimationData": "暂无可显示的动画数据",
|
||||
"animationNameEmpty": "动画名称不能为空",
|
||||
"animationNameDuplicate": "动画名称不能重复",
|
||||
"animationRenameFailed": "无法修改动画名称",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"zoomFit": "适中",
|
||||
"frameStatus": "帧 {current} / {total}"
|
||||
},
|
||||
"emission": {
|
||||
"title": "发射模式",
|
||||
"reset": "重置发射模式参数",
|
||||
"stream": "持续",
|
||||
"burst": "爆发",
|
||||
"normalStream": "常规持续",
|
||||
"loopStream": "循环持续",
|
||||
"rate": "发射速率",
|
||||
"duration": "发射时长",
|
||||
"loopDuration": "发射时长 (f)",
|
||||
"generateStart": "生成开始循环动画",
|
||||
"generateEnd": "生成结束循环动画",
|
||||
"customDuration": "指定时长",
|
||||
"auto": "自动 {frames}f",
|
||||
"startLoopDuration": "开始循环段时长",
|
||||
"endLoopDuration": "结束循环段时长",
|
||||
"loopWarning": "循环持续模式不支持骨骼跟随和路径跟随;进入或退出时会将粒子跟随设置恢复默认值。",
|
||||
"burstCount": "单次数量",
|
||||
"delay": "延迟时间",
|
||||
"shapeTitle": "发射器形状",
|
||||
"shapeReset": "重置发射器形状参数",
|
||||
"shape": "形状",
|
||||
"point": "点",
|
||||
"circle": "圆形",
|
||||
"rect": "矩形",
|
||||
"cone": "锥形",
|
||||
"path": "路径",
|
||||
"innerDiameter": "内径",
|
||||
"outerDiameter": "外径",
|
||||
"innerRadius": "内半径",
|
||||
"outerRadius": "外半径",
|
||||
"coneDirection": "锥形朝向",
|
||||
"coneAngle": "锥形开口角度",
|
||||
"innerWidth": "内宽",
|
||||
"innerHeight": "内高",
|
||||
"outerWidth": "外宽",
|
||||
"outerHeight": "外高",
|
||||
"selectPath": "选择路径",
|
||||
"noPath": "无路径(点发射)",
|
||||
"birthDistribution": "出生分布",
|
||||
"evenDistribution": "等间距固定分布",
|
||||
"randomDistribution": "随机分布",
|
||||
"emissionAngle": "发射角度",
|
||||
"radial": "径向(中心向外)",
|
||||
"pathNormal": "垂直于路径",
|
||||
"pathTangent": "沿路径方向",
|
||||
"fixedAngle": "固定角度",
|
||||
"baseAngle": "基准角度",
|
||||
"randomRange": "随机范围",
|
||||
"showShape": "显示形状"
|
||||
},
|
||||
"attributes": {
|
||||
"title": "粒子属性",
|
||||
"reset": "重置粒子属性参数",
|
||||
"initialParams": "粒子初始参数",
|
||||
"initialLife": "初始生命周期",
|
||||
"randomSample": "随机采样 →",
|
||||
"initialSpeed": "初始速度",
|
||||
"initialScale": "初始缩放",
|
||||
"uniform": "统一",
|
||||
"separateXY": "XY分离",
|
||||
"scaleX": "缩放 X",
|
||||
"scaleY": "缩放 Y",
|
||||
"initialRotation": "初始旋转",
|
||||
"lifecycleTitle": "粒子生命周期变化参数",
|
||||
"speedOverLife": "生命周期内速度",
|
||||
"speedMultiplier": "速度倍率",
|
||||
"lifeTo": "生命周期 →",
|
||||
"scaleOverLife": "生命周期内缩放",
|
||||
"scaleMultiplier": "缩放倍率",
|
||||
"multiplierX": "倍率 X",
|
||||
"multiplierY": "倍率 Y",
|
||||
"rotationOverLife": "生命周期内旋转",
|
||||
"rotationSpeed": "旋转速度"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外观与资源",
|
||||
"reset": "重置外观与资源参数",
|
||||
"opacity": "透明度",
|
||||
"curveMode": "曲线模式",
|
||||
"transparent": "0(透明)",
|
||||
"opaque": "1(不透明)",
|
||||
"renderLayer": "渲染层级(Spine 插槽)",
|
||||
"top": "最上层",
|
||||
"spineSlot": "Spine 插槽",
|
||||
"selectSpine": "请选择 Spine",
|
||||
"noSpine": "无可用 Spine",
|
||||
"noSlot": "尚未选择插槽",
|
||||
"reselect": "重新选择",
|
||||
"selectSlot": "选择插槽",
|
||||
"searchSlots": "搜索插槽或附件名称…",
|
||||
"searchSlotsAria": "搜索插槽",
|
||||
"emptySlot": "空插槽",
|
||||
"noMatchingSlots": "没有匹配的插槽",
|
||||
"above": "在此插槽图片之上",
|
||||
"below": "在此插槽图片之下",
|
||||
"followAttachment": "随插槽附件显隐",
|
||||
"previewOnly": "调整显示层级只影响编辑器预览,不影响导出数据。",
|
||||
"imageResources": "图片资源",
|
||||
"addImage": "新增图片资源",
|
||||
"scale": "缩放",
|
||||
"color": "颜色",
|
||||
"fixedColor": "固定色",
|
||||
"randomColor": "随机颜色",
|
||||
"lifetime": "生命周期",
|
||||
"normal": "正常",
|
||||
"add": "相加",
|
||||
"multiply": "相乘",
|
||||
"screen": "滤色",
|
||||
"fixedColorValue": "固定颜色值",
|
||||
"randomColorHint": "每个粒子出生时从色带随机取色,生命周期内保持不变",
|
||||
"independentAlpha": "独立透明度",
|
||||
"off": "关闭"
|
||||
},
|
||||
"renderLayer": {
|
||||
"slotLabelAbove": "{spine} / {slot} · 图片之上",
|
||||
"slotLabelBelow": "{spine} / {slot} · 图片之下",
|
||||
"resolvedAbove": "渲染在 {slot} 图片之上、其后插槽之下",
|
||||
"resolvedBetween": "渲染在 {previous} 与 {slot} 之间",
|
||||
"warningNoSpine": "没有已加载的 Spine,粒子临时显示在最上层。",
|
||||
"warningNoSelection": "尚未选择 Spine,粒子临时显示在最上层。",
|
||||
"warningMissingSpine": "目标 Spine 不存在,粒子临时显示在最上层。",
|
||||
"warningNotReady": "目标 Spine 未就绪,粒子临时显示在最上层。",
|
||||
"warningMissingSlot": "目标插槽不存在(资源可能已更换),粒子临时显示在最上层。",
|
||||
"warningFirstSlot": "这是第一个插槽,无法显示在其下方,粒子临时显示在最上层。"
|
||||
},
|
||||
"export": {
|
||||
"title": "导出设置",
|
||||
"reset": "重置导出设置",
|
||||
"imagesPath": "图片路径 (Spine Images Path)",
|
||||
"spineVersion": "Spine 版本",
|
||||
"keyframeCurve": "关键帧插值曲线",
|
||||
"linear": "线性 (Linear)",
|
||||
"smoothBezier": "平滑贝塞尔 (Smooth Bézier)",
|
||||
"bezierHelp": "平滑贝塞尔会在误差允许范围内拟合连续运动;出生、死亡、附件切换和突变仍使用阶跃关键帧。",
|
||||
"spine38Help": "Spine 3.8 不支持 4.x 的逐通道数组贝塞尔,导出时会转换为经过容差精简的分段线性关键帧。",
|
||||
"fps": "导出 FPS",
|
||||
"omitFps": "不写 fps",
|
||||
"fpsHelp": "修改 FPS 时保持动画实际秒数不变;“不写 fps”只省略 Spine 的 fps 元数据。",
|
||||
"integerFrames": "整数帧对齐",
|
||||
"bonePool": "骨骼对象池",
|
||||
"excludeEmpty": "排除无内容动画",
|
||||
"baking": "正在重新计算并烘焙动画",
|
||||
"exportJsonTitle": "导出 Spine {version} JSON",
|
||||
"preparing": "正在准备导出…",
|
||||
"exportJson": "导出Spine Json",
|
||||
"exportImagesTitle": "单独下载当前粒子系统使用的图片 ZIP",
|
||||
"organizingImages": "正在整理图片…",
|
||||
"exportImages": "导出 images 包",
|
||||
"generatingProject": "正在生成工程…",
|
||||
"exportSpine": "导出 .spine 包",
|
||||
"authorize": "授权当前网站",
|
||||
"authorizeTitle": "在本机连接器中授权当前网站",
|
||||
"refreshConnector": "重新检测连接器",
|
||||
"refresh": "刷新",
|
||||
"recalculating": "正在重新计算粒子动画…",
|
||||
"exported": "已导出 Spine {version}:{systems} 个粒子系统、{animations} 个动画、{bones} 根骨骼{warning}",
|
||||
"resourceWarning": ";{count} 条资源提示(缺图仍已导出)",
|
||||
"timeout": "重新计算动画超时,请确认页面仍在运行后重试",
|
||||
"preparingProject": "正在整理图片并调用本机 Spine…",
|
||||
"projectExported": "已生成 {fileName}:包含 .spine 工程和 {images} 张图片,Spine {version}",
|
||||
"collectingImages": "正在整理当前粒子系统使用的图片…",
|
||||
"imagesExported": "已生成 {fileName}:{folder}/ 内包含 {images} 张图片",
|
||||
"connectorChecking": "正在检测本地 Spine 连接器…",
|
||||
"connectorOffline": "本地 Spine 连接器未启动",
|
||||
"spineNotFound": "连接器已启动,但未找到 Spine",
|
||||
"notAuthorized": "连接器已启动,当前网站尚未授权",
|
||||
"connectorReady": "连接器可用 · Spine 已就绪",
|
||||
"connectorBusy": "正在重新计算并生成 Spine 工程",
|
||||
"connectorGenerate": "调用本机 Spine 自动生成 {version} 工程",
|
||||
"unknown": "未知",
|
||||
"checking": "检测中",
|
||||
"offline": "未启动",
|
||||
"notFound": "未找到 Spine",
|
||||
"unauthorized": "未授权",
|
||||
"projectTransferTitle": "导出 Spine 包",
|
||||
"projectTransferDetail": "正在重新计算粒子动画…",
|
||||
"projectTransferDownload": "Spine 工程包已开始下载",
|
||||
"generatingJson": "正在生成 Spine JSON…",
|
||||
"preparingProjectFiles": "正在整理工程图片…",
|
||||
"uploading": "正在上传数据到 Spine 连接器…",
|
||||
"spineProcessing": "Spine 正在转换工程,请稍候…",
|
||||
"downloadingProject": "正在下载生成的 Spine 工程包…",
|
||||
"handingToBrowser": "正在交给浏览器下载…"
|
||||
},
|
||||
"transfer": {
|
||||
"completed": "已完成",
|
||||
"failed": "操作失败"
|
||||
},
|
||||
"scene": {
|
||||
"title": "场景对象",
|
||||
"addParticle": "添加粒子系统",
|
||||
"addParticleShort": "+粒子",
|
||||
"addCollider": "添加碰撞体",
|
||||
"addColliderShort": "+碰撞",
|
||||
"addPath": "添加路径",
|
||||
"addPathShort": "+路径",
|
||||
"addSpine": "添加 Spine",
|
||||
"copyTitle": "复制当前选中的场景对象",
|
||||
"showParticle": "显示粒子系统",
|
||||
"hideParticle": "隐藏粒子系统",
|
||||
"showCollider": "显示碰撞体",
|
||||
"hideCollider": "隐藏碰撞体",
|
||||
"showPath": "显示路径",
|
||||
"hidePath": "隐藏路径",
|
||||
"showSpine": "显示 Spine",
|
||||
"hideSpine": "隐藏 Spine",
|
||||
"particle": "粒子",
|
||||
"subEmitter": "子发射器",
|
||||
"collision": "碰撞",
|
||||
"path": "路径",
|
||||
"deleteParticle": "删除粒子系统",
|
||||
"deleteCollider": "删除碰撞体",
|
||||
"deletePath": "删除路径",
|
||||
"deleteSpine": "删除 Spine",
|
||||
"empty": "场景中暂无对象",
|
||||
"name": "名称",
|
||||
"positionX": "位置X",
|
||||
"positionY": "位置Y",
|
||||
"offsetX": "偏移X",
|
||||
"offsetY": "偏移Y",
|
||||
"rotation": "旋转",
|
||||
"scaleX": "缩放X",
|
||||
"scaleY": "缩放Y",
|
||||
"seed": "随机种子",
|
||||
"seedTitle": "控制粒子系统内全部随机属性、资源选择与修改器随机采样",
|
||||
"randomizeSeed": "生成新的全局随机种子",
|
||||
"seedHint": "控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果",
|
||||
"colliderTitle": "碰撞体属性",
|
||||
"colliderName": "碰撞体名称",
|
||||
"type": "类型",
|
||||
"circle": "圆形",
|
||||
"ellipse": "椭圆",
|
||||
"rect": "矩形",
|
||||
"polygon": "多边形",
|
||||
"custom": "自定义",
|
||||
"enabled": "启用",
|
||||
"xPosition": "X 位置",
|
||||
"yPosition": "Y 位置",
|
||||
"scaleXShort": "缩放 X",
|
||||
"scaleYShort": "缩放 Y",
|
||||
"radius": "半径",
|
||||
"radiusX": "X 半径",
|
||||
"radiusY": "Y 半径",
|
||||
"width": "宽度",
|
||||
"height": "高度",
|
||||
"sides": "边数",
|
||||
"collisionResponse": "碰撞响应",
|
||||
"physics": "物理(弹跳/摩擦)",
|
||||
"pause": "暂停(停止不动)",
|
||||
"destroy": "消失(立即销毁)",
|
||||
"elasticity": "弹性 (0-1)",
|
||||
"friction": "摩擦 (0-1)",
|
||||
"followPath": "跟随路径移动",
|
||||
"targetPath": "目标路径",
|
||||
"notSelected": "(未选择)",
|
||||
"moveDuration": "移动时长(秒)",
|
||||
"followDirection": "方向沿路径",
|
||||
"moveProgressCurve": "移动进度曲线",
|
||||
"moveProgressHelp": "横轴:实时/移动时长 纵轴:路径进度(0=起点 → 1=终点)",
|
||||
"pathTitle": "路径属性",
|
||||
"pathName": "路径名称",
|
||||
"closedPath": "闭合路径",
|
||||
"pathColor": "路径颜色",
|
||||
"pathHelp": "操作说明:",
|
||||
"pathHelpItems": [
|
||||
"双击空白处/曲线:新建锚点",
|
||||
"双击锚点:删除节点",
|
||||
"双击第二下按住拖动:新建并拉出手柄",
|
||||
"拖动锚点移动位置,拖动手柄调整曲线",
|
||||
"Shift+拖动手柄:两侧完全对称",
|
||||
"Alt+拖动手柄:独立控制单侧",
|
||||
"Ctrl+点击/框选:加选节点",
|
||||
"Alt+点击/框选:减选节点",
|
||||
"Alt+单击空白处:清除全部选中",
|
||||
"选中多个节点后可整体拖动"
|
||||
],
|
||||
"applyPresetShape": "应用预设形状",
|
||||
"choosePreset": "选择预设...",
|
||||
"square": "方形",
|
||||
"star": "星形",
|
||||
"applyPreset": "应用预设",
|
||||
"clearAnchors": "清空锚点",
|
||||
"panelHint": "使用右上角按钮创建场景对象"
|
||||
},
|
||||
"modifiers": {
|
||||
"title": "修改器",
|
||||
"reset": "重置修改器参数",
|
||||
"damping": "阻尼",
|
||||
"enableNoise": "开启移动噪声",
|
||||
"strength": "强度",
|
||||
"noiseRange": "波动范围",
|
||||
"noiseSpeed": "时间流速",
|
||||
"noiseHint": "强度控制影响比例 · 波动范围控制最大偏移 · 时间流速控制变化速度",
|
||||
"enableGravity": "开启重力",
|
||||
"gravityStrength": "重力强度",
|
||||
"strengthMin": "强度 min",
|
||||
"strengthMax": "强度 max",
|
||||
"direction": "方向",
|
||||
"gravityOverLife": "生命周期内重力",
|
||||
"windOverLife": "生命周期内风力",
|
||||
"axisStrength": "纵轴:强度 0~1",
|
||||
"directionHint": "0° 向右 · -90° 向下 · 负强度会反转方向",
|
||||
"enableWind": "开启风力",
|
||||
"windStrength": "风力强度",
|
||||
"enableForceField": "开启力场",
|
||||
"centerX": "中心 X",
|
||||
"centerY": "中心 Y",
|
||||
"forceStrength": "力场强度",
|
||||
"forceRadius": "力场半径",
|
||||
"falloffMode": "衰减模式",
|
||||
"constant": "恒定(范围内力度不变)",
|
||||
"linearFalloff": "线性衰减(边缘为 0)",
|
||||
"inverseFalloff": "距离反比(中心最强)",
|
||||
"forceHint": "正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆",
|
||||
"showShape": "显示形状",
|
||||
"enableVortex": "开启漩涡",
|
||||
"vortexHint": "正数逆时针 · 负数顺时针",
|
||||
"enableAttraction": "开启吸附",
|
||||
"attractionX": "吸附点 X",
|
||||
"attractionY": "吸附点 Y",
|
||||
"attractionRadius": "吸附半径",
|
||||
"attractionDuration": "吸附时长",
|
||||
"delay": "延迟时间",
|
||||
"delayMode": "延迟方式",
|
||||
"globalTime": "统一时间",
|
||||
"particleLife": "单粒子生命",
|
||||
"attractionPath": "吸附方式",
|
||||
"straight": "直线",
|
||||
"attractionHint": "延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆",
|
||||
"enableTrail": "开启拖尾",
|
||||
"trailImages": "拖尾独立图片",
|
||||
"addTrailImage": "增加拖尾图片资源",
|
||||
"trailRotation": "旋转",
|
||||
"trailBones": "拖尾骨骼数",
|
||||
"trailRatio": "拖尾比例 (%)",
|
||||
"trailLength": "拖尾长度",
|
||||
"randomRange": "随机范围",
|
||||
"lengthRatio": "长度比例 (%)",
|
||||
"lengthMin": "长度最小 (%)",
|
||||
"lengthMax": "长度最大 (%)",
|
||||
"trailWidth": "拖尾宽度",
|
||||
"widthRatio": "宽度比例 (%)",
|
||||
"widthMin": "宽度最小 (%)",
|
||||
"widthMax": "宽度最大 (%)",
|
||||
"widthScale": "宽度缩放",
|
||||
"followParticle": "跟随粒子",
|
||||
"lifeLengthCurve": "生命周期长度曲线",
|
||||
"shapeCurve": "形状曲线(首端 → 尾端宽度)",
|
||||
"colorMode": "颜色模式",
|
||||
"blendMode": "混合模式",
|
||||
"fixedColor": "固定颜色",
|
||||
"alphaMode": "透明度模式",
|
||||
"opacity": "透明度",
|
||||
"gridRows": "网格行数",
|
||||
"gridCols": "网格列数",
|
||||
"trailHint": "每个粒子按资源比例生成一组同级拖尾骨骼;随机长度与宽度在粒子出生时确定,相同种子可稳定复现",
|
||||
"enableFollow": "开启发射器跟随",
|
||||
"followMode": "跟随模式",
|
||||
"noFollow": "无",
|
||||
"followPath": "跟随路径",
|
||||
"selectedBone": "选中的骨骼",
|
||||
"emitterAngleMode": "发射器角度模式",
|
||||
"fixedAngle": "固定角度",
|
||||
"followMoveDirection": "跟随移动方向",
|
||||
"moveProgressCurve": "移动进度曲线 (0→1)",
|
||||
"moveDuration": "移动时长 (s)",
|
||||
"followSpace": "跟随空间",
|
||||
"localSpace": "局部空间(GEN 移动)",
|
||||
"worldSpace": "世界空间(粒子生成点移动)",
|
||||
"moveDirection": "移动方向",
|
||||
"forward": "正向(起点 → 终点)",
|
||||
"reverse": "反向(终点 → 起点)",
|
||||
"followOffset": "偏移起始点 (0-1)",
|
||||
"followSpaceHint": "局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。",
|
||||
"followTarget": "跟随目标:",
|
||||
"noValidBone": "尚未绑定有效骨骼,请先在 Spine 层级树中选择骨骼。",
|
||||
"rebind": "重新绑定",
|
||||
"bind": "绑定",
|
||||
"selectedBoneLabel": "当前选中骨骼:",
|
||||
"boneLocalSpace": "本地空间(随骨骼移动)",
|
||||
"boneWorldSpace": "世界空间(粒子生成后保持位置)",
|
||||
"offsetX": "位移偏移 X",
|
||||
"offsetY": "位移偏移 Y",
|
||||
"rotationOffset": "旋转偏移",
|
||||
"scaleOffsetX": "缩放偏移 X",
|
||||
"scaleOffsetY": "缩放偏移 Y",
|
||||
"boneFollowHint": "发射器只跟随所选 Spine 骨骼的位置,不继承 Spine 的旋转或缩放;粒子变换使用粒子系统自身参数和上方偏移。",
|
||||
"enableCollision": "开启碰撞",
|
||||
"collisionObjects": "参与的碰撞对象",
|
||||
"colliderDisabled": "{name}(碰撞体已禁用)",
|
||||
"clearCollider": "清除碰撞对象 {name}",
|
||||
"addCollider": "加入场景碰撞对象",
|
||||
"noAvailableColliders": "没有可加入的碰撞对象",
|
||||
"noCollidersSelected": "未选择碰撞对象,当前粒子不会发生碰撞",
|
||||
"available": "可用",
|
||||
"disabled": "已禁用",
|
||||
"collisionHint": "只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞",
|
||||
"enableSubEmitter": "开启子发射器",
|
||||
"triggerTiming": "选择触发时机",
|
||||
"birth": "出生",
|
||||
"death": "死亡",
|
||||
"collision": "碰撞",
|
||||
"chooseTarget": "选择目标粒子系统",
|
||||
"noTarget": "无(粒子系统)",
|
||||
"deleteSubEmitter": "删除该子发射器",
|
||||
"probability": "发射概率",
|
||||
"collisionLimit": "碰撞次数限制",
|
||||
"inherit": "继承",
|
||||
"colorInheritMode": "颜色继承方式",
|
||||
"add": "相加",
|
||||
"replace": "覆盖",
|
||||
"addSubEmitter": "+ 新增子发射器",
|
||||
"inheritNone": "无(默认值)",
|
||||
"inheritAll": "所有属性",
|
||||
"inheritColor": "颜色",
|
||||
"inheritScale": "大小",
|
||||
"inheritRotation": "旋转",
|
||||
"inheritLife": "生命周期"
|
||||
},
|
||||
"spine": {
|
||||
"title": "Spine 属性",
|
||||
"name": "Spine 名称",
|
||||
"xPosition": "X 位置",
|
||||
"yPosition": "Y 位置",
|
||||
"rotation": "旋转",
|
||||
"scaleX": "Scale X",
|
||||
"scaleY": "Scale Y",
|
||||
"startTime": "开始时间",
|
||||
"resources": "Spine 资源",
|
||||
"chooseFolder": "选择文件夹并加载",
|
||||
"folderHint": "一次读取文件夹中的 JSON / SKEL、Atlas 和全部纹理;仍可在下方单独替换资源。",
|
||||
"choose": "选择",
|
||||
"chooseMultiple": "选择(多选)",
|
||||
"loading": "加载中…",
|
||||
"load": "↧ 加载",
|
||||
"clear": "清除",
|
||||
"supportNote": "支持 Spine 3.8 / 4.0 / 4.1 / 4.2 / 4.3 JSON · 对应版本运行时按需加载 · 本地离线",
|
||||
"currentResource": "当前资源 {version}",
|
||||
"animations": "Spine 动画",
|
||||
"noAnimations": "无可用动画",
|
||||
"timelineAnimation": "时间轴播放动画",
|
||||
"no": "无",
|
||||
"hierarchy": "层级树",
|
||||
"clearBoneTitle": "点击清除骨骼选择",
|
||||
"noBoneTitle": "当前未选择骨骼",
|
||||
"clearBone": "清除选中骨骼:{name}",
|
||||
"currentBone": "当前骨骼:",
|
||||
"clearSlotTitle": "点击清除插槽选择",
|
||||
"noSlotTitle": "当前未选择插槽",
|
||||
"clearSlot": "清除选中插槽:{name}",
|
||||
"currentSlot": "当前插槽:",
|
||||
"notSelected": "未选择",
|
||||
"expandAll": "全部展开",
|
||||
"collapseAll": "全部折叠",
|
||||
"bones": "骨骼",
|
||||
"slots": "插槽",
|
||||
"notLoaded": "未加载 Spine",
|
||||
"searchBones": "搜索骨骼",
|
||||
"searchBonesPlaceholder": "搜索骨骼名称…",
|
||||
"clearSearch": "清空搜索",
|
||||
"clearBoneSearch": "清空骨骼搜索",
|
||||
"noMatchingBones": "未找到匹配的骨骼",
|
||||
"expandChildren": "展开子骨骼",
|
||||
"collapseChildren": "折叠子骨骼",
|
||||
"notLoadedSlots": "未加载 Spine 插槽",
|
||||
"searchSlots": "搜索插槽",
|
||||
"searchSlotsPlaceholder": "搜索插槽或附件名称…",
|
||||
"clearSlotSearch": "清空插槽搜索",
|
||||
"noMatchingSlots": "未找到匹配的插槽",
|
||||
"slotTitle": "{name}(骨骼:{bone},附件:{attachment})",
|
||||
"empty": "空",
|
||||
"textureSummary": "{count} 张:{names}",
|
||||
"textureImages": "纹理图片",
|
||||
"missingFolderAssets": "所选文件夹缺少:{missing}",
|
||||
"chooseAssets": "请依次选择 Spine 数据、Atlas 和全部纹理图片"
|
||||
},
|
||||
"help": {
|
||||
"title": "使用说明",
|
||||
"subtitle": "从创建粒子到保存、导出的快速入门",
|
||||
"close": "关闭使用说明",
|
||||
"closeShort": "关闭 (Esc)",
|
||||
"quickStart": "快速开始",
|
||||
"stepCreate": "创建对象:在左侧“场景对象”中用 +粒子 / +碰撞 / +路径 / +Spine 添加场景对象。",
|
||||
"stepEdit": "编辑粒子:选中粒子系统,依次调整“发射模式 → 发射器形状 → 粒子属性 → 外观与资源 → 修改器”。",
|
||||
"stepPreview": "查看效果:底部时间轴可播放、暂停、逐帧或拖动;修改参数后会自动重新计算。",
|
||||
"stepExport": "保存与导出:用“保存配置”保留工程;“导出 Spine Json / images 包 / .spine 包”输出成品。",
|
||||
"quickNote": "每个模块组标题右侧的“重置”可恢复该组默认参数;系统设置会自动保存在当前浏览器。",
|
||||
"commonOps": "常用操作",
|
||||
"eye": "小眼睛",
|
||||
"eyeDesc": "单独显示/隐藏对象;隐藏的粒子系统不会被导出。",
|
||||
"copyDesc": "完整复制当前选中对象,方便对比不同参数。",
|
||||
"backgroundDesc": "上传参考图并调整位置和透明度,只用于画布预览。",
|
||||
"settingsDesc": "管理界面布局、辅助显示、刻度等,自动存入浏览器。",
|
||||
"undoDesc": "Ctrl+Z 撤回一步;最大步数可在系统设置中调整。",
|
||||
"spineBoneDesc": "在层级树搜索并选择骨骼,可用于粒子发射器跟随。",
|
||||
"shortcuts": "快捷键",
|
||||
"undoShortcut": "撤回上一步",
|
||||
"saveShortcut": "保存配置",
|
||||
"panShortcut": "拖动画布",
|
||||
"translateShortcut": "位移工具",
|
||||
"rotateShortcut": "旋转工具",
|
||||
"scaleShortcut": "缩放工具",
|
||||
"playShortcut": "播放 / 暂停",
|
||||
"startShortcut": "回到第一帧",
|
||||
"shortcutNote": "Q / W / E / R 需先点击画布;光标位于输入框时,单键快捷键不会触发。",
|
||||
"modules": "功能模块详解",
|
||||
"configTitle": "配置管理",
|
||||
"saveConfigDesc": "把当前全部场景对象和参数保存为工程 JSON 文件(也支持 Ctrl+S)。",
|
||||
"loadConfigDesc": "选择本地配置文件,恢复完整工程。",
|
||||
"presetDesc": "从下拉中选择预设快速套用;点击下拉刷新列表。",
|
||||
"configScope": "范围",
|
||||
"configScopeDesc": "配置包含对象与参数;界面和系统设置单独存于浏览器。",
|
||||
"sceneTitle": "场景对象",
|
||||
"particleDesc": "核心对象。可设置位置、偏移、旋转、缩放和随机种子;相同种子可复现相同效果。",
|
||||
"colliderDesc": "圆形/椭圆/矩形/多边形/自定义,支持物理弹跳、暂停、销毁,以及沿路径移动。",
|
||||
"pathDesc": "贝塞尔路径,支持锚点、控制手柄、闭合、颜色和预设形状。",
|
||||
"spineDesc": "加载 Spine 数据、Atlas 和纹理,浏览骨骼与插槽,并用于发射器跟随。",
|
||||
"sceneCommonDesc": "对象可独立显隐和复制;隐藏的粒子系统不会导出。",
|
||||
"emissionDesc": "持续发射、爆发、延迟,以及开始/循环/结束三段式循环持续。",
|
||||
"burstDesc": "一次性发射指定数量的粒子。",
|
||||
"delayDesc": "发射开始前的等待时间,对应粒子条左端。",
|
||||
"loopNote": "循环持续不支持骨骼和路径跟随,切换时会恢复跟随设置。",
|
||||
"shapeDesc": "点、圆形、矩形、锥形或路径出生区域。圆/锥支持内外半径,矩形支持内外宽高。",
|
||||
"angleDesc": "固定、随机、径向、路径法线或路径切线。",
|
||||
"initialDesc": "生命周期、速度、缩放和旋转支持固定、随机、曲线,旋转可跟随发射方向。",
|
||||
"overLifeDesc": "速度倍率、缩放倍率、旋转速度可用生命周期曲线控制。",
|
||||
"alphaDesc": "透明度支持固定或曲线;曲线模式可跟随导出、线性或贝塞尔。",
|
||||
"imageDesc": "单图或序列帧、多图权重随机、轴点、颜色、混合模式和独立透明度。",
|
||||
"sequenceDesc": "播放方式有单次隐藏、单次定格、正向循环、反向循环。",
|
||||
"noiseDesc": "产生抖动轨迹,并控制强度、范围和速度。",
|
||||
"gravityDesc": "重力、风力和生命周期强度曲线。",
|
||||
"forceDesc": "全局力场、漩涡、吸附和碰撞。",
|
||||
"trailDesc": "独立拖尾资源、骨骼数、长度、宽度、形状、颜色与透明度曲线。",
|
||||
"followDesc": "发射器可跟随路径或 Spine 骨骼。",
|
||||
"collisionDesc": "选择场景碰撞对象和粒子碰撞响应。",
|
||||
"exportDesc": "设置图片路径、Spine 版本、关键帧曲线、FPS、整数帧、骨骼池和空动画。",
|
||||
"exportButtonsDesc": "可导出 Spine JSON、images ZIP,或通过连接器生成 .spine 工程。",
|
||||
"connectorTitle": "Spine 连接器使用说明",
|
||||
"connectorIntro": "连接器把网页生成的 JSON 和图片交给本机已安装并激活的 Spine,转换成可编辑的 .spine 工程。",
|
||||
"prerequisite": "前置",
|
||||
"prerequisiteDesc": "电脑已安装并激活 Spine,并已启动桌面版连接器。",
|
||||
"download": "下载",
|
||||
"webSide": "网页端",
|
||||
"webSideDesc": "显示“连接器可用 · Spine 已就绪”后,点击“导出 .spine 包”。",
|
||||
"authorization": "首次授权",
|
||||
"authorizationDesc": "首次连接需核对网站来源并允许一次;本机会保存授权白名单。",
|
||||
"pathSetup": "路径设置",
|
||||
"pathSetupDesc": "连接器会自动检测 Spine;找不到时可手动指定应用路径。",
|
||||
"downloadResult": "下载结果",
|
||||
"downloadResultDesc": "得到 ZIP 包,内含 .spine 工程和图片目录。",
|
||||
"version": "版本",
|
||||
"versionDesc": "连接器按 JSON 版本自动匹配 Spine 3.8~4.3。",
|
||||
"security": "安全",
|
||||
"securityDesc": "仅监听本机地址,授权保存在连接器,临时文件转换后清理。",
|
||||
"differenceTitle": "配置与导出有什么区别?",
|
||||
"differenceDesc": "“保存配置”保存全部可继续编辑的对象和参数;“导出 Spine Json”只导出显示中的粒子系统,并把碰撞、跟随、拖尾和子发射器结果烘焙为独立动画数据。"
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "进入循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?",
|
||||
"confirmLoopFollowExit": "退出循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?",
|
||||
"invalidJson": "配置文件不是有效的 JSON",
|
||||
"invalidConfigFormat": "不是 SpineParticle 编辑器配置文件",
|
||||
"unsupportedConfigVersion": "暂不支持配置版本:{version}",
|
||||
"missingScene": "配置文件缺少完整场景数据",
|
||||
"missingEmbeddedSpine": "配置未包含可复用的 Spine 原始资源",
|
||||
"chooseJsonConfig": "请选择 .json 配置文件",
|
||||
"settingsInvalidJson": "系统设置缓存不是有效的 JSON",
|
||||
"settingsFormat": "不是 SpineParticle 编辑器系统设置缓存",
|
||||
"settingsVersion": "暂不支持系统配置版本:{version}",
|
||||
"settingsMissing": "系统设置缓存缺少 settings",
|
||||
"noExportableImages": "当前可见粒子系统没有可导出的图片",
|
||||
"invalidZipPath": "ZIP 文件路径无效",
|
||||
"spineFileType": "Spine 数据仅支持 .json 或 .skel",
|
||||
"atlasFileType": "图集文件仅支持 .atlas",
|
||||
"chooseTexture": "请至少选择一张纹理图片",
|
||||
"binaryParseFailed": "Spine 二进制解析失败(4.3:{v43};旧版:{v42})",
|
||||
"invalidSpineJson": "Spine JSON 文件格式无效",
|
||||
"unsupportedSpineJsonVersion": "Spine JSON 版本不受支持,仅支持 {versions}",
|
||||
"unsupportedSpineDataVersion": "Spine 数据版本不受支持,仅支持 {versions}",
|
||||
"embeddedReadFailed": "无法读取内嵌 Spine 文件:{name}",
|
||||
"missingImageWarning": "图片“{file}”缺失或尺寸不可读,已保留路径。",
|
||||
"missingTrailImageWarning": "拖尾图片“{file}”缺失,已保留路径。",
|
||||
"emptyCacheWarning": "“{name}”没有可用帧缓存。",
|
||||
"readExportImageFailed": "无法读取导出图片:{name}",
|
||||
"connectorOffline": "未检测到本地 Spine 连接器,请先启动连接器",
|
||||
"connectorHttpError": "Spine 连接器返回错误:HTTP {status}",
|
||||
"invalidTrailUV": "拖尾网格“{name}”的 UV 数据无效",
|
||||
"missingTrailVertices": "拖尾网格“{name}”缺少顶点数据",
|
||||
"trailIndexOutOfRange": "拖尾网格“{name}”的三角形索引越界",
|
||||
"invalidTrailHull": "拖尾网格“{name}”的外轮廓数量无效",
|
||||
"trailHullIndexOutOfRange": "拖尾网格“{name}”的外轮廓边索引越界",
|
||||
"invalidInfluenceCount": "拖尾网格“{name}”包含无效骨骼权重数量",
|
||||
"missingBoneReference": "拖尾网格“{name}”引用了不存在的骨骼",
|
||||
"invalidWeight": "拖尾网格“{name}”包含无效权重数据",
|
||||
"weightSum": "拖尾网格“{name}”的顶点权重之和不为 1",
|
||||
"vertexCountMismatch": "拖尾网格“{name}”的顶点数量与 UV 不一致",
|
||||
"invalidInherit": "骨骼“{name}”的 inherit 值“{inherit}”不符合 Spine {version} JSON 格式",
|
||||
"trailBoneParent": "拖尾骨骼“{name}”必须直接挂在粒子系统骨骼下,不能组成骨骼链",
|
||||
"trailSlotEmpty": "加权拖尾插槽“{name}”不能使用空附件,未出现或死亡阶段应将对应骨骼缩放设为 0",
|
||||
"spine38Version": "Spine 3.8 导出的版本标识必须为 3.8",
|
||||
"spine38Inherit": "Spine 3.8 骨骼“{name}”应使用 transform,不能使用 inherit",
|
||||
"spine38SlotColor": "Spine 3.8 插槽颜色时间轴必须使用 color / twoColor",
|
||||
"spine38AttachmentName": "Spine 3.8 附件时间轴的隐藏帧必须显式写入 name: null",
|
||||
"spine38RotationAngle": "Spine 3.8 旋转时间轴必须使用 angle",
|
||||
"spine38Curve": "Spine 3.8 不支持 4.x 数组曲线格式",
|
||||
"unsupportedExportVersion": "导出版本不受支持,仅支持 Spine {versions}",
|
||||
"exportVersionMismatch": "导出版本不一致:设置为 Spine {expected},JSON 标记为 Spine {actual}",
|
||||
"exportMissingBones": "导出结果缺少骨骼",
|
||||
"missingAtlasTexture": "Atlas 引用的纹理未选择:{name}"
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { i18n } from './i18n'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app')
|
||||
createApp(App).use(createPinia()).use(i18n).mount('#app')
|
||||
@@ -1,56 +1,56 @@
|
||||
<template>
|
||||
<div class="spine-editor">
|
||||
<div class="editor-title">Spine 属性</div>
|
||||
<label class="field wide"><span>Spine 名称</span><input v-model="spineObject.name" /></label>
|
||||
<div class="editor-title">{{ $t('spine.title') }}</div>
|
||||
<label class="field wide"><span>{{ $t('spine.name') }}</span><input v-model="spineObject.name" /></label>
|
||||
<div class="grid three">
|
||||
<label class="field"><span>X 位置</span><input v-model.number="spineObject.x" type="number" /></label>
|
||||
<label class="field"><span>Y 位置</span><input v-model.number="spineObject.y" type="number" /></label>
|
||||
<label class="field"><span>旋转</span><input v-model.number="spineObject.rotation" type="number" /></label>
|
||||
<label class="field"><span>{{ $t('spine.xPosition') }}</span><input v-model.number="spineObject.x" type="number" /></label>
|
||||
<label class="field"><span>{{ $t('spine.yPosition') }}</span><input v-model.number="spineObject.y" type="number" /></label>
|
||||
<label class="field"><span>{{ $t('spine.rotation') }}</span><input v-model.number="spineObject.rotation" type="number" /></label>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<label class="field"><span>Scale X</span><input v-model.number="spineObject.scaleX" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>Scale Y</span><input v-model.number="spineObject.scaleY" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>开始时间</span><input v-model.number="spineObject.timeOffset" type="number" min="0" step="0.033333" @change="recalcTimeline" /></label>
|
||||
<label class="field"><span>{{ $t('spine.startTime') }}</span><input v-model.number="spineObject.timeOffset" type="number" min="0" step="0.033333" @change="recalcTimeline" /></label>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="section-title">Spine 资源</div>
|
||||
<div class="section-title">{{ $t('spine.resources') }}</div>
|
||||
<input ref="skeletonInput" hidden type="file" accept=".json,.skel,application/json,application/octet-stream" @change="selectSkeleton" />
|
||||
<input ref="atlasInput" hidden type="file" accept=".atlas,text/plain" @change="selectAtlas" />
|
||||
<input ref="textureInput" hidden type="file" multiple accept=".png,.jpg,.jpeg,.webp,.wepb,image/png,image/jpeg,image/webp" @change="selectTextures" />
|
||||
<input ref="folderInput" hidden type="file" multiple webkitdirectory directory @change="selectFolder" />
|
||||
<button class="folder-select" :disabled="spineObject.status === 'loading'" @click="folderInput?.click()">选择文件夹并加载</button>
|
||||
<div class="folder-hint">一次读取文件夹中的 JSON / SKEL、Atlas 和全部纹理;仍可在下方单独替换资源。</div>
|
||||
<button class="folder-select" :disabled="spineObject.status === 'loading'" @click="folderInput?.click()">{{ $t('spine.chooseFolder') }}</button>
|
||||
<div class="folder-hint">{{ $t('spine.folderHint') }}</div>
|
||||
<div class="asset-row">
|
||||
<div class="asset-name" :title="skeletonName">{{ skeletonName || 'JSON / SKEL...' }}</div>
|
||||
<button @click="skeletonInput?.click()">选择</button>
|
||||
<button @click="skeletonInput?.click()">{{ $t('spine.choose') }}</button>
|
||||
</div>
|
||||
<div class="asset-row">
|
||||
<div class="asset-name" :title="atlasName">{{ atlasName || 'Atlas...' }}</div>
|
||||
<button @click="atlasInput?.click()">选择</button>
|
||||
<button @click="atlasInput?.click()">{{ $t('spine.choose') }}</button>
|
||||
</div>
|
||||
<div class="asset-row">
|
||||
<div class="asset-name" :title="textureNames.join(', ')">{{ textureSummary }}</div>
|
||||
<button @click="textureInput?.click()">选择(多选)</button>
|
||||
<button @click="textureInput?.click()">{{ $t('spine.chooseMultiple') }}</button>
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<button class="load" :disabled="spineObject.status === 'loading'" @click="loadBundle">
|
||||
{{ spineObject.status === 'loading' ? '加载中…' : '↧ 加载' }}
|
||||
{{ spineObject.status === 'loading' ? $t('spine.loading') : $t('spine.load') }}
|
||||
</button>
|
||||
<button class="clear" @click="clearBundle">清除</button>
|
||||
<button class="clear" @click="clearBundle">{{ $t('spine.clear') }}</button>
|
||||
</div>
|
||||
<div class="runtime-note">
|
||||
支持 Spine 3.8 / 4.0 / 4.1 / 4.2 / 4.3 JSON · 对应版本运行时按需加载 · 本地离线
|
||||
<span v-if="spineObject.dataVersion"> · 当前资源 {{ spineObject.dataVersion }}</span>
|
||||
{{ $t('spine.supportNote') }}
|
||||
<span v-if="spineObject.dataVersion"> · {{ $t('spine.currentResource', { version: spineObject.dataVersion }) }}</span>
|
||||
</div>
|
||||
<div v-if="spineObject.error" class="error">{{ spineObject.error }}</div>
|
||||
|
||||
<div class="section-box">
|
||||
<div class="section-title">Spine 动画</div>
|
||||
<div v-if="!spineObject.animations.length" class="empty">无可用动画</div>
|
||||
<div class="section-title">{{ $t('spine.animations') }}</div>
|
||||
<div v-if="!spineObject.animations.length" class="empty">{{ $t('spine.noAnimations') }}</div>
|
||||
<label v-else class="field wide">
|
||||
<span>时间轴播放动画</span>
|
||||
<span>{{ $t('spine.timelineAnimation') }}</span>
|
||||
<select v-model="spineObject.selectedAnimation" @change="onAnimationChange">
|
||||
<option value="">无</option>
|
||||
<option value="">{{ $t('spine.no') }}</option>
|
||||
<option v-for="animation in spineObject.animations" :key="animation.name" :value="animation.name">
|
||||
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
|
||||
</option>
|
||||
@@ -60,35 +60,35 @@
|
||||
|
||||
<div class="section-box">
|
||||
<div class="tree-title-row">
|
||||
<div class="section-title">层级树</div>
|
||||
<div class="section-title">{{ $t('spine.hierarchy') }}</div>
|
||||
<button
|
||||
v-if="treeTab === 'bone'"
|
||||
class="selected-bone"
|
||||
:class="{ active: spineObject.selectedBone }"
|
||||
:disabled="!spineObject.selectedBone"
|
||||
:title="spineObject.selectedBone ? '点击清除骨骼选择' : '当前未选择骨骼'"
|
||||
:title="spineObject.selectedBone ? $t('spine.clearBoneTitle') : $t('spine.noBoneTitle')"
|
||||
@click="spineObject.selectedBone = ''"
|
||||
>
|
||||
<template v-if="spineObject.selectedBone">清除选中骨骼:<strong>{{ spineObject.selectedBone }}</strong></template>
|
||||
<template v-else>当前骨骼:<strong>未选择</strong></template>
|
||||
<template v-if="spineObject.selectedBone">{{ $t('spine.clearBone', { name: '' }) }}<strong>{{ spineObject.selectedBone }}</strong></template>
|
||||
<template v-else>{{ $t('spine.currentBone') }}<strong>{{ $t('spine.notSelected') }}</strong></template>
|
||||
</button>
|
||||
<button v-else class="selected-bone" :class="{ active: spineObject.selectedSlot }" :disabled="!spineObject.selectedSlot" :title="spineObject.selectedSlot ? '点击清除插槽选择' : '当前未选择插槽'" @click="spineObject.selectedSlot = ''">
|
||||
<template v-if="spineObject.selectedSlot">清除选中插槽:<strong>{{ spineObject.selectedSlot }}</strong></template>
|
||||
<template v-else>当前插槽:<strong>未选择</strong></template>
|
||||
<button v-else class="selected-bone" :class="{ active: spineObject.selectedSlot }" :disabled="!spineObject.selectedSlot" :title="spineObject.selectedSlot ? $t('spine.clearSlotTitle') : $t('spine.noSlotTitle')" @click="spineObject.selectedSlot = ''">
|
||||
<template v-if="spineObject.selectedSlot">{{ $t('spine.clearSlot', { name: '' }) }}<strong>{{ spineObject.selectedSlot }}</strong></template>
|
||||
<template v-else>{{ $t('spine.currentSlot') }}<strong>{{ $t('spine.notSelected') }}</strong></template>
|
||||
</button>
|
||||
<button v-if="treeTab === 'bone' && spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
|
||||
<button v-if="treeTab === 'bone' && spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? $t('spine.expandAll') : $t('spine.collapseAll') }}</button>
|
||||
</div>
|
||||
<div class="tree-tabs">
|
||||
<button :class="{ on: treeTab === 'bone' }" @click="treeTab = 'bone'">骨骼</button>
|
||||
<button :class="{ on: treeTab === 'slot' }" @click="treeTab = 'slot'">插槽</button>
|
||||
<button :class="{ on: treeTab === 'bone' }" @click="treeTab = 'bone'">{{ $t('spine.bones') }}</button>
|
||||
<button :class="{ on: treeTab === 'slot' }" @click="treeTab = 'slot'">{{ $t('spine.slots') }}</button>
|
||||
</div>
|
||||
<template v-if="treeTab === 'bone'">
|
||||
<div v-if="!spineObject.bones.length" class="empty">未加载 Spine</div>
|
||||
<div v-if="!spineObject.bones.length" class="empty">{{ $t('spine.notLoaded') }}</div>
|
||||
<div v-else class="bone-search">
|
||||
<input v-model.trim="boneSearch" type="search" aria-label="搜索骨骼" placeholder="搜索骨骼名称…" />
|
||||
<button v-if="boneSearch" title="清空搜索" aria-label="清空骨骼搜索" @click="boneSearch = ''">✕</button>
|
||||
<input v-model.trim="boneSearch" type="search" :aria-label="$t('spine.searchBones')" :placeholder="$t('spine.searchBonesPlaceholder')" />
|
||||
<button v-if="boneSearch" :title="$t('spine.clearSearch')" :aria-label="$t('spine.clearBoneSearch')" @click="boneSearch = ''">✕</button>
|
||||
</div>
|
||||
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">未找到匹配的骨骼</div>
|
||||
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">{{ $t('spine.noMatchingBones') }}</div>
|
||||
<div v-else-if="spineObject.bones.length" class="bone-tree">
|
||||
<div
|
||||
v-for="bone in visibleBones"
|
||||
@@ -104,7 +104,7 @@
|
||||
<button
|
||||
v-if="parentBoneNames.has(bone.name)"
|
||||
class="bone-toggle"
|
||||
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
|
||||
:title="collapsedBones.has(bone.name) ? $t('spine.expandChildren') : $t('spine.collapseChildren')"
|
||||
@click.stop="toggleBone(bone.name)"
|
||||
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
|
||||
<span v-else class="bone-leaf">•</span>
|
||||
@@ -113,12 +113,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="!spineObject.slots.length" class="empty">未加载 Spine 插槽</div>
|
||||
<div v-if="!spineObject.slots.length" class="empty">{{ $t('spine.notLoadedSlots') }}</div>
|
||||
<div v-else class="bone-search">
|
||||
<input v-model.trim="slotSearch" type="search" aria-label="搜索插槽" placeholder="搜索插槽或附件名称…" />
|
||||
<button v-if="slotSearch" title="清空搜索" aria-label="清空插槽搜索" @click="slotSearch = ''">✕</button>
|
||||
<input v-model.trim="slotSearch" type="search" :aria-label="$t('spine.searchSlots')" :placeholder="$t('spine.searchSlotsPlaceholder')" />
|
||||
<button v-if="slotSearch" :title="$t('spine.clearSearch')" :aria-label="$t('spine.clearSlotSearch')" @click="slotSearch = ''">✕</button>
|
||||
</div>
|
||||
<div v-if="spineObject.slots.length && !visibleSlots.length" class="empty">未找到匹配的插槽</div>
|
||||
<div v-if="spineObject.slots.length && !visibleSlots.length" class="empty">{{ $t('spine.noMatchingSlots') }}</div>
|
||||
<div v-else-if="spineObject.slots.length" class="bone-tree slot-tree">
|
||||
<div
|
||||
v-for="slot in visibleSlots"
|
||||
@@ -127,13 +127,13 @@
|
||||
:class="{ selected: spineObject.selectedSlot === slot.name }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="`${slot.name}(骨骼:${slot.bone || '-'},附件:${slot.attachment || '空'})`"
|
||||
:title="$t('spine.slotTitle', { name: slot.name, bone: slot.bone || '-', attachment: slot.attachment || $t('spine.empty') })"
|
||||
@click="selectSlot(slot.name)"
|
||||
@keydown.enter.prevent="selectSlot(slot.name)"
|
||||
>
|
||||
<span class="slot-order">{{ slot.order + 1 }}</span>
|
||||
<span class="bone-name">{{ slot.name }}</span>
|
||||
<span class="slot-attachment">{{ slot.attachment || '空' }}</span>
|
||||
<span class="slot-attachment">{{ slot.attachment || $t('spine.empty') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -143,6 +143,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { parseSpineBundle } from './spineAssetLoader'
|
||||
import { registerSpineAsset, releaseSpineAsset } from './spineAssetRegistry'
|
||||
@@ -150,6 +151,7 @@ import type { SpineSceneObject } from './spineTypes'
|
||||
import { embedSpineSource } from './spineSourceBundle'
|
||||
|
||||
const props = defineProps<{ spineObject: SpineSceneObject }>()
|
||||
const { t } = useI18n()
|
||||
const store = useParticleStore()
|
||||
const skeletonInput = ref<HTMLInputElement | null>(null)
|
||||
const atlasInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -173,7 +175,7 @@ if (props.spineObject.dataVersion == null) props.spineObject.dataVersion = ''
|
||||
const skeletonName = computed(() => skeletonFile.value?.name || props.spineObject.skeletonFileName)
|
||||
const atlasName = computed(() => atlasFile.value?.name || props.spineObject.atlasFileName)
|
||||
const textureNames = computed(() => textureFiles.value.length ? textureFiles.value.map((file) => file.name) : props.spineObject.textureFileNames)
|
||||
const textureSummary = computed(() => textureNames.value.length ? `${textureNames.value.length} 张:${textureNames.value.join(', ')}` : 'PNG / JPEG / WebP...')
|
||||
const textureSummary = computed(() => textureNames.value.length ? t('spine.textureSummary', { count: textureNames.value.length, names: textureNames.value.join(', ') }) : 'PNG / JPEG / WebP...')
|
||||
const parentBoneNames = computed(() => new Set(props.spineObject.bones.map((bone) => bone.parent).filter((name): name is string => !!name)))
|
||||
const boneByName = computed(() => new Map(props.spineObject.bones.map((bone) => [bone.name, bone])))
|
||||
const visibleBones = computed(() => props.spineObject.bones.filter((bone) => {
|
||||
@@ -244,9 +246,9 @@ async function selectFolder(event: Event) {
|
||||
atlasFile.value = atlas
|
||||
textureFiles.value = textures
|
||||
if (!skeleton || !atlas || !textures.length) {
|
||||
const missing = [!skeleton && 'JSON / SKEL', !atlas && 'Atlas', !textures.length && '纹理图片'].filter(Boolean).join('、')
|
||||
const missing = [!skeleton && 'JSON / SKEL', !atlas && 'Atlas', !textures.length && t('spine.textureImages')].filter(Boolean).join(', ')
|
||||
props.spineObject.status = 'error'
|
||||
props.spineObject.error = `所选文件夹缺少:${missing}`
|
||||
props.spineObject.error = t('spine.missingFolderAssets', { missing })
|
||||
return
|
||||
}
|
||||
await loadBundle()
|
||||
@@ -266,7 +268,7 @@ async function loadBundle() {
|
||||
const atlas = atlasFile.value
|
||||
if (!skeleton || !atlas || !textureFiles.value.length) {
|
||||
props.spineObject.status = 'error'
|
||||
props.spineObject.error = '请依次选择 Spine 数据、Atlas 和全部纹理图片'
|
||||
props.spineObject.error = t('spine.chooseAssets')
|
||||
return
|
||||
}
|
||||
props.spineObject.status = 'loading'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ALPHA_MODES } from 'pixi.js'
|
||||
import type { SpineTextureMap } from './spineRuntimeTypes'
|
||||
import { translate } from '../../i18n'
|
||||
|
||||
function basename(path: string) {
|
||||
return path.replace(/\\/g, '/').split('/').pop() || path
|
||||
@@ -8,7 +9,7 @@ function basename(path: string) {
|
||||
export function bindAtlasTextures(atlas: any, textures: SpineTextureMap, SpineTexture: { from(baseTexture: any): any }) {
|
||||
for (const page of atlas.pages) {
|
||||
const texture = textures.get(basename(page.name).toLowerCase())
|
||||
if (!texture) throw new Error(`Atlas 引用的纹理未选择:${page.name}`)
|
||||
if (!texture) throw new Error(translate('errors.missingAtlasTexture', { name: page.name }))
|
||||
texture.baseTexture.alphaMode = page.pma ? ALPHA_MODES.PMA : ALPHA_MODES.UNPACK
|
||||
page.setTexture(SpineTexture.from(texture.baseTexture))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parseBinary43, parseJson43 } from './runtime/spineRuntime43'
|
||||
import type { SpineAnimationInfo, SpineBoneInfo, SpineSlotInfo } from './spineTypes'
|
||||
import { normalizeSpine38JsonFor42 } from './spine38Compatibility'
|
||||
import { normalizeSpineVersion, spineRuntimeVersion, supportedSpineVersionsText, type SupportedSpineVersion } from './spineVersion'
|
||||
import { translate } from '../i18n'
|
||||
|
||||
type CompatibleSkeletonData = LoadedSpineAsset['skeletonData']
|
||||
|
||||
@@ -52,9 +53,9 @@ function slotMetadata(skeletonData: CompatibleSkeletonData): SpineSlotInfo[] {
|
||||
}
|
||||
|
||||
export async function parseSpineBundle(skeletonFile: File, atlasFile: File, textureFiles: File[]): Promise<ParsedSpineBundle> {
|
||||
if (!/\.(json|skel)$/i.test(skeletonFile.name)) throw new Error('Spine 数据仅支持 .json 或 .skel')
|
||||
if (!/\.atlas$/i.test(atlasFile.name)) throw new Error('图集文件仅支持 .atlas')
|
||||
if (!textureFiles.length) throw new Error('请至少选择一张纹理图片')
|
||||
if (!/\.(json|skel)$/i.test(skeletonFile.name)) throw new Error(translate('errors.spineFileType'))
|
||||
if (!/\.atlas$/i.test(atlasFile.name)) throw new Error(translate('errors.atlasFileType'))
|
||||
if (!textureFiles.length) throw new Error(translate('errors.chooseTexture'))
|
||||
|
||||
const atlasText = await atlasFile.text()
|
||||
const texturesByName = await readTextures(textureFiles)
|
||||
@@ -78,7 +79,7 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
|
||||
} catch (error42) {
|
||||
const message43 = error43 instanceof Error ? error43.message : String(error43)
|
||||
const message42 = error42 instanceof Error ? error42.message : String(error42)
|
||||
throw new Error(`Spine 二进制解析失败(4.3:${message43};旧版:${message42})`)
|
||||
throw new Error(translate('errors.binaryParseFailed', { v43: message43, v42: message42 }))
|
||||
}
|
||||
}
|
||||
dataVersion = normalizeSpineVersion(skeletonData.version)
|
||||
@@ -87,10 +88,10 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
|
||||
try {
|
||||
source = JSON.parse(await skeletonFile.text())
|
||||
} catch {
|
||||
throw new Error('Spine JSON 文件格式无效')
|
||||
throw new Error(translate('errors.invalidSpineJson'))
|
||||
}
|
||||
dataVersion = normalizeSpineVersion(source?.skeleton?.spine)
|
||||
if (!dataVersion) throw new Error(`Spine JSON 版本不受支持,仅支持 ${supportedSpineVersionsText()}`)
|
||||
if (!dataVersion) throw new Error(translate('errors.unsupportedSpineJsonVersion', { versions: supportedSpineVersionsText() }))
|
||||
runtimeVersion = spineRuntimeVersion(dataVersion)
|
||||
if (dataVersion === '3.8') normalizeSpine38JsonFor42(source)
|
||||
const parsed = runtimeVersion === '4.3'
|
||||
@@ -99,7 +100,7 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
|
||||
skeletonData = parsed.skeletonData
|
||||
SpineClass = parsed.SpineClass
|
||||
}
|
||||
if (!dataVersion) throw new Error(`Spine 数据版本不受支持,仅支持 ${supportedSpineVersionsText()}`)
|
||||
if (!dataVersion) throw new Error(translate('errors.unsupportedSpineDataVersion', { versions: supportedSpineVersionsText() }))
|
||||
|
||||
return {
|
||||
skeletonData,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { translate } from '../i18n'
|
||||
|
||||
export interface EmbeddedSpineFile {
|
||||
name: string
|
||||
dataUrl: string
|
||||
@@ -28,7 +30,7 @@ export async function embedSpineSource(skeleton: File, atlas: File, textures: Fi
|
||||
|
||||
async function embeddedFile(source: EmbeddedSpineFile) {
|
||||
const response = await fetch(source.dataUrl)
|
||||
if (!response.ok) throw new Error(`无法读取内嵌 Spine 文件:${source.name}`)
|
||||
if (!response.ok) throw new Error(translate('errors.embeddedReadFailed', { name: source.name }))
|
||||
return new File([await response.blob()], source.name)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ref="bar"
|
||||
class="gradient-bar"
|
||||
:style="{ background: gradientCss }"
|
||||
title="双击色带添加色标"
|
||||
:title="$t('gradient.addTitle')"
|
||||
@dblclick="addAtPointer"
|
||||
>
|
||||
<button
|
||||
@@ -22,21 +22,21 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selectedStop" class="gradient-controls">
|
||||
<input type="color" class="gradient-color" :value="selectedStop.color" aria-label="色标颜色" @input="setSelectedColor" />
|
||||
<input type="color" class="gradient-color" :value="selectedStop.color" :aria-label="$t('gradient.color')" @input="setSelectedColor" />
|
||||
<input
|
||||
type="text"
|
||||
class="gradient-hex-input"
|
||||
:value="selectedStop.color"
|
||||
aria-label="色标颜色值"
|
||||
:aria-label="$t('gradient.colorValue')"
|
||||
spellcheck="false"
|
||||
@change="setSelectedColorText"
|
||||
@keydown.enter="commitColorText"
|
||||
/>
|
||||
<label class="gradient-position"><input type="number" min="0" max="100" step="1" :value="Math.round(clamp(selectedStop.position) * 100)" aria-label="色标位置" @input="setSelectedPosition" /><span>%</span></label>
|
||||
<button type="button" class="gradient-action" title="在中间新增颜色" @click="addMiddle">+</button>
|
||||
<button type="button" class="gradient-action danger" title="删除当前色标" :disabled="modelValue.length <= 2" @click="removeStop(selectedIndex)">×</button>
|
||||
<label class="gradient-position"><input type="number" min="0" max="100" step="1" :value="Math.round(clamp(selectedStop.position) * 100)" :aria-label="$t('gradient.position')" @input="setSelectedPosition" /><span>%</span></label>
|
||||
<button type="button" class="gradient-action" :title="$t('gradient.addMiddle')" @click="addMiddle">+</button>
|
||||
<button type="button" class="gradient-action danger" :title="$t('gradient.delete')" :disabled="modelValue.length <= 2" @click="removeStop(selectedIndex)">×</button>
|
||||
</div>
|
||||
<div class="gradient-hint">双击色带新增 · 拖动色标调整位置 · 双击色标删除</div>
|
||||
<div class="gradient-hint">{{ $t('gradient.hint') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="ce-nav"><span class="ce-tip">{{ mode === 'bezier' ? '拖动方形手柄调整弧度·双击空白新建·双击节点删除' : '双击空白新建·双击节点删除·拖动节点调整' }}</span></div>
|
||||
<div class="ce-nav"><span class="ce-tip">{{ mode === 'bezier' ? $t('curve.bezierHint') : $t('curve.linearHint') }}</span></div>
|
||||
</div>
|
||||
<button class="ce-reset" type="button" title="恢复默认曲线" @click="resetCurve">重置</button>
|
||||
<button class="ce-reset" type="button" :title="$t('curve.resetTitle')" @click="resetCurve">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+80
-86
@@ -3,197 +3,191 @@
|
||||
<section class="help-dialog" role="dialog" aria-modal="true" aria-labelledby="help-title">
|
||||
<header class="help-head">
|
||||
<div>
|
||||
<h2 id="help-title">使用说明</h2>
|
||||
<p>从创建粒子到保存、导出的快速入门</p>
|
||||
<h2 id="help-title">{{ $t('help.title') }}</h2>
|
||||
<p>{{ $t('help.subtitle') }}</p>
|
||||
</div>
|
||||
<button class="help-close" aria-label="关闭使用说明" title="关闭 (Esc)" @click="close">✕</button>
|
||||
<button class="help-close" :aria-label="$t('help.close')" :title="$t('help.closeShort')" @click="close">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="help-body">
|
||||
<section class="help-section help-start">
|
||||
<h3>快速开始</h3>
|
||||
<h3>{{ $t('help.quickStart') }}</h3>
|
||||
<ol>
|
||||
<li><b>创建对象:</b>在左侧“场景对象”中用 +粒子 / +碰撞 / +路径 / +Spine 添加场景对象。</li>
|
||||
<li><b>编辑粒子:</b>选中某个粒子系统,依次调整“发射模式 → 发射器形状 → 粒子属性 → 外观与资源 → 修改器”。</li>
|
||||
<li><b>查看效果:</b>底部时间轴可播放、暂停、逐帧或拖动;修改任意参数后会自动重新计算。</li>
|
||||
<li><b>保存与导出:</b>用“保存配置”保留工程;“导出 Spine Json / images 包 / .spine 包”输出成品。</li>
|
||||
<li>{{ $t('help.stepCreate') }}</li>
|
||||
<li>{{ $t('help.stepEdit') }}</li>
|
||||
<li>{{ $t('help.stepPreview') }}</li>
|
||||
<li>{{ $t('help.stepExport') }}</li>
|
||||
</ol>
|
||||
<p class="help-note">每个模块组标题右侧的“重置”可把该组参数恢复默认;系统设置会自动保存在当前浏览器。</p>
|
||||
<p class="help-note">{{ $t('help.quickNote') }}</p>
|
||||
</section>
|
||||
|
||||
<div class="help-grid">
|
||||
<section class="help-section">
|
||||
<h3>常用操作</h3>
|
||||
<h3>{{ $t('help.commonOps') }}</h3>
|
||||
<dl class="help-list">
|
||||
<div><dt>小眼睛</dt><dd>单独显示/隐藏对象;隐藏的粒子系统不会被导出。</dd></div>
|
||||
<div><dt>+Copy</dt><dd>完整复制当前选中对象,方便对比不同参数。</dd></div>
|
||||
<div><dt>背景图</dt><dd>上传参考图并调整位置和透明度,只用于画布预览。</dd></div>
|
||||
<div><dt>系统设置</dt><dd>管理界面布局、辅助显示、刻度等,自动存入浏览器。</dd></div>
|
||||
<div><dt>撤销/重做</dt><dd>Ctrl+Z 撤回一步;可达成的步数在系统设置中调整。</dd></div>
|
||||
<div><dt>Spine 骨骼</dt><dd>在层级树搜索并选择骨骼,可用于粒子发射器跟随。</dd></div>
|
||||
<div><dt>{{ $t('help.eye') }}</dt><dd>{{ $t('help.eyeDesc') }}</dd></div>
|
||||
<div><dt>+Copy</dt><dd>{{ $t('help.copyDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('toolbar.background') }}</dt><dd>{{ $t('help.backgroundDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('toolbar.settings') }}</dt><dd>{{ $t('help.settingsDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.undoShortcut') }}</dt><dd>{{ $t('help.undoDesc') }}</dd></div>
|
||||
<div><dt>Spine Bone</dt><dd>{{ $t('help.spineBoneDesc') }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h3>快捷键</h3>
|
||||
<h3>{{ $t('help.shortcuts') }}</h3>
|
||||
<div class="shortcut-list">
|
||||
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>Z</kbd><em>撤回上一步</em></div>
|
||||
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>S</kbd><em>保存配置</em></div>
|
||||
<div><kbd>Q</kbd><em>拖动画布</em></div>
|
||||
<div><kbd>W</kbd><em>位移工具</em></div>
|
||||
<div><kbd>E</kbd><em>旋转工具</em></div>
|
||||
<div><kbd>R</kbd><em>缩放工具</em></div>
|
||||
<div><kbd>D</kbd><em>播放 / 暂停</em></div>
|
||||
<div><kbd>S</kbd><em>回到第一帧</em></div>
|
||||
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>Z</kbd><em>{{ $t('help.undoShortcut') }}</em></div>
|
||||
<div><kbd>Ctrl/⌘</kbd><span>+</span><kbd>S</kbd><em>{{ $t('help.saveShortcut') }}</em></div>
|
||||
<div><kbd>Q</kbd><em>{{ $t('help.panShortcut') }}</em></div>
|
||||
<div><kbd>W</kbd><em>{{ $t('help.translateShortcut') }}</em></div>
|
||||
<div><kbd>E</kbd><em>{{ $t('help.rotateShortcut') }}</em></div>
|
||||
<div><kbd>R</kbd><em>{{ $t('help.scaleShortcut') }}</em></div>
|
||||
<div><kbd>D</kbd><em>{{ $t('help.playShortcut') }}</em></div>
|
||||
<div><kbd>S</kbd><em>{{ $t('help.startShortcut') }}</em></div>
|
||||
</div>
|
||||
<p class="help-note">Q / W / E / R 需先点击画布;光标位于输入框时,单键快捷键不会触发。</p>
|
||||
<p class="help-note">{{ $t('help.shortcutNote') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="help-doc-title">功能模块详解</div>
|
||||
<div class="help-doc-title">{{ $t('help.modules') }}</div>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[0] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[0] ? 'true' : 'false'" @click="openModules[0] = !openModules[0]">
|
||||
<span class="mnum">1</span><span class="mtitle">配置管理</span><span class="caret">{{ openModules[0] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">1</span><span class="mtitle">{{ $t('help.configTitle') }}</span><span class="caret">{{ openModules[0] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[0]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>保存配置</dt><dd>把当前全部场景对象和参数保存为工程 JSON 文件(也支持 Ctrl+S)。</dd></div>
|
||||
<div><dt>加载配置</dt><dd>选择本地配置文件,恢复完整工程。</dd></div>
|
||||
<div><dt>预设</dt><dd>从下拉中选择已保存的预设快速套用;点击下拉即可刷新列表。</dd></div>
|
||||
<div><dt>范围</dt><dd>配置只含对象与参数,不含“系统设置”;系统设置单独存于浏览器。</dd></div>
|
||||
<div><dt>{{ $t('config.save') }}</dt><dd>{{ $t('help.saveConfigDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('config.load') }}</dt><dd>{{ $t('help.loadConfigDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('config.selectPreset') }}</dt><dd>{{ $t('help.presetDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.configScope') }}</dt><dd>{{ $t('help.configScopeDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[1] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[1] ? 'true' : 'false'" @click="openModules[1] = !openModules[1]">
|
||||
<span class="mnum">2</span><span class="mtitle">场景对象</span><span class="caret">{{ openModules[1] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">2</span><span class="mtitle">{{ $t('help.sceneTitle') }}</span><span class="caret">{{ openModules[1] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[1]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>粒子</dt><dd>核心对象。可设名称、位置 X/Y、偏移、旋转、缩放 X/Y,以及“随机种子”——相同种子可精确复现同一次效果。</dd></div>
|
||||
<div><dt>碰撞</dt><dd>圆形/椭圆/矩形/多边形/自定义;启用开关、碰撞响应(物理弹跳/暂停/销毁)、弹性与摩擦,还可沿某条路径移动并带进度曲线。</dd></div>
|
||||
<div><dt>路径</dt><dd>贝塞尔路径,可双击新建/删除锚点、拖手柄调曲线;内置圆/方/多边形/星形预设,支持闭合与颜色。</dd></div>
|
||||
<div><dt>Spine</dt><dd>加载 .json+.atlas+.png 预览骨骼动画,可查看/搜索骨骼层级树,用于粒子“发射器跟随”。</dd></div>
|
||||
<div><dt>通用</dt><dd>每个对象都有小眼睛显隐;“+Copy”可以复制当前对象;隐藏的粒子系统不会进入导出。</dd></div>
|
||||
<div><dt>{{ $t('scene.particle') }}</dt><dd>{{ $t('help.particleDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('scene.collision') }}</dt><dd>{{ $t('help.colliderDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('scene.path') }}</dt><dd>{{ $t('help.pathDesc') }}</dd></div>
|
||||
<div><dt>Spine</dt><dd>{{ $t('help.spineDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.commonOps') }}</dt><dd>{{ $t('help.sceneCommonDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[2] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[2] ? 'true' : 'false'" @click="openModules[2] = !openModules[2]">
|
||||
<span class="mnum">3</span><span class="mtitle">发射模式</span><span class="caret">{{ openModules[2] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">3</span><span class="mtitle">{{ $t('emission.title') }}</span><span class="caret">{{ openModules[2] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[2]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>持续</dt><dd>按“每秒发射数量”持续喷射;再分“常规持续”(发射时长)与“循环持续”(三段式:开始循环 / 纯循环 / 结束循环,可自动或自定义帧长,做到无缝衔接)。</dd></div>
|
||||
<div><dt>爆发</dt><dd>一次性发射“单次数量”颗粒子。</dd></div>
|
||||
<div><dt>延迟时间</dt><dd>粒子条左端位置,表示发射开始前的等待时间。</dd></div>
|
||||
<div><dt>注意</dt><dd>循环持续模式不支持骨骼跟随和路径跟随;开关时会把跟随设置恢复默认。</dd></div>
|
||||
<div><dt>{{ $t('emission.stream') }}</dt><dd>{{ $t('help.emissionDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('emission.burst') }}</dt><dd>{{ $t('help.burstDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('emission.delay') }}</dt><dd>{{ $t('help.delayDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('modifiers.showShape') }}</dt><dd>{{ $t('help.loopNote') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[3] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[3] ? 'true' : 'false'" @click="openModules[3] = !openModules[3]">
|
||||
<span class="mnum">4</span><span class="mtitle">发射器形状</span><span class="caret">{{ openModules[3] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">4</span><span class="mtitle">{{ $t('emission.shapeTitle') }}</span><span class="caret">{{ openModules[3] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[3]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>形状</dt><dd>点 / 圆形 / 矩形 / 锥形 / 路径。</dd></div>
|
||||
<div><dt>圆·锥</dt><dd>内半径与外半径,形成环形或锥形发射区;锥形额外有“锥形朝向”和“开口角度”。</dd></div>
|
||||
<div><dt>矩形</dt><dd>“外框 − 内框”形成环带,可分别设置内外宽高。</dd></div>
|
||||
<div><dt>路径</dt><dd>选一条场景路径作为出生线;出生分布可选等距固定或随机。</dd></div>
|
||||
<div><dt>发射角度</dt><dd>固定 / 随机(基准角度 + 随机范围)/ 径向(圆、矩、锥中心向外)/ 垂直于路径 / 沿路径方向。</dd></div>
|
||||
<div><dt>{{ $t('emission.shape') }}</dt><dd>{{ $t('help.shapeDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('emission.emissionAngle') }}</dt><dd>{{ $t('help.angleDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[4] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[4] ? 'true' : 'false'" @click="openModules[4] = !openModules[4]">
|
||||
<span class="mnum">5</span><span class="mtitle">粒子属性</span><span class="caret">{{ openModules[4] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">5</span><span class="mtitle">{{ $t('attributes.title') }}</span><span class="caret">{{ openModules[4] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[4]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>初始参数</dt><dd>生命周期、速度、缩放(统一或 X/Y 分离)、初始旋转(可跟随发射方向)。每个属性支持固定 / 随机 / 曲线三种取值。</dd></div>
|
||||
<div><dt>生命周期变化</dt><dd>速度倍率、缩放倍率(统一/分离)、旋转速度,均可用“生命周期 → 倍率”曲线控制。</dd></div>
|
||||
<div><dt>曲线含义</dt><dd>横轴为粒子生命周期 0→1,纵轴为倍率或对应数值。</dd></div>
|
||||
<div><dt>{{ $t('attributes.initialParams') }}</dt><dd>{{ $t('help.initialDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('attributes.lifecycleTitle') }}</dt><dd>{{ $t('help.overLifeDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[5] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[5] ? 'true' : 'false'" @click="openModules[5] = !openModules[5]">
|
||||
<span class="mnum">6</span><span class="mtitle">外观与资源</span><span class="caret">{{ openModules[5] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">6</span><span class="mtitle">{{ $t('appearance.title') }}</span><span class="caret">{{ openModules[5] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[5]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>透明度</dt><dd>曲线或固定;曲线模式可选“跟随导出 / 线性 / 贝塞尔”。</dd></div>
|
||||
<div><dt>图片资源</dt><dd>“+”新增,可上传单图或序列帧;多张图片按权重随机。每张可调缩放、轴心、颜色(固定/随机/生命周期渐变)、混合模式(正常/相加/相乘/滤色)、独立透明度(关/固定/曲线)。</dd></div>
|
||||
<div><dt>序列帧</dt><dd>播放方式:单次隐藏 / 单次定格 / 正向循环 / 反向循环,可设每秒帧数。</dd></div>
|
||||
<div><dt>{{ $t('appearance.opacity') }}</dt><dd>{{ $t('help.alphaDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('appearance.imageResources') }}</dt><dd>{{ $t('help.imageDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('image.sequence') }}</dt><dd>{{ $t('help.sequenceDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[6] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[6] ? 'true' : 'false'" @click="openModules[6] = !openModules[6]">
|
||||
<span class="mnum">7</span><span class="mtitle">修改器</span><span class="caret">{{ openModules[6] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">7</span><span class="mtitle">{{ $t('modifiers.title') }}</span><span class="caret">{{ openModules[6] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[6]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>移动噪声</dt><dd>强度、波动范围、时间流速,让粒子轨迹产生抖动。</dd></div>
|
||||
<div><dt>重力 / 风力</dt><dd>固定或随机强度、方向,以及生命周期内强度曲线。</dd></div>
|
||||
<div><dt>力场</dt><dd>中心 X/Y、强度(正吸负斥)、半径、衰减(恒定/线性/反比)。</dd></div>
|
||||
<div><dt>吸附</dt><dd>延迟后把粒子吸到指定点,到达范围即死亡;可直线或曲线。</dd></div>
|
||||
<div><dt>拖尾</dt><dd>独立拖尾图片(多资源按权重)、旋转、拖尾骨骼数、拖尾比例;长度/宽度可固定或随机并支持放大;可加生命周期长度曲线与形状曲线;颜色/透明度可跟随粒子、固定或曲线。</dd></div>
|
||||
<div><dt>发射器跟随</dt><dd>跟随某条路径(局部/世界、正/反向、进度曲线),或跟随 Spine 骨骼(选中的骨骼 + 偏移)。</dd></div>
|
||||
<div><dt>碰撞 + 阻尼</dt><dd>选择场景中已启用的碰撞对象参与,并设置速度阻尼。</dd></div>
|
||||
<div><dt>{{ $t('modifiers.enableNoise') }}</dt><dd>{{ $t('help.noiseDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('modifiers.enableGravity') }} / {{ $t('modifiers.enableWind') }}</dt><dd>{{ $t('help.gravityDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('modifiers.enableForceField') }} / {{ $t('modifiers.enableVortex') }} / {{ $t('modifiers.enableAttraction') }}</dt><dd>{{ $t('help.forceDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('modifiers.enableTrail') }}</dt><dd>{{ $t('help.trailDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('modifiers.enableFollow') }}</dt><dd>{{ $t('help.followDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('modifiers.enableCollision') }} / {{ $t('modifiers.damping') }}</dt><dd>{{ $t('help.collisionDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module" :class="{ open: openModules[7] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[7] ? 'true' : 'false'" @click="openModules[7] = !openModules[7]">
|
||||
<span class="mnum">8</span><span class="mtitle">导出设置</span><span class="caret">{{ openModules[7] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">8</span><span class="mtitle">{{ $t('export.title') }}</span><span class="caret">{{ openModules[7] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[7]" class="help-module-body">
|
||||
<dl class="help-list">
|
||||
<div><dt>图片路径</dt><dd>写入 Spine 的 images 路径,例如 ./images/。</dd></div>
|
||||
<div><dt>Spine 版本</dt><dd>3.8 / 4.0 / 4.1 / 4.2 / 4.3。</dd></div>
|
||||
<div><dt>关键帧曲线</dt><dd>线性或平滑贝塞尔;出生、死亡、突变仍使用阶跃关键帧。</dd></div>
|
||||
<div><dt>导出 FPS</dt><dd>修改 FPS 会保持动画实际秒数不变;“不写 fps”只省略元数据。</dd></div>
|
||||
<div><dt>其它</dt><dd>整数帧对齐、骨骼对象池、排除无内容动画。</dd></div>
|
||||
<div><dt>导出按钮</dt><dd>“导出 Spine Json”生成骨骼动画文件;“导出 images 包”单独下载图片 ZIP;“导出 .spine 包”需要连接器。</dd></div>
|
||||
<div><dt>{{ $t('export.imagesPath') }}</dt><dd>{{ $t('help.exportDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('export.spineVersion') }}</dt><dd>3.8 / 4.0 / 4.1 / 4.2 / 4.3</dd></div>
|
||||
<div><dt>{{ $t('export.keyframeCurve') }}</dt><dd>{{ $t('export.bezierHelp') }}</dd></div>
|
||||
<div><dt>{{ $t('export.fps') }}</dt><dd>{{ $t('export.fpsHelp') }}</dd></div>
|
||||
<div><dt>{{ $t('export.title') }}</dt><dd>{{ $t('help.exportButtonsDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-module help-connector" :class="{ open: openModules[8] }">
|
||||
<button type="button" class="help-module-head" :aria-expanded="openModules[8] ? 'true' : 'false'" @click="openModules[8] = !openModules[8]">
|
||||
<span class="mnum">9</span><span class="mtitle">连接器(Spine 连接器)使用说明</span><span class="caret">{{ openModules[8] ? '▾' : '▸' }}</span>
|
||||
<span class="mnum">9</span><span class="mtitle">{{ $t('help.connectorTitle') }}</span><span class="caret">{{ openModules[8] ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="openModules[8]" class="help-module-body">
|
||||
<p class="connector-intro">连接器用于把网页生成的 JSON 和图片,调用你电脑上<b>已安装并激活</b>的 Spine,转换成可编辑的 <b>.spine</b> 工程。</p>
|
||||
<p class="connector-intro">{{ $t('help.connectorIntro') }}</p>
|
||||
<dl class="help-list">
|
||||
<div><dt>前置</dt><dd>电脑已安装并激活 Spine;并已启动桌面版连接器APP。</dd></div>
|
||||
<div><dt>下载</dt><dd>
|
||||
<a class="dl-link" href="/downloads/connector/SpineParticleConnector-windows-x64.exe" download>Windows 版<br><small>SpineParticleConnector-windows-x64.exe</small></a>
|
||||
<a class="dl-link" href="/downloads/connector/SpineParticleConnector-macos-universal.zip" download>macOS 版(Universal)<br><small>SpineParticleConnector-macos-universal.zip</small></a>
|
||||
<div><dt>{{ $t('help.prerequisite') }}</dt><dd>{{ $t('help.prerequisiteDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.download') }}</dt><dd>
|
||||
<a class="dl-link" href="/downloads/connector/SpineParticleConnector-windows-x64.exe" download>Windows<br><small>SpineParticleConnector-windows-x64.exe</small></a>
|
||||
<a class="dl-link" href="/downloads/connector/SpineParticleConnector-macos-universal.zip" download>macOS (Universal)<br><small>SpineParticleConnector-macos-universal.zip</small></a>
|
||||
</dd></div>
|
||||
<div><dt>网页端</dt><dd>导出区状态显示“连接器可用 · Spine 已就绪”后,点击“导出 .spine 包”。</dd></div>
|
||||
<div><dt>首次授权</dt><dd>生产网站首次连接会显示“授权当前网站”;点击后在本机确认页核对完整来源(协议 + 域名 + 端口),再点“允许此网站”。授权一次后本机会保存白名单。本地 127.0.0.1:5173 默认允许。</dd></div>
|
||||
<div><dt>路径设置</dt><dd>连接器自动检测 Spine 安装位置;检测不到时可在桌面窗口手动指定:Windows 选 Spine.com,macOS 选 Spine.app/Contents/MacOS/Spine。</dd></div>
|
||||
<div><dt>下载结果</dt><dd>得到 ZIP 包,内含 .spine 工程和图片目录(如 images/star.png)。</dd></div>
|
||||
<div><dt>版本</dt><dd>连接器按请求与 JSON 内的版本自动匹配 3.8~4.3,使用最新可用补丁。</dd></div>
|
||||
<div><dt>安全</dt><dd>仅监听本机 127.0.0.1:27843;授权与白名单保存在连接器里;文件在临时目录转换后清理,并有大小限制。</dd></div>
|
||||
<div><dt>{{ $t('help.webSide') }}</dt><dd>{{ $t('help.webSideDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.authorization') }}</dt><dd>{{ $t('help.authorizationDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.pathSetup') }}</dt><dd>{{ $t('help.pathSetupDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.downloadResult') }}</dt><dd>{{ $t('help.downloadResultDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.version') }}</dt><dd>{{ $t('help.versionDesc') }}</dd></div>
|
||||
<div><dt>{{ $t('help.security') }}</dt><dd>{{ $t('help.securityDesc') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section help-export">
|
||||
<h3>配置与导出有什么区别?</h3>
|
||||
<p><b>保存配置</b>会保存编辑器中的全部对象和参数,之后可以继续修改;<b>导出 Spine Json</b>只导出显示中的粒子系统,并把跟随、碰撞和拖尾结果烘焙为独立动画数据。</p>
|
||||
<h3>{{ $t('help.differenceTitle') }}</h3>
|
||||
<p>{{ $t('help.differenceDesc') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
<template>
|
||||
<div class="image-card">
|
||||
<div class="imgrow">
|
||||
<button class="img-thumb" :title="supportsSequence && resource.imageMode === 'sequence' ? '选择序列帧图片' : '选择本地图片'" @click="pickImage">
|
||||
<button class="img-thumb" :title="supportsSequence && resource.imageMode === 'sequence' ? $t('image.chooseSequence') : $t('image.chooseLocal')" @click="pickImage">
|
||||
<img v-if="displayPreviewUrl" :src="displayPreviewUrl" :alt="resource.textureName" />
|
||||
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
|
||||
</button>
|
||||
<div class="img-fields">
|
||||
<label v-if="showFolder" class="row"><span>子文件夹</span><input v-model="resource.textureFolder" type="text" class="inp" placeholder="subfolder/" /></label>
|
||||
<label class="row"><span>名称</span><input v-model="resource.textureName" type="text" class="inp" :placeholder="namePlaceholder" /></label>
|
||||
<label v-if="showFolder" class="row"><span>{{ $t('image.subfolder') }}</span><input v-model="resource.textureFolder" type="text" class="inp" placeholder="subfolder/" /></label>
|
||||
<label class="row"><span>{{ $t('image.name') }}</span><input v-model="resource.textureName" type="text" class="inp" :placeholder="namePlaceholder" /></label>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="mini-btn2" :title="`恢复 ${defaultFile}`" @click="$emit('reset', resource.id)">⟳</button>
|
||||
<button class="mini-btn2" title="删除图片资源" :disabled="resourceCount === 1" @click="$emit('remove', resource.id)">🗑</button>
|
||||
<button class="mini-btn2" :title="$t('image.restore', { name: defaultFile })" @click="$emit('reset', resource.id)">⟳</button>
|
||||
<button class="mini-btn2" :title="$t('image.deleteResource')" :disabled="resourceCount === 1" @click="$emit('remove', resource.id)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thumb-note">{{ displayFileName }}</div>
|
||||
<label v-if="supportsSequence" class="row"><span>图片模式</span>
|
||||
<label v-if="supportsSequence" class="row"><span>{{ $t('image.imageMode') }}</span>
|
||||
<select v-model="resource.imageMode" class="inp">
|
||||
<option value="fixed">固定</option>
|
||||
<option value="sequence">序列帧</option>
|
||||
<option value="fixed">{{ $t('image.fixed') }}</option>
|
||||
<option value="sequence">{{ $t('image.sequence') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="supportsSequence && resource.imageMode === 'sequence'">
|
||||
<label class="row"><span>播放模式</span>
|
||||
<label class="row"><span>{{ $t('image.playbackMode') }}</span>
|
||||
<select v-model="resource.sequencePlayback" class="inp">
|
||||
<option value="once-hide">单次-尾帧消失</option>
|
||||
<option value="once-hold">单次-尾帧固定</option>
|
||||
<option value="loop-forward">正向循环</option>
|
||||
<option value="loop-reverse">反向循环</option>
|
||||
<option value="once-hide">{{ $t('image.onceHide') }}</option>
|
||||
<option value="once-hold">{{ $t('image.onceHold') }}</option>
|
||||
<option value="loop-forward">{{ $t('image.loopForward') }}</option>
|
||||
<option value="loop-reverse">{{ $t('image.loopReverse') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>播放帧率</span><input v-model.number="resource.sequenceFps" type="number" min="1" max="120" step="1" class="inp" /></label>
|
||||
<button class="sequence-pick" @click="$emit('pickSequence', resource.id)">选择序列帧(多选)</button>
|
||||
<label class="row"><span>{{ $t('image.playbackFps') }}</span><input v-model.number="resource.sequenceFps" type="number" min="1" max="120" step="1" class="inp" /></label>
|
||||
<button class="sequence-pick" @click="$emit('pickSequence', resource.id)">{{ $t('image.chooseSequenceMulti') }}</button>
|
||||
<div class="sequence-note">{{ sequenceOrderText }}</div>
|
||||
</template>
|
||||
<div class="resource-weight-row">
|
||||
<NumSlider
|
||||
class="resource-weight-control"
|
||||
label="占比%"
|
||||
:label="$t('image.weight')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
@@ -49,7 +49,7 @@
|
||||
class="weight-lock"
|
||||
:class="{ on: resource.locked }"
|
||||
:disabled="resourceCount === 1"
|
||||
:title="resource.locked ? '解除占比锁定' : '锁定当前占比'"
|
||||
:title="resource.locked ? $t('image.unlockWeight') : $t('image.lockWeight')"
|
||||
@click="$emit('toggleLock', resource.id)"
|
||||
>
|
||||
<svg v-if="resource.locked" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||
@@ -60,10 +60,10 @@
|
||||
<div v-if="supportsPivot" class="pivot-toggle-row">
|
||||
<div class="pivot-toggle-icon">◎</div>
|
||||
<div class="pivot-toggle-text">
|
||||
<div class="pivot-toggle-title">贴图轴点</div>
|
||||
<div class="pivot-toggle-title">{{ $t('image.pivot') }}</div>
|
||||
<div class="pivot-toggle-sub" :title="pivotSummary">{{ pivotSummary }}</div>
|
||||
</div>
|
||||
<label class="pivot-switch" :title="pivotOpen ? '收起轴点调整' : '展开轴点调整'">
|
||||
<label class="pivot-switch" :title="pivotOpen ? $t('image.collapsePivot') : $t('image.expandPivot')">
|
||||
<input v-model="pivotOpen" type="checkbox" @change="onPivotToggle" />
|
||||
<span class="pivot-switch-ui"></span>
|
||||
</label>
|
||||
@@ -81,19 +81,19 @@
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="pivotFullscreen" class="pivot-modal-backdrop" @pointerdown.self="closePivotFullscreen(false)">
|
||||
<div class="pivot-modal" role="dialog" aria-modal="true" aria-label="贴图旋转轴点大图精修">
|
||||
<div class="pivot-modal" role="dialog" aria-modal="true" :aria-label="$t('image.pivotModal')">
|
||||
<div class="pivot-modal-head">
|
||||
<div class="pivot-modal-icon">◎</div>
|
||||
<div class="pivot-modal-info">
|
||||
<div class="pivot-modal-title">
|
||||
贴图旋转轴点大图精修
|
||||
{{ $t('image.pivotModal') }}
|
||||
<span class="pivot-modal-badge">{{ resource.textureName || defaultFile }}</span>
|
||||
</div>
|
||||
<div class="pivot-modal-desc">在下方贴图上任意点击或拖拽,精确设置粒子的旋转/摆动中心</div>
|
||||
<div class="pivot-modal-desc">{{ $t('image.pivotModalDesc') }}</div>
|
||||
</div>
|
||||
<button type="button" class="pivot-modal-close" title="关闭" @click="closePivotFullscreen(false)">✕</button>
|
||||
<button type="button" class="pivot-modal-close" :title="$t('common.close')" @click="closePivotFullscreen(false)">✕</button>
|
||||
</div>
|
||||
<div class="pivot-modal-hint">支持 100% ~ 400% 放大,精确对准尖端或根部:</div>
|
||||
<div class="pivot-modal-hint">{{ $t('image.pivotZoomHint') }}</div>
|
||||
<PivotEditor
|
||||
large
|
||||
:preview-url="displayPreviewUrl"
|
||||
@@ -103,8 +103,8 @@
|
||||
@update:anchorY="draftY = $event"
|
||||
/>
|
||||
<div class="pivot-modal-actions">
|
||||
<button type="button" class="pivot-modal-cancel" @click="closePivotFullscreen(false)">取消</button>
|
||||
<button type="button" class="pivot-modal-confirm" @click="closePivotFullscreen(true)">确定并保存轴点</button>
|
||||
<button type="button" class="pivot-modal-cancel" @click="closePivotFullscreen(false)">{{ $t('common.cancel') }}</button>
|
||||
<button type="button" class="pivot-modal-confirm" @click="closePivotFullscreen(true)">{{ $t('image.confirmPivot') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +113,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import NumSlider from './NumSlider.vue'
|
||||
import PivotEditor from './PivotEditor.vue'
|
||||
|
||||
@@ -154,14 +155,17 @@ const emit = defineEmits<{
|
||||
updateWeight: [id: number, value: number]
|
||||
toggleLock: [id: number]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayFileName = computed(() => {
|
||||
if (props.resource.imageMode === 'sequence') {
|
||||
const frames = props.resource.sequenceFrames || []
|
||||
if (!frames.length) return '未选择序列帧'
|
||||
const first = frames[0]?.name || '第 1 帧'
|
||||
if (!frames.length) return t('image.noSequence')
|
||||
const first = frames[0]?.name || t('image.firstFrame')
|
||||
const last = frames[frames.length - 1]?.name || first
|
||||
return frames.length === 1 ? `1 帧 · ${first}` : `${frames.length} 帧 · ${first} → ${last}`
|
||||
return frames.length === 1
|
||||
? t('image.frameCountOne', { name: first })
|
||||
: t('image.frameCountMany', { count: frames.length, first, last })
|
||||
}
|
||||
const name = props.resource.textureName || props.defaultFile.replace(/\.[^.]+$/, '')
|
||||
return name.includes('.') ? name : `${name}.png`
|
||||
@@ -207,9 +211,9 @@ const draftY = ref(0.5)
|
||||
const pivotSummary = computed(() => {
|
||||
const x = ((props.resource.anchorX ?? 0.5) * 100).toFixed(1)
|
||||
const y = ((props.resource.anchorY ?? 0.5) * 100).toFixed(1)
|
||||
if (pivotOpen.value) return `已开启:X ${x}% | Y ${y}%`
|
||||
if (pivotCentered.value) return '未开启,默认围绕图片正中心 (50%, 50%)'
|
||||
return `轴点已自定义:X ${x}% | Y ${y}%`
|
||||
if (pivotOpen.value) return t('image.pivotOn', { x, y })
|
||||
if (pivotCentered.value) return t('image.pivotOff')
|
||||
return t('image.pivotCustom', { x, y })
|
||||
})
|
||||
|
||||
function openPivotFullscreen() {
|
||||
@@ -243,10 +247,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
const sequenceOrderText = computed(() => {
|
||||
const frames = props.resource.sequenceFrames || []
|
||||
if (!frames.length) return '未选择序列帧'
|
||||
const first = frames[0]?.name || '第一张图片'
|
||||
const last = frames[frames.length - 1]?.name || '最后一张图片'
|
||||
return `已选择 ${frames.length} 帧,按 ${first}-${last} 顺序播放`
|
||||
if (!frames.length) return t('image.noSequence')
|
||||
const first = frames[0]?.name || t('image.firstImage')
|
||||
const last = frames[frames.length - 1]?.name || t('image.lastImage')
|
||||
return t('image.sequenceSelected', { count: frames.length, first, last })
|
||||
})
|
||||
|
||||
function pickImage() {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<template v-if="mode === 'fixed' || mode === 'direction'">
|
||||
<NumSlider
|
||||
:label="mode === 'direction' ? '角度偏移' : '数值'"
|
||||
:label="mode === 'direction' ? $t('attributeControl.angleOffset') : $t('attributeControl.value')"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@@ -25,18 +25,18 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="mode === 'random'">
|
||||
<NumSlider label="下限" :min="min" :max="max" :step="step" :model-value="valueMin" @update:model-value="$emit('update:valueMin', $event)" />
|
||||
<NumSlider label="上限" :min="min" :max="max" :step="step" :model-value="valueMax" @update:model-value="$emit('update:valueMax', $event)" />
|
||||
<NumSlider :label="$t('attributeControl.lower')" :min="min" :max="max" :step="step" :model-value="valueMin" @update:model-value="$emit('update:valueMin', $event)" />
|
||||
<NumSlider :label="$t('attributeControl.upper')" :min="min" :max="max" :step="step" :model-value="valueMax" @update:model-value="$emit('update:valueMax', $event)" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="pac-range">
|
||||
<NumSlider label="范围下限" :min="min" :max="max" :step="step" :model-value="valueMin" @update:model-value="$emit('update:valueMin', $event)" />
|
||||
<NumSlider label="范围上限" :min="min" :max="max" :step="step" :model-value="valueMax" @update:model-value="$emit('update:valueMax', $event)" />
|
||||
<NumSlider :label="$t('attributeControl.rangeLower')" :min="min" :max="max" :step="step" :model-value="valueMin" @update:model-value="$emit('update:valueMin', $event)" />
|
||||
<NumSlider :label="$t('attributeControl.rangeUpper')" :min="min" :max="max" :step="step" :model-value="valueMax" @update:model-value="$emit('update:valueMax', $event)" />
|
||||
</div>
|
||||
<div class="pac-curve">
|
||||
<CurveEditor :model-value="curve" :default-value="defaultCurve" @update:model-value="$emit('update:curve', $event)" />
|
||||
<div class="pac-axis"><span>0</span><span>{{ curveLabel }}</span><span>1</span></div>
|
||||
<div class="pac-axis"><span>0</span><span>{{ curveLabel || $t('attributeControl.curveInput') }}</span><span>1</span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import NumSlider from './NumSlider.vue'
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
@@ -69,7 +70,7 @@ const props = withDefaults(defineProps<{
|
||||
allowDirection: false,
|
||||
allowCurve: true,
|
||||
showModes: true,
|
||||
curveLabel: '曲线输入 →',
|
||||
curveLabel: '',
|
||||
defaultCurve: () => [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||
})
|
||||
|
||||
@@ -80,13 +81,14 @@ defineEmits<{
|
||||
(e: 'update:curve', value: CurvePoint[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modeItems = computed(() => {
|
||||
const items: { label: string; value: AttributeMode }[] = [
|
||||
{ label: '固定', value: 'fixed' },
|
||||
{ label: '随机', value: 'random' },
|
||||
{ label: t('common.fixed'), value: 'fixed' },
|
||||
{ label: t('common.random'), value: 'random' },
|
||||
]
|
||||
if (props.allowCurve) items.push({ label: '曲线', value: 'curve' })
|
||||
if (props.allowDirection) items.push({ label: '发射方向', value: 'direction' })
|
||||
if (props.allowCurve) items.push({ label: t('common.curve'), value: 'curve' })
|
||||
if (props.allowDirection) items.push({ label: t('attributeControl.direction'), value: 'direction' })
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>Spine粒子编辑器-Yeomai</span>
|
||||
<span>{{ $t('app.title') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-scroll">
|
||||
|
||||
+28
-26
@@ -3,27 +3,27 @@
|
||||
<div class="pivot-toolbar">
|
||||
<div class="pivot-hint">
|
||||
<span class="pivot-hint-icon">⊕</span>
|
||||
<span>贴图轴点</span>
|
||||
<span>{{ $t('pivot.title') }}</span>
|
||||
</div>
|
||||
<div class="pivot-zoom">
|
||||
<button type="button" :disabled="zoom <= 100" title="缩小" @click="zoomBy(-25)">−</button>
|
||||
<button type="button" :disabled="zoom <= 100" :title="$t('pivot.zoomOut')" @click="zoomBy(-25)">−</button>
|
||||
<span>{{ zoom }}%</span>
|
||||
<button type="button" :disabled="zoom >= 400" title="放大" @click="zoomBy(25)">+</button>
|
||||
<button type="button" :disabled="zoom >= 400" :title="$t('pivot.zoomIn')" @click="zoomBy(25)">+</button>
|
||||
</div>
|
||||
<button v-if="!large" type="button" class="pivot-expand" title="放大到全屏操作" @click="$emit('fullscreen')">⤢</button>
|
||||
<button v-if="!large" type="button" class="pivot-expand" :title="$t('pivot.expand')" @click="$emit('fullscreen')">⤢</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="stageRef"
|
||||
class="pivot-stage"
|
||||
:class="{ panning }"
|
||||
title="左键点击/拖动设置轴点 · 右键拖动画布 · 滚轮缩放"
|
||||
:title="$t('pivot.interaction')"
|
||||
@pointerdown="onPointerDown"
|
||||
@wheel.prevent="onWheel"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div v-if="previewUrl" ref="boxRef" class="pivot-box" :style="boxStyle">
|
||||
<img :src="previewUrl" alt="轴点预览" draggable="false" @load="onImageLoad" />
|
||||
<img :src="previewUrl" :alt="$t('pivot.previewAlt')" draggable="false" @load="onImageLoad" />
|
||||
</div>
|
||||
<template v-if="previewUrl">
|
||||
<!-- 准线与轴点放在缩放容器之外:尺寸恒定,准线贯穿整个交互区。 -->
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="pivot-marker" :style="{ left: `${pivotScreen.x}px`, top: `${pivotScreen.y}px` }"></div>
|
||||
<div class="pivot-badge" :style="{ left: `${pivotScreen.x}px`, top: `${pivotScreen.y}px` }">X: {{ anchorXText }}% | Y: {{ anchorYText }}%</div>
|
||||
</template>
|
||||
<div v-else class="pivot-empty">未选择图片</div>
|
||||
<div v-else class="pivot-empty">{{ $t('pivot.noImage') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="pivot-bottom">
|
||||
@@ -49,10 +49,10 @@
|
||||
@click="pickPreset(preset)"
|
||||
>{{ preset.icon }}</button>
|
||||
</div>
|
||||
<button type="button" class="pivot-reset" @click="resetCenter">↺ 复位居中 (50%, 50%)</button>
|
||||
<button type="button" class="pivot-reset" @click="resetCenter">↺ {{ $t('pivot.resetCenter') }}</button>
|
||||
</div>
|
||||
<div class="pivot-card">
|
||||
<div class="pivot-card-head"><span><span class="pivot-card-icon">◎</span> 轴点坐标(%)</span></div>
|
||||
<div class="pivot-card-head"><span><span class="pivot-card-icon">◎</span> {{ $t('pivot.coordinates') }}</span></div>
|
||||
<div class="pivot-coords">
|
||||
<label>X:
|
||||
<input type="number" min="-1000" max="1000" step="0.1" :value="anchorXText" @input="updateCoord('x', $event)" />
|
||||
@@ -66,20 +66,20 @@
|
||||
<!-- 旋转测试只在大图精修时显示,避免内联面板过高。 -->
|
||||
<template v-if="large">
|
||||
<div class="pivot-card-head pivot-rotate-head">
|
||||
<span><span class="pivot-card-icon">✥</span> 轴心旋转测试</span>
|
||||
<span class="pivot-rotate-state">{{ rotationMode === 'static' ? '静止' : rotationMode === 'spin' ? '自转' : '摆动' }}</span>
|
||||
<span><span class="pivot-card-icon">✥</span> {{ $t('pivot.rotationTest') }}</span>
|
||||
<span class="pivot-rotate-state">{{ rotationMode === 'static' ? $t('pivot.static') : rotationMode === 'spin' ? $t('pivot.spin') : $t('pivot.swing') }}</span>
|
||||
</div>
|
||||
<div class="pivot-rotate-body">
|
||||
<div class="pivot-rotate-modes">
|
||||
<button type="button" :class="{ on: rotationMode === 'static' }" @click="rotationMode = 'static'">静止</button>
|
||||
<button type="button" :class="{ on: rotationMode === 'spin' }" @click="rotationMode = 'spin'">↻ 自转</button>
|
||||
<button type="button" :class="{ on: rotationMode === 'swing' }" @click="rotationMode = 'swing'">⇄ 摆动</button>
|
||||
<button type="button" :class="{ on: rotationMode === 'static' }" @click="rotationMode = 'static'">{{ $t('pivot.static') }}</button>
|
||||
<button type="button" :class="{ on: rotationMode === 'spin' }" @click="rotationMode = 'spin'">↻ {{ $t('pivot.spin') }}</button>
|
||||
<button type="button" :class="{ on: rotationMode === 'swing' }" @click="rotationMode = 'swing'">⇄ {{ $t('pivot.swing') }}</button>
|
||||
</div>
|
||||
<div class="pivot-rotate-preview">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
alt="旋转测试"
|
||||
:alt="$t('pivot.rotationAlt')"
|
||||
draggable="false"
|
||||
:class="rotationMode"
|
||||
:style="rotateStyle"
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
previewUrl: string
|
||||
@@ -110,17 +111,18 @@ const emit = defineEmits<{
|
||||
fullscreen: []
|
||||
}>()
|
||||
|
||||
const presets = [
|
||||
{ icon: '↖', label: '左上', x: 0, y: 0 },
|
||||
{ icon: '↑', label: '正上', x: 50, y: 0 },
|
||||
{ icon: '↗', label: '右上', x: 100, y: 0 },
|
||||
{ icon: '←', label: '正左', x: 0, y: 50 },
|
||||
{ icon: '◯', label: '正中', x: 50, y: 50 },
|
||||
{ icon: '→', label: '正右', x: 100, y: 50 },
|
||||
{ icon: '↙', label: '左下', x: 0, y: 100 },
|
||||
{ icon: '↓', label: '正下·底部', x: 50, y: 100 },
|
||||
{ icon: '↘', label: '右下', x: 100, y: 100 },
|
||||
]
|
||||
const { t } = useI18n()
|
||||
const presets = computed(() => [
|
||||
{ icon: '↖', label: t('pivot.topLeft'), x: 0, y: 0 },
|
||||
{ icon: '↑', label: t('pivot.top'), x: 50, y: 0 },
|
||||
{ icon: '↗', label: t('pivot.topRight'), x: 100, y: 0 },
|
||||
{ icon: '←', label: t('pivot.left'), x: 0, y: 50 },
|
||||
{ icon: '◯', label: t('pivot.center'), x: 50, y: 50 },
|
||||
{ icon: '→', label: t('pivot.right'), x: 100, y: 50 },
|
||||
{ icon: '↙', label: t('pivot.bottomLeft'), x: 0, y: 100 },
|
||||
{ icon: '↓', label: t('pivot.bottom'), x: 50, y: 100 },
|
||||
{ icon: '↘', label: t('pivot.bottomRight'), x: 100, y: 100 },
|
||||
])
|
||||
|
||||
const stageRef = ref<HTMLElement | null>(null)
|
||||
const boxRef = ref<HTMLElement | null>(null)
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
<HelpDialog :show="showHelp" @close="showHelp = false" />
|
||||
|
||||
<div class="hud" v-if="info">
|
||||
系统: <b class="hud-number hud-system-count">{{ info.systems }}</b> · 粒子(骨骼): <b class="hud-number hud-particle-count">{{ info.particles }}</b> · 根骨骼: <b>root</b>
|
||||
<span class="hud-tip">滚轮缩放 · 左键拖动画布</span>
|
||||
{{ $t('app.systems') }}: <b class="hud-number hud-system-count">{{ info.systems }}</b> · {{ $t('app.particlesBones') }}: <b class="hud-number hud-particle-count">{{ info.particles }}</b> · {{ $t('app.rootBone') }}: <b>root</b>
|
||||
<span class="hud-tip">{{ $t('app.hudTip') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { setUndoLimit } from '../editor/editorHistory'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const store = useParticleStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function onUndoLimitChange(event: Event) {
|
||||
setUndoLimit(store, Number((event.target as HTMLInputElement).value))
|
||||
@@ -19,60 +21,60 @@ function normalizeBoneDebugSizes() {
|
||||
|
||||
<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>
|
||||
<div class="sp-head">{{ t('settings.title') }} <button class="sp-close" @click="emit('close')">✕</button></div>
|
||||
<div class="sp-section-title">{{ t('settings.layout') }}</div>
|
||||
<label class="sp-switch-row">
|
||||
<span>参数面板位置</span>
|
||||
<span>{{ t('settings.parameterPanelPosition') }}</span>
|
||||
<span class="sp-switch-control">
|
||||
<input type="checkbox" v-model="store.settings.parameterPanelOnRight" aria-label="参数面板显示在右侧" />
|
||||
<input type="checkbox" v-model="store.settings.parameterPanelOnRight" :aria-label="t('settings.panelOnRight')" />
|
||||
<span class="sp-switch-ui" aria-hidden="true"></span>
|
||||
<b>{{ store.settings.parameterPanelOnRight ? '右侧' : '左侧' }}</b>
|
||||
<b>{{ store.settings.parameterPanelOnRight ? t('common.right') : t('common.left') }}</b>
|
||||
</span>
|
||||
</label>
|
||||
<div class="sp-divider"></div>
|
||||
<div class="sp-section-title">操作历史</div>
|
||||
<label class="sp-row"><span>最大撤回步数</span>
|
||||
<div class="sp-section-title">{{ t('settings.history') }}</div>
|
||||
<label class="sp-row"><span>{{ t('settings.maxUndo') }}</span>
|
||||
<input
|
||||
class="sp-num sp-undo-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
v-model.number="store.settings.undoLimit"
|
||||
aria-label="最大撤回步数"
|
||||
:aria-label="t('settings.maxUndo')"
|
||||
@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>
|
||||
<div class="sp-section-title">{{ t('settings.canvasGuides') }}</div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showEmitter" />{{ t('settings.emitter') }}</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showBones" />{{ t('settings.bones') }}</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 class="sp-row"><span>{{ t('settings.bonePointSize') }}</span>
|
||||
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.bonePointDrawSize" :aria-label="t('settings.bonePointSize')" @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 class="sp-row"><span>{{ t('settings.boneAxisSize') }}</span>
|
||||
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.boneAxisDrawSize" :aria-label="t('settings.boneAxisSize')" @change="normalizeBoneDebugSizes" />
|
||||
</label>
|
||||
</template>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showSkinMesh" />蒙皮网格</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showSkinMesh" />{{ t('settings.skinMesh') }}</label>
|
||||
<div class="sp-divider"></div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />{{ t('settings.enableTicks') }}</label>
|
||||
<template v-if="store.settings.tickEnabled">
|
||||
<label class="sp-row"><span>刻度文字大小</span>
|
||||
<label class="sp-row"><span>{{ t('settings.tickFontSize') }}</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>
|
||||
<label class="sp-row"><span>{{ t('settings.tickWidth') }}</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>
|
||||
<label class="sp-row"><span>{{ t('settings.axisColor') }}</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>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />{{ t('settings.showAxisLabels') }}</label>
|
||||
</template>
|
||||
<div class="sp-cache-note">整个系统设置会自动保存到当前浏览器</div>
|
||||
<div class="sp-cache-note">{{ t('settings.cacheNote') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+45
-22
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { zoomIn, zoomOut, fitView, resetView, toggleGrid, type StageContext } from './stage/viewControls'
|
||||
import { undoLastOperation, editorHistoryState } from '../editor/editorHistory'
|
||||
import { setAppLocale, SUPPORTED_LOCALES, type AppLocale } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
ctx: StageContext
|
||||
@@ -20,6 +22,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const store = useParticleStore()
|
||||
const { t, locale } = useI18n()
|
||||
const showBackgroundMenu = ref(false)
|
||||
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||
const backgroundFileName = computed(() => store.settings.backgroundImageName)
|
||||
@@ -56,42 +59,47 @@ function clearBackground(resetControls = true) {
|
||||
function undo() {
|
||||
undoLastOperation()
|
||||
}
|
||||
|
||||
function changeLanguage(event: Event) {
|
||||
const next = (event.target as HTMLSelectElement).value as AppLocale
|
||||
setAppLocale(next)
|
||||
}
|
||||
</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>
|
||||
<button class="settings-btn" @click="toggleSettings" :title="t('toolbar.settings')">{{ t('toolbar.settings') }}</button>
|
||||
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" :title="t('toolbar.undo')">↶</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>
|
||||
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="toggleTool('')" :title="t('toolbar.pan')">✋</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="toggleTool('translate')" :title="t('toolbar.translate')">✥</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="toggleTool('rotate')" :title="t('toolbar.rotate')">⟳</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="toggleTool('scale')" :title="t('toolbar.scale')">⤢</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>
|
||||
<button class="tool-btn" @click="zoomIn(ctx)" :title="t('toolbar.zoomIn')">+</button>
|
||||
<button class="tool-btn" @click="zoomOut(ctx)" :title="t('toolbar.zoomOut')">-</button>
|
||||
<button class="tool-btn" @click="fitView(ctx)" :title="t('toolbar.fitView')">⤢</button>
|
||||
<button class="tool-btn" :class="{ on: store.settings.showGrid }" @click="toggleGrid(ctx)" :title="t('toolbar.toggleGrid')">▦</button>
|
||||
<button class="tool-btn" @click="resetView(ctx)" :title="t('toolbar.resetView')">⟳</button>
|
||||
</div>
|
||||
|
||||
<div class="background-menu" @click.stop>
|
||||
<button
|
||||
class="background-btn"
|
||||
:class="{ on: showBackgroundMenu }"
|
||||
title="背景图显示设置"
|
||||
:title="t('toolbar.backgroundSettings')"
|
||||
@click="toggleBackgroundMenu"
|
||||
>背景图</button>
|
||||
>{{ t('toolbar.background') }}</button>
|
||||
<div v-if="showBackgroundMenu" class="background-panel">
|
||||
<button class="background-upload" @click="backgroundFileInput?.click()">
|
||||
{{ backgroundFileName ? '更换图像' : '上传图像' }}
|
||||
{{ backgroundFileName ? t('toolbar.changeBackground') : t('toolbar.uploadBackground') }}
|
||||
</button>
|
||||
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">清除背景</button>
|
||||
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">{{ t('toolbar.clearBackground') }}</button>
|
||||
<input
|
||||
ref="backgroundFileInput"
|
||||
class="background-file-input"
|
||||
@@ -99,20 +107,26 @@ function undo() {
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@change="onBackgroundFileChange"
|
||||
/>
|
||||
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || '未上传图像' }}</div>
|
||||
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || t('toolbar.noBackground') }}</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>
|
||||
<label><span>X</span><input v-model.number="store.settings.backgroundX" type="number" step="1" :aria-label="t('toolbar.backgroundX')" /></label>
|
||||
<label><span>Y</span><input v-model.number="store.settings.backgroundY" type="number" step="1" :aria-label="t('toolbar.backgroundY')" /></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="背景图透明度" />
|
||||
<span>{{ t('toolbar.opacity') }}</span>
|
||||
<input v-model.number="store.settings.backgroundOpacity" type="range" min="0" max="1" step="0.01" :aria-label="t('toolbar.backgroundOpacity')" />
|
||||
<b>{{ Math.round(store.settings.backgroundOpacity * 100) }}%</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="help-btn" :class="{ on: showHelp }" title="查看使用说明" @click="openHelp">使用说明</button>
|
||||
<label class="language-picker">
|
||||
<span>{{ t('language.label') }}</span>
|
||||
<select :value="locale" :aria-label="t('language.label')" @change="changeLanguage">
|
||||
<option v-for="entry in SUPPORTED_LOCALES" :key="entry.code" :value="entry.code">{{ entry.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="help-btn" :class="{ on: showHelp }" :title="t('toolbar.openHelp')" @click="openHelp">{{ t('toolbar.help') }}</button>
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -165,8 +179,17 @@ function undo() {
|
||||
.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; }
|
||||
.language-picker {
|
||||
display: flex; height: 28px; align-items: center; gap: 6px; margin-left: auto;
|
||||
color: #9aa8c1; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.language-picker select {
|
||||
height: 28px; padding: 0 24px 0 8px; border: 1px solid #3a4963; border-radius: 6px;
|
||||
outline: none; background: #202a3b; color: #dbe4f5; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.language-picker select:focus { border-color: #7774ff; }
|
||||
.help-btn {
|
||||
height: 28px; margin-left: auto; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||
height: 28px; 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; }
|
||||
|
||||
+35
-33
@@ -2,33 +2,33 @@
|
||||
<div class="timeline">
|
||||
<!-- 控制按钮 -->
|
||||
<div class="tl-controls">
|
||||
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停 (D)' : '播放 (D)'">
|
||||
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? $t('timeline.pause') : $t('timeline.play')">
|
||||
{{ store.timeline.playing ? '❚❚' : '▶' }}
|
||||
</button>
|
||||
<button class="tl-btn" @click="store.toStart()" title="回到第一帧 (S)">⏮</button>
|
||||
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
|
||||
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
|
||||
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
|
||||
<button class="tl-btn" :class="{ on: store.timeline.loop }" title="持续循环播放" aria-label="持续循环播放" @click="store.toggleLoop()">⟳</button>
|
||||
<button class="tl-btn" @click="store.toStart()" :title="$t('timeline.toStart')">⏮</button>
|
||||
<button class="tl-btn" @click="store.previousFrame()" :title="$t('timeline.previous')">◀</button>
|
||||
<button class="tl-btn" @click="store.nextFrame()" :title="$t('timeline.next')">▶</button>
|
||||
<button class="tl-btn" @click="store.toEnd()" :title="$t('timeline.toEnd')">⏭</button>
|
||||
<button class="tl-btn" :class="{ on: store.timeline.loop }" :title="$t('timeline.continuousLoop')" :aria-label="$t('timeline.continuousLoop')" @click="store.toggleLoop()">⟳</button>
|
||||
<div class="tl-ab-loop">
|
||||
<button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" title="独立 A/B 区间循环" @click="store.toggleABLoop()">
|
||||
<span>AB 循环</span><i class="tl-switch"><b></b></i>
|
||||
<button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" :title="$t('timeline.abLoop')" @click="store.toggleABLoop()">
|
||||
<span>{{ $t('timeline.abLoopShort') }}</span><i class="tl-switch"><b></b></i>
|
||||
</button>
|
||||
<div v-if="store.timeline.abLoop" class="tl-ab-popover" @pointerdown.stop>
|
||||
<div class="tl-ab-title">AB 循环区间</div>
|
||||
<label class="tl-range" title="自定义播放起始帧"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" aria-label="播放起始帧" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
|
||||
<label class="tl-range" title="自定义播放结束帧"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" aria-label="播放结束帧" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
|
||||
<div class="tl-ab-title">{{ $t('timeline.abRange') }}</div>
|
||||
<label class="tl-range" :title="$t('timeline.playbackStart')"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" :aria-label="$t('timeline.startFrame')" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
|
||||
<label class="tl-range" :title="$t('timeline.playbackEnd')"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" :aria-label="$t('timeline.endFrame')" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ Math.max(0, store.timeline.totalFrames - 1) }}</b></span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
|
||||
<span class="tl-frame">{{ $t('timeline.frameStatus', { current: store.timeline.frame, total: Math.max(0, store.timeline.totalFrames - 1) }) }}</span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>{{ $t('timeline.emission') }} <i class="legend-life"></i>{{ $t('timeline.particleLife') }} <i class="legend-spine"></i>{{ $t('timeline.spineAnimation') }}</span>
|
||||
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
|
||||
<span>所属动画</span>
|
||||
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" title="设置当前粒子系统所属的动画" @click.stop="toggleAnimationMenu">
|
||||
<span>{{ $t('timeline.ownerAnimation') }}</span>
|
||||
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" :title="$t('timeline.setOwnerAnimation')" @click.stop="toggleAnimationMenu">
|
||||
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i>⌄</i>
|
||||
</button>
|
||||
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
|
||||
<div class="tl-animation-menu-title">选择所属动画</div>
|
||||
<div class="tl-animation-menu-title">{{ $t('timeline.chooseOwnerAnimation') }}</div>
|
||||
<button
|
||||
v-for="animation in store.timeline.animations"
|
||||
:key="animation"
|
||||
@@ -39,25 +39,25 @@
|
||||
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation">✓</i>
|
||||
</button>
|
||||
<div class="tl-animation-editor">
|
||||
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" aria-label="动画名称" @keydown.enter="renameCurrentAnimation" />
|
||||
<button title="重命名当前动画" @click="renameCurrentAnimation">重命名</button>
|
||||
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" :aria-label="$t('timeline.animationName')" @keydown.enter="renameCurrentAnimation" />
|
||||
<button :title="$t('timeline.renameAnimation')" @click="renameCurrentAnimation">{{ $t('timeline.rename') }}</button>
|
||||
</div>
|
||||
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
|
||||
<div class="tl-animation-actions">
|
||||
<button @click="addAnimation">+ 新增</button>
|
||||
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">删除</button>
|
||||
<button @click="addAnimation">{{ $t('timeline.newAnimation') }}</button>
|
||||
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">至少需要保留一个动画</div>
|
||||
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">{{ $t('timeline.keepOneAnimation') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="tl-anim tl-spine-preview">Spine 预览动画
|
||||
<label class="tl-anim tl-spine-preview">{{ $t('timeline.spinePreview') }}
|
||||
<select
|
||||
class="tl-sel"
|
||||
:value="previewSpine?.selectedAnimation || ''"
|
||||
:disabled="!previewSpine"
|
||||
@change="onSpinePreviewAnimationChange"
|
||||
>
|
||||
<option value="">{{ previewSpine ? '无' : '无可用 Spine 动画' }}</option>
|
||||
<option value="">{{ previewSpine ? $t('timeline.none') : $t('timeline.noSpineAnimation') }}</option>
|
||||
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
|
||||
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
|
||||
</option>
|
||||
@@ -99,17 +99,17 @@
|
||||
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
|
||||
@pointerdown.stop="onBarDown($event, sys)"
|
||||
>
|
||||
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
|
||||
<span class="lane-name">{{ (sys.config as any).name || $t('timeline.systemName', { index: i + 1 }) }}</span>
|
||||
<span class="bar-life" :class="{ 'loop-life': !isSubEmitter(sys) && isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
|
||||
<template v-if="!isSubEmitter(sys) && isLoopStream(sys)">
|
||||
<span v-if="loopStartWidthPx(sys)" class="bar-loop-transition bar-loop-start" :style="{ width: loopStartWidthPx(sys) + 'px' }"></span>
|
||||
<span class="bar-emission bar-loop" :style="{ left: loopStartWidthPx(sys) + 'px', width: emissionWidthPx(sys) + 'px' }">
|
||||
<span class="bar-handle" title="拖动循环段时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
<span class="bar-handle" :title="$t('timeline.dragLoopDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
</span>
|
||||
<span v-if="loopEndWidthPx(sys)" class="bar-loop-transition bar-loop-end" :style="{ left: (loopStartWidthPx(sys) + emissionWidthPx(sys)) + 'px', width: loopEndWidthPx(sys) + 'px' }"></span>
|
||||
</template>
|
||||
<span v-else-if="!isSubEmitter(sys) && sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
|
||||
<span class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
<span class="bar-handle" :title="$t('timeline.dragEmissionDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,13 +119,13 @@
|
||||
<div
|
||||
class="spine-bar"
|
||||
:style="{ left: spineLeftPx(spine) + 'px', width: spineWidthPx(spine) + 'px' }"
|
||||
title="拖动调整动画开始时间"
|
||||
:title="$t('timeline.dragSpineStart')"
|
||||
@pointerdown.stop="onSpineBarDown($event, spine)"
|
||||
>
|
||||
<span class="lane-name">{{ spine.name }} · {{ spine.selectedAnimation }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">暂无可显示的动画数据</div>
|
||||
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">{{ $t('timeline.noAnimationData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +133,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import { loopSegmentTiming } from '../core/loopSegmentTiming'
|
||||
@@ -142,6 +143,7 @@ import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
|
||||
type SysLike = { id: number; config: any }
|
||||
|
||||
const store = useParticleStore()
|
||||
const { t } = useI18n()
|
||||
const vpRef = ref<HTMLElement | null>(null)
|
||||
const animationMenuRef = ref<HTMLElement | null>(null)
|
||||
const animationMenuOpen = ref(false)
|
||||
@@ -182,16 +184,16 @@ function addAnimation() {
|
||||
function renameCurrentAnimation() {
|
||||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
const next = animationNameDraft.value.trim()
|
||||
if (!next) { animationError.value = '动画名称不能为空'; return }
|
||||
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = '动画名称不能重复'; return }
|
||||
if (!store.renameAnimation(current, next)) { animationError.value = '无法修改动画名称'; return }
|
||||
if (!next) { animationError.value = t('timeline.animationNameEmpty'); return }
|
||||
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = t('timeline.animationNameDuplicate'); return }
|
||||
if (!store.renameAnimation(current, next)) { animationError.value = t('timeline.animationRenameFailed'); return }
|
||||
animationNameDraft.value = next
|
||||
animationError.value = ''
|
||||
}
|
||||
|
||||
function deleteCurrentAnimation() {
|
||||
const current = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
if (!store.removeAnimation(current)) { animationError.value = '至少需要保留一个动画'; return }
|
||||
if (!store.removeAnimation(current)) { animationError.value = t('timeline.keepOneAnimation'); return }
|
||||
const next = activeParticleSystem.value?.animation || store.timeline.animation
|
||||
animationNameDraft.value = next
|
||||
animationError.value = ''
|
||||
@@ -225,7 +227,7 @@ const totalFrames = computed(() => store.timeline.totalFrames)
|
||||
watch(totalFrames, (value) => {
|
||||
if (value > viewFrames.value) viewFrames.value = Math.min(2000, value)
|
||||
})
|
||||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
|
||||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? t('timeline.zoomIn') : viewFrames.value >= 300 ? t('timeline.zoomOut') : t('timeline.zoomFit')))
|
||||
const spineLanes = computed(() => store.spines.filter((spine) =>
|
||||
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
|
||||
))
|
||||
|
||||
@@ -2,127 +2,127 @@
|
||||
<!-- 2. 发射模式 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('emitMode')">
|
||||
<span class="caret">{{ collapsed.emitMode ? '▸' : '▾' }}</span><span>发射模式</span>
|
||||
<button class="group-reset" title="重置发射模式参数" @click.stop="resetModule('emitMode')">重置</button>
|
||||
<span class="caret">{{ collapsed.emitMode ? '▸' : '▾' }}</span><span>{{ $t('emission.title') }}</span>
|
||||
<button class="group-reset" :title="$t('emission.reset')" @click.stop="resetModule('emitMode')">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.emitMode">
|
||||
<div class="segs">
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'stream' }" @click="setEmitMode('stream')">持续</button>
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="setEmitMode('burst')">爆发</button>
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'stream' }" @click="setEmitMode('stream')">{{ $t('emission.stream') }}</button>
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="setEmitMode('burst')">{{ $t('emission.burst') }}</button>
|
||||
</div>
|
||||
<template v-if="sys.config.mode === 'stream'">
|
||||
<div class="segs stream-behavior-segs">
|
||||
<button class="seg" :class="{ on: sys.config.streamBehavior === 'normal' }" @click="setStreamBehavior('normal')">常规持续</button>
|
||||
<button class="seg" :class="{ on: sys.config.streamBehavior === 'loop' }" @click="setStreamBehavior('loop')">循环持续</button>
|
||||
<button class="seg" :class="{ on: sys.config.streamBehavior === 'normal' }" @click="setStreamBehavior('normal')">{{ $t('emission.normalStream') }}</button>
|
||||
<button class="seg" :class="{ on: sys.config.streamBehavior === 'loop' }" @click="setStreamBehavior('loop')">{{ $t('emission.loopStream') }}</button>
|
||||
</div>
|
||||
<NumSlider label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
|
||||
<NumSlider v-if="sys.config.streamBehavior === 'normal'" label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
|
||||
<NumSlider v-else label="发射时长 (f)" :min="1" :max="1800" :step="1" :model-value="loopDurationFrames" @update:model-value="setLoopDurationFrames" />
|
||||
<NumSlider :label="$t('emission.rate')" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
|
||||
<NumSlider v-if="sys.config.streamBehavior === 'normal'" :label="$t('emission.duration')" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
|
||||
<NumSlider v-else :label="$t('emission.loopDuration')" :min="1" :max="1800" :step="1" :model-value="loopDurationFrames" @update:model-value="setLoopDurationFrames" />
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="stream-loop-options">
|
||||
<div class="loop-section-row">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopStartAnimation" @change="setLoopSection('start', $event)" />
|
||||
<span class="switch-ui"></span><span>生成开始循环动画</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('emission.generateStart') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.generateLoopStartAnimation" class="loop-duration-control">
|
||||
<label class="switch-row loop-duration-toggle">
|
||||
<input type="checkbox" :checked="sys.config.loopStartUseCustomDuration" @change="setLoopCustomDuration('start', $event)" />
|
||||
<span class="switch-ui"></span><span>指定时长</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('emission.customDuration') }}</span>
|
||||
</label>
|
||||
<label class="loop-section-duration">
|
||||
<input v-if="sys.config.loopStartUseCustomDuration" :value="sys.config.loopStartDurationFrames" type="number" min="1" max="1800" step="1" aria-label="开始循环段时长" @change="setLoopSectionDuration('start', $event)" /><span v-if="sys.config.loopStartUseCustomDuration">f</span>
|
||||
<span v-else class="loop-auto-duration">自动 {{ automaticLoopDuration }}f</span>
|
||||
<input v-if="sys.config.loopStartUseCustomDuration" :value="sys.config.loopStartDurationFrames" type="number" min="1" max="1800" step="1" :aria-label="$t('emission.startLoopDuration')" @change="setLoopSectionDuration('start', $event)" /><span v-if="sys.config.loopStartUseCustomDuration">f</span>
|
||||
<span v-else class="loop-auto-duration">{{ $t('emission.auto', { frames: automaticLoopDuration }) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loop-section-row">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopEndAnimation" @change="setLoopSection('end', $event)" />
|
||||
<span class="switch-ui"></span><span>生成结束循环动画</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('emission.generateEnd') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.generateLoopEndAnimation" class="loop-duration-control">
|
||||
<label class="switch-row loop-duration-toggle">
|
||||
<input type="checkbox" :checked="sys.config.loopEndUseCustomDuration" @change="setLoopCustomDuration('end', $event)" />
|
||||
<span class="switch-ui"></span><span>指定时长</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('emission.customDuration') }}</span>
|
||||
</label>
|
||||
<label class="loop-section-duration">
|
||||
<input v-if="sys.config.loopEndUseCustomDuration" :value="sys.config.loopEndDurationFrames" type="number" min="1" max="1800" step="1" aria-label="结束循环段时长" @change="setLoopSectionDuration('end', $event)" /><span v-if="sys.config.loopEndUseCustomDuration">f</span>
|
||||
<span v-else class="loop-auto-duration">自动 {{ automaticLoopDuration }}f</span>
|
||||
<input v-if="sys.config.loopEndUseCustomDuration" :value="sys.config.loopEndDurationFrames" type="number" min="1" max="1800" step="1" :aria-label="$t('emission.endLoopDuration')" @change="setLoopSectionDuration('end', $event)" /><span v-if="sys.config.loopEndUseCustomDuration">f</span>
|
||||
<span v-else class="loop-auto-duration">{{ $t('emission.auto', { frames: automaticLoopDuration }) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="modifier-hint stream-loop-hint">循环持续模式不支持骨骼跟随和路径跟随;进入或退出时会将粒子跟随设置恢复默认值。</div>
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="modifier-hint stream-loop-hint">{{ $t('emission.loopWarning') }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
|
||||
<NumSlider :label="$t('emission.burstCount')" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
|
||||
</template>
|
||||
<NumSlider label="延迟时间" :min="0" :max="5" :step="0.1" v-model="sys.config.delay" />
|
||||
<NumSlider :label="$t('emission.delay')" :min="0" :max="5" :step="0.1" v-model="sys.config.delay" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 发射器形状 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('shape')">
|
||||
<span class="caret">{{ collapsed.shape ? '▸' : '▾' }}</span><span>发射器形状</span>
|
||||
<button class="group-reset" title="重置发射器形状参数" @click.stop="resetModule('shape')">重置</button>
|
||||
<span class="caret">{{ collapsed.shape ? '▸' : '▾' }}</span><span>{{ $t('emission.shapeTitle') }}</span>
|
||||
<button class="group-reset" :title="$t('emission.shapeReset')" @click.stop="resetModule('shape')">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.shape">
|
||||
<label class="row"><span>形状</span>
|
||||
<label class="row"><span>{{ $t('emission.shape') }}</span>
|
||||
<select v-model="sys.config.shape" class="inp" @change="onShapeChange">
|
||||
<option value="point">点</option>
|
||||
<option value="circle">圆形</option>
|
||||
<option value="rect">矩形</option>
|
||||
<option value="cone">锥形</option>
|
||||
<option value="path">路径</option>
|
||||
<option value="point">{{ $t('emission.point') }}</option>
|
||||
<option value="circle">{{ $t('emission.circle') }}</option>
|
||||
<option value="rect">{{ $t('emission.rect') }}</option>
|
||||
<option value="cone">{{ $t('emission.cone') }}</option>
|
||||
<option value="path">{{ $t('emission.path') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="sys.config.shape === 'circle' || sys.config.shape === 'cone'">
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '内径' : '内半径'" :min="0" :max="sys.config.outerRadius" :step="1" v-model="sys.config.innerRadius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '外径' : '外半径'" :min="sys.config.innerRadius" :max="300" :step="1" v-model="sys.config.outerRadius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? $t('emission.innerDiameter') : $t('emission.innerRadius')" :min="0" :max="sys.config.outerRadius" :step="1" v-model="sys.config.innerRadius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? $t('emission.outerDiameter') : $t('emission.outerRadius')" :min="sys.config.innerRadius" :max="300" :step="1" v-model="sys.config.outerRadius" />
|
||||
</template>
|
||||
<template v-if="sys.config.shape === 'cone'">
|
||||
<NumSlider label="锥形朝向" :min="-360" :max="360" :step="1" v-model="sys.config.coneDirection" />
|
||||
<NumSlider label="锥形开口角度" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
|
||||
<NumSlider :label="$t('emission.coneDirection')" :min="-360" :max="360" :step="1" v-model="sys.config.coneDirection" />
|
||||
<NumSlider :label="$t('emission.coneAngle')" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
|
||||
</template>
|
||||
<template v-else-if="sys.config.shape === 'rect'">
|
||||
<NumSlider label="内宽" :min="0" :max="sys.config.outerRectW" :step="1" v-model="sys.config.innerRectW" />
|
||||
<NumSlider label="内高" :min="0" :max="sys.config.outerRectH" :step="1" v-model="sys.config.innerRectH" />
|
||||
<NumSlider label="外宽" :min="sys.config.innerRectW" :max="400" :step="1" v-model="sys.config.outerRectW" />
|
||||
<NumSlider label="外高" :min="sys.config.innerRectH" :max="400" :step="1" v-model="sys.config.outerRectH" />
|
||||
<NumSlider :label="$t('emission.innerWidth')" :min="0" :max="sys.config.outerRectW" :step="1" v-model="sys.config.innerRectW" />
|
||||
<NumSlider :label="$t('emission.innerHeight')" :min="0" :max="sys.config.outerRectH" :step="1" v-model="sys.config.innerRectH" />
|
||||
<NumSlider :label="$t('emission.outerWidth')" :min="sys.config.innerRectW" :max="400" :step="1" v-model="sys.config.outerRectW" />
|
||||
<NumSlider :label="$t('emission.outerHeight')" :min="sys.config.innerRectH" :max="400" :step="1" v-model="sys.config.outerRectH" />
|
||||
</template>
|
||||
<template v-else-if="sys.config.shape === 'path'">
|
||||
<label class="row"><span>选择路径</span>
|
||||
<label class="row"><span>{{ $t('emission.selectPath') }}</span>
|
||||
<select v-model.number="sys.config.spawnPathId" class="inp" @change="onSpawnPathChange">
|
||||
<option :value="0">无路径(点发射)</option>
|
||||
<option :value="0">{{ $t('emission.noPath') }}</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id" :disabled="!path.enabled">{{ path.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="sys.config.spawnPathId > 0" class="row"><span>出生分布</span>
|
||||
<label v-if="sys.config.spawnPathId > 0" class="row"><span>{{ $t('emission.birthDistribution') }}</span>
|
||||
<select v-model="sys.config.spawnPathDistribution" class="inp">
|
||||
<option value="fixed">等间距固定分布</option>
|
||||
<option value="random">随机分布</option>
|
||||
<option value="fixed">{{ $t('emission.evenDistribution') }}</option>
|
||||
<option value="random">{{ $t('emission.randomDistribution') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<div class="divider"></div>
|
||||
<label class="row"><span>发射角度</span>
|
||||
<label class="row"><span>{{ $t('emission.emissionAngle') }}</span>
|
||||
<select v-model="sys.config.emissionAngleMode" class="inp">
|
||||
<option value="fixed">固定</option>
|
||||
<option value="random">随机</option>
|
||||
<option v-if="sys.config.shape === 'circle' || sys.config.shape === 'rect' || sys.config.shape === 'cone'" value="radial">径向(中心向外)</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-normal">垂直于路径</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-tangent">沿路径方向</option>
|
||||
<option value="fixed">{{ $t('common.fixed') }}</option>
|
||||
<option value="random">{{ $t('common.random') }}</option>
|
||||
<option v-if="sys.config.shape === 'circle' || sys.config.shape === 'rect' || sys.config.shape === 'cone'" value="radial">{{ $t('emission.radial') }}</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-normal">{{ $t('emission.pathNormal') }}</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-tangent">{{ $t('emission.pathTangent') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumSlider v-if="sys.config.emissionAngleMode === 'fixed'" label="固定角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider v-if="sys.config.emissionAngleMode === 'fixed'" :label="$t('emission.fixedAngle')" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<template v-else-if="sys.config.emissionAngleMode === 'random'">
|
||||
<NumSlider label="基准角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider label="随机范围" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
|
||||
<NumSlider :label="$t('emission.baseAngle')" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider :label="$t('emission.randomRange')" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
|
||||
</template>
|
||||
<label v-if="sys.config.shape !== 'point'" class="switch-row">
|
||||
<input type="checkbox" v-model="sys.config.showShape" />
|
||||
<span class="switch-ui"></span><span>显示形状</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('emission.showShape') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
<!-- 7. 导出设置:当前阶段只维护参数,后续接入 JSON 生成。 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('export')">
|
||||
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span>
|
||||
<button class="group-reset" title="重置导出设置" @click.stop="resetModule('export')">重置</button>
|
||||
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>{{ $t('export.title') }}</span>
|
||||
<button class="group-reset" :title="$t('export.reset')" @click.stop="resetModule('export')">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
<div class="group-body export-settings" v-show="!collapsed.export">
|
||||
<label class="export-field">
|
||||
<span>图片路径 (Spine Images Path)</span>
|
||||
<span>{{ $t('export.imagesPath') }}</span>
|
||||
<input v-model.trim="exportSettings.imagesPath" class="inp export-input" type="text" placeholder="./images/" />
|
||||
</label>
|
||||
|
||||
<label class="export-field">
|
||||
<span>Spine 版本</span>
|
||||
<span>{{ $t('export.spineVersion') }}</span>
|
||||
<select v-model="exportSettings.spineVersion" class="inp export-input">
|
||||
<option value="3.8">Spine 3.8</option>
|
||||
<option value="4.0">Spine 4.0</option>
|
||||
@@ -23,36 +23,36 @@
|
||||
</label>
|
||||
|
||||
<label class="export-field">
|
||||
<span>关键帧插值曲线</span>
|
||||
<span>{{ $t('export.keyframeCurve') }}</span>
|
||||
<select v-model="exportSettings.keyframeCurve" class="inp export-input">
|
||||
<option value="linear">线性 (Linear)</option>
|
||||
<option value="bezier">平滑贝塞尔 (Smooth Bézier)</option>
|
||||
<option value="linear">{{ $t('export.linear') }}</option>
|
||||
<option value="bezier">{{ $t('export.smoothBezier') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="export-help">平滑贝塞尔会在误差允许范围内拟合连续运动;出生、死亡、附件切换和突变仍使用阶跃关键帧。</div>
|
||||
<div v-if="exportSettings.spineVersion === '3.8'" class="export-help">Spine 3.8 不支持 4.x 的逐通道数组贝塞尔,导出时会转换为经过容差精简的分段线性关键帧。</div>
|
||||
<div class="export-help">{{ $t('export.bezierHelp') }}</div>
|
||||
<div v-if="exportSettings.spineVersion === '3.8'" class="export-help">{{ $t('export.spine38Help') }}</div>
|
||||
|
||||
<div class="export-fps-row">
|
||||
<label class="export-field export-fps-field">
|
||||
<span>导出 FPS</span>
|
||||
<span>{{ $t('export.fps') }}</span>
|
||||
<input v-model.number="exportSettings.fps" class="inp export-input" type="number" min="1" max="240" step="1" @change="normalizeExportFps" />
|
||||
</label>
|
||||
<label class="export-checkbox"><input v-model="exportSettings.omitFps" type="checkbox" />不写 fps</label>
|
||||
<label class="export-checkbox"><input v-model="exportSettings.omitFps" type="checkbox" />{{ $t('export.omitFps') }}</label>
|
||||
</div>
|
||||
<div class="export-help">修改 FPS 时保持动画实际秒数不变;“不写 fps”只省略 Spine 的 fps 元数据。</div>
|
||||
<div class="export-help">{{ $t('export.fpsHelp') }}</div>
|
||||
|
||||
<div class="export-switches">
|
||||
<label class="switch-row export-switch">
|
||||
<input v-model="exportSettings.integerFrameAlignment" type="checkbox" />
|
||||
<span class="switch-ui"></span><span>整数帧对齐</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('export.integerFrames') }}</span>
|
||||
</label>
|
||||
<label class="switch-row export-switch">
|
||||
<input v-model="exportSettings.bonePool" type="checkbox" />
|
||||
<span class="switch-ui"></span><span>骨骼对象池</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('export.bonePool') }}</span>
|
||||
</label>
|
||||
<label class="switch-row export-switch export-switch-last">
|
||||
<input v-model="exportSettings.excludeEmptyAnimations" type="checkbox" />
|
||||
<span class="switch-ui"></span><span>排除无内容动画</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('export.excludeEmpty') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,53 +2,53 @@
|
||||
<!-- 5. 外观与资源 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('look')">
|
||||
<span class="caret">{{ collapsed.look ? '▸' : '▾' }}</span><span>外观与资源</span>
|
||||
<button class="group-reset" title="重置外观与资源参数" @click.stop="resetModule('look')">重置</button>
|
||||
<span class="caret">{{ collapsed.look ? '▸' : '▾' }}</span><span>{{ $t('appearance.title') }}</span>
|
||||
<button class="group-reset" :title="$t('appearance.reset')" @click.stop="resetModule('look')">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.look">
|
||||
<!-- 透明度 -->
|
||||
<div class="look-title">透明度</div>
|
||||
<div class="look-title">{{ $t('appearance.opacity') }}</div>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'curve' }" @click="sys.config.alphaMode = 'curve'">曲线</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'fixed' }" @click="sys.config.alphaMode = 'fixed'">固定</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'curve' }" @click="sys.config.alphaMode = 'curve'">{{ $t('common.curve') }}</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'fixed' }" @click="sys.config.alphaMode = 'fixed'">{{ $t('common.fixed') }}</button>
|
||||
</div>
|
||||
<template v-if="sys.config.alphaMode === 'fixed'">
|
||||
<NumSlider label="透明度" :min="0" :max="1" :step="0.01" v-model="sys.config.alpha" />
|
||||
<NumSlider :label="$t('appearance.opacity')" :min="0" :max="1" :step="0.01" v-model="sys.config.alpha" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="row"><span>曲线模式</span>
|
||||
<label class="row"><span>{{ $t('appearance.curveMode') }}</span>
|
||||
<select v-model="sys.config.curveMode" class="inp">
|
||||
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
|
||||
<option value="跟随导出">{{ $t('common.followExport') }}</option><option value="线性">{{ $t('common.linear') }}</option><option value="贝塞尔">{{ $t('common.bezier') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="curve-wrap">
|
||||
<CurveEditor v-model="sys.config.alphaCurve" :default-value="ALPHA_CURVE" :mode="resolvedCurveEditorMode(sys.config.curveMode)" />
|
||||
<div class="curve-labels"><span>0(透明)</span><span>生命周期 →</span><span>1(不透明)</span></div>
|
||||
<div class="curve-labels"><span>{{ $t('appearance.transparent') }}</span><span>{{ $t('attributes.lifeTo') }}</span><span>{{ $t('appearance.opaque') }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 渲染层级(仅编辑器预览,不影响导出数据) -->
|
||||
<div class="look-title">渲染层级(Spine 插槽)</div>
|
||||
<div class="look-title">{{ $t('appearance.renderLayer') }}</div>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'top' }" @click="sys.config.renderLayerMode = 'top'">最上层</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'slot' }" @click="setRenderLayerSlotMode">Spine 插槽</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'top' }" @click="sys.config.renderLayerMode = 'top'">{{ $t('appearance.top') }}</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'slot' }" @click="setRenderLayerSlotMode">{{ $t('appearance.spineSlot') }}</button>
|
||||
</div>
|
||||
<template v-if="sys.config.renderLayerMode === 'slot'">
|
||||
<label class="row"><span>Spine</span>
|
||||
<select class="inp" :value="sys.config.renderLayerSpineId" @change="selectRenderLayerSpine">
|
||||
<option :value="0">{{ renderLayerReadySpines.length ? '请选择 Spine' : '无可用 Spine' }}</option>
|
||||
<option :value="0">{{ renderLayerReadySpines.length ? $t('appearance.selectSpine') : $t('appearance.noSpine') }}</option>
|
||||
<option v-for="spine in renderLayerReadySpines" :key="spine.id" :value="spine.id">{{ spine.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- 没有可用的 Spine 时,插槽选择与层级说明整体隐藏。 -->
|
||||
<template v-if="renderLayerSpineReady">
|
||||
<div class="render-layer-current">
|
||||
<span class="render-layer-text" :title="renderLayerSlotLabel">{{ renderLayerSlotLabel || '尚未选择插槽' }}</span>
|
||||
<button class="mini-btn" @click="renderLayerSlotPickerOpen = !renderLayerSlotPickerOpen">{{ renderLayerSlotLabel ? '重新选择' : '选择插槽' }}</button>
|
||||
<button v-if="sys.config.renderLayerSlotName" class="mini-btn ghost" @click="clearRenderLayerSlot">清除</button>
|
||||
<span class="render-layer-text" :title="renderLayerSlotLabel">{{ renderLayerSlotLabel || $t('appearance.noSlot') }}</span>
|
||||
<button class="mini-btn" @click="renderLayerSlotPickerOpen = !renderLayerSlotPickerOpen">{{ renderLayerSlotLabel ? $t('appearance.reselect') : $t('appearance.selectSlot') }}</button>
|
||||
<button v-if="sys.config.renderLayerSlotName" class="mini-btn ghost" @click="clearRenderLayerSlot">{{ $t('common.clear') }}</button>
|
||||
</div>
|
||||
<div v-if="renderLayerSlotPickerOpen" class="slot-picker">
|
||||
<input v-model.trim="renderLayerSlotSearch" class="inp" type="search" placeholder="搜索插槽或附件名称…" aria-label="搜索插槽" />
|
||||
<input v-model.trim="renderLayerSlotSearch" class="inp" type="search" :placeholder="$t('appearance.searchSlots')" :aria-label="$t('appearance.searchSlotsAria')" />
|
||||
<div class="slot-list">
|
||||
<button
|
||||
v-for="slot in renderLayerFilteredSlots"
|
||||
@@ -59,31 +59,31 @@
|
||||
>
|
||||
<span class="slot-order">{{ slot.order + 1 }}</span>
|
||||
<span class="slot-name">{{ slot.name }}</span>
|
||||
<span class="slot-attachment">{{ slot.attachment || '空插槽' }}</span>
|
||||
<span class="slot-attachment">{{ slot.attachment || $t('appearance.emptySlot') }}</span>
|
||||
</button>
|
||||
<div v-if="!renderLayerFilteredSlots.length" class="modifier-hint">没有匹配的插槽</div>
|
||||
<div v-if="!renderLayerFilteredSlots.length" class="modifier-hint">{{ $t('appearance.noMatchingSlots') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="sys.config.renderLayerSlotName">
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'above' }" @click="sys.config.renderLayerSlotPosition = 'above'">在此插槽图片之上</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'below' }" @click="sys.config.renderLayerSlotPosition = 'below'">在此插槽图片之下</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'above' }" @click="sys.config.renderLayerSlotPosition = 'above'">{{ $t('appearance.above') }}</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'below' }" @click="sys.config.renderLayerSlotPosition = 'below'">{{ $t('appearance.below') }}</button>
|
||||
</div>
|
||||
<label class="switch-row render-layer-switch">
|
||||
<input v-model="sys.config.renderLayerFollowAttachment" type="checkbox" :disabled="sys.config.renderLayerSlotPosition === 'below'" />
|
||||
<span class="switch-ui"></span><span>随插槽附件显隐</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('appearance.followAttachment') }}</span>
|
||||
</label>
|
||||
<div v-if="renderLayerResolvedLabel" class="modifier-hint">{{ renderLayerResolvedLabel }}</div>
|
||||
</template>
|
||||
<div v-if="renderLayerWarning" class="modifier-hint render-layer-warning">{{ renderLayerWarning }}</div>
|
||||
<div class="modifier-hint">调整显示层级只影响编辑器预览,不影响导出数据。</div>
|
||||
<div class="modifier-hint">{{ $t('appearance.previewOnly') }}</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 图片资源 -->
|
||||
<div class="look-title image-title">
|
||||
<span>图片资源</span>
|
||||
<button class="image-add" title="新增图片资源" @click="addImageResource">+</button>
|
||||
<span>{{ $t('appearance.imageResources') }}</span>
|
||||
<button class="image-add" :title="$t('appearance.addImage')" @click="addImageResource">+</button>
|
||||
</div>
|
||||
<ImageResourceCard
|
||||
v-for="resource in sys.config.imageResources"
|
||||
@@ -100,43 +100,43 @@
|
||||
@update-weight="setResourceWeight"
|
||||
@toggle-lock="toggleResourceLock"
|
||||
>
|
||||
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
|
||||
<NumSlider :label="$t('appearance.scale')" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
|
||||
|
||||
<div class="resource-divider"></div>
|
||||
<label class="row resource-color-row"><span>颜色</span>
|
||||
<label class="row resource-color-row"><span>{{ $t('appearance.color') }}</span>
|
||||
<select v-model="resource.colorMode" class="inp">
|
||||
<option value="fixed">固定色</option>
|
||||
<option value="random">随机颜色</option>
|
||||
<option value="lifetime">生命周期</option>
|
||||
<option value="fixed">{{ $t('appearance.fixedColor') }}</option>
|
||||
<option value="random">{{ $t('appearance.randomColor') }}</option>
|
||||
<option value="lifetime">{{ $t('appearance.lifetime') }}</option>
|
||||
</select>
|
||||
<select v-model="resource.blend" class="inp blend-select">
|
||||
<option value="normal">正常</option>
|
||||
<option value="add">相加</option>
|
||||
<option value="multiply">相乘</option>
|
||||
<option value="screen">滤色</option>
|
||||
<option value="normal">{{ $t('appearance.normal') }}</option>
|
||||
<option value="add">{{ $t('appearance.add') }}</option>
|
||||
<option value="multiply">{{ $t('appearance.multiply') }}</option>
|
||||
<option value="screen">{{ $t('appearance.screen') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="resource.colorMode === 'fixed'">
|
||||
<label class="row resource-color-picker"><span>固定色</span><input type="color" v-model="resource.colorStart" class="colr" /><input type="text" class="hex-input" :value="resource.colorStart" spellcheck="false" aria-label="固定颜色值" @change="setResourceFixedColor(resource, $event)" @keydown.enter="commitResourceFixedColor(resource, $event)" /></label>
|
||||
<label class="row resource-color-picker"><span>{{ $t('appearance.fixedColor') }}</span><input type="color" v-model="resource.colorStart" class="colr" /><input type="text" class="hex-input" :value="resource.colorStart" spellcheck="false" :aria-label="$t('appearance.fixedColorValue')" @change="setResourceFixedColor(resource, $event)" @keydown.enter="commitResourceFixedColor(resource, $event)" /></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ColorGradientEditor v-model="resource.colorGradient" />
|
||||
<div v-if="resource.colorMode === 'random'" class="random-color-hint">每个粒子出生时从色带随机取色,生命周期内保持不变</div>
|
||||
<div v-if="resource.colorMode === 'random'" class="random-color-hint">{{ $t('appearance.randomColorHint') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="resource-alpha-head">
|
||||
<span>独立透明度</span>
|
||||
<span>{{ $t('appearance.independentAlpha') }}</span>
|
||||
<div class="alpha-modes">
|
||||
<button :class="{ on: resource.independentAlpha === 'off' }" @click="resource.independentAlpha = 'off'">关闭</button>
|
||||
<button :class="{ on: resource.independentAlpha === 'fixed' }" @click="resource.independentAlpha = 'fixed'">固定</button>
|
||||
<button :class="{ on: resource.independentAlpha === 'curve' }" @click="resource.independentAlpha = 'curve'">曲线</button>
|
||||
<button :class="{ on: resource.independentAlpha === 'off' }" @click="resource.independentAlpha = 'off'">{{ $t('appearance.off') }}</button>
|
||||
<button :class="{ on: resource.independentAlpha === 'fixed' }" @click="resource.independentAlpha = 'fixed'">{{ $t('common.fixed') }}</button>
|
||||
<button :class="{ on: resource.independentAlpha === 'curve' }" @click="resource.independentAlpha = 'curve'">{{ $t('common.curve') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<NumSlider v-if="resource.independentAlpha === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
|
||||
<NumSlider v-if="resource.independentAlpha === 'fixed'" :label="$t('appearance.opacity')" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
|
||||
<template v-else-if="resource.independentAlpha === 'curve'">
|
||||
<label class="row"><span>曲线模式</span>
|
||||
<label class="row"><span>{{ $t('appearance.curveMode') }}</span>
|
||||
<select v-model="resource.alphaCurveMode" class="inp">
|
||||
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
|
||||
<option value="跟随导出">{{ $t('common.followExport') }}</option><option value="线性">{{ $t('common.linear') }}</option><option value="贝塞尔">{{ $t('common.bezier') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="resource-alpha-curve">
|
||||
|
||||
@@ -2,54 +2,54 @@
|
||||
<!-- 4. 粒子属性 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('attr')">
|
||||
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
|
||||
<button class="group-reset" title="重置粒子属性参数" @click.stop="resetModule('attr')">重置</button>
|
||||
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>{{ $t('attributes.title') }}</span>
|
||||
<button class="group-reset" :title="$t('attributes.reset')" @click.stop="resetModule('attr')">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
<div class="group-body attr-body" v-show="!collapsed.attr">
|
||||
<div class="attr-section-title">粒子初始参数</div>
|
||||
<div class="attr-section-title">{{ $t('attributes.initialParams') }}</div>
|
||||
|
||||
<ParticleAttributeControl
|
||||
label="初始生命周期"
|
||||
:label="$t('attributes.initialLife')"
|
||||
:allow-curve="false"
|
||||
v-model:mode="sys.config.lifeMode"
|
||||
v-model:value-min="sys.config.lifeMin"
|
||||
v-model:value-max="sys.config.lifeMax"
|
||||
v-model:curve="sys.config.lifeCurve"
|
||||
:min="0.1" :max="10" :step="0.1"
|
||||
curve-label="随机采样 →"
|
||||
:curve-label="$t('attributes.randomSample')"
|
||||
/>
|
||||
|
||||
<ParticleAttributeControl
|
||||
label="初始速度"
|
||||
:label="$t('attributes.initialSpeed')"
|
||||
:allow-curve="false"
|
||||
v-model:mode="sys.config.speedMode"
|
||||
v-model:value-min="sys.config.speedMin"
|
||||
v-model:value-max="sys.config.speedMax"
|
||||
v-model:curve="sys.config.speedCurve"
|
||||
:min="0" :max="1000" :step="1"
|
||||
curve-label="随机采样 →"
|
||||
:curve-label="$t('attributes.randomSample')"
|
||||
/>
|
||||
|
||||
<div class="attr-axis-head">
|
||||
<span>初始缩放</span>
|
||||
<span>{{ $t('attributes.initialScale') }}</span>
|
||||
<div class="axis-modes">
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'uniform' }" @click="sys.config.scaleAxisMode = 'uniform'">统一</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'separate' }" @click="sys.config.scaleAxisMode = 'separate'">XY分离</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'uniform' }" @click="sys.config.scaleAxisMode = 'uniform'">{{ $t('attributes.uniform') }}</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleAxisMode === 'separate' }" @click="sys.config.scaleAxisMode = 'separate'">{{ $t('attributes.separateXY') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<ParticleAttributeControl
|
||||
:label="sys.config.scaleAxisMode === 'uniform' ? '初始缩放' : '缩放 X'"
|
||||
:label="sys.config.scaleAxisMode === 'uniform' ? $t('attributes.initialScale') : $t('attributes.scaleX')"
|
||||
:allow-curve="false"
|
||||
v-model:mode="sys.config.scaleMode"
|
||||
v-model:value-min="sys.config.scaleMin"
|
||||
v-model:value-max="sys.config.scaleMax"
|
||||
v-model:curve="sys.config.scaleCurve"
|
||||
:min="0" :max="5" :step="0.05"
|
||||
curve-label="随机采样 →"
|
||||
:curve-label="$t('attributes.randomSample')"
|
||||
/>
|
||||
<ParticleAttributeControl
|
||||
v-if="sys.config.scaleAxisMode === 'separate'"
|
||||
label="缩放 Y"
|
||||
:label="$t('attributes.scaleY')"
|
||||
:show-modes="false"
|
||||
:allow-curve="false"
|
||||
v-model:mode="sys.config.scaleMode"
|
||||
@@ -57,11 +57,11 @@
|
||||
v-model:value-max="sys.config.scaleYMax"
|
||||
v-model:curve="sys.config.scaleYCurve"
|
||||
:min="0" :max="5" :step="0.05"
|
||||
curve-label="随机采样 →"
|
||||
:curve-label="$t('attributes.randomSample')"
|
||||
/>
|
||||
|
||||
<ParticleAttributeControl
|
||||
label="初始旋转"
|
||||
:label="$t('attributes.initialRotation')"
|
||||
allow-direction
|
||||
:allow-curve="false"
|
||||
v-model:mode="sys.config.initialRotationMode"
|
||||
@@ -69,19 +69,19 @@
|
||||
v-model:value-max="sys.config.initialRotationMax"
|
||||
v-model:curve="sys.config.initialRotationCurve"
|
||||
:min="-360" :max="360" :step="1"
|
||||
curve-label="随机采样 →"
|
||||
:curve-label="$t('attributes.randomSample')"
|
||||
/>
|
||||
|
||||
<div class="attr-section-title life-title">粒子生命周期变化参数</div>
|
||||
<div class="attr-section-title life-title">{{ $t('attributes.lifecycleTitle') }}</div>
|
||||
|
||||
<div class="life-item">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" v-model="sys.config.speedOverLifeEnabled" />
|
||||
<span class="switch-ui"></span><span>生命周期内速度</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('attributes.speedOverLife') }}</span>
|
||||
</label>
|
||||
<ParticleAttributeControl
|
||||
v-if="sys.config.speedOverLifeEnabled"
|
||||
label="速度倍率"
|
||||
:label="$t('attributes.speedMultiplier')"
|
||||
:mode="sys.config.speedOverLifeMode"
|
||||
@update:mode="setSpeedOverLifeMode"
|
||||
v-model:value-min="sys.config.speedOverLifeMin"
|
||||
@@ -89,22 +89,22 @@
|
||||
v-model:curve="sys.config.speedOverLifeCurve"
|
||||
:default-curve="FLAT_CURVE"
|
||||
:min="0" :max="3" :step="0.05"
|
||||
curve-label="生命周期 →"
|
||||
:curve-label="$t('attributes.lifeTo')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="life-item">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" v-model="sys.config.scaleOverLifeEnabled" @change="prepareScaleOverLifeCurve" />
|
||||
<span class="switch-ui"></span><span>生命周期内缩放</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('attributes.scaleOverLife') }}</span>
|
||||
</label>
|
||||
<template v-if="sys.config.scaleOverLifeEnabled">
|
||||
<div class="axis-modes life-axis">
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'uniform' }" @click="sys.config.scaleOverLifeAxisMode = 'uniform'">统一</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'separate' }" @click="sys.config.scaleOverLifeAxisMode = 'separate'">XY分离</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'uniform' }" @click="sys.config.scaleOverLifeAxisMode = 'uniform'">{{ $t('attributes.uniform') }}</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.scaleOverLifeAxisMode === 'separate' }" @click="sys.config.scaleOverLifeAxisMode = 'separate'">{{ $t('attributes.separateXY') }}</button>
|
||||
</div>
|
||||
<ParticleAttributeControl
|
||||
:label="sys.config.scaleOverLifeAxisMode === 'uniform' ? '缩放倍率' : '倍率 X'"
|
||||
:label="sys.config.scaleOverLifeAxisMode === 'uniform' ? $t('attributes.scaleMultiplier') : $t('attributes.multiplierX')"
|
||||
:mode="sys.config.scaleOverLifeMode"
|
||||
@update:mode="setScaleOverLifeMode"
|
||||
v-model:value-min="sys.config.scaleOverLifeMin"
|
||||
@@ -112,11 +112,11 @@
|
||||
v-model:curve="sys.config.scaleOverLifeCurve"
|
||||
:default-curve="FLAT_CURVE"
|
||||
:min="0" :max="3" :step="0.05"
|
||||
curve-label="生命周期 →"
|
||||
:curve-label="$t('attributes.lifeTo')"
|
||||
/>
|
||||
<ParticleAttributeControl
|
||||
v-if="sys.config.scaleOverLifeAxisMode === 'separate'"
|
||||
label="倍率 Y"
|
||||
:label="$t('attributes.multiplierY')"
|
||||
:show-modes="false"
|
||||
v-model:mode="sys.config.scaleOverLifeMode"
|
||||
v-model:value-min="sys.config.scaleYOverLifeMin"
|
||||
@@ -124,7 +124,7 @@
|
||||
v-model:curve="sys.config.scaleYOverLifeCurve"
|
||||
:default-curve="FLAT_CURVE"
|
||||
:min="0" :max="3" :step="0.05"
|
||||
curve-label="生命周期 →"
|
||||
:curve-label="$t('attributes.lifeTo')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -132,18 +132,18 @@
|
||||
<div class="life-item">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" v-model="sys.config.rotationOverLifeEnabled" />
|
||||
<span class="switch-ui"></span><span>生命周期内旋转</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('attributes.rotationOverLife') }}</span>
|
||||
</label>
|
||||
<ParticleAttributeControl
|
||||
v-if="sys.config.rotationOverLifeEnabled"
|
||||
label="旋转速度"
|
||||
:label="$t('attributes.rotationSpeed')"
|
||||
v-model:mode="sys.config.rotationOverLifeMode"
|
||||
v-model:value-min="sys.config.rotationSpeedMin"
|
||||
v-model:value-max="sys.config.rotationSpeedMax"
|
||||
v-model:curve="sys.config.rotationOverLifeCurve"
|
||||
:default-curve="LINEAR_CURVE"
|
||||
:min="-720" :max="720" :step="10"
|
||||
curve-label="生命周期 →"
|
||||
:curve-label="$t('attributes.lifeTo')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<div class="spine-export-actions">
|
||||
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
|
||||
{{ exportBusy && exportTarget === 'json' ? '正在准备导出…' : '导出Spine Json' }}
|
||||
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? $t('export.baking') : $t('export.exportJsonTitle', { version: exportSettings.spineVersion })" @click="exportSpineJson">
|
||||
{{ exportBusy && exportTarget === 'json' ? $t('export.preparing') : $t('export.exportJson') }}
|
||||
</button>
|
||||
<button type="button" class="export-spine-images" :disabled="exportBusy" title="单独下载当前粒子系统使用的图片 ZIP" @click="exportSpineImages">
|
||||
{{ exportBusy && exportTarget === 'images' ? '正在整理图片…' : '导出 images 包' }}
|
||||
<button type="button" class="export-spine-images" :disabled="exportBusy" :title="$t('export.exportImagesTitle')" @click="exportSpineImages">
|
||||
{{ exportBusy && exportTarget === 'images' ? $t('export.organizingImages') : $t('export.exportImages') }}
|
||||
</button>
|
||||
<button type="button" class="export-spine-project" :disabled="exportBusy || !connectorReady" :title="connectorButtonTitle" @click="exportSpineProject">
|
||||
<span>{{ exportBusy && exportTarget === 'spine' ? '正在生成工程…' : '导出 .spine 包' }}</span>
|
||||
<span>{{ exportBusy && exportTarget === 'spine' ? $t('export.generatingProject') : $t('export.exportSpine') }}</span>
|
||||
<small v-if="!exportBusy && connectorDisabledReason">{{ connectorDisabledReason }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="connector-status" :class="connectorStatusClass">
|
||||
<span class="connector-dot"></span>{{ connectorStatusText }}
|
||||
<a v-if="connectorAuthorizationUrl" :href="connectorAuthorizationUrl" target="_blank" rel="noopener" title="在本机连接器中授权当前网站">授权当前网站</a>
|
||||
<button type="button" title="重新检测连接器" @click="refreshConnectorHealth">刷新</button>
|
||||
<a v-if="connectorAuthorizationUrl" :href="connectorAuthorizationUrl" target="_blank" rel="noopener" :title="$t('export.authorizeTitle')">{{ $t('export.authorize') }}</a>
|
||||
<button type="button" :title="$t('export.refreshConnector')" @click="refreshConnectorHealth">{{ $t('export.refresh') }}</button>
|
||||
</div>
|
||||
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">
|
||||
{{ exportMessage }}
|
||||
<a v-if="connectorPairUrl" :href="connectorPairUrl" target="_blank" rel="noopener" @click="connectorPairUrl = ''">授权当前网站</a>
|
||||
<a v-if="connectorPairUrl" :href="connectorPairUrl" target="_blank" rel="noopener" @click="connectorPairUrl = ''">{{ $t('export.authorize') }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,147 +2,147 @@
|
||||
<!-- 6. 修改器 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('mods')">
|
||||
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>修改器</span>
|
||||
<button class="group-reset" title="重置修改器参数" @click.stop="resetModule('mods')">重置</button>
|
||||
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>{{ $t('modifiers.title') }}</span>
|
||||
<button class="group-reset" :title="$t('modifiers.reset')" @click.stop="resetModule('mods')">{{ $t('common.reset') }}</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.mods">
|
||||
<div class="modifier-item damping-item">
|
||||
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
|
||||
<NumSlider :label="$t('modifiers.damping')" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.noise" />
|
||||
<span class="switch-ui"></span><span>开启移动噪声</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableNoise') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.noise" class="modifier-params">
|
||||
<NumSlider label="强度" :min="0" :max="100" :step="1" v-model="sys.config.noiseAmt" />
|
||||
<NumSlider label="波动范围" :min="0" :max="500" :step="1" v-model="sys.config.noiseRange" />
|
||||
<NumSlider label="时间流速" :min="0" :max="10" :step="0.1" v-model="sys.config.noiseSpeed" />
|
||||
<div class="modifier-hint">强度控制影响比例 · 波动范围控制最大偏移 · 时间流速控制变化速度</div>
|
||||
<NumSlider :label="$t('modifiers.strength')" :min="0" :max="100" :step="1" v-model="sys.config.noiseAmt" />
|
||||
<NumSlider :label="$t('modifiers.noiseRange')" :min="0" :max="500" :step="1" v-model="sys.config.noiseRange" />
|
||||
<NumSlider :label="$t('modifiers.noiseSpeed')" :min="0" :max="10" :step="0.1" v-model="sys.config.noiseSpeed" />
|
||||
<div class="modifier-hint">{{ $t('modifiers.noiseHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.useGravity" />
|
||||
<span class="switch-ui"></span><span>开启重力</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableGravity') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.useGravity" class="modifier-params gravity-params">
|
||||
<div class="modifier-mode-row">
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'fixed' }" @click="sys.config.gravityMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'random' }" @click="sys.config.gravityMode = 'random'">随机</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'fixed' }" @click="sys.config.gravityMode = 'fixed'">{{ $t('common.fixed') }}</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'random' }" @click="sys.config.gravityMode = 'random'">{{ $t('common.random') }}</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.gravityMode === 'fixed'" label="重力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<NumSlider v-if="sys.config.gravityMode === 'fixed'" :label="$t('modifiers.gravityStrength')" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<template v-else>
|
||||
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMax" />
|
||||
<NumSlider :label="$t('modifiers.strengthMin')" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<NumSlider :label="$t('modifiers.strengthMax')" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMax" />
|
||||
</template>
|
||||
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.gravityDirection" />
|
||||
<div class="gravity-curve-title"><span>生命周期内重力</span><span>纵轴:强度 0~1</span></div>
|
||||
<NumSlider :label="$t('modifiers.direction')" :min="-360" :max="360" :step="1" v-model="sys.config.gravityDirection" />
|
||||
<div class="gravity-curve-title"><span>{{ $t('modifiers.gravityOverLife') }}</span><span>{{ $t('modifiers.axisStrength') }}</span></div>
|
||||
<CurveEditor v-model="sys.config.gravityCurve" :default-value="FLAT_CURVE" />
|
||||
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
|
||||
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
|
||||
<div class="gravity-curve-axis"><span>0</span><span>{{ $t('attributes.lifeTo') }}</span><span>1</span></div>
|
||||
<div class="modifier-hint">{{ $t('modifiers.directionHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.wind" />
|
||||
<span class="switch-ui"></span><span>开启风力</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableWind') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.wind" class="modifier-params gravity-params">
|
||||
<div class="modifier-mode-row">
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'fixed' }" @click="sys.config.windMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'random' }" @click="sys.config.windMode = 'random'">随机</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'fixed' }" @click="sys.config.windMode = 'fixed'">{{ $t('common.fixed') }}</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'random' }" @click="sys.config.windMode = 'random'">{{ $t('common.random') }}</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.windMode === 'fixed'" label="风力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<NumSlider v-if="sys.config.windMode === 'fixed'" :label="$t('modifiers.windStrength')" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<template v-else>
|
||||
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMax" />
|
||||
<NumSlider :label="$t('modifiers.strengthMin')" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<NumSlider :label="$t('modifiers.strengthMax')" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMax" />
|
||||
</template>
|
||||
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.windDirection" />
|
||||
<div class="gravity-curve-title"><span>生命周期内风力</span><span>纵轴:强度 0~1</span></div>
|
||||
<NumSlider :label="$t('modifiers.direction')" :min="-360" :max="360" :step="1" v-model="sys.config.windDirection" />
|
||||
<div class="gravity-curve-title"><span>{{ $t('modifiers.windOverLife') }}</span><span>{{ $t('modifiers.axisStrength') }}</span></div>
|
||||
<CurveEditor v-model="sys.config.windCurve" :default-value="FLAT_CURVE" />
|
||||
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
|
||||
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
|
||||
<div class="gravity-curve-axis"><span>0</span><span>{{ $t('attributes.lifeTo') }}</span><span>1</span></div>
|
||||
<div class="modifier-hint">{{ $t('modifiers.directionHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.forceField" />
|
||||
<span class="switch-ui"></span><span>开启力场</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableForceField') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.forceField" class="modifier-params">
|
||||
<NumSlider label="中心 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterX" />
|
||||
<NumSlider label="中心 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterY" />
|
||||
<NumSlider label="力场强度" :min="-300" :max="300" :step="1" v-model="sys.config.forceStrength" />
|
||||
<NumSlider label="力场半径" :min="1" :max="2000" :step="1" v-model="sys.config.forceRadius" />
|
||||
<label class="row"><span>衰减模式</span>
|
||||
<NumSlider :label="$t('modifiers.centerX')" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterX" />
|
||||
<NumSlider :label="$t('modifiers.centerY')" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterY" />
|
||||
<NumSlider :label="$t('modifiers.forceStrength')" :min="-300" :max="300" :step="1" v-model="sys.config.forceStrength" />
|
||||
<NumSlider :label="$t('modifiers.forceRadius')" :min="1" :max="2000" :step="1" v-model="sys.config.forceRadius" />
|
||||
<label class="row"><span>{{ $t('modifiers.falloffMode') }}</span>
|
||||
<select v-model="sys.config.forceFalloff" class="inp">
|
||||
<option value="constant">恒定(范围内力度不变)</option>
|
||||
<option value="linear">线性衰减(边缘为 0)</option>
|
||||
<option value="inverse">距离反比(中心最强)</option>
|
||||
<option value="constant">{{ $t('modifiers.constant') }}</option>
|
||||
<option value="linear">{{ $t('modifiers.linearFalloff') }}</option>
|
||||
<option value="inverse">{{ $t('modifiers.inverseFalloff') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆</div>
|
||||
<label class="switch-row"><input type="checkbox" v-model="sys.config.showForceFieldShape" /><span class="switch-ui"></span><span>显示形状</span></label>
|
||||
<div class="modifier-hint">{{ $t('modifiers.forceHint') }}</div>
|
||||
<label class="switch-row"><input type="checkbox" v-model="sys.config.showForceFieldShape" /><span class="switch-ui"></span><span>{{ $t('modifiers.showShape') }}</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.vortex" />
|
||||
<span class="switch-ui"></span><span>开启漩涡</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableVortex') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.vortex" class="modifier-params">
|
||||
<NumSlider label="中心 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.vortexCenterX" />
|
||||
<NumSlider label="中心 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.vortexCenterY" />
|
||||
<NumSlider label="力场强度" :min="-300" :max="300" :step="1" v-model="sys.config.vortexStrength" />
|
||||
<NumSlider label="力场半径" :min="1" :max="2000" :step="1" v-model="sys.config.vortexRadius" />
|
||||
<label class="row"><span>衰减模式</span>
|
||||
<NumSlider :label="$t('modifiers.centerX')" :min="-5000" :max="5000" :step="1" v-model="sys.config.vortexCenterX" />
|
||||
<NumSlider :label="$t('modifiers.centerY')" :min="-5000" :max="5000" :step="1" v-model="sys.config.vortexCenterY" />
|
||||
<NumSlider :label="$t('modifiers.forceStrength')" :min="-300" :max="300" :step="1" v-model="sys.config.vortexStrength" />
|
||||
<NumSlider :label="$t('modifiers.forceRadius')" :min="1" :max="2000" :step="1" v-model="sys.config.vortexRadius" />
|
||||
<label class="row"><span>{{ $t('modifiers.falloffMode') }}</span>
|
||||
<select v-model="sys.config.vortexFalloff" class="inp">
|
||||
<option value="constant">恒定(范围内力度不变)</option>
|
||||
<option value="linear">线性衰减(边缘为 0)</option>
|
||||
<option value="inverse">距离反比(中心最强)</option>
|
||||
<option value="constant">{{ $t('modifiers.constant') }}</option>
|
||||
<option value="linear">{{ $t('modifiers.linearFalloff') }}</option>
|
||||
<option value="inverse">{{ $t('modifiers.inverseFalloff') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">正数逆时针 · 负数顺时针</div>
|
||||
<label class="switch-row"><input type="checkbox" v-model="sys.config.showVortexShape" /><span class="switch-ui"></span><span>显示形状</span></label>
|
||||
<div class="modifier-hint">{{ $t('modifiers.vortexHint') }}</div>
|
||||
<label class="switch-row"><input type="checkbox" v-model="sys.config.showVortexShape" /><span class="switch-ui"></span><span>{{ $t('modifiers.showShape') }}</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.attraction" />
|
||||
<span class="switch-ui"></span><span>开启吸附</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableAttraction') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.attraction" class="modifier-params">
|
||||
<NumSlider label="吸附点 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterX" />
|
||||
<NumSlider label="吸附点 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterY" />
|
||||
<NumSlider label="吸附半径" :min="0" :max="2000" :step="1" v-model="sys.config.attractionRadius" />
|
||||
<NumSlider label="吸附时长" :min="0.05" :max="30" :step="0.05" v-model="sys.config.attractionDuration" />
|
||||
<NumSlider label="延迟时间" :min="0" :max="30" :step="0.05" v-model="sys.config.attractionDelay" />
|
||||
<label class="row"><span>延迟方式</span>
|
||||
<NumSlider :label="$t('modifiers.attractionX')" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterX" />
|
||||
<NumSlider :label="$t('modifiers.attractionY')" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterY" />
|
||||
<NumSlider :label="$t('modifiers.attractionRadius')" :min="0" :max="2000" :step="1" v-model="sys.config.attractionRadius" />
|
||||
<NumSlider :label="$t('modifiers.attractionDuration')" :min="0.05" :max="30" :step="0.05" v-model="sys.config.attractionDuration" />
|
||||
<NumSlider :label="$t('modifiers.delay')" :min="0" :max="30" :step="0.05" v-model="sys.config.attractionDelay" />
|
||||
<label class="row"><span>{{ $t('modifiers.delayMode') }}</span>
|
||||
<select v-model="sys.config.attractionDelayMode" class="inp">
|
||||
<option value="global">统一时间</option>
|
||||
<option value="particle">单粒子生命</option>
|
||||
<option value="global">{{ $t('modifiers.globalTime') }}</option>
|
||||
<option value="particle">{{ $t('modifiers.particleLife') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>吸附方式</span>
|
||||
<label class="row"><span>{{ $t('modifiers.attractionPath') }}</span>
|
||||
<select v-model="sys.config.attractionPath" class="inp">
|
||||
<option value="linear">直线</option>
|
||||
<option value="curve">曲线</option>
|
||||
<option value="linear">{{ $t('modifiers.straight') }}</option>
|
||||
<option value="curve">{{ $t('common.curve') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
|
||||
<label class="switch-row"><input type="checkbox" v-model="sys.config.showAttractionShape" /><span class="switch-ui"></span><span>显示形状</span></label>
|
||||
<div class="modifier-hint">{{ $t('modifiers.attractionHint') }}</div>
|
||||
<label class="switch-row"><input type="checkbox" v-model="sys.config.showAttractionShape" /><span class="switch-ui"></span><span>{{ $t('modifiers.showShape') }}</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.trail" />
|
||||
<span class="switch-ui"></span><span>开启拖尾</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableTrail') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.trail" class="modifier-params trail-params">
|
||||
<div class="trail-section-head">
|
||||
<div class="trail-section-title">拖尾独立图片</div>
|
||||
<button class="image-add" title="增加拖尾图片资源" @click="addTrailResource">+</button>
|
||||
<div class="trail-section-title">{{ $t('modifiers.trailImages') }}</div>
|
||||
<button class="image-add" :title="$t('modifiers.addTrailImage')" @click="addTrailResource">+</button>
|
||||
</div>
|
||||
<ImageResourceCard
|
||||
v-for="resource in sys.config.trailResources"
|
||||
@@ -159,149 +159,149 @@
|
||||
@toggle-lock="toggleTrailResourceLock"
|
||||
>
|
||||
<div class="resource-divider"></div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" :model-value="resource.rotation" @update:model-value="onTrailResourceRotationChange(resource.id, $event)" />
|
||||
<NumSlider label="拖尾骨骼数" :min="2" :max="16" :step="1" :model-value="resource.boneCount" @update:model-value="onTrailResourceBoneCountChange(resource.id, $event)" />
|
||||
<NumSlider label="拖尾比例 (%)" :min="0" :max="100" :step="1" v-model="resource.trailRatio" />
|
||||
<label class="row"><span>拖尾长度</span>
|
||||
<NumSlider :label="$t('modifiers.trailRotation')" :min="-360" :max="360" :step="1" :model-value="resource.rotation" @update:model-value="onTrailResourceRotationChange(resource.id, $event)" />
|
||||
<NumSlider :label="$t('modifiers.trailBones')" :min="2" :max="16" :step="1" :model-value="resource.boneCount" @update:model-value="onTrailResourceBoneCountChange(resource.id, $event)" />
|
||||
<NumSlider :label="$t('modifiers.trailRatio')" :min="0" :max="100" :step="1" v-model="resource.trailRatio" />
|
||||
<label class="row"><span>{{ $t('modifiers.trailLength') }}</span>
|
||||
<select v-model="resource.lengthMode" class="inp">
|
||||
<option value="fixed">固定</option>
|
||||
<option value="random">随机范围</option>
|
||||
<option value="fixed">{{ $t('common.fixed') }}</option>
|
||||
<option value="random">{{ $t('modifiers.randomRange') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumSlider v-if="resource.lengthMode === 'fixed'" label="长度比例 (%)" :min="0" :max="500" :step="1" v-model="resource.length" />
|
||||
<NumSlider v-if="resource.lengthMode === 'fixed'" :label="$t('modifiers.lengthRatio')" :min="0" :max="500" :step="1" v-model="resource.length" />
|
||||
<template v-else>
|
||||
<NumSlider label="长度最小 (%)" :min="0" :max="500" :step="1" v-model="resource.lengthMin" />
|
||||
<NumSlider label="长度最大 (%)" :min="0" :max="500" :step="1" v-model="resource.lengthMax" />
|
||||
<NumSlider :label="$t('modifiers.lengthMin')" :min="0" :max="500" :step="1" v-model="resource.lengthMin" />
|
||||
<NumSlider :label="$t('modifiers.lengthMax')" :min="0" :max="500" :step="1" v-model="resource.lengthMax" />
|
||||
</template>
|
||||
<label class="row"><span>拖尾宽度</span>
|
||||
<label class="row"><span>{{ $t('modifiers.trailWidth') }}</span>
|
||||
<select v-model="resource.widthSizeMode" class="inp">
|
||||
<option value="fixed">固定</option>
|
||||
<option value="random">随机范围</option>
|
||||
<option value="fixed">{{ $t('common.fixed') }}</option>
|
||||
<option value="random">{{ $t('modifiers.randomRange') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumSlider v-if="resource.widthSizeMode === 'fixed'" label="宽度比例 (%)" :min="0" :max="500" :step="1" v-model="resource.width" />
|
||||
<NumSlider v-if="resource.widthSizeMode === 'fixed'" :label="$t('modifiers.widthRatio')" :min="0" :max="500" :step="1" v-model="resource.width" />
|
||||
<template v-else>
|
||||
<NumSlider label="宽度最小 (%)" :min="0" :max="500" :step="1" v-model="resource.widthMin" />
|
||||
<NumSlider label="宽度最大 (%)" :min="0" :max="500" :step="1" v-model="resource.widthMax" />
|
||||
<NumSlider :label="$t('modifiers.widthMin')" :min="0" :max="500" :step="1" v-model="resource.widthMin" />
|
||||
<NumSlider :label="$t('modifiers.widthMax')" :min="0" :max="500" :step="1" v-model="resource.widthMax" />
|
||||
</template>
|
||||
<label class="row"><span>宽度缩放</span>
|
||||
<label class="row"><span>{{ $t('modifiers.widthScale') }}</span>
|
||||
<select v-model="resource.widthMode" class="inp">
|
||||
<option value="particle">跟随粒子</option>
|
||||
<option value="fixed">固定</option>
|
||||
<option value="particle">{{ $t('modifiers.followParticle') }}</option>
|
||||
<option value="fixed">{{ $t('common.fixed') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="switch-row trail-sub-switch">
|
||||
<input type="checkbox" v-model="resource.lifeLengthEnabled" />
|
||||
<span class="switch-ui"></span><span>生命周期长度曲线</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.lifeLengthCurve') }}</span>
|
||||
</label>
|
||||
<CurveEditor v-if="resource.lifeLengthEnabled" v-model="resource.lifeLengthCurve" :default-value="FLAT_CURVE" />
|
||||
|
||||
<label class="switch-row trail-sub-switch">
|
||||
<input type="checkbox" v-model="resource.shapeEnabled" />
|
||||
<span class="switch-ui"></span><span>形状曲线(首端 → 尾端宽度)</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.shapeCurve') }}</span>
|
||||
</label>
|
||||
<CurveEditor v-if="resource.shapeEnabled" v-model="resource.shapeCurve" :default-value="TRAIL_SHAPE_CURVE" />
|
||||
|
||||
<div class="trail-mode-head"><span>颜色模式</span>
|
||||
<div class="trail-mode-head"><span>{{ $t('modifiers.colorMode') }}</span>
|
||||
<div class="alpha-modes">
|
||||
<button class="axis-mode" :class="{ on: resource.colorMode === 'particle' }" @click="resource.colorMode = 'particle'">跟随粒子</button>
|
||||
<button class="axis-mode" :class="{ on: resource.colorMode === 'fixed' }" @click="resource.colorMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: resource.colorMode === 'curve' }" @click="resource.colorMode = 'curve'">曲线</button>
|
||||
<button class="axis-mode" :class="{ on: resource.colorMode === 'particle' }" @click="resource.colorMode = 'particle'">{{ $t('modifiers.followParticle') }}</button>
|
||||
<button class="axis-mode" :class="{ on: resource.colorMode === 'fixed' }" @click="resource.colorMode = 'fixed'">{{ $t('common.fixed') }}</button>
|
||||
<button class="axis-mode" :class="{ on: resource.colorMode === 'curve' }" @click="resource.colorMode = 'curve'">{{ $t('common.curve') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="row"><span>混合模式</span>
|
||||
<label class="row"><span>{{ $t('modifiers.blendMode') }}</span>
|
||||
<select v-model="resource.blend" class="inp">
|
||||
<option value="normal">正常</option>
|
||||
<option value="add">相加</option>
|
||||
<option value="multiply">相乘</option>
|
||||
<option value="screen">滤色</option>
|
||||
<option value="normal">{{ $t('appearance.normal') }}</option>
|
||||
<option value="add">{{ $t('appearance.add') }}</option>
|
||||
<option value="multiply">{{ $t('appearance.multiply') }}</option>
|
||||
<option value="screen">{{ $t('appearance.screen') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="resource.colorMode === 'fixed'" class="row trail-fixed-color"><span>固定颜色</span>
|
||||
<label v-if="resource.colorMode === 'fixed'" class="row trail-fixed-color"><span>{{ $t('modifiers.fixedColor') }}</span>
|
||||
<input class="colr" type="color" v-model="resource.color" />
|
||||
<input class="hex-input" :value="resource.color" @change="setTrailColor(resource, $event)" @keydown.enter.prevent="commitTrailColor(resource, $event)" />
|
||||
</label>
|
||||
<ColorGradientEditor v-else-if="resource.colorMode === 'curve'" v-model="resource.colorGradient" />
|
||||
|
||||
<div class="trail-mode-head"><span>透明度模式</span>
|
||||
<div class="trail-mode-head"><span>{{ $t('modifiers.alphaMode') }}</span>
|
||||
<div class="alpha-modes">
|
||||
<button class="axis-mode" :class="{ on: resource.alphaMode === 'particle' }" @click="resource.alphaMode = 'particle'">跟随粒子</button>
|
||||
<button class="axis-mode" :class="{ on: resource.alphaMode === 'fixed' }" @click="resource.alphaMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: resource.alphaMode === 'curve' }" @click="resource.alphaMode = 'curve'">曲线</button>
|
||||
<button class="axis-mode" :class="{ on: resource.alphaMode === 'particle' }" @click="resource.alphaMode = 'particle'">{{ $t('modifiers.followParticle') }}</button>
|
||||
<button class="axis-mode" :class="{ on: resource.alphaMode === 'fixed' }" @click="resource.alphaMode = 'fixed'">{{ $t('common.fixed') }}</button>
|
||||
<button class="axis-mode" :class="{ on: resource.alphaMode === 'curve' }" @click="resource.alphaMode = 'curve'">{{ $t('common.curve') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<NumSlider v-if="resource.alphaMode === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
|
||||
<NumSlider v-if="resource.alphaMode === 'fixed'" :label="$t('modifiers.opacity')" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
|
||||
<CurveEditor v-if="resource.alphaMode === 'curve'" v-model="resource.alphaCurve" :default-value="TRAIL_ALPHA_CURVE" />
|
||||
</ImageResourceCard>
|
||||
<input ref="trailTextureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp" @change="onTrailTextureFile" />
|
||||
|
||||
<div class="trail-grid-row">
|
||||
<NumSlider label="网格行数" :min="2" :max="16" :step="1" :model-value="sys.config.trailGridRows" @update:model-value="onTrailRigChange('trailGridRows', $event)" />
|
||||
<NumSlider label="网格列数" :min="2" :max="32" :step="1" :model-value="sys.config.trailGridCols" @update:model-value="onTrailRigChange('trailGridCols', $event)" />
|
||||
<NumSlider :label="$t('modifiers.gridRows')" :min="2" :max="16" :step="1" :model-value="sys.config.trailGridRows" @update:model-value="onTrailRigChange('trailGridRows', $event)" />
|
||||
<NumSlider :label="$t('modifiers.gridCols')" :min="2" :max="32" :step="1" :model-value="sys.config.trailGridCols" @update:model-value="onTrailRigChange('trailGridCols', $event)" />
|
||||
</div>
|
||||
<div class="modifier-hint">每个粒子按资源比例生成一组同级拖尾骨骼;随机长度与宽度在粒子出生时确定,相同种子可稳定复现</div>
|
||||
<div class="modifier-hint">{{ $t('modifiers.trailHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.emitterFollow" :disabled="sys.config.streamBehavior === 'loop'" />
|
||||
<span class="switch-ui"></span><span>开启发射器跟随</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableFollow') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.emitterFollow && sys.config.streamBehavior !== 'loop'" class="modifier-params emitter-follow-params">
|
||||
<label class="field-block"><span>跟随模式</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
|
||||
<option value="none">无</option>
|
||||
<option value="path">跟随路径</option>
|
||||
<option v-if="selectedSpineBone || sys.config.emitterFollowMode === 'bone'" value="bone">选中的骨骼</option>
|
||||
<label class="field-block"><span>{{ $t('modifiers.followMode') }}</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
|
||||
<option value="none">{{ $t('modifiers.noFollow') }}</option>
|
||||
<option value="path">{{ $t('modifiers.followPath') }}</option>
|
||||
<option v-if="selectedSpineBone || sys.config.emitterFollowMode === 'bone'" value="bone">{{ $t('modifiers.selectedBone') }}</option>
|
||||
</select></label>
|
||||
<template v-if="sys.config.emitterFollowMode === 'path'">
|
||||
<label class="field-block"><span>跟随路径</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
|
||||
<option :value="0">无</option>
|
||||
<label class="field-block"><span>{{ $t('modifiers.followPath') }}</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
|
||||
<option :value="0">{{ $t('modifiers.noFollow') }}</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
|
||||
</select></label>
|
||||
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
|
||||
<option value="fixed">固定角度</option>
|
||||
<option value="direction">跟随移动方向</option>
|
||||
<label class="field-block"><span>{{ $t('modifiers.emitterAngleMode') }}</span><select v-model="sys.config.emitterAngleMode" class="inp">
|
||||
<option value="fixed">{{ $t('modifiers.fixedAngle') }}</option>
|
||||
<option value="direction">{{ $t('modifiers.followMoveDirection') }}</option>
|
||||
</select></label>
|
||||
<div class="gravity-curve-title"><span>移动进度曲线 (0→1)</span></div>
|
||||
<div class="gravity-curve-title"><span>{{ $t('modifiers.moveProgressCurve') }}</span></div>
|
||||
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
|
||||
<div class="field-grid follow-grid">
|
||||
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">局部空间(GEN 移动)</option>
|
||||
<option value="world">世界空间(粒子生成点移动)</option>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.moveDuration') }}</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.followSpace') }}</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">{{ $t('modifiers.localSpace') }}</option>
|
||||
<option value="world">{{ $t('modifiers.worldSpace') }}</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
|
||||
<option value="forward">正向(起点 → 终点)</option>
|
||||
<option value="reverse">反向(终点 → 起点)</option>
|
||||
<label class="field-block"><span>{{ $t('modifiers.moveDirection') }}</span><select v-model="sys.config.emitterFollowDirection" class="inp">
|
||||
<option value="forward">{{ $t('modifiers.forward') }}</option>
|
||||
<option value="reverse">{{ $t('modifiers.reverse') }}</option>
|
||||
</select></label>
|
||||
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
|
||||
<div class="modifier-hint">局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。</div>
|
||||
<NumSlider :label="$t('modifiers.followOffset')" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
|
||||
<div class="modifier-hint">{{ $t('modifiers.followSpaceHint') }}</div>
|
||||
</template>
|
||||
<template v-else-if="sys.config.emitterFollowMode === 'bone'">
|
||||
<div v-if="boundSpineBone" class="bone-follow-target">跟随目标:<strong>{{ boundSpineBone.spine.name }} / {{ boundSpineBone.boneName }}</strong></div>
|
||||
<div v-else class="bone-follow-missing">尚未绑定有效骨骼,请先在 Spine 层级树中选择骨骼。</div>
|
||||
<div v-if="boundSpineBone" class="bone-follow-target">{{ $t('modifiers.followTarget') }}<strong>{{ boundSpineBone.spine.name }} / {{ boundSpineBone.boneName }}</strong></div>
|
||||
<div v-else class="bone-follow-missing">{{ $t('modifiers.noValidBone') }}</div>
|
||||
<button
|
||||
v-if="selectedSpineBone && (!boundSpineBone || selectedSpineBone.spine.id !== boundSpineBone.spine.id || selectedSpineBone.boneName !== boundSpineBone.boneName)"
|
||||
class="bone-follow-rebind"
|
||||
@click="bindSelectedBone(sys.config)"
|
||||
>{{ boundSpineBone ? '重新绑定' : '绑定' }}当前选中骨骼:{{ selectedSpineBone.spine.name }} / {{ selectedSpineBone.boneName }}</button>
|
||||
>{{ boundSpineBone ? $t('modifiers.rebind') : $t('modifiers.bind') }}{{ $t('modifiers.selectedBoneLabel') }}{{ selectedSpineBone.spine.name }} / {{ selectedSpineBone.boneName }}</button>
|
||||
<template v-if="boundSpineBone">
|
||||
<label class="field-block"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">本地空间(随骨骼移动)</option>
|
||||
<option value="world">世界空间(粒子生成后保持位置)</option>
|
||||
<label class="field-block"><span>{{ $t('modifiers.followSpace') }}</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">{{ $t('modifiers.boneLocalSpace') }}</option>
|
||||
<option value="world">{{ $t('modifiers.boneWorldSpace') }}</option>
|
||||
</select></label>
|
||||
<div class="field-grid follow-grid">
|
||||
<label class="compact-field"><span>位移偏移 X</span><input v-model.number="sys.config.emitterFollowBoneOffsetX" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>位移偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneOffsetY" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.offsetX') }}</span><input v-model.number="sys.config.emitterFollowBoneOffsetX" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.offsetY') }}</span><input v-model.number="sys.config.emitterFollowBoneOffsetY" type="number" step="1" class="inp" /></label>
|
||||
</div>
|
||||
<div class="bone-follow-transform-grid">
|
||||
<label class="compact-field"><span>旋转偏移</span><input v-model.number="sys.config.emitterFollowBoneRotation" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放偏移 X</span><input v-model.number="sys.config.emitterFollowBoneScaleX" type="number" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放偏移 Y</span><input v-model.number="sys.config.emitterFollowBoneScaleY" type="number" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.rotationOffset') }}</span><input v-model.number="sys.config.emitterFollowBoneRotation" type="number" step="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.scaleOffsetX') }}</span><input v-model.number="sys.config.emitterFollowBoneScaleX" type="number" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('modifiers.scaleOffsetY') }}</span><input v-model.number="sys.config.emitterFollowBoneScaleY" type="number" step="0.05" class="inp" /></label>
|
||||
</div>
|
||||
<div class="modifier-hint">发射器只跟随所选 Spine 骨骼的位置,不继承 Spine 的旋转或缩放;粒子变换使用粒子系统自身参数和上方偏移。</div>
|
||||
<div class="modifier-hint">{{ $t('modifiers.boneFollowHint') }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
@@ -309,31 +309,31 @@
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.collision" />
|
||||
<span class="switch-ui"></span><span>开启碰撞</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableCollision') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.collision" class="modifier-params collision-filter-params">
|
||||
<div class="collision-filter-title">参与的碰撞对象</div>
|
||||
<div class="collision-filter-title">{{ $t('modifiers.collisionObjects') }}</div>
|
||||
<div class="collision-filter-tags">
|
||||
<span
|
||||
v-for="collider in selectedCollisionBodies"
|
||||
:key="collider.id"
|
||||
class="collision-filter-tag"
|
||||
:class="{ disabled: !collider.enabled }"
|
||||
:title="collider.enabled ? collider.name : `${collider.name}(碰撞体已禁用)`"
|
||||
:title="collider.enabled ? collider.name : $t('modifiers.colliderDisabled', { name: collider.name })"
|
||||
>
|
||||
<span>{{ collider.name }}</span>
|
||||
<button type="button" :aria-label="`清除碰撞对象 ${collider.name}`" @click="removeCollisionBody(collider.id)">×</button>
|
||||
<button type="button" :aria-label="$t('modifiers.clearCollider', { name: collider.name })" @click="removeCollisionBody(collider.id)">×</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="collision-filter-add"
|
||||
:class="{ active: collisionPickerOpen }"
|
||||
:disabled="availableCollisionBodies.length === 0"
|
||||
:title="availableCollisionBodies.length ? '加入场景碰撞对象' : '没有可加入的碰撞对象'"
|
||||
:title="availableCollisionBodies.length ? $t('modifiers.addCollider') : $t('modifiers.noAvailableColliders')"
|
||||
@click="collisionPickerOpen = !collisionPickerOpen"
|
||||
>+</button>
|
||||
</div>
|
||||
<div v-if="selectedCollisionBodies.length === 0" class="collision-filter-empty">未选择碰撞对象,当前粒子不会发生碰撞</div>
|
||||
<div v-if="selectedCollisionBodies.length === 0" class="collision-filter-empty">{{ $t('modifiers.noCollidersSelected') }}</div>
|
||||
<div v-if="collisionPickerOpen && availableCollisionBodies.length" class="collision-filter-picker">
|
||||
<button
|
||||
v-for="collider in availableCollisionBodies"
|
||||
@@ -342,56 +342,56 @@
|
||||
@click="addCollisionBody(collider.id)"
|
||||
>
|
||||
<span>{{ collider.name }}</span>
|
||||
<small>{{ collider.enabled ? '可用' : '已禁用' }}</small>
|
||||
<small>{{ collider.enabled ? $t('modifiers.available') : $t('modifiers.disabled') }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modifier-hint">只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞</div>
|
||||
<div class="modifier-hint">{{ $t('modifiers.collisionHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item events-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input v-model="sys.config.eventEnabled" type="checkbox" />
|
||||
<span class="switch-ui"></span><span>开启子发射器</span>
|
||||
<span class="switch-ui"></span><span>{{ $t('modifiers.enableSubEmitter') }}</span>
|
||||
</label>
|
||||
<div v-if="sys.config.eventEnabled" class="event-rules">
|
||||
<div v-for="(rule, index) in sys.config.events" :key="rule.id" class="event-card">
|
||||
<div class="event-selects">
|
||||
<select v-model="rule.event" class="inp event-select" title="选择触发时机">
|
||||
<option value="birth">出生</option>
|
||||
<option value="death">死亡</option>
|
||||
<option value="collision">碰撞</option>
|
||||
<select v-model="rule.event" class="inp event-select" :title="$t('modifiers.triggerTiming')">
|
||||
<option value="birth">{{ $t('modifiers.birth') }}</option>
|
||||
<option value="death">{{ $t('modifiers.death') }}</option>
|
||||
<option value="collision">{{ $t('modifiers.collision') }}</option>
|
||||
</select>
|
||||
<select v-model.number="rule.targetSystemId" class="inp event-target" title="选择目标粒子系统">
|
||||
<option :value="0">无(粒子系统)</option>
|
||||
<select v-model.number="rule.targetSystemId" class="inp event-target" :title="$t('modifiers.chooseTarget')">
|
||||
<option :value="0">{{ $t('modifiers.noTarget') }}</option>
|
||||
<option v-for="system in eventTargetSystems" :key="system.id" :value="system.id">
|
||||
{{ system.config.name || `ParticleSystem${system.id}` }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="event-remove" title="删除该子发射器" @click="removeEventRule(index)">✕</button>
|
||||
<button type="button" class="event-remove" :title="$t('modifiers.deleteSubEmitter')" @click="removeEventRule(index)">✕</button>
|
||||
</div>
|
||||
<label class="event-probability">
|
||||
<span>发射概率</span>
|
||||
<span>{{ $t('modifiers.probability') }}</span>
|
||||
<input v-model.number="rule.probability" type="number" min="0" max="1" step="0.01" class="inp" />
|
||||
</label>
|
||||
<label v-if="rule.event === 'collision'" class="event-probability event-collision-limit">
|
||||
<span>碰撞次数限制</span>
|
||||
<span>{{ $t('modifiers.collisionLimit') }}</span>
|
||||
<input :value="rule.collisionLimit ?? 1" type="number" min="1" max="4" step="1" class="inp" @input="setCollisionLimit(rule, $event)" />
|
||||
</label>
|
||||
<div class="event-probability event-inherit">
|
||||
<span>继承</span>
|
||||
<span>{{ $t('modifiers.inherit') }}</span>
|
||||
<button type="button" class="inherit-trigger" @pointerdown.stop @click.stop="toggleInheritMenu(rule, $event)">
|
||||
<span class="inherit-summary">{{ inheritSummary(rule) }}</span><i>⌄</i>
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="hasInherit(rule, 'color')" class="event-probability event-color-mode">
|
||||
<span>颜色继承方式</span>
|
||||
<span>{{ $t('modifiers.colorInheritMode') }}</span>
|
||||
<select v-model="rule.colorInheritMode" class="inp">
|
||||
<option value="add">相加</option>
|
||||
<option value="replace">覆盖</option>
|
||||
<option value="add">{{ $t('modifiers.add') }}</option>
|
||||
<option value="replace">{{ $t('modifiers.replace') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="event-add" @click="addEventRule">+ 新增子发射器</button>
|
||||
<button type="button" class="event-add" @click="addEventRule">{{ $t('modifiers.addSubEmitter') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,10 +404,10 @@
|
||||
@pointerdown.stop
|
||||
>
|
||||
<button type="button" class="inherit-option" @click="selectInheritNone(activeInheritRule)">
|
||||
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 0 ? '✓' : '' }}</span>无(默认值)
|
||||
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 0 ? '✓' : '' }}</span>{{ $t('modifiers.inheritNone') }}
|
||||
</button>
|
||||
<button type="button" class="inherit-option" @click="selectInheritAll(activeInheritRule)">
|
||||
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 4 ? '✓' : '' }}</span>所有属性
|
||||
<span class="inherit-check">{{ inheritList(activeInheritRule).length === 4 ? '✓' : '' }}</span>{{ $t('modifiers.inheritAll') }}
|
||||
</button>
|
||||
<div class="inherit-divider"></div>
|
||||
<button
|
||||
@@ -425,6 +425,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import NumSlider from '../NumSlider.vue'
|
||||
import CurveEditor from '../CurveEditor.vue'
|
||||
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
|
||||
@@ -434,6 +435,7 @@ import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
|
||||
import { useParticlePanelContext } from './particlePanelContext'
|
||||
import { collectEmitterAncestors } from '../../core/subEmitterIds'
|
||||
const { store, sys, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, onEmitterFollowModeChange, bindSelectedBone, resetModule, select, LINEAR_CURVE, FLAT_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE } = useParticlePanelContext()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 不能指向自身或任何上游父系统,避免循环依赖。
|
||||
const eventTargetSystems = computed(() => {
|
||||
@@ -444,12 +446,12 @@ const eventTargetSystems = computed(() => {
|
||||
})
|
||||
|
||||
type InheritRule = { id: number; inherit: string[] }
|
||||
const INHERIT_ATTRIBUTES = [
|
||||
{ key: 'color', label: '颜色' },
|
||||
{ key: 'scale', label: '大小' },
|
||||
{ key: 'rotation', label: '旋转' },
|
||||
{ key: 'life', label: '生命周期' },
|
||||
] as const
|
||||
const INHERIT_ATTRIBUTES = computed(() => [
|
||||
{ key: 'color', label: t('modifiers.inheritColor') },
|
||||
{ key: 'scale', label: t('modifiers.inheritScale') },
|
||||
{ key: 'rotation', label: t('modifiers.inheritRotation') },
|
||||
{ key: 'life', label: t('modifiers.inheritLife') },
|
||||
] as const)
|
||||
|
||||
const inheritMenu = ref<{ ruleId: number; left: number; top: number; width: number } | null>(null)
|
||||
const activeInheritRule = computed<InheritRule | null>(() => {
|
||||
@@ -466,9 +468,9 @@ function inheritList(rule: InheritRule) {
|
||||
|
||||
function inheritSummary(rule: InheritRule) {
|
||||
const list = inheritList(rule)
|
||||
if (!list.length) return '无(默认值)'
|
||||
if (list.length === INHERIT_ATTRIBUTES.length) return '所有属性'
|
||||
return INHERIT_ATTRIBUTES.filter((attribute) => list.includes(attribute.key)).map((attribute) => attribute.label).join('、')
|
||||
if (!list.length) return t('modifiers.inheritNone')
|
||||
if (list.length === INHERIT_ATTRIBUTES.value.length) return t('modifiers.inheritAll')
|
||||
return INHERIT_ATTRIBUTES.value.filter((attribute) => list.includes(attribute.key)).map((attribute) => attribute.label).join('、')
|
||||
}
|
||||
|
||||
function hasInherit(rule: InheritRule, key: string) {
|
||||
@@ -487,7 +489,7 @@ function selectInheritNone(rule: InheritRule) {
|
||||
}
|
||||
|
||||
function selectInheritAll(rule: InheritRule) {
|
||||
inheritList(rule).splice(0, inheritList(rule).length, ...INHERIT_ATTRIBUTES.map((attribute) => attribute.key))
|
||||
inheritList(rule).splice(0, inheritList(rule).length, ...INHERIT_ATTRIBUTES.value.map((attribute) => attribute.key))
|
||||
}
|
||||
|
||||
function toggleInheritMenu(rule: InheritRule, event: MouseEvent) {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="group config-management">
|
||||
<div class="group-head" @click="toggle('config')">
|
||||
<span class="caret">{{ collapsed.config ? '▸' : '▾' }}</span><span>配置管理</span>
|
||||
<span class="caret">{{ collapsed.config ? '▸' : '▾' }}</span><span>{{ $t('config.title') }}</span>
|
||||
</div>
|
||||
<div class="group-body config-management-body" v-show="!collapsed.config">
|
||||
<div class="config-toolbar">
|
||||
<button title="保存全部编辑器配置 (Ctrl+S)" @click="saveConfig">保存配置</button>
|
||||
<button title="加载本地编辑器配置" @click="configFileInput?.click()">加载配置</button>
|
||||
<select v-model="selectedPreset" aria-label="预设列表" :disabled="!presetOptions.length || presetLoading" @pointerdown="refreshPresets" @focus="refreshPresets" @change="loadSelectedPreset">
|
||||
<option value="">{{ presetOptions.length ? '选择预设' : '无预设文件' }}</option>
|
||||
<option v-for="preset in presetOptions" :key="preset.file" :value="preset.file">{{ preset.name }}</option>
|
||||
<button :title="$t('config.saveTitle')" @click="saveConfig">{{ $t('config.save') }}</button>
|
||||
<button :title="$t('config.loadTitle')" @click="configFileInput?.click()">{{ $t('config.load') }}</button>
|
||||
<select v-model="selectedPreset" :aria-label="$t('config.presets')" :disabled="!presetOptions.length || presetLoading" @pointerdown="refreshPresets" @focus="refreshPresets" @change="loadSelectedPreset">
|
||||
<option value="">{{ presetOptions.length ? $t('config.selectPreset') : $t('config.noPresets') }}</option>
|
||||
<option v-for="preset in presetOptions" :key="preset.file" :value="preset.file">{{ presetDisplayName(preset) }}</option>
|
||||
</select>
|
||||
<input ref="configFileInput" hidden type="file" accept=".json,application/json" @change="onConfigFileChange" />
|
||||
</div>
|
||||
@@ -19,6 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import NumSlider from '../NumSlider.vue'
|
||||
import CurveEditor from '../CurveEditor.vue'
|
||||
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
|
||||
@@ -27,6 +28,19 @@ import ImageResourceCard from '../ImageResourceCard.vue'
|
||||
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
|
||||
import { useParticlePanelContext } from './particlePanelContext'
|
||||
const { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, collapsed, toggle, select } = useParticlePanelContext()
|
||||
const { t } = useI18n()
|
||||
const presetNameKeys: Record<string, string> = {
|
||||
'1.粒子扫过.json': 'presets.particleSweep',
|
||||
'2.长条方块.json': 'presets.barBlocks',
|
||||
'3.力场拖尾.json': 'presets.forceTrail',
|
||||
'4.雷电序列帧.json': 'presets.lightningSequence',
|
||||
'5.贴图轴点.json': 'presets.texturePivot',
|
||||
'6.子发射器.json': 'presets.subEmitter',
|
||||
}
|
||||
function presetDisplayName(preset: { name: string; file: string }) {
|
||||
const key = presetNameKeys[preset.file]
|
||||
return key ? t(key) : preset.name
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<!-- 1. 场景对象:创建入口始终可用,即使当前没有粒子系统。 -->
|
||||
<div class="group">
|
||||
<div class="group-head scene-head" @click="toggle('scene')">
|
||||
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
|
||||
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>{{ $t('scene.title') }}</span>
|
||||
<div class="scene-actions">
|
||||
<button class="scene-add" title="添加粒子系统" @click.stop="addSys">+粒子</button>
|
||||
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
|
||||
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
|
||||
<button class="scene-add" title="添加 Spine" @click.stop="addSpine">+Spine</button>
|
||||
<button class="scene-add" :disabled="!canCopyObject" title="复制当前选中的场景对象" @click.stop="copySelectedObject">+Copy</button>
|
||||
<button class="scene-add" :title="$t('scene.addParticle')" @click.stop="addSys">{{ $t('scene.addParticleShort') }}</button>
|
||||
<button class="scene-add" :title="$t('scene.addCollider')" @click.stop="addCollider">{{ $t('scene.addColliderShort') }}</button>
|
||||
<button class="scene-add" :title="$t('scene.addPath')" @click.stop="addPath">{{ $t('scene.addPathShort') }}</button>
|
||||
<button class="scene-add" :title="$t('scene.addSpine')" @click.stop="addSpine">+Spine</button>
|
||||
<button class="scene-add" :disabled="!canCopyObject" :title="$t('scene.copyTitle')" @click.stop="copySelectedObject">+Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.scene">
|
||||
@@ -24,11 +24,11 @@
|
||||
<span v-if="entry.depth > 0" class="sub-branch">└</span>
|
||||
<span v-else class="tdot">●</span>
|
||||
<span class="tlabel">{{ entry.system.config.name || 'ParticleSystem' }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: entry.system.visible === false }" :title="entry.system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" :aria-label="entry.system.visible === false ? '显示粒子系统' : '隐藏粒子系统'" @click.stop="entry.system.visible = entry.system.visible === false">
|
||||
<button class="scene-object-eye" :class="{ off: entry.system.visible === false }" :title="entry.system.visible === false ? $t('scene.showParticle') : $t('scene.hideParticle')" :aria-label="entry.system.visible === false ? $t('scene.showParticle') : $t('scene.hideParticle')" @click.stop="entry.system.visible = entry.system.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="entry.system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind" :class="{ 'sub-emitter-kind': entry.depth > 0 }">{{ entry.depth > 0 ? '子发射器' : '粒子' }}</span>
|
||||
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(entry.system.id)">✕</button>
|
||||
<span class="scene-object-kind" :class="{ 'sub-emitter-kind': entry.depth > 0 }">{{ entry.depth > 0 ? $t('scene.subEmitter') : $t('scene.particle') }}</span>
|
||||
<button class="scene-object-delete" :title="$t('scene.deleteParticle')" @click.stop="store.removeSystem(entry.system.id)">✕</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="collider in store.colliders"
|
||||
@@ -39,11 +39,11 @@
|
||||
>
|
||||
<span class="tdot collision-dot">■</span>
|
||||
<span class="tlabel">{{ collider.name }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: collider.visible === false }" :title="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" :aria-label="collider.visible === false ? '显示碰撞体' : '隐藏碰撞体'" @click.stop="collider.visible = collider.visible === false">
|
||||
<button class="scene-object-eye" :class="{ off: collider.visible === false }" :title="collider.visible === false ? $t('scene.showCollider') : $t('scene.hideCollider')" :aria-label="collider.visible === false ? $t('scene.showCollider') : $t('scene.hideCollider')" @click.stop="collider.visible = collider.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="collider.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">碰撞</span>
|
||||
<button class="scene-object-delete" title="删除碰撞体" @click.stop="store.removeCollider(collider.id)">✕</button>
|
||||
<span class="scene-object-kind">{{ $t('scene.collision') }}</span>
|
||||
<button class="scene-object-delete" :title="$t('scene.deleteCollider')" @click.stop="store.removeCollider(collider.id)">✕</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="path in store.paths"
|
||||
@@ -54,11 +54,11 @@
|
||||
>
|
||||
<span class="tdot path-dot">◇</span>
|
||||
<span class="tlabel">{{ path.name }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: path.visible === false }" :title="path.visible === false ? '显示路径' : '隐藏路径'" :aria-label="path.visible === false ? '显示路径' : '隐藏路径'" @click.stop="path.visible = path.visible === false">
|
||||
<button class="scene-object-eye" :class="{ off: path.visible === false }" :title="path.visible === false ? $t('scene.showPath') : $t('scene.hidePath')" :aria-label="path.visible === false ? $t('scene.showPath') : $t('scene.hidePath')" @click.stop="path.visible = path.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="path.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">路径</span>
|
||||
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
|
||||
<span class="scene-object-kind">{{ $t('scene.path') }}</span>
|
||||
<button class="scene-object-delete" :title="$t('scene.deletePath')" @click.stop="store.removePath(path.id)">✕</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="spine in store.spines"
|
||||
@@ -69,80 +69,80 @@
|
||||
>
|
||||
<span class="tdot spine-dot">◆</span>
|
||||
<span class="tlabel">{{ spine.name }}</span>
|
||||
<button class="scene-object-eye" :class="{ off: spine.visible === false }" :title="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" :aria-label="spine.visible === false ? '显示 Spine' : '隐藏 Spine'" @click.stop="spine.visible = spine.visible === false">
|
||||
<button class="scene-object-eye" :class="{ off: spine.visible === false }" :title="spine.visible === false ? $t('scene.showSpine') : $t('scene.hideSpine')" :aria-label="spine.visible === false ? $t('scene.showSpine') : $t('scene.hideSpine')" @click.stop="spine.visible = spine.visible === false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="spine.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||||
</button>
|
||||
<span class="scene-object-kind">Spine</span>
|
||||
<button class="scene-object-delete" title="删除 Spine" @click.stop="store.removeSpine(spine.id)">✕</button>
|
||||
<button class="scene-object-delete" :title="$t('scene.deleteSpine')" @click.stop="store.removeSpine(spine.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">场景中暂无对象</div>
|
||||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">{{ $t('scene.empty') }}</div>
|
||||
</div>
|
||||
<template v-if="activeObjectType === 'particle' && sys">
|
||||
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
|
||||
<NumSlider label="位置X" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
|
||||
<NumSlider label="位置Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
|
||||
<NumSlider label="偏移X" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetX" />
|
||||
<NumSlider label="偏移Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetY" />
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
|
||||
<NumSlider label="缩放X" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
|
||||
<NumSlider label="缩放Y" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
|
||||
<label class="row"><span>{{ $t('scene.name') }}</span><input v-model="sys.config.name" class="inp" /></label>
|
||||
<NumSlider :label="$t('scene.positionX')" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
|
||||
<NumSlider :label="$t('scene.positionY')" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
|
||||
<NumSlider :label="$t('scene.offsetX')" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetX" />
|
||||
<NumSlider :label="$t('scene.offsetY')" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetY" />
|
||||
<NumSlider :label="$t('scene.rotation')" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
|
||||
<NumSlider :label="$t('scene.scaleX')" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
|
||||
<NumSlider :label="$t('scene.scaleY')" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
|
||||
<div class="seed-row scene-seed">
|
||||
<label for="system-seed">随机种子</label>
|
||||
<input id="system-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" title="控制粒子系统内全部随机属性、资源选择与修改器随机采样" />
|
||||
<button class="seed-random" title="生成新的全局随机种子" @click="randomizeSeed">⚄</button>
|
||||
<label for="system-seed">{{ $t('scene.seed') }}</label>
|
||||
<input id="system-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" :title="$t('scene.seedTitle')" />
|
||||
<button class="seed-random" :title="$t('scene.randomizeSeed')" @click="randomizeSeed">⚄</button>
|
||||
</div>
|
||||
<div class="seed-hint">控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果</div>
|
||||
<div class="seed-hint">{{ $t('scene.seedHint') }}</div>
|
||||
</template>
|
||||
<template v-else-if="activeCollider">
|
||||
<div class="object-editor-title">碰撞体属性</div>
|
||||
<label class="field-block"><span>碰撞体名称</span><input v-model="activeCollider.name" class="inp" /></label>
|
||||
<div class="object-editor-title">{{ $t('scene.colliderTitle') }}</div>
|
||||
<label class="field-block"><span>{{ $t('scene.colliderName') }}</span><input v-model="activeCollider.name" class="inp" /></label>
|
||||
<div class="field-grid">
|
||||
<label class="field-block"><span>类型</span><select v-model="activeCollider.shape" class="inp">
|
||||
<option value="circle">圆形</option><option value="ellipse">椭圆</option><option value="rect">矩形</option><option value="polygon">多边形</option><option value="custom">自定义</option>
|
||||
<label class="field-block"><span>{{ $t('scene.type') }}</span><select v-model="activeCollider.shape" class="inp">
|
||||
<option value="circle">{{ $t('scene.circle') }}</option><option value="ellipse">{{ $t('scene.ellipse') }}</option><option value="rect">{{ $t('scene.rect') }}</option><option value="polygon">{{ $t('scene.polygon') }}</option><option value="custom">{{ $t('scene.custom') }}</option>
|
||||
</select></label>
|
||||
<label class="field-block check-field"><span>启用</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
|
||||
<label class="field-block check-field"><span>{{ $t('scene.enabled') }}</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>X 位置</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.xPosition') }}</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.yPosition') }}</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||||
<NumSlider :label="$t('scene.rotation')" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>缩放 X</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.scaleXShort') }}</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.scaleYShort') }}</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
|
||||
<NumSlider v-if="activeCollider.shape === 'circle'" :label="$t('scene.radius')" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
|
||||
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
|
||||
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>Y 半径</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.radiusX') }}</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.radiusY') }}</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
|
||||
</div>
|
||||
<div v-else class="field-grid">
|
||||
<label class="compact-field"><span>宽度</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>高度</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.width') }}</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.height') }}</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" label="边数" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
|
||||
<label class="field-block"><span>碰撞响应</span><select v-model="activeCollider.response" class="inp">
|
||||
<option value="physics">物理(弹跳/摩擦)</option><option value="pause">暂停(停止不动)</option><option value="destroy">消失(立即销毁)</option>
|
||||
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" :label="$t('scene.sides')" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
|
||||
<label class="field-block"><span>{{ $t('scene.collisionResponse') }}</span><select v-model="activeCollider.response" class="inp">
|
||||
<option value="physics">{{ $t('scene.physics') }}</option><option value="pause">{{ $t('scene.pause') }}</option><option value="destroy">{{ $t('scene.destroy') }}</option>
|
||||
</select></label>
|
||||
<div v-if="activeCollider.response === 'physics'" class="field-grid">
|
||||
<label class="compact-field"><span>弹性 (0-1)</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||||
<label class="compact-field"><span>摩擦 (0-1)</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.elasticity') }}</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.friction') }}</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||||
</div>
|
||||
<div class="collider-follow-section">
|
||||
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随路径移动</label>
|
||||
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />{{ $t('scene.followPath') }}</label>
|
||||
<template v-if="activeCollider.followPath">
|
||||
<label class="field-block"><span>目标路径</span><select v-model.number="activeCollider.followPathId" class="inp">
|
||||
<option :value="0">(未选择)</option>
|
||||
<label class="field-block"><span>{{ $t('scene.targetPath') }}</span><select v-model.number="activeCollider.followPathId" class="inp">
|
||||
<option :value="0">{{ $t('scene.notSelected') }}</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
|
||||
</select></label>
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>移动时长(秒)</span><input v-model.number="activeCollider.followDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>曲线模式</span><select v-model="activeCollider.followCurveMode" class="inp">
|
||||
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
|
||||
<label class="compact-field"><span>{{ $t('scene.moveDuration') }}</span><input v-model.number="activeCollider.followDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('appearance.curveMode') }}</span><select v-model="activeCollider.followCurveMode" class="inp">
|
||||
<option value="跟随导出">{{ $t('common.followExport') }}</option><option value="线性">{{ $t('common.linear') }}</option><option value="贝塞尔">{{ $t('common.bezier') }}</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label class="follow-path"><input v-model="activeCollider.followPathRotation" type="checkbox" />方向沿路径</label>
|
||||
<div class="collider-curve-title">移动进度曲线 <small>横轴:实时/移动时长 纵轴:路径进度(0=起点 → 1=终点)</small></div>
|
||||
<label class="follow-path"><input v-model="activeCollider.followPathRotation" type="checkbox" />{{ $t('scene.followDirection') }}</label>
|
||||
<div class="collider-curve-title">{{ $t('scene.moveProgressCurve') }} <small>{{ $t('scene.moveProgressHelp') }}</small></div>
|
||||
<CurveEditor
|
||||
:model-value="activeCollider.followCurve || LINEAR_CURVE"
|
||||
:default-value="LINEAR_CURVE"
|
||||
@@ -153,32 +153,32 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activePath">
|
||||
<div class="object-editor-title">路径属性</div>
|
||||
<label class="field-block"><span>路径名称</span><input v-model="activePath.name" class="inp" /></label>
|
||||
<div class="object-editor-title">{{ $t('scene.pathTitle') }}</div>
|
||||
<label class="field-block"><span>{{ $t('scene.pathName') }}</span><input v-model="activePath.name" class="inp" /></label>
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>X 位置</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>Y 位置</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.xPosition') }}</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.yPosition') }}</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
|
||||
<NumSlider :label="$t('scene.rotation')" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>缩放 X</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.scaleXShort') }}</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>{{ $t('scene.scaleYShort') }}</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
</div>
|
||||
<label class="setting-line"><span>闭合路径</span><input v-model="activePath.closed" type="checkbox" /></label>
|
||||
<label class="setting-line"><span>路径颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
|
||||
<div class="path-help"><span>操作说明:</span><ul>
|
||||
<li>双击空白处/曲线:新建锚点</li><li>双击锚点:删除节点</li><li>双击第二下按住拖动:新建并拉出手柄</li>
|
||||
<li>拖动锚点移动位置,拖动手柄调整曲线</li><li>Shift+拖动手柄:两侧完全对称</li><li>Alt+拖动手柄:独立控制单侧</li>
|
||||
<li>Ctrl+点击/框选:加选节点</li><li>Alt+点击/框选:减选节点</li><li>Alt+单击空白处:清除全部选中</li><li>选中多个节点后可整体拖动</li>
|
||||
<label class="setting-line"><span>{{ $t('scene.closedPath') }}</span><input v-model="activePath.closed" type="checkbox" /></label>
|
||||
<label class="setting-line"><span>{{ $t('scene.pathColor') }}</span><input v-model="activePath.color" type="color" class="path-color" /></label>
|
||||
<div class="path-help"><span>{{ $t('scene.pathHelp') }}</span><ul>
|
||||
<li>{{ $t('scene.pathHelpItems.0') }}</li><li>{{ $t('scene.pathHelpItems.1') }}</li><li>{{ $t('scene.pathHelpItems.2') }}</li>
|
||||
<li>{{ $t('scene.pathHelpItems.3') }}</li><li>{{ $t('scene.pathHelpItems.4') }}</li><li>{{ $t('scene.pathHelpItems.5') }}</li>
|
||||
<li>{{ $t('scene.pathHelpItems.6') }}</li><li>{{ $t('scene.pathHelpItems.7') }}</li><li>{{ $t('scene.pathHelpItems.8') }}</li><li>{{ $t('scene.pathHelpItems.9') }}</li>
|
||||
</ul></div>
|
||||
<label class="field-block"><span>应用预设形状</span><select v-model="activePath.preset" class="inp">
|
||||
<option value="">选择预设...</option><option value="circle">圆形</option><option value="square">方形</option><option value="polygon">多边形</option><option value="star">星形</option>
|
||||
<label class="field-block"><span>{{ $t('scene.applyPresetShape') }}</span><select v-model="activePath.preset" class="inp">
|
||||
<option value="">{{ $t('scene.choosePreset') }}</option><option value="circle">{{ $t('scene.circle') }}</option><option value="square">{{ $t('scene.square') }}</option><option value="polygon">{{ $t('scene.polygon') }}</option><option value="star">{{ $t('scene.star') }}</option>
|
||||
</select></label>
|
||||
<button class="path-action primary" @click="applyPathPreset">应用预设</button>
|
||||
<button class="path-action" @click="clearPathAnchors">清空锚点</button>
|
||||
<button class="path-action primary" @click="applyPathPreset">{{ $t('scene.applyPreset') }}</button>
|
||||
<button class="path-action" @click="clearPathAnchors">{{ $t('scene.clearAnchors') }}</button>
|
||||
</template>
|
||||
<SpineObjectPanel v-else-if="activeSpine" :spine-object="activeSpine" />
|
||||
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
|
||||
<div v-else class="panel-hint">{{ $t('scene.panelHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../../store/particleStore'
|
||||
import type { ParticleSystem } from '../../store/particleStore'
|
||||
@@ -27,6 +28,7 @@ import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming'
|
||||
|
||||
|
||||
export function useParticleExport(store: ReturnType<typeof useParticleStore>) {
|
||||
const { t } = useI18n()
|
||||
store.settings.exportSettings = ensureSpineExportSettings(store.settings.exportSettings)
|
||||
const exportSettings = computed(() => store.settings.exportSettings)
|
||||
const exportBusy = ref(false)
|
||||
@@ -39,11 +41,11 @@ const connectorExportErrorCode = ref('')
|
||||
|
||||
const connectorStatusText = computed(() => {
|
||||
const health = connectorHealth.value
|
||||
if (!health) return '正在检测本地 Spine 连接器…'
|
||||
if (!health.available) return '本地 Spine 连接器未启动'
|
||||
if (!health.spineFound) return '连接器已启动,但未找到 Spine'
|
||||
if (!health.authorized) return '连接器已启动,当前网站尚未授权'
|
||||
return '连接器可用 · Spine 已就绪'
|
||||
if (!health) return t('export.connectorChecking')
|
||||
if (!health.available) return t('export.connectorOffline')
|
||||
if (!health.spineFound) return t('export.spineNotFound')
|
||||
if (!health.authorized) return t('export.notAuthorized')
|
||||
return t('export.connectorReady')
|
||||
})
|
||||
const connectorStatusClass = computed(() => ({
|
||||
ready: !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized,
|
||||
@@ -57,16 +59,16 @@ const connectorAuthorizationUrl = computed(() => {
|
||||
})
|
||||
const connectorDisabledReason = computed(() => {
|
||||
const health = connectorHealth.value
|
||||
if (!health) return '检测中'
|
||||
if (!health.available) return '未启动'
|
||||
if (!health.spineFound) return '未找到 Spine'
|
||||
if (!health.authorized) return '未授权'
|
||||
if (!health) return t('export.checking')
|
||||
if (!health.available) return t('export.offline')
|
||||
if (!health.spineFound) return t('export.notFound')
|
||||
if (!health.authorized) return t('export.unauthorized')
|
||||
return ''
|
||||
})
|
||||
const connectorButtonTitle = computed(() => {
|
||||
if (exportBusy.value) return '正在重新计算并生成 Spine 工程'
|
||||
if (connectorDisabledReason.value) return `暂不可用:${connectorDisabledReason.value}`
|
||||
return `调用本机 Spine 自动生成 ${exportSettings.value.spineVersion} 工程`
|
||||
if (exportBusy.value) return t('export.connectorBusy')
|
||||
if (connectorDisabledReason.value) return `${t('common.disabled')}: ${connectorDisabledReason.value}`
|
||||
return t('export.connectorGenerate', { version: exportSettings.value.spineVersion })
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -118,7 +120,7 @@ async function prepareExportFrames(onProgress?: (progress: number) => void) {
|
||||
const expectedMs = store.timeline.totalFrames / Math.max(1, store.timeline.fps) * 1000
|
||||
const deadline = performance.now() + Math.max(8000, expectedMs * 2.5 + 3000)
|
||||
while (!store.timeline.recorded) {
|
||||
if (performance.now() > deadline) throw new Error('重新计算动画超时,请确认页面仍在运行后重试')
|
||||
if (performance.now() > deadline) throw new Error(t('export.timeout'))
|
||||
onProgress?.(Math.min(0.98, store.timeline.frame / Math.max(1, store.timeline.totalFrames - 1)))
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
@@ -132,14 +134,20 @@ async function exportSpineJson() {
|
||||
exportError.value = false
|
||||
connectorExportErrorCode.value = ''
|
||||
connectorPairUrl.value = ''
|
||||
exportMessage.value = '正在重新计算粒子动画…'
|
||||
exportMessage.value = t('export.recalculating')
|
||||
try {
|
||||
await prepareExportFrames()
|
||||
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
|
||||
validateSpineJson(result.text, exportSettings.value.spineVersion)
|
||||
downloadSpineJson(result, exportSettings.value.fileName)
|
||||
const warning = result.warnings.length ? `;${result.warnings.length} 条资源提示(缺图仍已导出)` : ''
|
||||
exportMessage.value = `已导出 Spine ${exportSettings.value.spineVersion}:${result.particleSystemCount} 个粒子系统、${result.animationCount} 个动画、${result.boneCount} 根骨骼${warning}`
|
||||
const warning = result.warnings.length ? t('export.resourceWarning', { count: result.warnings.length }) : ''
|
||||
exportMessage.value = t('export.exported', {
|
||||
version: exportSettings.value.spineVersion,
|
||||
systems: result.particleSystemCount,
|
||||
animations: result.animationCount,
|
||||
bones: result.boneCount,
|
||||
warning,
|
||||
})
|
||||
} catch (error) {
|
||||
exportError.value = true
|
||||
exportMessage.value = error instanceof Error ? error.message : String(error)
|
||||
@@ -156,15 +164,15 @@ async function exportSpineProject() {
|
||||
exportError.value = false
|
||||
connectorExportErrorCode.value = ''
|
||||
connectorPairUrl.value = ''
|
||||
exportMessage.value = '正在重新计算粒子动画…'
|
||||
const transfer = beginTransfer('导出 Spine 包', '正在重新计算粒子动画…')
|
||||
exportMessage.value = t('export.recalculating')
|
||||
const transfer = beginTransfer(t('export.projectTransferTitle'), t('export.projectTransferDetail'))
|
||||
try {
|
||||
await transfer.paint()
|
||||
await prepareExportFrames((progress) => transfer.update(progress * 18, '正在重新计算粒子动画…'))
|
||||
transfer.update(20, '正在生成 Spine JSON…')
|
||||
await prepareExportFrames((progress) => transfer.update(progress * 18, t('export.recalculating')))
|
||||
transfer.update(20, t('export.generatingJson'))
|
||||
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
|
||||
validateSpineJson(result.text, exportSettings.value.spineVersion)
|
||||
exportMessage.value = '正在整理图片并调用本机 Spine…'
|
||||
exportMessage.value = t('export.preparingProject')
|
||||
const projectName = exportSettings.value.fileName.replace(/\.json$/i, '') || 'SpineParticle'
|
||||
const converted = await convertWithSpineConnector({
|
||||
projectName,
|
||||
@@ -172,16 +180,20 @@ async function exportSpineProject() {
|
||||
skeletonJson: result.text,
|
||||
systems: store.systems as unknown as ParticleSystem[],
|
||||
}, ({ phase, progress }) => {
|
||||
if (phase === 'images') transfer.update(progress == null ? null : 22 + progress * 18, '正在整理工程图片…')
|
||||
else if (phase === 'upload') transfer.update(progress == null ? null : 40 + progress * 30, '正在上传数据到 Spine 连接器…')
|
||||
else if (phase === 'processing') transfer.update(null, 'Spine 正在转换工程,请稍候…')
|
||||
else transfer.update(progress == null ? null : 82 + progress * 16, '正在下载生成的 Spine 工程包…')
|
||||
if (phase === 'images') transfer.update(progress == null ? null : 22 + progress * 18, t('export.preparingProjectFiles'))
|
||||
else if (phase === 'upload') transfer.update(progress == null ? null : 40 + progress * 30, t('export.uploading'))
|
||||
else if (phase === 'processing') transfer.update(null, t('export.spineProcessing'))
|
||||
else transfer.update(progress == null ? null : 82 + progress * 16, t('export.downloadingProject'))
|
||||
})
|
||||
transfer.update(99, '正在交给浏览器下载…')
|
||||
transfer.update(99, t('export.handingToBrowser'))
|
||||
downloadSpineProject(converted.blob, converted.fileName)
|
||||
exportMessage.value = `已生成 ${converted.fileName}:包含 .spine 工程和 ${converted.imageCount} 张图片,Spine ${converted.spineEditorVersion}`
|
||||
exportMessage.value = t('export.projectExported', {
|
||||
fileName: converted.fileName,
|
||||
images: converted.imageCount,
|
||||
version: converted.spineEditorVersion,
|
||||
})
|
||||
await refreshConnectorHealth()
|
||||
await transfer.finish('Spine 工程包已开始下载')
|
||||
await transfer.finish(t('export.projectTransferDownload'))
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
exportError.value = true
|
||||
@@ -203,14 +215,14 @@ async function exportSpineImages() {
|
||||
exportError.value = false
|
||||
connectorExportErrorCode.value = ''
|
||||
connectorPairUrl.value = ''
|
||||
exportMessage.value = '正在整理当前粒子系统使用的图片…'
|
||||
exportMessage.value = t('export.collectingImages')
|
||||
try {
|
||||
const archive = await buildSpineImagesArchive({
|
||||
systems: store.systems as unknown as ParticleSystem[],
|
||||
imagesPath: exportSettings.value.imagesPath,
|
||||
})
|
||||
const fileName = downloadSpineImagesArchive(archive.blob, exportSettings.value.fileName)
|
||||
exportMessage.value = `已生成 ${fileName}:${archive.folder}/ 内包含 ${archive.imageCount} 张图片`
|
||||
exportMessage.value = t('export.imagesExported', { fileName, folder: archive.folder, images: archive.imageCount })
|
||||
} catch (error) {
|
||||
exportError.value = true
|
||||
exportMessage.value = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../../store/particleStore'
|
||||
import type { ParticleSystem } from '../../store/particleStore'
|
||||
@@ -34,6 +35,7 @@ import { useParticleResources } from './useParticleResources'
|
||||
/** 每个参数面板创建一次状态,子分组共享选择、折叠状态与操作。 */
|
||||
export function useParticlePanel() {
|
||||
const store = useParticleStore()
|
||||
const { t } = useI18n()
|
||||
const { exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorHealth, connectorPairUrl, connectorExportErrorCode, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, connectorHealthTimer, connectorHealthRequestId, refreshConnectorHealth, nextPaint, prepareExportFrames, exportSpineJson, exportSpineProject, exportSpineImages } = useParticleExport(store)
|
||||
const { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset } = useProjectPresets(store)
|
||||
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
|
||||
@@ -121,30 +123,33 @@ const renderLayerSlotLabel = computed(() => {
|
||||
const spine = renderLayerSpine.value
|
||||
const target = renderLayerTargetSlot.value
|
||||
if (!config || !spine || !target) return ''
|
||||
return `${spine.name} / ${target.name} · 图片${config.renderLayerSlotPosition === 'below' ? '之下' : '之上'}`
|
||||
return t(config.renderLayerSlotPosition === 'below' ? 'renderLayer.slotLabelBelow' : 'renderLayer.slotLabelAbove', {
|
||||
spine: spine.name,
|
||||
slot: target.name,
|
||||
})
|
||||
})
|
||||
const renderLayerResolvedLabel = computed(() => {
|
||||
const config = sys.value?.config
|
||||
const target = renderLayerTargetSlot.value
|
||||
if (!config || !target) return ''
|
||||
if (config.renderLayerSlotPosition === 'above') return `渲染在 ${target.name} 图片之上、其后插槽之下`
|
||||
if (config.renderLayerSlotPosition === 'above') return t('renderLayer.resolvedAbove', { slot: target.name })
|
||||
return renderLayerPreviousSlot.value
|
||||
? `渲染在 ${renderLayerPreviousSlot.value.name} 与 ${target.name} 之间`
|
||||
? t('renderLayer.resolvedBetween', { previous: renderLayerPreviousSlot.value.name, slot: target.name })
|
||||
: ''
|
||||
})
|
||||
const renderLayerWarning = computed(() => {
|
||||
const config = sys.value?.config
|
||||
if (!config || config.renderLayerMode !== 'slot') return ''
|
||||
if (!renderLayerReadySpines.value.length) return '没有已加载的 Spine,粒子临时显示在最上层。'
|
||||
if (!config.renderLayerSpineId) return '尚未选择 Spine,粒子临时显示在最上层。'
|
||||
if (!renderLayerReadySpines.value.length) return t('renderLayer.warningNoSpine')
|
||||
if (!config.renderLayerSpineId) return t('renderLayer.warningNoSelection')
|
||||
const spine = renderLayerSpine.value
|
||||
if (!spine) return '目标 Spine 不存在,粒子临时显示在最上层。'
|
||||
if (!spine.enabled || spine.status !== 'ready') return '目标 Spine 未就绪,粒子临时显示在最上层。'
|
||||
if (!spine) return t('renderLayer.warningMissingSpine')
|
||||
if (!spine.enabled || spine.status !== 'ready') return t('renderLayer.warningNotReady')
|
||||
// 未选择插槽时面板本身已显示“尚未选择插槽”,这里不再重复提示。
|
||||
if (!config.renderLayerSlotName) return ''
|
||||
if (!renderLayerTargetSlot.value) return '目标插槽不存在(资源可能已更换),粒子临时显示在最上层。'
|
||||
if (!renderLayerTargetSlot.value) return t('renderLayer.warningMissingSlot')
|
||||
if (config.renderLayerSlotPosition === 'below' && !renderLayerPreviousSlot.value) {
|
||||
return '这是第一个插槽,无法显示在其下方,粒子临时显示在最上层。'
|
||||
return t('renderLayer.warningFirstSlot')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
@@ -344,8 +349,8 @@ function setStreamBehavior(behavior: 'normal' | 'loop') {
|
||||
if (!config || config.streamBehavior === behavior) return
|
||||
const entering = behavior === 'loop'
|
||||
const message = entering
|
||||
? '进入循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?'
|
||||
: '退出循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?'
|
||||
? t('errors.confirmLoopFollowEnter')
|
||||
: t('errors.confirmLoopFollowExit')
|
||||
if (!window.confirm(message)) return
|
||||
resetParticleFollow(config)
|
||||
config.streamBehavior = behavior
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type SpineConnectorHealth,
|
||||
} from '../../export/spineConnectorClient'
|
||||
import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming'
|
||||
import { translate } from '../../i18n'
|
||||
|
||||
|
||||
export function useProjectPresets(store: ReturnType<typeof useParticleStore>) {
|
||||
@@ -36,18 +37,18 @@ const configError = ref(false)
|
||||
function saveConfig() {
|
||||
downloadEditorConfig(store)
|
||||
configError.value = false
|
||||
configMessage.value = '配置已保存到本地'
|
||||
configMessage.value = translate('config.saved')
|
||||
}
|
||||
|
||||
async function onConfigFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
configMessage.value = '正在加载配置…'
|
||||
configMessage.value = translate('config.loading')
|
||||
configError.value = false
|
||||
try {
|
||||
await loadEditorConfigFile(store, file)
|
||||
configMessage.value = `已加载:${file.name}`
|
||||
configMessage.value = translate('config.loaded', { name: file.name })
|
||||
} catch (error) {
|
||||
configError.value = true
|
||||
configMessage.value = error instanceof Error ? error.message : String(error)
|
||||
@@ -59,7 +60,7 @@ async function onConfigFileChange(event: Event) {
|
||||
async function refreshPresets() {
|
||||
try {
|
||||
const response = await fetch(`/Preset/index.json?t=${Date.now()}`, { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error('无法读取预设列表')
|
||||
if (!response.ok) throw new Error(translate('config.presetListError'))
|
||||
const presets = await response.json()
|
||||
presetOptions.value = Array.isArray(presets)
|
||||
? presets.filter((item): item is { name: string; file: string } => !!item && typeof item.name === 'string' && typeof item.file === 'string')
|
||||
@@ -74,24 +75,24 @@ async function loadSelectedPreset() {
|
||||
if (!file) return
|
||||
presetLoading.value = true
|
||||
configError.value = false
|
||||
configMessage.value = '正在加载预设…'
|
||||
const transfer = beginTransfer('加载预设', `正在下载 ${file}…`)
|
||||
configMessage.value = translate('config.presetLoading')
|
||||
const transfer = beginTransfer(translate('config.presetTransferTitle'), translate('config.presetDownloading', { file }))
|
||||
try {
|
||||
await transfer.paint()
|
||||
const response = await fetch(`/Preset/${encodeURIComponent(file)}`, { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error(`无法读取预设:${file}`)
|
||||
if (!response.ok) throw new Error(translate('config.presetReadError', { file }))
|
||||
const text = await readResponseTextWithProgress(response, (progress) => {
|
||||
transfer.update(progress == null ? null : progress * 55, `正在下载 ${file}…`)
|
||||
transfer.update(progress == null ? null : progress * 55, translate('config.presetDownloading', { file }))
|
||||
})
|
||||
transfer.update(58, '正在解析预设配置…')
|
||||
transfer.update(70, '正在解析并恢复预设资源…')
|
||||
transfer.update(58, translate('config.presetParsing'))
|
||||
transfer.update(70, translate('config.presetRestoring'))
|
||||
await loadEditorConfigText(store, text)
|
||||
configMessage.value = `已加载预设:${file.replace(/\.json$/i, '')}`
|
||||
await transfer.finish('预设加载完成')
|
||||
configMessage.value = translate('config.presetLoaded', { name: file.replace(/\.json$/i, '') })
|
||||
await transfer.finish(translate('config.presetComplete'))
|
||||
} catch (error) {
|
||||
configError.value = true
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
configMessage.value = `${message},请检查预设文件命名`
|
||||
configMessage.value = translate('config.presetNamingError', { message })
|
||||
await transfer.fail(message)
|
||||
} finally {
|
||||
presetLoading.value = false
|
||||
|
||||
Reference in new issue
Block a user