中英双语

This commit is contained in:
tianmo committed 2026-10-22 18:13:43 +08:00
1 parent ea26af26ee
commit b98c0333e6
50 files changed
+4082 -2049

No files matched your search

+1
View File
@@ -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
-1239
View File
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;
-1
View File
@@ -1 +0,0 @@
.help-overlay[data-v-78204b9d]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:72px 24px 28px;box-sizing:border-box;background:#05080fb8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.help-dialog[data-v-78204b9d]{width:min(820px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #3a4864;border-radius:14px;background:#141a27;color:#b8c3d8;box-shadow:0 22px 70px #0000009e}.help-head[data-v-78204b9d]{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px 20px 15px;border-bottom:1px solid #2b354a;background:linear-gradient(135deg,#202942,#181e2d)}.help-head h2[data-v-78204b9d]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-78204b9d]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-78204b9d]{width:28px;height:28px;flex:0 0 28px;border:1px solid #43516b;border-radius:6px;background:#273249;color:#ccd7ed;cursor:pointer}.help-close[data-v-78204b9d]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-78204b9d]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-78204b9d]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-78204b9d]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-78204b9d]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-78204b9d]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-78204b9d]{color:#dce4f7}.help-grid[data-v-78204b9d]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-78204b9d]{display:grid;gap:0;margin:0}.help-list>div[data-v-78204b9d]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-78204b9d]:last-child{border-bottom:0}.help-list dt[data-v-78204b9d]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-78204b9d]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-78204b9d]{display:grid;gap:7px}.shortcut-list>div[data-v-78204b9d]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-78204b9d]{min-width:24px;box-sizing:border-box;padding:3px 6px;border:1px solid #465470;border-bottom-width:2px;border-radius:5px;background:#101623;color:#e0e7f6;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center}.shortcut-list span[data-v-78204b9d]{color:#65728b;font-size:10px}.shortcut-list em[data-v-78204b9d]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-78204b9d]{margin:11px 0 0;padding-top:9px;border-top:1px solid #2a3549;color:#697791;font-size:10px;line-height:1.55}.help-export[data-v-78204b9d]{margin-top:12px}.help-export p[data-v-78204b9d]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-78204b9d]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-78204b9d]{margin-top:12px}.help-module-head[data-v-78204b9d]{display:flex;align-items:center;gap:8px;width:100%;padding:0;border:0;background:transparent;color:#9fb2ff;font:inherit;font-size:13px;font-weight:700;text-align:left;cursor:pointer}.help-module-head[data-v-78204b9d]:hover{color:#fff}.help-module-head .mtitle[data-v-78204b9d]{flex:1;min-width:0}.help-module-head .caret[data-v-78204b9d]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-78204b9d]{color:#fff}.help-module.open .help-module-head .caret[data-v-78204b9d]{color:#9fb2ff}.help-module .mnum[data-v-78204b9d]{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#4a53b8,#6a5fd0);color:#fff;font-size:12px;font-weight:700}.help-module.help-connector[data-v-78204b9d]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-78204b9d]{color:#b8c2ff}.help-module .connector-intro[data-v-78204b9d]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-78204b9d]{color:#dce4f7}.help-module .dl-link[data-v-78204b9d]{display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;margin:0 10px 6px 0;padding:6px 12px;border:1px solid #5652b5;border-radius:6px;background:#4540a0;color:#fff;font-size:12px;font-weight:700;text-decoration:none}.help-module .dl-link[data-v-78204b9d]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-78204b9d]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-78204b9d]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-78204b9d]{padding:66px 12px 14px}.help-grid[data-v-78204b9d]{grid-template-columns:1fr}}.topbar[data-v-4749834a]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
@@ -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
+1257
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -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
View File
@@ -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; }
+90 -1
View File
@@ -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
View File
@@ -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",
+59
View File
@@ -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
View File
@@ -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>
+7 -6
View File
@@ -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())
}
+5 -4
View File
@@ -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
}
+3 -2
View File
@@ -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),
}
}
+4 -3
View File
@@ -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')
+2 -1
View File
@@ -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}`,
+4 -3
View File
@@ -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 数组顺序绘制。保持每个粒子的拖尾在图片下方。
+23 -22
View File
@@ -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 }
}
+3 -1
View File
@@ -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('/')
}
+72
View File
@@ -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)
+857
View File
@@ -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}"
}
}
+857
View File
@@ -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
View File
@@ -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')
+47 -45
View File
@@ -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'
+2 -1
View File
@@ -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))
}
+8 -7
View File
@@ -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,
+3 -1
View File
@@ -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)
}
+7 -7
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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>
+40 -36
View File
@@ -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() {
+13 -11
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+24 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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),
))
+51 -51
View File
@@ -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>
+42 -30
View File
@@ -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)
+16 -11
View File
@@ -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
+14 -13
View File
@@ -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