=0&&a>=0&&n>=0&&o>=0){var p=Math.ceil(2.3*Math.sqrt(s+a)),_=8*p+(n?4:0)+(o?4:0);if(h.length=_,0!==_){if(0===p)return h.length=8,h[0]=h[6]=r+n,h[1]=h[3]=i+o,h[2]=h[4]=r-n,void(h[5]=h[7]=i-o);var v,m,y=0,g=4*p+(n?2:0)+2,T=g,E=_,b=r+(v=n+s),x=r-v,A=i+(m=o);if(h[y++]=b,h[y++]=A,h[--g]=A,h[--g]=x,o){var S=i-m;h[T++]=x,h[T++]=S,h[--E]=S,h[--E]=b}for(var R=1;Rc&&(c+=2*Math.PI);var d=l,f=c-l,p=Math.abs(f),_=Math.sqrt(h*h+u*u),v=1+(15*p*Math.sqrt(_)/Math.PI>>0),m=f/v;if(d+=m,a){s.push(t,e),s.push(r,i);for(var y=1,g=d;y=0&&(s.join===t.LINE_JOIN.ROUND?_+=is(b,x,b-R*w,x-O*w,b-M*w,x-P*w,f,!1)+4:_+=2,f.push(b-M*C,x-P*C),f.push(b+M*w,x+P*w));else{var j=(-R+T)*(-O+x)-(-R+b)*(-O+E),H=(-M+A)*(-P+x)-(-M+b)*(-P+S),Y=(F*H-B*j)/k,V=(G*j-L*H)/k,z=(Y-b)*(Y-b)+(V-x)*(V-x),W=b+(Y-b)*w,q=x+(V-x)*w,K=b-(Y-b)*C,Z=x-(V-x)*C,Q=X?w:C;z<=Math.min(F*F+L*L,B*B+G*G)+Q*Q*y?s.join===t.LINE_JOIN.BEVEL||z/y>g?(X?(f.push(W,q),f.push(b+R*C,x+O*C),f.push(W,q),f.push(b+M*C,x+P*C)):(f.push(b-R*w,x-O*w),f.push(K,Z),f.push(b-M*w,x-P*w),f.push(K,Z)),_+=2):s.join===t.LINE_JOIN.ROUND?X?(f.push(W,q),f.push(b+R*C,x+O*C),_+=is(b,x,b+R*C,x+O*C,b+M*C,x+P*C,f,!0)+4,f.push(W,q),f.push(b+M*C,x+P*C)):(f.push(b-R*w,x-O*w),f.push(K,Z),_+=is(b,x,b-R*w,x-O*w,b-M*w,x-P*w,f,!1)+4,f.push(b-M*w,x-P*w),f.push(K,Z)):(f.push(W,q),f.push(K,Z)):(f.push(b-R*w,x-O*w),f.push(b+R*C,x+O*C),s.join===t.LINE_JOIN.ROUND?_+=X?is(b,x,b+R*C,x+O*C,b+M*C,x+P*C,f,!0)+2:is(b,x,b-R*w,x-O*w,b-M*w,x-P*w,f,!1)+2:s.join===t.LINE_JOIN.MITER&&z/y<=g&&(X?(f.push(K,Z),f.push(K,Z)):(f.push(W,q),f.push(W,q)),_+=2),f.push(b-M*w,x-P*w),f.push(b+M*C,x+P*C),_+=2)}}T=n[2*(p-2)],E=n[2*(p-2)+1],b=n[2*(p-1)],R=-(E-(x=n[2*(p-1)+1])),O=T-b,R/=I=Math.sqrt(R*R+O*O),O/=I,R*=m,O*=m,f.push(b-R*w,x-O*w),f.push(b+R*C,x+O*C),u||(s.cap===t.LINE_CAP.ROUND?_+=is(b-R*(w-C)*.5,x-O*(w-C)*.5,b-R*w,x-O*w,b+R*C,x+O*C,f,!1)+2:s.cap===t.LINE_CAP.SQUARE&&(_+=rs(b,x,R,O,w,C,!1,f)));var J=r.indices,$=Vo.epsilon*Vo.epsilon;for(N=v;N<_+v-2;++N)T=f[2*N],E=f[2*N+1],b=f[2*(N+1)],x=f[2*(N+1)+1],A=f[2*(N+2)],S=f[2*(N+2)+1],Math.abs(T*(x-S)+b*(S-E)+A*(E-x))<$||J.push(N,N+1,N+2)}}(e,r)}var os,ss=function(){function t(){}return t.curveTo=function(t,e,r,i,n,o){var s=o[o.length-2],a=o[o.length-1]-e,h=s-t,u=i-e,l=r-t,c=Math.abs(a*l-h*u);if(c<1e-8||0===n)return o[o.length-2]===t&&o[o.length-1]===e||o.push(t,e),null;var d=a*a+h*h,f=u*u+l*l,p=a*u+h*l,_=n*Math.sqrt(d)/c,v=n*Math.sqrt(f)/c,m=_*p/d,y=v*p/f,g=_*l+v*h,T=_*u+v*a,E=h*(v+m),b=a*(v+m),x=l*(_+y),A=u*(_+y);return{cx:g+t,cy:T+e,radius:n,startAngle:Math.atan2(b-T,E-g),endAngle:Math.atan2(A-T,x-g),anticlockwise:h*u>l*a}},t.arc=function(t,e,r,i,n,o,s,a,h){for(var u=s-o,l=Vo._segmentsCount(Math.abs(u)*n,40*Math.ceil(Math.abs(u)/Me)),c=u/(2*l),d=2*c,f=Math.cos(c),p=Math.sin(c),_=l-1,v=_%1/_,m=0;m<=_;++m){var y=c+o+d*(m+v*m),g=Math.cos(y),T=-Math.sin(y);h.push((f*g+p*T)*n+r,(f*-T+p*g)*n+i)}},t}(),as=function(){function t(){}return t.curveLength=function(t,e,r,i,n,o,s,a){for(var h=0,u=0,l=0,c=0,d=0,f=0,p=0,_=0,v=0,m=0,y=0,g=t,T=e,E=1;E<=10;++E)m=g-(_=(p=(f=(d=1-(u=E/10))*d)*d)*t+3*f*u*r+3*d*(l=u*u)*n+(c=l*u)*s),y=T-(v=p*e+3*f*u*i+3*d*l*o+c*a),g=_,T=v,h+=Math.sqrt(m*m+y*y);return h},t.curveTo=function(e,r,i,n,o,s,a){var h=a[a.length-2],u=a[a.length-1];a.length-=2;var l=Vo._segmentsCount(t.curveLength(h,u,e,r,i,n,o,s)),c=0,d=0,f=0,p=0,_=0;a.push(h,u);for(var v=1,m=0;v<=l;++v)f=(d=(c=1-(m=v/l))*c)*c,_=(p=m*m)*m,a.push(f*h+3*d*m*e+3*c*p*i+_*o,f*u+3*d*m*r+3*c*p*n+_*s)},t}(),hs=function(){function t(){}return t.curveLength=function(t,e,r,i,n,o){var s=t-2*r+n,a=e-2*i+o,h=2*r-2*t,u=2*i-2*e,l=4*(s*s+a*a),c=4*(s*h+a*u),d=h*h+u*u,f=2*Math.sqrt(l+c+d),p=Math.sqrt(l),_=2*l*p,v=2*Math.sqrt(d),m=c/p;return(_*f+p*c*(f-v)+(4*d*l-c*c)*Math.log((2*p+m+f)/(m+v)))/(4*_)},t.curveTo=function(e,r,i,n,o){for(var s=o[o.length-2],a=o[o.length-1],h=Vo._segmentsCount(t.curveLength(s,a,e,r,i,n)),u=0,l=0,c=1;c<=h;++c){var d=c/h;u=s+(e-s)*d,l=a+(r-a)*d,o.push(u+(e+(i-e)*d-u)*d,l+(r+(n-r)*d-l)*d)}},t}(),us=function(){function t(){this.reset()}return t.prototype.begin=function(t,e,r){this.reset(),this.style=t,this.start=e,this.attribStart=r},t.prototype.end=function(t,e){this.attribSize=e-this.attribStart,this.size=t-this.start},t.prototype.reset=function(){this.style=null,this.size=0,this.start=0,this.attribStart=0,this.attribSize=0},t}(),ls=((os={})[t.SHAPES.POLY]=Zo,os[t.SHAPES.CIRC]=Qo,os[t.SHAPES.ELIP]=Qo,os[t.SHAPES.RECT]=Jo,os[t.SHAPES.RREC]=es,os),cs=[],ds=[],fs=function(){function t(t,e,r,i){void 0===e&&(e=null),void 0===r&&(r=null),void 0===i&&(i=null),this.points=[],this.holes=[],this.shape=t,this.lineStyle=r,this.fillStyle=e,this.matrix=i,this.type=t.type}return t.prototype.clone=function(){return new t(this.shape,this.fillStyle,this.lineStyle,this.matrix)},t.prototype.destroy=function(){this.shape=null,this.holes.length=0,this.holes=null,this.points.length=0,this.points=null,this.lineStyle=null,this.fillStyle=null},t}(),ps=new De,_s=function(e){function r(){var t=e.call(this)||this;return t.closePointEps=1e-4,t.boundsPadding=0,t.uvsFloat32=null,t.indicesUint16=null,t.batchable=!1,t.points=[],t.colors=[],t.uvs=[],t.indices=[],t.textureIds=[],t.graphicsData=[],t.drawCalls=[],t.batchDirty=-1,t.batches=[],t.dirty=0,t.cacheDirty=-1,t.clearDirty=0,t.shapeIndex=0,t._bounds=new Ke,t.boundsDirty=-1,t}return qo(r,e),Object.defineProperty(r.prototype,"bounds",{get:function(){return this.updateBatches(),this.boundsDirty!==this.dirty&&(this.boundsDirty=this.dirty,this.calculateBounds()),this._bounds},enumerable:!1,configurable:!0}),r.prototype.invalidate=function(){this.boundsDirty=-1,this.dirty++,this.batchDirty++,this.shapeIndex=0,this.points.length=0,this.colors.length=0,this.uvs.length=0,this.indices.length=0,this.textureIds.length=0;for(var t=0;t0&&(this.invalidate(),this.clearDirty++,this.graphicsData.length=0),this},r.prototype.drawShape=function(t,e,r,i){void 0===e&&(e=null),void 0===r&&(r=null),void 0===i&&(i=null);var n=new fs(t,e,r,i);return this.graphicsData.push(n),this.dirty++,this},r.prototype.drawHole=function(t,e){if(void 0===e&&(e=null),!this.graphicsData.length)return null;var r=new fs(t,null,null,e),i=this.graphicsData[this.graphicsData.length-1];return r.lineStyle=i.lineStyle,i.holes.push(r),this.dirty++,this},r.prototype.destroy=function(){e.prototype.destroy.call(this);for(var t=0;t0&&(n=(i=this.batches[this.batches.length-1]).style);for(var o=this.shapeIndex;o65535;this.indicesUint16&&this.indices.length===this.indicesUint16.length&&m===this.indicesUint16.BYTES_PER_ELEMENT>2?this.indicesUint16.set(this.indices):this.indicesUint16=m?new Uint32Array(this.indices):new Uint16Array(this.indices),this.batchable=this.isBatchable(),this.batchable?this.packBatches():this.buildDrawCalls()}else this.batchable=!0}}else this.batchable=!0},r.prototype._compareStyles=function(t,e){return!(!t||!e)&&t.texture.baseTexture===e.texture.baseTexture&&t.color+t.alpha===e.color+e.alpha&&!!t.native==!!e.native},r.prototype.validateBatching=function(){if(this.dirty===this.cacheDirty||!this.graphicsData.length)return!1;for(var t=0,e=this.graphicsData.length;t131070)return!1;for(var t=this.batches,e=0;e0&&((o=ds.pop())||((o=new En).texArray=new bn),this.drawCalls.push(o)),o.start=c,o.size=0,o.texArray.count=0,o.type=l),p.touched=1,p._batchEnabled=e,p._batchLocation=s,p.wrapMode=t.WRAP_MODES.REPEAT,o.texArray.elements[o.texArray.count++]=p,s++)),o.size+=d.size,c+=d.size,h=p._batchLocation,this.addColors(i,f.color,f.alpha,d.attribSize,d.attribStart),this.addTextureIds(n,h,d.attribSize,d.attribStart)}mr._globalBatch=e,this.packAttributes()},r.prototype.packAttributes=function(){for(var t=this.points,e=this.uvs,r=this.colors,i=this.textureIds,n=new ArrayBuffer(3*t.length*4),o=new Float32Array(n),s=new Uint32Array(n),a=0,h=0;h>16)+(65280&e)+((255&e)<<16),r);t.length=Math.max(t.length,n+i);for(var s=0;s0&&e.alpha>0;return r?(e.matrix&&(e.matrix=e.matrix.clone(),e.matrix.invert()),Object.assign(this._lineStyle,{visible:r},e)):this._lineStyle.reset(),this},r.prototype.startPoly=function(){if(this.currentPath){var t=this.currentPath.points,e=this.currentPath.points.length;e>2&&(this.drawShape(this.currentPath),this.currentPath=new Le,this.currentPath.closeStroke=!1,this.currentPath.points.push(t[e-2],t[e-1]))}else this.currentPath=new Le,this.currentPath.closeStroke=!1},r.prototype.finishPoly=function(){this.currentPath&&(this.currentPath.points.length>2?(this.drawShape(this.currentPath),this.currentPath=null):this.currentPath.points.length=0)},r.prototype.moveTo=function(t,e){return this.startPoly(),this.currentPath.points[0]=t,this.currentPath.points[1]=e,this},r.prototype.lineTo=function(t,e){this.currentPath||this.moveTo(0,0);var r=this.currentPath.points,i=r[r.length-2],n=r[r.length-1];return i===t&&n===e||r.push(t,e),this},r.prototype._initCurve=function(t,e){void 0===t&&(t=0),void 0===e&&(e=0),this.currentPath?0===this.currentPath.points.length&&(this.currentPath.points=[t,e]):this.moveTo(t,e)},r.prototype.quadraticCurveTo=function(t,e,r,i){this._initCurve();var n=this.currentPath.points;return 0===n.length&&this.moveTo(0,0),hs.curveTo(t,e,r,i,n),this},r.prototype.bezierCurveTo=function(t,e,r,i,n,o){return this._initCurve(),as.curveTo(t,e,r,i,n,o,this.currentPath.points),this},r.prototype.arcTo=function(t,e,r,i,n){this._initCurve(t,e);var o=this.currentPath.points,s=ss.curveTo(t,e,r,i,n,o);if(s){var a=s.cx,h=s.cy,u=s.radius,l=s.startAngle,c=s.endAngle,d=s.anticlockwise;this.arc(a,h,u,l,c,d)}return this},r.prototype.arc=function(t,e,r,i,n,o){if(void 0===o&&(o=!1),i===n)return this;if(!o&&n<=i?n+=Me:o&&i<=n&&(i+=Me),0==n-i)return this;var s=t+Math.cos(i)*r,a=e+Math.sin(i)*r,h=this._geometry.closePointEps,u=this.currentPath?this.currentPath.points:null;if(u){var l=Math.abs(u[u.length-2]-s),c=Math.abs(u[u.length-1]-a);l0;return e?(t.matrix&&(t.matrix=t.matrix.clone(),t.matrix.invert()),Object.assign(this._fillStyle,{visible:e},t)):this._fillStyle.reset(),this},r.prototype.endFill=function(){return this.finishPoly(),this._fillStyle.reset(),this},r.prototype.drawRect=function(t,e,r,i){return this.drawShape(new Ce(t,e,r,i))},r.prototype.drawRoundedRect=function(t,e,r,i,n){return this.drawShape(new Be(t,e,r,i,n))},r.prototype.drawCircle=function(t,e,r){return this.drawShape(new Ne(t,e,r))},r.prototype.drawEllipse=function(t,e,r,i){return this.drawShape(new Fe(t,e,r,i))},r.prototype.drawPolygon=function(){for(var t,e=arguments,r=[],i=0;i>16&255)/255*n,o.tint[1]=(i>>8&255)/255*n,o.tint[2]=(255&i)/255*n,o.tint[3]=n,t.shader.bind(e),t.geometry.bind(r,e),t.state.set(this.state);for(var a=0,h=s.length;a>16)+(65280&n)+((255&n)<<16)}}},r.prototype.calculateVertices=function(){var t=this.transform._worldID;if(this._transformID!==t){this._transformID=t;for(var e=this.transform.worldTransform,r=e.a,i=e.b,n=e.c,o=e.d,s=e.tx,a=e.ty,h=this._geometry.points,u=this.vertexData,l=0,c=0;c=i&&bs.x=n&&bs.y>16)+(65280&t)+((255&t)<<16)},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"texture",{get:function(){return this._texture},set:function(t){this._texture!==t&&(this._texture&&this._texture.off("update",this._onTextureUpdate,this),this._texture=t||Nr.EMPTY,this._cachedTint=16777215,this._textureID=-1,this._textureTrimmedID=-1,t&&(t.baseTexture.valid?this._onTextureUpdate():t.once("update",this._onTextureUpdate,this)))},enumerable:!1,configurable:!0}),r}(er);t.TEXT_GRADIENT=void 0;var Ss=function(t,e){return Ss=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},Ss(t,e)};!function(t){t[t.LINEAR_VERTICAL=0]="LINEAR_VERTICAL",t[t.LINEAR_HORIZONTAL=1]="LINEAR_HORIZONTAL"}(t.TEXT_GRADIENT||(t.TEXT_GRADIENT={}));var Rs={align:"left",breakWords:!1,dropShadow:!1,dropShadowAlpha:1,dropShadowAngle:Math.PI/6,dropShadowBlur:0,dropShadowColor:"black",dropShadowDistance:5,fill:"black",fillGradientType:t.TEXT_GRADIENT.LINEAR_VERTICAL,fillGradientStops:[],fontFamily:"Arial",fontSize:26,fontStyle:"normal",fontVariant:"normal",fontWeight:"normal",letterSpacing:0,lineHeight:0,lineJoin:"miter",miterLimit:10,padding:0,stroke:"black",strokeThickness:0,textBaseline:"alphabetic",trim:!1,whiteSpace:"pre",wordWrap:!1,wordWrapWidth:100,leading:0},Os=["serif","sans-serif","monospace","cursive","fantasy","system-ui"],Ms=function(){function t(t){this.styleID=0,this.reset(),Ds(this,t,t)}return t.prototype.clone=function(){var e={};return Ds(e,this,Rs),new t(e)},t.prototype.reset=function(){Ds(this,Rs,Rs)},Object.defineProperty(t.prototype,"align",{get:function(){return this._align},set:function(t){this._align!==t&&(this._align=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"breakWords",{get:function(){return this._breakWords},set:function(t){this._breakWords!==t&&(this._breakWords=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dropShadow",{get:function(){return this._dropShadow},set:function(t){this._dropShadow!==t&&(this._dropShadow=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dropShadowAlpha",{get:function(){return this._dropShadowAlpha},set:function(t){this._dropShadowAlpha!==t&&(this._dropShadowAlpha=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dropShadowAngle",{get:function(){return this._dropShadowAngle},set:function(t){this._dropShadowAngle!==t&&(this._dropShadowAngle=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dropShadowBlur",{get:function(){return this._dropShadowBlur},set:function(t){this._dropShadowBlur!==t&&(this._dropShadowBlur=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dropShadowColor",{get:function(){return this._dropShadowColor},set:function(t){var e=Is(t);this._dropShadowColor!==e&&(this._dropShadowColor=e,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"dropShadowDistance",{get:function(){return this._dropShadowDistance},set:function(t){this._dropShadowDistance!==t&&(this._dropShadowDistance=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"fill",{get:function(){return this._fill},set:function(t){var e=Is(t);this._fill!==e&&(this._fill=e,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"fillGradientType",{get:function(){return this._fillGradientType},set:function(t){this._fillGradientType!==t&&(this._fillGradientType=t,this.styleID++)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"fillGradientStops",{get:function(){return this._fillGradientStops},set:function(t){(function(t,e){if(!Array.isArray(t)||!Array.isArray(e))return!1;if(t.length!==e.length)return!1;for(var r=0;r=0;r--){var i=e[r].trim();!/([\"\'])[^\'\"]+\1/.test(i)&&Os.indexOf(i)<0&&(i='"'+i+'"'),e[r]=i}return this.fontStyle+" "+this.fontVariant+" "+this.fontWeight+" "+t+" "+e.join(",")},t}();function Ps(t){return"number"==typeof t?te(t):("string"==typeof t&&0===t.indexOf("0x")&&(t=t.replace("0x","#")),t)}function Is(t){if(Array.isArray(t)){for(var e=0;ep)if(""!==s&&(a+=t.addLine(s),s="",o=0),t.canBreakWords(m,r.breakWords))for(var E=t.wordWrapSplit(m),b=0;bp&&(a+=t.addLine(s),f=!1,s="",o=0),s+=x,o+=O}else{s.length>0&&(a+=t.addLine(s),s="",o=0);var M=v===_.length-1;a+=t.addLine(m,!M),f=!1,s="",o=0}else T+o>p&&(f=!1,a+=t.addLine(s),s="",o=0),(s.length>0||!t.isBreakingSpace(m)||f)&&(s+=m,o+=T)}return a+t.addLine(s,!1)},t.addLine=function(e,r){return void 0===r&&(r=!0),e=t.trimRight(e),r?e+"\n":e},t.getFromCache=function(t,e,r,i){var n=r[t];if("number"!=typeof n){var o=t.length*e;n=i.measureText(t).width+o,r[t]=n}return n},t.collapseSpaces=function(t){return"normal"===t||"pre-line"===t},t.collapseNewlines=function(t){return"normal"===t},t.trimRight=function(e){if("string"!=typeof e)return"";for(var r=e.length-1;r>=0;r--){var i=e[r];if(!t.isBreakingSpace(i))break;e=e.slice(0,-1)}return e},t.isNewline=function(e){return"string"==typeof e&&t._newlines.indexOf(e.charCodeAt(0))>=0},t.isBreakingSpace=function(e,r){return"string"==typeof e&&t._breakingSpaces.indexOf(e.charCodeAt(0))>=0},t.tokenize=function(e){var r=[],i="";if("string"!=typeof e)return r;for(var n=0;na;--d){for(_=0;_0&&_>v&&(m=(v+_)/2);var y=v+p,g=i.lineHeight*(f+1),T=y;f+10},t}();function Gs(t,e){var r=!1;if(t&&t._textures&&t._textures.length)for(var i=0;i=0;e--)this.add(t.children[e]);return this},e.prototype.destroy=function(){this.ticking&&ar.system.remove(this.tick,this),this.ticking=!1,this.addHooks=null,this.uploadHooks=null,this.renderer=null,this.completes=null,this.queue=null,this.limiter=null,this.uploadHookHelper=null},e}();function zs(t,e){return e instanceof mr&&(e._glTextures[t.CONTEXT_UID]||t.texture.bind(e),!0)}function Ws(t,e){if(!(e instanceof gs))return!1;var r=e.geometry;e.finishPoly(),r.updateBatches();for(var i=r.batches,n=0;n=i&&ea.x=n&&ea.y1?Ci.from("#version 300 es\n#define SHADER_NAME Tiling-Sprite-300\n\nprecision lowp float;\n\nin vec2 aVertexPosition;\nin vec2 aTextureCoord;\n\nuniform mat3 projectionMatrix;\nuniform mat3 translationMatrix;\nuniform mat3 uTransform;\n\nout vec2 vTextureCoord;\n\nvoid main(void)\n{\n gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);\n\n vTextureCoord = (uTransform * vec3(aTextureCoord, 1.0)).xy;\n}\n","#version 300 es\n#define SHADER_NAME Tiling-Sprite-100\n\nprecision lowp float;\n\nin vec2 vTextureCoord;\n\nout vec4 fragmentColor;\n\nuniform sampler2D uSampler;\nuniform vec4 uColor;\nuniform mat3 uMapCoord;\nuniform vec4 uClampFrame;\nuniform vec2 uClampOffset;\n\nvoid main(void)\n{\n vec2 coord = vTextureCoord + ceil(uClampOffset - vTextureCoord);\n coord = (uMapCoord * vec3(coord, 1.0)).xy;\n vec2 unclamped = coord;\n coord = clamp(coord, uClampFrame.xy, uClampFrame.zw);\n\n vec4 texSample = texture(uSampler, coord, unclamped == coord ? 0.0f : -32.0f);// lod-bias very negative to force lod 0\n\n fragmentColor = texSample * uColor;\n}\n",e):Ci.from(ia,"#version 100\n#ifdef GL_EXT_shader_texture_lod\n #extension GL_EXT_shader_texture_lod : enable\n#endif\n#define SHADER_NAME Tiling-Sprite-100\n\nprecision lowp float;\n\nvarying vec2 vTextureCoord;\n\nuniform sampler2D uSampler;\nuniform vec4 uColor;\nuniform mat3 uMapCoord;\nuniform vec4 uClampFrame;\nuniform vec2 uClampOffset;\n\nvoid main(void)\n{\n vec2 coord = vTextureCoord + ceil(uClampOffset - vTextureCoord);\n coord = (uMapCoord * vec3(coord, 1.0)).xy;\n vec2 unclamped = coord;\n coord = clamp(coord, uClampFrame.xy, uClampFrame.zw);\n\n #ifdef GL_EXT_shader_texture_lod\n vec4 texSample = unclamped == coord\n ? texture2D(uSampler, coord) \n : texture2DLodEXT(uSampler, coord, 0);\n #else\n vec4 texSample = texture2D(uSampler, coord);\n #endif\n\n gl_FragColor = texSample * uColor;\n}\n",e)},r.prototype.render=function(e){var r=this.renderer,i=this.quad,n=i.vertices;n[0]=n[6]=e._width*-e.anchor.x,n[1]=n[3]=e._height*-e.anchor.y,n[2]=n[4]=e._width*(1-e.anchor.x),n[5]=n[7]=e._height*(1-e.anchor.y);var o=e.uvRespectAnchor?e.anchor.x:0,s=e.uvRespectAnchor?e.anchor.y:0;(n=i.uvs)[0]=n[6]=-o,n[1]=n[3]=-s,n[2]=n[4]=1-o,n[5]=n[7]=1-s,i.invalidate();var a=e._texture,h=a.baseTexture,u=h.alphaMode>0,l=e.tileTransform.localTransform,c=e.uvMatrix,d=h.isPowerOfTwo&&a.frame.width===h.width&&a.frame.height===h.height;d&&(h._glTextures[r.CONTEXT_UID]?d=h.wrapMode!==t.WRAP_MODES.CLAMP:h.wrapMode===t.WRAP_MODES.CLAMP&&(h.wrapMode=t.WRAP_MODES.REPEAT));var f=d?this.simpleShader:this.shader,p=a.width,_=a.height,v=e._width,m=e._height;na.set(l.a*p/v,l.b*p/m,l.c*_/v,l.d*_/m,l.tx/v,l.ty/m),na.invert(),d?na.prepend(c.mapCoord):(f.uniforms.uMapCoord=c.mapCoord.toArray(!0),f.uniforms.uClampFrame=c.uClampFrame,f.uniforms.uClampOffset=c.uClampOffset),f.uniforms.uTransform=na.toArray(!0),f.uniforms.uColor=se(e.tint,e.worldAlpha,f.uniforms.uColor,u),f.uniforms.translationMatrix=e.transform.worldTransform.toArray(!0),f.uniforms.uSampler=a,r.shader.bind(f),r.geometry.bind(i),this.state.blendMode=ie(e.blendMode,u),r.state.set(this.state),r.geometry.draw(this.renderer.gl.TRIANGLES,6,0)},r.extension={name:"tilingSprite",type:t.ExtensionType.RendererPlugin},r}($r),sa=function(t,e){return sa=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},sa(t,e)};function aa(t,e){function r(){this.constructor=t}sa(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}var ha=function(){function t(t,e){this.uvBuffer=t,this.uvMatrix=e,this.data=null,this._bufferUpdateId=-1,this._textureUpdateId=-1,this._updateID=0}return t.prototype.update=function(t){if(t||this._bufferUpdateId!==this.uvBuffer._updateID||this._textureUpdateId!==this.uvMatrix._updateID){this._bufferUpdateId=this.uvBuffer._updateID,this._textureUpdateId=this.uvMatrix._updateID;var e=this.uvBuffer.data;this.data&&this.data.length===e.length||(this.data=new Float32Array(e.length)),this.uvMatrix.multiplyUvs(e,this.data),this._updateID++}},t}(),ua=new De,la=new Le,ca=function(e){function r(r,i,n,o){void 0===o&&(o=t.DRAW_MODES.TRIANGLES);var s=e.call(this)||this;return s.geometry=r,s.shader=i,s.state=n||Ni.for2d(),s.drawMode=o,s.start=0,s.size=0,s.uvs=null,s.indices=null,s.vertexData=new Float32Array(1),s.vertexDirty=-1,s._transformID=-1,s._roundPixels=U.ROUND_PIXELS,s.batchUvs=null,s}return aa(r,e),Object.defineProperty(r.prototype,"geometry",{get:function(){return this._geometry},set:function(t){this._geometry!==t&&(this._geometry&&(this._geometry.refCount--,0===this._geometry.refCount&&this._geometry.dispose()),this._geometry=t,this._geometry&&this._geometry.refCount++,this.vertexDirty=-1)},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"uvBuffer",{get:function(){return this.geometry.buffers[1]},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"verticesBuffer",{get:function(){return this.geometry.buffers[0]},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"material",{get:function(){return this.shader},set:function(t){this.shader=t},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"blendMode",{get:function(){return this.state.blendMode},set:function(t){this.state.blendMode=t},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"roundPixels",{get:function(){return this._roundPixels},set:function(t){this._roundPixels!==t&&(this._transformID=-1),this._roundPixels=t},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"tint",{get:function(){return"tint"in this.shader?this.shader.tint:null},set:function(t){this.shader.tint=t},enumerable:!1,configurable:!0}),Object.defineProperty(r.prototype,"texture",{get:function(){return"texture"in this.shader?this.shader.texture:null},set:function(t){this.shader.texture=t},enumerable:!1,configurable:!0}),r.prototype._render=function(e){var i=this.geometry.buffers[0].data;this.shader.batchable&&this.drawMode===t.DRAW_MODES.TRIANGLES&&i.length<2*r.BATCHABLE_SIZE?this._renderToBatch(e):this._renderDefault(e)},r.prototype._renderDefault=function(t){var e=this.shader;e.alpha=this.worldAlpha,e.update&&e.update(),t.batch.flush(),e.uniforms.translationMatrix=this.transform.worldTransform.toArray(!0),t.shader.bind(e),t.state.set(this.state),t.geometry.bind(this.geometry,e),t.geometry.draw(this.drawMode,this.size,this.start,this.geometry.instanceCount)},r.prototype._renderToBatch=function(t){var e=this.geometry,r=this.shader;r.uvMatrix&&(r.uvMatrix.update(),this.calculateUvs()),this.calculateVertices(),this.indices=e.indexBuffer.data,this._tintRGB=r._tintRGB,this._texture=r.texture;var i=this.material.pluginName;t.batch.setObjectRenderer(t.plugins[i]),t.plugins[i].render(this)},r.prototype.calculateVertices=function(){var t=this.geometry.buffers[0],e=t.data,r=t._updateID;if(r!==this.vertexDirty||this._transformID!==this.transform._worldID){this._transformID=this.transform._worldID,this.vertexData.length!==e.length&&(this.vertexData=new Float32Array(e.length));for(var i=this.transform.worldTransform,n=i.a,o=i.b,s=i.c,a=i.d,h=i.tx,u=i.ty,l=this.vertexData,c=0;c>16)+(65280&t)+((255&t)<<16),this._colorDirty=!0)},enumerable:!1,configurable:!0}),e.prototype.update=function(){if(this._colorDirty){this._colorDirty=!1;var t=this.texture.baseTexture;se(this._tint,this._alpha,this.uniforms.uColor,t.alphaMode)}this.uvMatrix.update()&&(this.uniforms.uTextureMatrix=this.uvMatrix.mapCoord)},e}(Ci),fa=function(e){function r(r,i,n){var o=e.call(this)||this,s=new Ur(r),a=new Ur(i,!0),h=new Ur(n,!0,!0);return o.addAttribute("aVertexPosition",s,2,!1,t.TYPES.FLOAT).addAttribute("aTextureCoord",a,2,!1,t.TYPES.FLOAT).addIndex(h),o._updateId=-1,o}return aa(r,e),Object.defineProperty(r.prototype,"vertexDirtyId",{get:function(){return this.buffers[0]._updateID},enumerable:!1,configurable:!0}),r}(Yr),pa=function(t,e){return pa=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},pa(t,e)},_a=function(){this.info=[],this.common=[],this.page=[],this.char=[],this.kerning=[],this.distanceField=[]},va=function(){function t(){}return t.test=function(t){return"string"==typeof t&&0===t.indexOf("info face=")},t.parse=function(t){var e=t.match(/^[a-z]+\s+.+$/gm),r={info:[],common:[],page:[],char:[],chars:[],kerning:[],kernings:[],distanceField:[]};for(var i in e){var n=e[i].match(/^[a-z]+/gm)[0],o=e[i].match(/[a-zA-Z]+=([^\s"']+|"([^"]*)")/gm),s={};for(var a in o){var h=o[a].split("="),u=h[0],l=h[1].replace(/"/gm,""),c=parseFloat(l),d=isNaN(c)?l:c;s[u]=d}r[n].push(s)}var f=new _a;return r.info.forEach((function(t){return f.info.push({face:t.face,size:parseInt(t.size,10)})})),r.common.forEach((function(t){return f.common.push({lineHeight:parseInt(t.lineHeight,10)})})),r.page.forEach((function(t){return f.page.push({id:parseInt(t.id,10),file:t.file})})),r.char.forEach((function(t){return f.char.push({id:parseInt(t.id,10),page:parseInt(t.page,10),x:parseInt(t.x,10),y:parseInt(t.y,10),width:parseInt(t.width,10),height:parseInt(t.height,10),xoffset:parseInt(t.xoffset,10),yoffset:parseInt(t.yoffset,10),xadvance:parseInt(t.xadvance,10)})})),r.kerning.forEach((function(t){return f.kerning.push({first:parseInt(t.first,10),second:parseInt(t.second,10),amount:parseInt(t.amount,10)})})),r.distanceField.forEach((function(t){return f.distanceField.push({distanceRange:parseInt(t.distanceRange,10),fieldType:t.fieldType})})),f},t}(),ma=function(){function t(){}return t.test=function(t){return t instanceof XMLDocument&&t.getElementsByTagName("page").length&&null!==t.getElementsByTagName("info")[0].getAttribute("face")},t.parse=function(t){for(var e=new _a,r=t.getElementsByTagName("info"),i=t.getElementsByTagName("common"),n=t.getElementsByTagName("page"),o=t.getElementsByTagName("char"),s=t.getElementsByTagName("kerning"),a=t.getElementsByTagName("distanceField"),h=0;h")>-1){var e=(new globalThis.DOMParser).parseFromString(t,"text/xml");return ma.test(e)}return!1},t.parse=function(t){var e=(new globalThis.DOMParser).parseFromString(t,"text/xml");return ma.parse(e)},t}(),ga=[va,ma,ya];function Ta(t){for(var e=0;e=u-S*a){if(0===y)throw new Error("[BitmapFont] textureHeight "+u+"px is too small (fontFamily: '"+c.fontFamily+"', fontSize: "+c.fontSize+"px, char: '"+b+"')");--E,p=null,_=null,v=null,y=0,m=0,g=0}else if(g=Math.max(S+x.fontProperties.descent,g),R*a+m>=d){if(0===m)throw new Error("[BitmapFont] textureWidth "+h+"px is too small (fontFamily: '"+c.fontFamily+"', fontSize: "+c.fontSize+"px, char: '"+b+"')");--E,y+=g*a,y=Math.ceil(y),m=0,g=0}else{Ea(p,_,x,m,y,a,c);var O=xa(x.text);f.char.push({id:O,page:T.length-1,x:m/a,y:y/a,width:R,height:S,xoffset:0,yoffset:0,xadvance:Math.ceil(A-(c.dropShadow?c.dropShadowDistance:0)-(c.stroke?c.strokeThickness:0))}),m+=(R+2*s)*a,m=Math.ceil(m)}}if(!(null==i?void 0:i.skipKerning)){E=0;for(var M=l.length;E0&&o.x>l&&(fe(s,1+v-++y,1+E-v),E=v,v=-1,a.push(m),h.push(s.length>0?s[s.length-1].prevSpaces:0),p=Math.max(p,m),_++,o.x=0,o.y+=r.lineHeight,d=null,T=0)}}else a.push(f),h.push(-1),p=Math.max(p,f),++_,++y,o.x=0,o.y+=r.lineHeight,d=null,T=0}var S=u[u.length-1];"\r"!==S&&"\n"!==S&&(/(?:\s)/.test(S)&&(f=m),a.push(f),p=Math.max(p,f),h.push(-1));var R=[];for(E=0;E<=_;E++){var O=0;"right"===this._align?O=p-a[E]:"center"===this._align?O=(p-a[E])/2:"justify"===this._align&&(O=h[E]<0?0:(p-a[E])/h[E]),R.push(O)}var M=s.length,P={},I=[],D=this._activePagesMeshData;for(c.push.apply(c,D),E=0;E 0.99) {\r\n alpha = 1.0;\r\n }\r\n\r\n // NPM Textures, NPM outputs\r\n gl_FragColor = vec4(uColor.rgb, uColor.a * alpha);\r\n\r\n}\r\n"),uniforms:{uFWidth:0}}),F=t.BLEND_MODES.NORMAL_NPM);var L=new ca(C,N);L.blendMode=F,Q={index:0,indexCount:0,vertexCount:0,uvsCount:0,total:0,mesh:L,vertices:null,uvs:null,indices:null}}Q.index=0,Q.indexCount=0,Q.vertexCount=0,Q.uvsCount=0,Q.total=0;var B=this._textureCache;B[w]=B[w]||new Nr(Y.baseTexture),Q.mesh.texture=B[w],Q.mesh.tint=this._tint,I.push(Q),P[w]=Q}P[w].total++}for(E=0;E6*G)||Q.vertices.length<2*ca.BATCHABLE_SIZE)Q.vertices=new Float32Array(8*G),Q.uvs=new Float32Array(8*G),Q.indices=new Uint16Array(6*G);else for(var U=Q.total,k=Q.vertices,X=4*U*2;X=i&&(e=t-s-1),o+=a=a.replace("%value%",r[e].toString()),o+="\n"}return(n=n.replace("%blur%",o)).replace("%size%",t.toString())}(o);return(s=e.call(this,a,h)||this).horizontal=t,s.resolution=n,s._quality=0,s.quality=i,s.blur=r,s}return Ca(r,e),r.prototype.apply=function(e,r,i,n){if(i?this.horizontal?this.uniforms.strength=1/i.width*(i.width/r.width):this.uniforms.strength=1/i.height*(i.height/r.height):this.horizontal?this.uniforms.strength=1/e.renderer.width*(e.renderer.width/r.width):this.uniforms.strength=1/e.renderer.height*(e.renderer.height/r.height),this.uniforms.strength*=this.strength,this.uniforms.strength/=this.passes,1===this.passes)e.applyFilter(this,r,i,n);else{var o=e.getFilterTexture(),s=e.renderer,a=r,h=o;this.state.blend=!1,e.applyFilter(this,a,h,t.CLEAR_MODES.CLEAR);for(var u=1;u 0.0) {\n c.rgb /= c.a;\n }\n\n vec4 result;\n\n result.r = (m[0] * c.r);\n result.r += (m[1] * c.g);\n result.r += (m[2] * c.b);\n result.r += (m[3] * c.a);\n result.r += m[4];\n\n result.g = (m[5] * c.r);\n result.g += (m[6] * c.g);\n result.g += (m[7] * c.b);\n result.g += (m[8] * c.a);\n result.g += m[9];\n\n result.b = (m[10] * c.r);\n result.b += (m[11] * c.g);\n result.b += (m[12] * c.b);\n result.b += (m[13] * c.a);\n result.b += m[14];\n\n result.a = (m[15] * c.r);\n result.a += (m[16] * c.g);\n result.a += (m[17] * c.b);\n result.a += (m[18] * c.a);\n result.a += m[19];\n\n vec3 rgb = mix(c.rgb, result.rgb, uAlpha);\n\n // Premultiply alpha again.\n rgb *= result.a;\n\n gl_FragColor = vec4(rgb, result.a);\n}\n",r)||this).alpha=1,e}return function(t,e){function r(){this.constructor=t}Ga(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(e,t),e.prototype._loadMatrix=function(t,e){void 0===e&&(e=!1);var r=t;e&&(this._multiply(r,this.uniforms.m,t),r=this._colorMatrix(r)),this.uniforms.m=r},e.prototype._multiply=function(t,e,r){return t[0]=e[0]*r[0]+e[1]*r[5]+e[2]*r[10]+e[3]*r[15],t[1]=e[0]*r[1]+e[1]*r[6]+e[2]*r[11]+e[3]*r[16],t[2]=e[0]*r[2]+e[1]*r[7]+e[2]*r[12]+e[3]*r[17],t[3]=e[0]*r[3]+e[1]*r[8]+e[2]*r[13]+e[3]*r[18],t[4]=e[0]*r[4]+e[1]*r[9]+e[2]*r[14]+e[3]*r[19]+e[4],t[5]=e[5]*r[0]+e[6]*r[5]+e[7]*r[10]+e[8]*r[15],t[6]=e[5]*r[1]+e[6]*r[6]+e[7]*r[11]+e[8]*r[16],t[7]=e[5]*r[2]+e[6]*r[7]+e[7]*r[12]+e[8]*r[17],t[8]=e[5]*r[3]+e[6]*r[8]+e[7]*r[13]+e[8]*r[18],t[9]=e[5]*r[4]+e[6]*r[9]+e[7]*r[14]+e[8]*r[19]+e[9],t[10]=e[10]*r[0]+e[11]*r[5]+e[12]*r[10]+e[13]*r[15],t[11]=e[10]*r[1]+e[11]*r[6]+e[12]*r[11]+e[13]*r[16],t[12]=e[10]*r[2]+e[11]*r[7]+e[12]*r[12]+e[13]*r[17],t[13]=e[10]*r[3]+e[11]*r[8]+e[12]*r[13]+e[13]*r[18],t[14]=e[10]*r[4]+e[11]*r[9]+e[12]*r[14]+e[13]*r[19]+e[14],t[15]=e[15]*r[0]+e[16]*r[5]+e[17]*r[10]+e[18]*r[15],t[16]=e[15]*r[1]+e[16]*r[6]+e[17]*r[11]+e[18]*r[16],t[17]=e[15]*r[2]+e[16]*r[7]+e[17]*r[12]+e[18]*r[17],t[18]=e[15]*r[3]+e[16]*r[8]+e[17]*r[13]+e[18]*r[18],t[19]=e[15]*r[4]+e[16]*r[9]+e[17]*r[14]+e[18]*r[19]+e[19],t},e.prototype._colorMatrix=function(t){var e=new Float32Array(t);return e[4]/=255,e[9]/=255,e[14]/=255,e[19]/=255,e},e.prototype.brightness=function(t,e){var r=[t,0,0,0,0,0,t,0,0,0,0,0,t,0,0,0,0,0,1,0];this._loadMatrix(r,e)},e.prototype.tint=function(t,e){var r=[(t>>16&255)/255,0,0,0,0,0,(t>>8&255)/255,0,0,0,0,0,(255&t)/255,0,0,0,0,0,1,0];this._loadMatrix(r,e)},e.prototype.greyscale=function(t,e){var r=[t,t,t,0,0,t,t,t,0,0,t,t,t,0,0,0,0,0,1,0];this._loadMatrix(r,e)},e.prototype.blackAndWhite=function(t){this._loadMatrix([.3,.6,.1,0,0,.3,.6,.1,0,0,.3,.6,.1,0,0,0,0,0,1,0],t)},e.prototype.hue=function(t,e){t=(t||0)/180*Math.PI;var r=Math.cos(t),i=Math.sin(t),n=1/3,o=(0,Math.sqrt)(n),s=[r+(1-r)*n,n*(1-r)-o*i,n*(1-r)+o*i,0,0,n*(1-r)+o*i,r+n*(1-r),n*(1-r)-o*i,0,0,n*(1-r)-o*i,n*(1-r)+o*i,r+n*(1-r),0,0,0,0,0,1,0];this._loadMatrix(s,e)},e.prototype.contrast=function(t,e){var r=(t||0)+1,i=-.5*(r-1),n=[r,0,0,0,i,0,r,0,0,i,0,0,r,0,i,0,0,0,1,0];this._loadMatrix(n,e)},e.prototype.saturate=function(t,e){void 0===t&&(t=0);var r=2*t/3+1,i=-.5*(r-1),n=[r,i,i,0,0,i,r,i,0,0,i,i,r,0,0,0,0,0,1,0];this._loadMatrix(n,e)},e.prototype.desaturate=function(){this.saturate(-1)},e.prototype.negative=function(t){this._loadMatrix([-1,0,0,1,0,0,-1,0,1,0,0,0,-1,1,0,0,0,0,1,0],t)},e.prototype.sepia=function(t){this._loadMatrix([.393,.7689999,.18899999,0,0,.349,.6859999,.16799999,0,0,.272,.5339999,.13099999,0,0,0,0,0,1,0],t)},e.prototype.technicolor=function(t){this._loadMatrix([1.9125277891456083,-.8545344976951645,-.09155508482755585,0,11.793603434377337,-.3087833385928097,1.7658908555458428,-.10601743074722245,0,-70.35205161461398,-.231103377548616,-.7501899197440212,1.847597816108189,0,30.950940869491138,0,0,0,1,0],t)},e.prototype.polaroid=function(t){this._loadMatrix([1.438,-.062,-.062,0,0,-.122,1.378,-.122,0,0,-.016,-.016,1.483,0,0,0,0,0,1,0],t)},e.prototype.toBGR=function(t){this._loadMatrix([0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0],t)},e.prototype.kodachrome=function(t){this._loadMatrix([1.1285582396593525,-.3967382283601348,-.03992559172921793,0,63.72958762196502,-.16404339962244616,1.0835251566291304,-.05498805115633132,0,24.732407896706203,-.16786010706155763,-.5603416277695248,1.6014850761964943,0,35.62982807460946,0,0,0,1,0],t)},e.prototype.browni=function(t){this._loadMatrix([.5997023498159715,.34553243048391263,-.2708298674538042,0,47.43192855600873,-.037703249837783157,.8609577587992641,.15059552388459913,0,-36.96841498319127,.24113635128153335,-.07441037908422492,.44972182064877153,0,-7.562075277591283,0,0,0,1,0],t)},e.prototype.vintage=function(t){this._loadMatrix([.6279345635605994,.3202183420819367,-.03965408211312453,0,9.651285835294123,.02578397704808868,.6441188644374771,.03259127616149294,0,7.462829176470591,.0466055556782719,-.0851232987247891,.5241648018700465,0,5.159190588235296,0,0,0,1,0],t)},e.prototype.colorTone=function(t,e,r,i,n){var o=((r=r||16770432)>>16&255)/255,s=(r>>8&255)/255,a=(255&r)/255,h=((i=i||3375104)>>16&255)/255,u=(i>>8&255)/255,l=(255&i)/255,c=[.3,.59,.11,0,0,o,s,a,t=t||.2,0,h,u,l,e=e||.15,0,o-h,s-u,a-l,0,0];this._loadMatrix(c,n)},e.prototype.night=function(t,e){var r=[-2*(t=t||.1),-t,0,0,0,-t,0,t,0,0,0,t,2*t,0,0,0,0,0,1,0];this._loadMatrix(r,e)},e.prototype.predator=function(t,e){var r=[11.224130630493164*t,-4.794486999511719*t,-2.8746118545532227*t,0*t,.40342438220977783*t,-3.6330697536468506*t,9.193157196044922*t,-2.951810836791992*t,0*t,-1.316135048866272*t,-3.2184197902679443*t,-4.2375030517578125*t,7.476448059082031*t,0*t,.8044459223747253*t,0,0,0,1,0];this._loadMatrix(r,e)},e.prototype.lsd=function(t){this._loadMatrix([2,-.4,.5,0,0,-.5,2,-.4,0,0,-.4,-.5,3,0,0,0,0,0,1,0],t)},e.prototype.reset=function(){this._loadMatrix([1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0],!1)},Object.defineProperty(e.prototype,"matrix",{get:function(){return this.uniforms.m},set:function(t){this.uniforms.m=t},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"alpha",{get:function(){return this.uniforms.uAlpha},set:function(t){this.uniforms.uAlpha=t},enumerable:!1,configurable:!0}),e}(Fi);Ua.prototype.grayscale=Ua.prototype.greyscale;var ka=function(t,e){return ka=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},ka(t,e)},Xa=function(t){function e(e,r){var i=this,n=new Ue;return e.renderable=!1,(i=t.call(this,"attribute vec2 aVertexPosition;\n\nuniform mat3 projectionMatrix;\nuniform mat3 filterMatrix;\n\nvarying vec2 vTextureCoord;\nvarying vec2 vFilterCoord;\n\nuniform vec4 inputSize;\nuniform vec4 outputFrame;\n\nvec4 filterVertexPosition( void )\n{\n vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;\n\n return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);\n}\n\nvec2 filterTextureCoord( void )\n{\n return aVertexPosition * (outputFrame.zw * inputSize.zw);\n}\n\nvoid main(void)\n{\n\tgl_Position = filterVertexPosition();\n\tvTextureCoord = filterTextureCoord();\n\tvFilterCoord = ( filterMatrix * vec3( vTextureCoord, 1.0) ).xy;\n}\n","varying vec2 vFilterCoord;\nvarying vec2 vTextureCoord;\n\nuniform vec2 scale;\nuniform mat2 rotation;\nuniform sampler2D uSampler;\nuniform sampler2D mapSampler;\n\nuniform highp vec4 inputSize;\nuniform vec4 inputClamp;\n\nvoid main(void)\n{\n vec4 map = texture2D(mapSampler, vFilterCoord);\n\n map -= 0.5;\n map.xy = scale * inputSize.zw * (rotation * map.xy);\n\n gl_FragColor = texture2D(uSampler, clamp(vec2(vTextureCoord.x + map.x, vTextureCoord.y + map.y), inputClamp.xy, inputClamp.zw));\n}\n",{mapSampler:e._texture,filterMatrix:n,scale:{x:1,y:1},rotation:new Float32Array([1,0,0,1])})||this).maskSprite=e,i.maskMatrix=n,null==r&&(r=20),i.scale=new De(r,r),i}return function(t,e){function r(){this.constructor=t}ka(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(e,t),e.prototype.apply=function(t,e,r,i){this.uniforms.filterMatrix=t.calculateSpriteMatrix(this.maskMatrix,this.maskSprite),this.uniforms.scale.x=this.scale.x,this.uniforms.scale.y=this.scale.y;var n=this.maskSprite.worldTransform,o=Math.sqrt(n.a*n.a+n.b*n.b),s=Math.sqrt(n.c*n.c+n.d*n.d);0!==o&&0!==s&&(this.uniforms.rotation[0]=n.a/o,this.uniforms.rotation[1]=n.b/o,this.uniforms.rotation[2]=n.c/s,this.uniforms.rotation[3]=n.d/s),t.applyFilter(this,e,r,i)},Object.defineProperty(e.prototype,"map",{get:function(){return this.uniforms.mapSampler},set:function(t){this.uniforms.mapSampler=t},enumerable:!1,configurable:!0}),e}(Fi),ja=function(t,e){return ja=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},ja(t,e)},Ha=function(t){function e(){return t.call(this,"\nattribute vec2 aVertexPosition;\n\nuniform mat3 projectionMatrix;\n\nvarying vec2 v_rgbNW;\nvarying vec2 v_rgbNE;\nvarying vec2 v_rgbSW;\nvarying vec2 v_rgbSE;\nvarying vec2 v_rgbM;\n\nvarying vec2 vFragCoord;\n\nuniform vec4 inputSize;\nuniform vec4 outputFrame;\n\nvec4 filterVertexPosition( void )\n{\n vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;\n\n return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);\n}\n\nvoid texcoords(vec2 fragCoord, vec2 inverseVP,\n out vec2 v_rgbNW, out vec2 v_rgbNE,\n out vec2 v_rgbSW, out vec2 v_rgbSE,\n out vec2 v_rgbM) {\n v_rgbNW = (fragCoord + vec2(-1.0, -1.0)) * inverseVP;\n v_rgbNE = (fragCoord + vec2(1.0, -1.0)) * inverseVP;\n v_rgbSW = (fragCoord + vec2(-1.0, 1.0)) * inverseVP;\n v_rgbSE = (fragCoord + vec2(1.0, 1.0)) * inverseVP;\n v_rgbM = vec2(fragCoord * inverseVP);\n}\n\nvoid main(void) {\n\n gl_Position = filterVertexPosition();\n\n vFragCoord = aVertexPosition * outputFrame.zw;\n\n texcoords(vFragCoord, inputSize.zw, v_rgbNW, v_rgbNE, v_rgbSW, v_rgbSE, v_rgbM);\n}\n",'varying vec2 v_rgbNW;\nvarying vec2 v_rgbNE;\nvarying vec2 v_rgbSW;\nvarying vec2 v_rgbSE;\nvarying vec2 v_rgbM;\n\nvarying vec2 vFragCoord;\nuniform sampler2D uSampler;\nuniform highp vec4 inputSize;\n\n\n/**\n Basic FXAA implementation based on the code on geeks3d.com with the\n modification that the texture2DLod stuff was removed since it\'s\n unsupported by WebGL.\n\n --\n\n From:\n https://github.com/mitsuhiko/webgl-meincraft\n\n Copyright (c) 2011 by Armin Ronacher.\n\n Some rights reserved.\n\n Redistribution and use in source and binary forms, with or without\n modification, are permitted provided that the following conditions are\n met:\n\n * Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n * Redistributions in binary form must reproduce the above\n copyright notice, this list of conditions and the following\n disclaimer in the documentation and/or other materials provided\n with the distribution.\n\n * The names of the contributors may not be used to endorse or\n promote products derived from this software without specific\n prior written permission.\n\n THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\n LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\n A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\n OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\n SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\n LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n */\n\n#ifndef FXAA_REDUCE_MIN\n#define FXAA_REDUCE_MIN (1.0/ 128.0)\n#endif\n#ifndef FXAA_REDUCE_MUL\n#define FXAA_REDUCE_MUL (1.0 / 8.0)\n#endif\n#ifndef FXAA_SPAN_MAX\n#define FXAA_SPAN_MAX 8.0\n#endif\n\n//optimized version for mobile, where dependent\n//texture reads can be a bottleneck\nvec4 fxaa(sampler2D tex, vec2 fragCoord, vec2 inverseVP,\n vec2 v_rgbNW, vec2 v_rgbNE,\n vec2 v_rgbSW, vec2 v_rgbSE,\n vec2 v_rgbM) {\n vec4 color;\n vec3 rgbNW = texture2D(tex, v_rgbNW).xyz;\n vec3 rgbNE = texture2D(tex, v_rgbNE).xyz;\n vec3 rgbSW = texture2D(tex, v_rgbSW).xyz;\n vec3 rgbSE = texture2D(tex, v_rgbSE).xyz;\n vec4 texColor = texture2D(tex, v_rgbM);\n vec3 rgbM = texColor.xyz;\n vec3 luma = vec3(0.299, 0.587, 0.114);\n float lumaNW = dot(rgbNW, luma);\n float lumaNE = dot(rgbNE, luma);\n float lumaSW = dot(rgbSW, luma);\n float lumaSE = dot(rgbSE, luma);\n float lumaM = dot(rgbM, luma);\n float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));\n float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));\n\n mediump vec2 dir;\n dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));\n dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));\n\n float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) *\n (0.25 * FXAA_REDUCE_MUL), FXAA_REDUCE_MIN);\n\n float rcpDirMin = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);\n dir = min(vec2(FXAA_SPAN_MAX, FXAA_SPAN_MAX),\n max(vec2(-FXAA_SPAN_MAX, -FXAA_SPAN_MAX),\n dir * rcpDirMin)) * inverseVP;\n\n vec3 rgbA = 0.5 * (\n texture2D(tex, fragCoord * inverseVP + dir * (1.0 / 3.0 - 0.5)).xyz +\n texture2D(tex, fragCoord * inverseVP + dir * (2.0 / 3.0 - 0.5)).xyz);\n vec3 rgbB = rgbA * 0.5 + 0.25 * (\n texture2D(tex, fragCoord * inverseVP + dir * -0.5).xyz +\n texture2D(tex, fragCoord * inverseVP + dir * 0.5).xyz);\n\n float lumaB = dot(rgbB, luma);\n if ((lumaB < lumaMin) || (lumaB > lumaMax))\n color = vec4(rgbA, texColor.a);\n else\n color = vec4(rgbB, texColor.a);\n return color;\n}\n\nvoid main() {\n\n vec4 color;\n\n color = fxaa(uSampler, vFragCoord, inputSize.zw, v_rgbNW, v_rgbNE, v_rgbSW, v_rgbSE, v_rgbM);\n\n gl_FragColor = color;\n}\n')||this}return function(t,e){function r(){this.constructor=t}ja(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(e,t),e}(Fi),Ya=function(t,e){return Ya=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},Ya(t,e)},Va=function(t){function e(e,r){void 0===e&&(e=.5),void 0===r&&(r=Math.random());var i=t.call(this,gn,"precision highp float;\n\nvarying vec2 vTextureCoord;\nvarying vec4 vColor;\n\nuniform float uNoise;\nuniform float uSeed;\nuniform sampler2D uSampler;\n\nfloat rand(vec2 co)\n{\n return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);\n}\n\nvoid main()\n{\n vec4 color = texture2D(uSampler, vTextureCoord);\n float randomValue = rand(gl_FragCoord.xy * uSeed);\n float diff = (randomValue - 0.5) * uNoise;\n\n // Un-premultiply alpha before applying the color matrix. See issue #3539.\n if (color.a > 0.0) {\n color.rgb /= color.a;\n }\n\n color.r += diff;\n color.g += diff;\n color.b += diff;\n\n // Premultiply alpha again.\n color.rgb *= color.a;\n\n gl_FragColor = color;\n}\n",{uNoise:0,uSeed:0})||this;return i.noise=e,i.seed=r,i}return function(t,e){function r(){this.constructor=t}Ya(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(e,t),Object.defineProperty(e.prototype,"noise",{get:function(){return this.uniforms.uNoise},set:function(t){this.uniforms.uNoise=t},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"seed",{get:function(){return this.uniforms.uSeed},set:function(t){this.uniforms.uSeed=t},enumerable:!1,configurable:!0}),e}(Fi),za=new Ue;Je.prototype._cacheAsBitmap=!1,Je.prototype._cacheData=null,Je.prototype._cacheAsBitmapResolution=null,Je.prototype._cacheAsBitmapMultisample=t.MSAA_QUALITY.NONE;var Wa=function(){this.textureCacheId=null,this.originalRender=null,this.originalRenderCanvas=null,this.originalCalculateBounds=null,this.originalGetLocalBounds=null,this.originalUpdateTransform=null,this.originalDestroy=null,this.originalMask=null,this.originalFilterArea=null,this.originalContainsPoint=null,this.sprite=null};Object.defineProperties(Je.prototype,{cacheAsBitmapResolution:{get:function(){return this._cacheAsBitmapResolution},set:function(t){t!==this._cacheAsBitmapResolution&&(this._cacheAsBitmapResolution=t,this.cacheAsBitmap&&(this.cacheAsBitmap=!1,this.cacheAsBitmap=!0))}},cacheAsBitmapMultisample:{get:function(){return this._cacheAsBitmapMultisample},set:function(t){t!==this._cacheAsBitmapMultisample&&(this._cacheAsBitmapMultisample=t,this.cacheAsBitmap&&(this.cacheAsBitmap=!1,this.cacheAsBitmap=!0))}},cacheAsBitmap:{get:function(){return this._cacheAsBitmap},set:function(t){var e;this._cacheAsBitmap!==t&&(this._cacheAsBitmap=t,t?(this._cacheData||(this._cacheData=new Wa),(e=this._cacheData).originalRender=this.render,e.originalRenderCanvas=this.renderCanvas,e.originalUpdateTransform=this.updateTransform,e.originalCalculateBounds=this.calculateBounds,e.originalGetLocalBounds=this.getLocalBounds,e.originalDestroy=this.destroy,e.originalContainsPoint=this.containsPoint,e.originalMask=this._mask,e.originalFilterArea=this.filterArea,this.render=this._renderCached,this.renderCanvas=this._renderCachedCanvas,this.destroy=this._cacheAsBitmapDestroy):((e=this._cacheData).sprite&&this._destroyCachedDisplayObject(),this.render=e.originalRender,this.renderCanvas=e.originalRenderCanvas,this.calculateBounds=e.originalCalculateBounds,this.getLocalBounds=e.originalGetLocalBounds,this.destroy=e.originalDestroy,this.updateTransform=e.originalUpdateTransform,this.containsPoint=e.originalContainsPoint,this._mask=e.originalMask,this.filterArea=e.originalFilterArea))}}}),Je.prototype._renderCached=function(t){!this.visible||this.worldAlpha<=0||!this.renderable||(this._initCachedDisplayObject(t),this._cacheData.sprite.transform._worldID=this.transform._worldID,this._cacheData.sprite.worldAlpha=this.worldAlpha,this._cacheData.sprite._render(t))},Je.prototype._initCachedDisplayObject=function(t){var e;if(!this._cacheData||!this._cacheData.sprite){var r=this.alpha;this.alpha=1,t.batch.flush();var i=this.getLocalBounds(null,!0).clone();if(this.filters&&this.filters.length){var n=this.filters[0].padding;i.pad(n)}i.ceil(U.RESOLUTION);var o=t.renderTexture.current,s=t.renderTexture.sourceFrame.clone(),a=t.renderTexture.destinationFrame.clone(),h=t.projection.transform,u=Fr.create({width:i.width,height:i.height,resolution:this.cacheAsBitmapResolution||t.resolution,multisample:null!==(e=this.cacheAsBitmapMultisample)&&void 0!==e?e:t.multisample}),l="cacheAsBitmap_"+ve();this._cacheData.textureCacheId=l,mr.addToCache(u.baseTexture,l),Nr.addToCache(u,l);var c=this.transform.localTransform.copyTo(za).invert().translate(-i.x,-i.y);this.render=this._cacheData.originalRender,t.render(this,{renderTexture:u,clear:!0,transform:c,skipUpdateTransform:!1}),t.framebuffer.blit(),t.projection.transform=h,t.renderTexture.bind(o,s,a),this.render=this._renderCached,this.updateTransform=this.displayObjectUpdateTransform,this.calculateBounds=this._calculateCachedBounds,this.getLocalBounds=this._getCachedLocalBounds,this._mask=null,this.filterArea=null,this.alpha=r;var d=new As(u);d.transform.worldTransform=this.transform.worldTransform,d.anchor.x=-i.x/i.width,d.anchor.y=-i.y/i.height,d.alpha=r,d._bounds=this._bounds,this._cacheData.sprite=d,this.transform._parentID=-1,this.parent?this.updateTransform():(this.enableTempParent(),this.updateTransform(),this.disableTempParent(null)),this.containsPoint=d.containsPoint.bind(d)}},Je.prototype._renderCachedCanvas=function(t){!this.visible||this.worldAlpha<=0||!this.renderable||(this._initCachedDisplayObjectCanvas(t),this._cacheData.sprite.worldAlpha=this.worldAlpha,this._cacheData.sprite._renderCanvas(t))},Je.prototype._initCachedDisplayObjectCanvas=function(t){if(!this._cacheData||!this._cacheData.sprite){var e=this.getLocalBounds(null,!0),r=this.alpha;this.alpha=1;var i=t.context,n=t._projTransform;e.ceil(U.RESOLUTION);var o=Fr.create({width:e.width,height:e.height}),s="cacheAsBitmap_"+ve();this._cacheData.textureCacheId=s,mr.addToCache(o.baseTexture,s),Nr.addToCache(o,s);var a=za;this.transform.localTransform.copyTo(a),a.invert(),a.tx-=e.x,a.ty-=e.y,this.renderCanvas=this._cacheData.originalRenderCanvas,t.render(this,{renderTexture:o,clear:!0,transform:a,skipUpdateTransform:!1}),t.context=i,t._projTransform=n,this.renderCanvas=this._renderCachedCanvas,this.updateTransform=this.displayObjectUpdateTransform,this.calculateBounds=this._calculateCachedBounds,this.getLocalBounds=this._getCachedLocalBounds,this._mask=null,this.filterArea=null,this.alpha=r;var h=new As(o);h.transform.worldTransform=this.transform.worldTransform,h.anchor.x=-e.x/e.width,h.anchor.y=-e.y/e.height,h.alpha=r,h._bounds=this._bounds,this._cacheData.sprite=h,this.transform._parentID=-1,this.parent?this.updateTransform():(this.parent=t._tempDisplayObjectParent,this.updateTransform(),this.parent=null),this.containsPoint=h.containsPoint.bind(h)}},Je.prototype._calculateCachedBounds=function(){this._bounds.clear(),this._cacheData.sprite.transform._worldID=this.transform._worldID,this._cacheData.sprite._calculateBounds(),this._bounds.updateID=this._boundsID},Je.prototype._getCachedLocalBounds=function(){return this._cacheData.sprite.getLocalBounds(null)},Je.prototype._destroyCachedDisplayObject=function(){this._cacheData.sprite._texture.destroy(!0),this._cacheData.sprite=null,mr.removeFromCache(this._cacheData.textureCacheId),Nr.removeFromCache(this._cacheData.textureCacheId),this._cacheData.textureCacheId=null},Je.prototype._cacheAsBitmapDestroy=function(t){this.cacheAsBitmap=!1,this.destroy(t)},Je.prototype.name=null,er.prototype.getChildByName=function(t,e){for(var r=0,i=this.children.length;r0){var d=a.x-t[l].x,f=a.y-t[l].y,p=Math.sqrt(d*d+f*f);a=t[l],s+=p/h}else s=l/(u-1);n[c]=s,n[c+1]=0,n[c+2]=s,n[c+3]=1}var _=0;for(l=0;l0?this.textureScale*this._width/2:this._width/2;i/=l,n/=l,i*=c,n*=c,o[u]=h.x+i,o[u+1]=h.y+n,o[u+2]=h.x-i,o[u+3]=h.y-n,r=h}this.buffers[0].update()}},e.prototype.update=function(){this.textureScale>0?this.build():this.updateVertices()},e}(fa),th=function(e){function r(r,i,n){void 0===n&&(n=0);var o=this,s=new $a(r.height,i,n),a=new da(r);return n>0&&(r.baseTexture.wrapMode=t.WRAP_MODES.REPEAT),(o=e.call(this,s,a)||this).autoUpdate=!0,o}return Qa(r,e),r.prototype._render=function(t){var r=this.geometry;(this.autoUpdate||r._width!==this.shader.texture.height)&&(r._width=this.shader.texture.height,r.update()),e.prototype._render.call(this,t)},r}(ca),eh=function(t){function e(e,r,i){var n=this,o=new Ja(e.width,e.height,r,i),s=new da(Nr.WHITE);return(n=t.call(this,o,s)||this).texture=e,n.autoResize=!0,n}return Qa(e,t),e.prototype.textureUpdated=function(){this._textureID=this.shader.texture._updateID;var t=this.geometry,e=this.shader.texture,r=e.width,i=e.height;!this.autoResize||t.width===r&&t.height===i||(t.width=this.shader.texture.width,t.height=this.shader.texture.height,t.build())},Object.defineProperty(e.prototype,"texture",{get:function(){return this.shader.texture},set:function(t){this.shader.texture!==t&&(this.shader.texture=t,this._textureID=-1,t.baseTexture.valid?this.textureUpdated():t.once("update",this.textureUpdated,this))},enumerable:!1,configurable:!0}),e.prototype._render=function(e){this._textureID!==this.shader.texture._updateID&&this.textureUpdated(),t.prototype._render.call(this,e)},e.prototype.destroy=function(e){this.shader.texture.off("update",this.textureUpdated,this),t.prototype.destroy.call(this,e)},e}(ca),rh=function(t){function e(e,r,i,n,o){void 0===e&&(e=Nr.EMPTY);var s=this,a=new fa(r,i,n);a.getBuffer("aVertexPosition").static=!1;var h=new da(e);return(s=t.call(this,a,h,null,o)||this).autoUpdate=!0,s}return Qa(e,t),Object.defineProperty(e.prototype,"vertices",{get:function(){return this.geometry.getBuffer("aVertexPosition").data},set:function(t){this.geometry.getBuffer("aVertexPosition").data=t},enumerable:!1,configurable:!0}),e.prototype._render=function(e){this.autoUpdate&&this.geometry.getBuffer("aVertexPosition").update(),t.prototype._render.call(this,e)},e}(ca),ih=function(t){function e(e,r,i,n,o){void 0===r&&(r=10),void 0===i&&(i=10),void 0===n&&(n=10),void 0===o&&(o=10);var s=t.call(this,Nr.WHITE,4,4)||this;return s._origWidth=e.orig.width,s._origHeight=e.orig.height,s._width=s._origWidth,s._height=s._origHeight,s._leftWidth=r,s._rightWidth=n,s._topHeight=i,s._bottomHeight=o,s.texture=e,s}return Qa(e,t),e.prototype.textureUpdated=function(){this._textureID=this.shader.texture._updateID,this._refresh()},Object.defineProperty(e.prototype,"vertices",{get:function(){return this.geometry.getBuffer("aVertexPosition").data},set:function(t){this.geometry.getBuffer("aVertexPosition").data=t},enumerable:!1,configurable:!0}),e.prototype.updateHorizontalVertices=function(){var t=this.vertices,e=this._getMinScale();t[9]=t[11]=t[13]=t[15]=this._topHeight*e,t[17]=t[19]=t[21]=t[23]=this._height-this._bottomHeight*e,t[25]=t[27]=t[29]=t[31]=this._height},e.prototype.updateVerticalVertices=function(){var t=this.vertices,e=this._getMinScale();t[2]=t[10]=t[18]=t[26]=this._leftWidth*e,t[4]=t[12]=t[20]=t[28]=this._width-this._rightWidth*e,t[6]=t[14]=t[22]=t[30]=this._width},e.prototype._getMinScale=function(){var t=this._leftWidth+this._rightWidth,e=this._width>t?1:this._width/t,r=this._topHeight+this._bottomHeight,i=this._height>r?1:this._height/r;return Math.min(e,i)},Object.defineProperty(e.prototype,"width",{get:function(){return this._width},set:function(t){this._width=t,this._refresh()},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"height",{get:function(){return this._height},set:function(t){this._height=t,this._refresh()},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"leftWidth",{get:function(){return this._leftWidth},set:function(t){this._leftWidth=t,this._refresh()},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"rightWidth",{get:function(){return this._rightWidth},set:function(t){this._rightWidth=t,this._refresh()},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"topHeight",{get:function(){return this._topHeight},set:function(t){this._topHeight=t,this._refresh()},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"bottomHeight",{get:function(){return this._bottomHeight},set:function(t){this._bottomHeight=t,this._refresh()},enumerable:!1,configurable:!0}),e.prototype._refresh=function(){var t=this.texture,e=this.geometry.buffers[1].data;this._origWidth=t.orig.width,this._origHeight=t.orig.height;var r=1/this._origWidth,i=1/this._origHeight;e[0]=e[8]=e[16]=e[24]=0,e[1]=e[3]=e[5]=e[7]=0,e[6]=e[14]=e[22]=e[30]=1,e[25]=e[27]=e[29]=e[31]=1,e[2]=e[10]=e[18]=e[26]=r*this._leftWidth,e[4]=e[12]=e[20]=e[28]=1-r*this._rightWidth,e[9]=e[11]=e[13]=e[15]=i*this._topHeight,e[17]=e[19]=e[21]=e[23]=1-i*this._bottomHeight,this.updateHorizontalVertices(),this.updateVerticalVertices(),this.geometry.buffers[0].update(),this.geometry.buffers[1].update()},e}(eh),nh=function(t,e){return nh=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)e.hasOwnProperty(r)&&(t[r]=e[r])},nh(t,e)},oh=function(e){function r(t,r){void 0===r&&(r=!0);var i=e.call(this,t[0]instanceof Nr?t[0]:t[0].texture)||this;return i._textures=null,i._durations=null,i._autoUpdate=r,i._isConnectedToTicker=!1,i.animationSpeed=1,i.loop=!0,i.updateAnchor=!1,i.onComplete=null,i.onFrameChange=null,i.onLoop=null,i._currentTime=0,i._playing=!1,i._previousFrame=null,i.textures=t,i}return function(t,e){function r(){this.constructor=t}nh(t,e),t.prototype=null===e?Object.create(e):(r.prototype=e.prototype,new r)}(r,e),r.prototype.stop=function(){this._playing&&(this._playing=!1,this._autoUpdate&&this._isConnectedToTicker&&(ar.shared.remove(this.update,this),this._isConnectedToTicker=!1))},r.prototype.play=function(){this._playing||(this._playing=!0,this._autoUpdate&&!this._isConnectedToTicker&&(ar.shared.add(this.update,this,t.UPDATE_PRIORITY.HIGH),this._isConnectedToTicker=!0))},r.prototype.gotoAndStop=function(t){this.stop();var e=this.currentFrame;this._currentTime=t,e!==this.currentFrame&&this.updateTexture()},r.prototype.gotoAndPlay=function(t){var e=this.currentFrame;this._currentTime=t,e!==this.currentFrame&&this.updateTexture(),this.play()},r.prototype.update=function(t){if(this._playing){var e=this.animationSpeed*t,r=this.currentFrame;if(null!==this._durations){var i=this._currentTime%1*this._durations[this.currentFrame];for(i+=e/60*1e3;i<0;)this._currentTime--,i+=this._durations[this.currentFrame];var n=Math.sign(this.animationSpeed*t);for(this._currentTime=Math.floor(this._currentTime);i>=this._durations[this.currentFrame];)i-=this._durations[this.currentFrame]*n,this._currentTime+=n;this._currentTime+=i/this._durations[this.currentFrame]}else this._currentTime+=e;this._currentTime<0&&!this.loop?(this.gotoAndStop(0),this.onComplete&&this.onComplete()):this._currentTime>=this._textures.length&&!this.loop?(this.gotoAndStop(this._textures.length-1),this.onComplete&&this.onComplete()):r!==this.currentFrame&&(this.loop&&this.onLoop&&(this.animationSpeed>0&&this.currentFramer)&&this.onLoop(),this.updateTexture())}},r.prototype.updateTexture=function(){var t=this.currentFrame;this._previousFrame!==t&&(this._previousFrame=t,this._texture=this._textures[t],this._textureID=-1,this._textureTrimmedID=-1,this._cachedTint=16777215,this.uvs=this._texture._uvs.uvsFloat32,this.updateAnchor&&this._anchor.copyFrom(this._texture.defaultAnchor),this.onFrameChange&&this.onFrameChange(this.currentFrame))},r.prototype.destroy=function(t){this.stop(),e.prototype.destroy.call(this,t),this.onComplete=null,this.onFrameChange=null,this.onLoop=null},r.fromFrames=function(t){for(var e=[],i=0;i {
+ const CHANNEL = "desktop-pet-model";
+ const canvas = document.querySelector("#model");
+ let application;
+ let model;
+ let modelId = "";
+ let generation = 0;
+ let automatic = true;
+ let gazeEnabled = true;
+ let busy = false;
+ let expressionIndex = -1;
+ let expressionQueue = Promise.resolve();
+ let mouthTarget = 0;
+ let mouthValue = 0;
+ let mouthAt = 0;
+ let pettingUntil = 0;
+ let nextMotionAt = 0;
+ let metadata = { expressions: [], motions: {} };
+
+ function notify(type, extra = {}) { parent.postMessage({ channel: CHANNEL, type, ...extra }, "*"); }
+ function fit() {
+ if (!application || !model) return;
+ const width = Math.max(1, innerWidth);
+ const height = Math.max(1, innerHeight);
+ application.renderer.resize(width, height);
+ const bounds = model.internalModel;
+ const scale = Math.min(width / bounds.width, height / bounds.height) * 0.94;
+ model.scale.set(scale);
+ model.anchor.set(0.5, 1);
+ model.position.set(width / 2, height - 2);
+ }
+
+ function stopMotions() {
+ if (!model) return;
+ model.internalModel.motionManager.stopAllMotions();
+ // Breathing and blinking remain active; large idle motions are explicitly paced below.
+ model.internalModel.motionManager.groups.idle = "__desktop_pet_no_automatic_motion__";
+ }
+
+ function expression(index) {
+ const target = model;
+ const token = generation;
+ expressionIndex = index;
+ expressionQueue = expressionQueue.then(async () => {
+ if (!target || model !== target || generation !== token) return;
+ const manager = target.internalModel.motionManager.expressionManager;
+ if (index < 0 && manager) {
+ // The library restores currentExpression after motions; neutral must persist too.
+ manager.currentExpression = manager.defaultExpression;
+ manager.resetExpression();
+ } else if (manager && !(await manager.setExpression(index))) { notify("action-error"); return; }
+ if (model === target) notify("expression", { index });
+ }).catch(() => notify("action-error"));
+ }
+
+ function motion(group, index = 0) {
+ if (!automatic || !model || !Object.hasOwn(metadata.motions, group)) return;
+ if (!Number.isInteger(index) || index < 0 || index >= metadata.motions[group]) return;
+ const target = model;
+ target.motion(group, index, 3).then((started) => { if (target === model) notify(started ? "motion" : "action-error", { group, index }); })
+ .catch(() => notify("action-error"));
+ nextMotionAt = performance.now() + 45000;
+ }
+
+ async function load(data) {
+ const token = ++generation;
+ modelId = data.id;
+ automatic = data.automatic !== false;
+ gazeEnabled = data.gaze !== false;
+ busy = data.busy === true;
+ expressionIndex = -1;
+ mouthTarget = mouthValue = 0;
+ model?.destroy({ children: true, texture: true, baseTexture: true });
+ model = null;
+ try {
+ if (!window.Live2DCubismCore || !window.PIXI?.live2d) throw new Error();
+ if (!application) {
+ application = new PIXI.Application({ view: canvas, backgroundAlpha: 0, antialias: true,
+ autoDensity: true, resolution: Math.min(devicePixelRatio || 1, 2), preserveDrawingBuffer: true });
+ application.ticker.maxFPS = 30;
+ }
+ const url = `/models/${modelId}/model.model3.json`;
+ const descriptor = await (await fetch(url)).json();
+ const refs = descriptor.FileReferences;
+ metadata = { expressions: (refs.Expressions || []).map((entry) => entry.Name),
+ motions: Object.fromEntries(Object.entries(refs.Motions || {}).map(([group, entries]) => [group, entries.length])) };
+ const loaded = await PIXI.live2d.Live2DModel.from(url, { autoInteract: false, idleMotionGroup: "__desktop_pet_no_automatic_motion__" });
+ if (generation !== token) { loaded.destroy({ children: true, texture: true, baseTexture: true }); return; }
+ model = loaded;
+ application.stage.addChild(model);
+ stopMotions();
+ fit();
+ nextMotionAt = performance.now() + 35000;
+ const internal = model.internalModel;
+ const core = internal.coreModel;
+ const ids = new Set(core.getModel().parameters.ids);
+ const lips = descriptor.Groups?.find((group) => group.Name === "LipSync")?.Ids || ["ParamMouthOpenY"];
+ internal.on("beforeModelUpdate", () => {
+ const now = performance.now();
+ if (now - mouthAt > 300) mouthTarget = 0;
+ mouthValue += (mouthTarget - mouthValue) * 0.4;
+ if (mouthTarget > 0 || mouthValue > 0.005) {
+ for (const id of lips) if (ids.has(id)) core.setParameterValueById(id, mouthValue);
+ }
+ if (automatic && now < pettingUntil) {
+ const weight = Math.min(1, (pettingUntil - now) / 400);
+ if (ids.has("ParamAngleZ")) core.addParameterValueById("ParamAngleZ", -4 * weight);
+ }
+ if (automatic && !busy && now > nextMotionAt) {
+ const group = Object.keys(metadata.motions).find((key) => /^idle$/i.test(key));
+ nextMotionAt = now + 45000 + Math.random() * 15000;
+ if (group && metadata.motions[group]) model.motion(group, 0, 1).catch(() => {});
+ }
+ });
+ notify("loaded", { expressions: metadata.expressions, motions: metadata.motions });
+ } catch {
+ if (generation === token) notify("error");
+ }
+ }
+
+ window.addEventListener("message", (event) => {
+ const data = event.data;
+ if (event.source !== parent || event.origin !== "null" || !data || data.channel !== CHANNEL) return;
+ if (data.type === "load" && /^imported-[a-f0-9]{24}$/.test(data.id)) { load(data); return; }
+ if (!model || data.id !== modelId) return;
+ if (data.type === "options") {
+ const changed = automatic !== (data.automatic !== false);
+ automatic = data.automatic !== false;
+ gazeEnabled = data.gaze !== false;
+ if (data.busy === true && !busy) stopMotions();
+ busy = data.busy === true;
+ if (changed) { stopMotions(); nextMotionAt = performance.now() + 45000; }
+ if (!gazeEnabled || !automatic) model.internalModel.focusController.focus(0, 0);
+ } else if (data.type === "gaze") {
+ if (!automatic || !gazeEnabled || !Number.isFinite(data.x) || !Number.isFinite(data.y)) return;
+ model.internalModel.focusController.focus(Math.max(-0.35, Math.min(0.35, data.x)), Math.max(-0.25, Math.min(0.25, data.y)));
+ } else if (data.type === "mouth" && Number.isFinite(data.value)) {
+ mouthTarget = Math.max(0, Math.min(1, data.value)); mouthAt = performance.now();
+ } else if (data.type === "cycle") {
+ expression((expressionIndex + 2) % (metadata.expressions.length + 1) - 1);
+ } else if (data.type === "expression" && Number.isInteger(data.index) && data.index >= -1 && data.index < metadata.expressions.length) {
+ expression(data.index);
+ } else if (data.type === "motion") {
+ motion(data.group, data.index);
+ } else if (data.type === "touch" && automatic && !busy) {
+ pettingUntil = performance.now() + 1200;
+ const group = Object.keys(metadata.motions).find((key) => /tapbody|tap_body|touch/i.test(key));
+ if (group && metadata.motions[group]) motion(group, 0);
+ }
+ });
+ window.addEventListener("resize", fit);
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) application?.ticker.stop();
+ else application?.ticker.start();
+ });
+ notify("boot");
+})();
diff --git a/src/main.js b/src/main.js
index 6053b22..7ab9271 100644
--- a/src/main.js
+++ b/src/main.js
@@ -8,17 +8,25 @@ const {
Notification,
safeStorage,
dialog,
+ protocol,
+ session,
+ shell,
} = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const i18n = require("./i18n");
const validation = require("./config-validation");
const { readChatResponse } = require("./chat-response");
+const { AUDIO_LIMIT, ERROR_LIMIT, readResponseBytes, readResponseJson, returnedAudioUrl, audioDataUrl: validateAudioDataUrl, redactServiceText } = require("./network-safety");
const { expressionInstruction, parseCompanionReply } = require("./companion-response");
const { companionBounds } = require("./companion-layout");
const { modelsEndpoint, readModelList } = require("./model-list");
const { readPack, installPack } = require("./character-import");
const { pathToFileURL } = require("node:url");
+const { readLive2DPack, installLive2DPack } = require("./live2d-import");
+const live2dHost = require("./live2d-host");
+
+protocol?.registerSchemesAsPrivileged([{ scheme: "pet-live2d", privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } }]);
const BASE_SIZE = { width: 192, height: 208 };
const BUBBLE_HEIGHT = 132;
@@ -121,28 +129,54 @@ function importedCharactersDir() { return path.join(app.getPath("userData"), "ch
async function previewCharacterImport(event, kind) {
const owner = BrowserWindow.fromWebContents(event.sender);
+ const live2d = kind === "live2d";
const result = await dialog.showOpenDialog(owner, kind === "folder"
? { properties: ["openDirectory"] }
- : { properties: ["openFile"], filters: [{ name: "ZIP", extensions: ["zip"] }] });
+ : { properties: ["openFile"], filters: [{ name: live2d ? "Live2D" : "ZIP", extensions: live2d ? ["json", "zip"] : ["zip"] }] });
if (result.canceled || !result.filePaths.length) return { cancelled: true };
try {
- const pack = readPack(result.filePaths[0], (bytes) => !nativeImage.createFromBuffer(bytes).isEmpty());
+ const pack = (live2d ? readLive2DPack : readPack)(result.filePaths[0], (bytes) => !nativeImage.createFromBuffer(bytes).isEmpty());
const token = require("node:crypto").randomUUID();
pendingImport = { pack, token, sender: event.sender.id, expires: Date.now() + 600000 };
- return { token, name: pack.manifest.name, description: pack.manifest.description,
- preview: `data:image/png;base64,${pack.files[pack.manifest.preview].toString("base64")}` };
+ return { token, name: pack.manifest.name, description: pack.manifest.description, type: pack.manifest.type || "sprite",
+ preview: live2d ? "" : `data:image/png;base64,${pack.files[pack.manifest.preview].toString("base64")}` };
} catch { return { error: "invalid_pack" }; }
}
function confirmCharacterImport(event, token) {
if (!pendingImport || pendingImport.token !== token || pendingImport.sender !== event.sender.id || pendingImport.expires < Date.now()) return { error: "expired" };
try {
- installPack(pendingImport.pack, importedCharactersDir());
+ (pendingImport.pack.manifest.type === "live2d" ? installLive2DPack : installPack)(pendingImport.pack, importedCharactersDir());
pendingImport = null;
return { ok: true, ...chatState() };
} catch { return { error: "import_failed" }; }
}
+async function importLive2DRuntime(event) {
+ const result = await dialog.showOpenDialog(BrowserWindow.fromWebContents(event.sender), {
+ properties: ["openFile"], filters: [{ name: "Cubism Core", extensions: ["js"] }],
+ });
+ if (result.canceled || !result.filePaths.length) return { cancelled: true };
+ try {
+ live2dHost.installCore(result.filePaths[0], app.getPath("userData"));
+ mainWindow?.webContents.send("app-state-updated", appState());
+ return { ok: true };
+ } catch { return { error: "invalid_runtime" }; }
+}
+
+function performModelAction(_event, action) {
+ const pack = getCharacterPack();
+ if (pack?.type !== "live2d" || !mainWindow || !action) return false;
+ if (action.type === "motion" && settings.expressionMode === "clickOnly") return false;
+ const valid = action.type === "expression"
+ ? Number.isInteger(action.index) && action.index >= -1 && action.index < pack.expressions.length
+ : action.type === "motion" && Object.hasOwn(pack.motions, action.group)
+ && Number.isInteger(action.index) && action.index >= 0 && action.index < pack.motions[action.group];
+ if (!valid) return false;
+ mainWindow.webContents.send("model-action", { type: action.type, index: action.index, group: action.group, id: pack.id });
+ return true;
+}
+
async function removeCharacterImport(event, id) {
if (!/^imported-[a-f0-9]{24}$/.test(id)) return { error: "invalid_id" };
const result = await dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender), {
@@ -293,6 +327,16 @@ function listCharacterPacks() {
try {
const root = path.join(importedCharactersDir(), id);
const raw = JSON.parse(fs.readFileSync(path.join(root, "character.json"), "utf8"));
+ if (raw.type === "live2d") {
+ if (raw.model !== "model.model3.json" || !Array.isArray(raw.resources)) return null;
+ return { id, type: "live2d", name: String(raw.name || "Character").slice(0, 80), description: "", imported: true,
+ frame: { width: 768, height: 832 }, columns: 1,
+ states: Object.fromEntries(["idle", "happy", "shy", "review", "failed", "waving"].map((key) => [key, { row: 0, frames: 1, interval: 1000 }])),
+ automaticActions: [], clickActions: [], staticExpressions: [],
+ expressions: Array.isArray(raw.expressions) ? raw.expressions.slice(0, 64).map((name) => String(name).slice(0, 80)) : [],
+ motions: raw.motions && typeof raw.motions === "object" ? raw.motions : {},
+ };
+ }
const pack = normalizeCharacterPack(raw, id);
return { ...pack, id, imported: true, absoluteSpritePath: path.join(root, raw.sprite),
spritePath: pathToFileURL(path.join(root, raw.sprite)).href,
@@ -563,6 +607,11 @@ function normalizePersonaSettings(rawPersona = {}) {
speakingStyle: normalizeLongText(raw.speakingStyle, 1200),
background: normalizeLongText(raw.background, 1600),
extraRules: normalizeLongText(raw.extraRules, 1600),
+ live2dGaze: raw.live2dGaze !== false,
+ live2dExpressions: Object.fromEntries(["happy", "shy", "review", "failed"].map((key) => {
+ const value = raw.live2dExpressions?.[key];
+ return [key, Number.isInteger(value) && value >= 0 && value < 64 ? value : -1];
+ })),
};
}
@@ -699,10 +748,11 @@ function createWindow() {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
- sandbox: false,
+ sandbox: true,
},
});
+ protectWindow(mainWindow, "index.html");
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
mainWindow.loadFile(path.join(__dirname, "index.html"));
mainWindow.once("ready-to-show", () => mainWindow.showInactive());
@@ -712,6 +762,18 @@ function createWindow() {
mainWindow.webContents.on("did-start-loading", () => stopVoice());
}
+function protectWindow(window, page) {
+ const localUrl = pathToFileURL(path.join(__dirname, page)).href;
+ window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
+ window.webContents.on("will-navigate", (event, url) => { if (url !== localUrl) event.preventDefault(); });
+ window.webContents.on("will-frame-navigate", (event) => {
+ const allowed = event.isMainFrame ? event.url === localUrl
+ : page === "index.html" && event.url === `${live2dHost.ORIGIN}/index.html`;
+ if (!allowed) event.preventDefault();
+ });
+ window.webContents.on("will-attach-webview", (event) => event.preventDefault());
+}
+
function enableInputMenu(window) {
window.webContents.on("context-menu", (_event, params) => {
if (!params.isEditable) return;
@@ -752,10 +814,11 @@ function createChatWindow() {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
- sandbox: false,
+ sandbox: true,
},
});
+ protectWindow(chatWindow, "chat.html");
chatWindow.loadFile(path.join(__dirname, "chat.html"));
enableInputMenu(chatWindow);
chatWindow.once("ready-to-show", () => chatWindow.show());
@@ -786,10 +849,11 @@ function createQuickChatWindow() {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
- sandbox: false,
+ sandbox: true,
},
});
+ protectWindow(quickChatWindow, "quick-chat.html");
quickChatWindow.loadFile(path.join(__dirname, "quick-chat.html"));
enableInputMenu(quickChatWindow);
quickChatWindow.once("ready-to-show", () => quickChatWindow.show());
@@ -817,10 +881,11 @@ function createCompanionSettingsWindow() {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
- sandbox: false,
+ sandbox: true,
},
});
+ protectWindow(companionSettingsWindow, "settings.html");
companionSettingsWindow.loadFile(path.join(__dirname, "settings.html"));
enableInputMenu(companionSettingsWindow);
companionSettingsWindow.once("ready-to-show", () => companionSettingsWindow.show());
@@ -1125,6 +1190,9 @@ function chatState() {
automaticActionCount: pack.automaticActions.length,
clickActionCount: pack.clickActions.length,
imported: pack.imported === true,
+ type: pack.type || "sprite",
+ expressions: pack.expressions || [],
+ motions: pack.motions || {},
}));
return {
language: settings.language,
@@ -1277,6 +1345,7 @@ async function requestAssistantReply(userText, controller = new AbortController(
}
const response = await fetch(llmEndpoint(assistant.baseUrl), {
method: "POST",
+ redirect: "error",
headers,
signal: controller.signal,
body: JSON.stringify({
@@ -1288,7 +1357,7 @@ async function requestAssistantReply(userText, controller = new AbortController(
});
if (!response.ok) {
- const detail = await responseErrorText(response);
+ const detail = await responseErrorText(response, [assistant.apiKey]);
return {
reply: t("assistant.statusError", {
status: response.status,
@@ -1346,6 +1415,7 @@ async function testAssistantConnection(_event, patch = {}) {
if (assistant.apiKey) headers.Authorization = `Bearer ${assistant.apiKey}`;
const response = await fetch(llmEndpoint(assistant.baseUrl), {
method: "POST",
+ redirect: "error",
headers,
signal: controller.signal,
body: JSON.stringify({
@@ -1359,7 +1429,7 @@ async function testAssistantConnection(_event, patch = {}) {
}),
});
if (!response.ok) {
- const detail = await responseErrorText(response);
+ const detail = await responseErrorText(response, [assistant.apiKey]);
return {
ok: false,
message: t("assistant.test.statusError", {
@@ -1550,46 +1620,50 @@ function appendQuery(url, params) {
function audioContentType(response, provider, tts = settings.tts || DEFAULT_SETTINGS.tts) {
const contentType = response.headers.get("content-type");
- if (contentType && contentType.includes("audio/")) return contentType.split(";")[0];
+ if (/^audio\/(wav|x-wav|wave|mpeg|mp3|mp4|ogg|aac|flac|webm)(;|$)/i.test(contentType || "")) return contentType.split(";")[0];
if (provider === "gptsovits") return `audio/${tts.mediaType || "wav"}`;
- return contentType || "audio/mpeg";
+ return "audio/mpeg";
}
async function audioDataFromResponse(response, provider, tts = settings.tts || DEFAULT_SETTINGS.tts, signal) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
- const data = await response.json();
+ const data = await readResponseJson(response, Math.ceil(AUDIO_LIMIT / 3) * 4 + 65536);
const audioValue = data.audioDataUrl || data.audio_url || data.url || data.audio || data.audio_base64;
if (!audioValue) return null;
if (typeof audioValue === "string" && audioValue.startsWith("data:")) {
- return audioValue;
+ return validateAudioDataUrl(audioValue);
}
if (typeof audioValue === "string" && /^https?:\/\//.test(audioValue)) {
- const audioResponse = await fetch(audioValue, { signal });
+ const target = returnedAudioUrl(audioValue, ttsEndpoint(tts));
+ const audioResponse = await fetch(target, { signal, redirect: "error" });
if (!audioResponse.ok) return null;
- const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
+ const audioBuffer = await readResponseBytes(audioResponse, AUDIO_LIMIT);
+ if (!audioBuffer.length) return null;
return `data:${audioContentType(audioResponse, provider, tts)};base64,${audioBuffer.toString("base64")}`;
}
if (typeof audioValue === "string") {
const mediaType = provider === "gptsovits" ? tts.mediaType : "mpeg";
- return `data:audio/${mediaType || "mpeg"};base64,${audioValue}`;
+ return validateAudioDataUrl(`data:audio/${mediaType || "mpeg"};base64,${audioValue}`);
}
return null;
}
- const buffer = Buffer.from(await response.arrayBuffer());
+ const buffer = await readResponseBytes(response, AUDIO_LIMIT);
if (!buffer.length) return null;
return `data:${audioContentType(response, provider, tts)};base64,${buffer.toString("base64")}`;
}
-async function responseErrorText(response) {
+async function responseErrorText(response, secrets = []) {
try {
+ const body = (await readResponseBytes(response, ERROR_LIMIT)).toString("utf8");
const contentType = response.headers.get("content-type") || "";
+ const sensitive = [...secrets, settings.assistant?.apiKey, settings.tts?.apiKey];
if (contentType.includes("application/json")) {
- const data = await response.json();
+ const data = JSON.parse(body);
const message = data.message || data.error?.message || data.error || data.Exception || JSON.stringify(data);
- return String(message).replace(/\s+/g, " ").slice(0, 160);
+ return redactServiceText(message, sensitive).replace(/\s+/g, " ").slice(0, 160);
}
- return (await response.text()).replace(/\s+/g, " ").slice(0, 160);
+ return redactServiceText(body, sensitive).replace(/\s+/g, " ").slice(0, 160);
} catch {
return "";
}
@@ -1641,9 +1715,9 @@ async function synthesizeSpeechWithSettings(rawText, nextSettings = settings, co
}
}
- const response = await fetch(url, requestOptions);
+ const response = await fetch(url, { ...requestOptions, redirect: "error" });
if (!response.ok) {
- const detail = await responseErrorText(response);
+ const detail = await responseErrorText(response, [tts.apiKey]);
return { ok: false, error: detail ? `tts_status_${response.status}: ${detail}` : `tts_status_${response.status}` };
}
const audioDataUrl = await audioDataFromResponse(response, tts.provider, tts, controller.signal);
@@ -1651,6 +1725,8 @@ async function synthesizeSpeechWithSettings(rawText, nextSettings = settings, co
return { ok: true, audioDataUrl };
} catch (error) {
if (controller.signal.aborted && !expired) return { ok: false, cancelled: true };
+ if (error?.message === "audio_origin_not_allowed") return { ok: false, error: t("tts.audioOrigin", {}, nextSettings) };
+ if (error?.message === "response_too_large") return { ok: false, error: t("tts.responseTooLarge", {}, nextSettings) };
return { ok: false, error: error?.name === "AbortError" ? "tts_timeout" : "tts_request_failed" };
} finally {
clearTimeout(timeout);
@@ -1907,41 +1983,58 @@ function setApplicationMenu() {
app.whenReady().then(() => {
app.setName(t("app.name"));
+ protocol.handle("pet-live2d", live2dHost.createModelHandler({ userData: app.getPath("userData"), sourceDir: __dirname, getActiveCharacter: getCharacterPack }));
+ session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
+ session.defaultSession.setPermissionCheckHandler(() => false);
createWindow();
- ipcMain.handle("get-app-state", () => appState());
- ipcMain.handle("get-chat-state", () => chatState());
- ipcMain.handle("save-chat-config", (_event, patch) => applyChatConfigPatch(patch));
- ipcMain.handle("test-assistant-connection", testAssistantConnection);
- ipcMain.handle("list-assistant-models", listAssistantModels);
- ipcMain.handle("test-tts-connection", testTtsConnection);
- ipcMain.handle("send-chat-message", sendChatMessage);
- ipcMain.handle("clear-chat-history", clearChatHistory);
- ipcMain.handle("stop-chat", stopChat);
- ipcMain.handle("confirm-discard", confirmDiscard);
- ipcMain.handle("preview-character-import", previewCharacterImport);
- ipcMain.handle("confirm-character-import", confirmCharacterImport);
- ipcMain.handle("remove-character-import", removeCharacterImport);
- ipcMain.handle("begin-speech", beginSpeech);
- ipcMain.on("speech-state-changed", speechStateChanged);
- ipcMain.handle("stop-voice", stopVoice);
- ipcMain.handle("replay-speech", replaySpeech);
- ipcMain.handle("synthesize-speech", synthesizeSpeech);
- ipcMain.on("set-settings", (_event, patch) => updateSettings(patch));
- ipcMain.on("record-interaction", (_event, kind) => updateMoodFromInteraction(kind));
- ipcMain.on("present-notification", (event, notice) => {
+ // Only our top-level local windows may invoke the preload bridge.
+ const trusted = (event) => [mainWindow, chatWindow, quickChatWindow, companionSettingsWindow].some((window) =>
+ window && !window.isDestroyed() && event.sender === window.webContents && event.senderFrame === window.webContents.mainFrame
+ && ["index.html", "chat.html", "quick-chat.html", "settings.html"].some((page) => event.senderFrame.url === pathToFileURL(path.join(__dirname, page)).href));
+ const handle = (channel, callback) => ipcMain.handle(channel, (event, ...args) => {
+ if (!trusted(event)) throw new Error("Unauthorized window");
+ return callback(event, ...args);
+ });
+ const on = (channel, callback) => ipcMain.on(channel, (event, ...args) => { if (trusted(event)) callback(event, ...args); });
+ handle("get-live2d-runtime", () => ({ ready: live2dHost.runtimeReady(app.getPath("userData")) }));
+ handle("import-live2d-runtime", importLive2DRuntime);
+ handle("perform-model-action", performModelAction);
+ on("open-live2d-download", () => { shell.openExternal(live2dHost.CORE_URL).catch(() => {}); });
+
+ handle("get-app-state", () => appState());
+ handle("get-chat-state", () => chatState());
+ handle("save-chat-config", (_event, patch) => applyChatConfigPatch(patch));
+ handle("test-assistant-connection", testAssistantConnection);
+ handle("list-assistant-models", listAssistantModels);
+ handle("test-tts-connection", testTtsConnection);
+ handle("send-chat-message", sendChatMessage);
+ handle("clear-chat-history", clearChatHistory);
+ handle("stop-chat", stopChat);
+ handle("confirm-discard", confirmDiscard);
+ handle("preview-character-import", previewCharacterImport);
+ handle("confirm-character-import", confirmCharacterImport);
+ handle("remove-character-import", removeCharacterImport);
+ handle("begin-speech", beginSpeech);
+ on("speech-state-changed", speechStateChanged);
+ handle("stop-voice", stopVoice);
+ handle("replay-speech", replaySpeech);
+ handle("synthesize-speech", synthesizeSpeech);
+ on("set-settings", (_event, patch) => updateSettings(patch));
+ on("record-interaction", (_event, kind) => updateMoodFromInteraction(kind));
+ on("present-notification", (event, notice) => {
if (event.sender !== mainWindow?.webContents || !Notification.isSupported()) return;
const title = normalizeCompactText(notice?.title, 120);
const body = normalizeLongText(notice?.body, 600);
if (title || body) new Notification({ title, body, silent: true }).show();
});
- ipcMain.on("open-chat-window", createChatWindow);
- ipcMain.on("open-quick-chat-window", createQuickChatWindow);
- ipcMain.on("open-companion-settings", createCompanionSettingsWindow);
- ipcMain.on("show-context-menu", showContextMenu);
- ipcMain.on("drag-start", beginDrag);
- ipcMain.handle("drag-move", moveDrag);
- ipcMain.on("drag-end", endDrag);
+ on("open-chat-window", createChatWindow);
+ on("open-quick-chat-window", createQuickChatWindow);
+ on("open-companion-settings", createCompanionSettingsWindow);
+ on("show-context-menu", showContextMenu);
+ on("drag-start", beginDrag);
+ handle("drag-move", moveDrag);
+ on("drag-end", endDrag);
if (process.platform === "darwin") {
app.dock.hide();
diff --git a/src/network-safety.js b/src/network-safety.js
new file mode 100644
index 0000000..8e41252
--- /dev/null
+++ b/src/network-safety.js
@@ -0,0 +1,61 @@
+const CHAT_LIMIT = 2 * 1024 * 1024;
+const AUDIO_LIMIT = 24 * 1024 * 1024;
+const ERROR_LIMIT = 32 * 1024;
+
+async function readResponseBytes(response, limit) {
+ const declared = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declared) && declared > limit) {
+ await response.body?.cancel().catch(() => {});
+ throw new Error("response_too_large");
+ }
+ if (!response.body) return Buffer.alloc(0);
+ const reader = response.body.getReader();
+ const chunks = [];
+ let size = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > limit) throw new Error("response_too_large");
+ chunks.push(Buffer.from(value));
+ }
+ return Buffer.concat(chunks, size);
+ } finally {
+ await reader.cancel().catch(() => {});
+ reader.releaseLock();
+ }
+}
+
+async function readResponseJson(response, limit = CHAT_LIMIT) {
+ return JSON.parse((await readResponseBytes(response, limit)).toString("utf8"));
+}
+
+function returnedAudioUrl(value, endpoint) {
+ const target = new URL(value);
+ const service = new URL(endpoint);
+ // Only the endpoint chosen by the user has network authority, not a URL in its reply.
+ if (!["http:", "https:"].includes(target.protocol) || target.origin !== service.origin
+ || target.username || target.password) throw new Error("audio_origin_not_allowed");
+ target.hash = "";
+ return target.href;
+}
+
+function audioDataUrl(value) {
+ const match = /^data:(audio\/(?:wav|x-wav|wave|mpeg|mp3|mp4|ogg|aac|flac|webm));base64,([A-Za-z0-9+/]*={0,2})$/i.exec(value);
+ if (!match || !match[2] || match[2].length > Math.ceil(AUDIO_LIMIT / 3) * 4) throw new Error("invalid_audio");
+ return `data:${match[1].toLowerCase()};base64,${match[2]}`;
+}
+
+function redactServiceText(value, secrets = []) {
+ let text = String(value || "");
+ for (const secret of secrets.filter((item) => typeof item === "string" && item.length)) {
+ for (const form of new Set([secret, encodeURIComponent(secret)])) text = text.split(form).join("[redacted]");
+ }
+ return text.replace(/\bsk-[A-Za-z0-9_-]{12,}\b/g, "[redacted]")
+ .replace(/\bBearer\s+[^\s,;"']+/gi, "Bearer [redacted]")
+ .replace(/\/(?:Users|home)\/[^\s"'<>]+/g, "[local path]")
+ .replace(/[A-Z]:\\Users\\[^\s"'<>]+/gi, "[local path]");
+}
+
+module.exports = { CHAT_LIMIT, AUDIO_LIMIT, ERROR_LIMIT, readResponseBytes, readResponseJson, returnedAudioUrl, audioDataUrl, redactServiceText };
diff --git a/src/preload.js b/src/preload.js
index 599dde9..ed33c12 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -2,6 +2,15 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("desktopPet", {
getAppState: () => ipcRenderer.invoke("get-app-state"),
+ getLive2DRuntime: () => ipcRenderer.invoke("get-live2d-runtime"),
+ importLive2DRuntime: () => ipcRenderer.invoke("import-live2d-runtime"),
+ openLive2DDownload: () => ipcRenderer.send("open-live2d-download"),
+ performModelAction: (action) => ipcRenderer.invoke("perform-model-action", action),
+ onModelAction: (callback) => {
+ const listener = (_event, action) => callback(action);
+ ipcRenderer.on("model-action", listener);
+ return () => ipcRenderer.off("model-action", listener);
+ },
setSettings: (patch) => ipcRenderer.send("set-settings", patch),
getChatState: () => ipcRenderer.invoke("get-chat-state"),
saveChatConfig: (patch) => ipcRenderer.invoke("save-chat-config", patch),
diff --git a/src/quick-chat.html b/src/quick-chat.html
index 74e9b12..794b1e5 100644
--- a/src/quick-chat.html
+++ b/src/quick-chat.html
@@ -2,6 +2,7 @@
+
Quick Companion Input
diff --git a/src/renderer.js b/src/renderer.js
index 1850a2d..6fc5459 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -104,6 +104,7 @@ function applyPetLayout() {
function startAnimation() {
window.clearInterval(animationTimer);
+ if (character.type === "live2d") return;
const current = character.states[state] || character.states.idle;
animationTimer = window.setInterval(() => {
if (settings.expressionMode === "clickOnly" && !dragStart) {
@@ -123,13 +124,17 @@ function setState(nextState) {
if (!character.states[nextState]) return;
if (state === nextState) return;
state = nextState;
+ if (character.type === "live2d") {
+ if (settings.expressionMode === "automatic") window.DesktopPetModel?.expression(settings.persona?.live2dExpressions?.[nextState] ?? -1);
+ return;
+ }
frameIndex = 0;
startAnimation();
}
function scheduleIdleBehavior() {
window.clearTimeout(idleTimer);
- if (settings.expressionMode !== "automatic" || conversationActive) return;
+ if (character.type === "live2d" || settings.expressionMode !== "automatic" || conversationActive) return;
const delay = 22000 + Math.random() * 15000;
idleTimer = window.setTimeout(() => {
@@ -219,6 +224,7 @@ function applyAppState(nextAppState = {}) {
expressionMode: nextAppState.settings?.expressionMode === "clickOnly" ? "clickOnly" : "automatic",
interactionResponses: nextAppState.settings?.interactionResponses !== false,
userName: nextAppState.settings?.persona?.userName || "",
+ persona: nextAppState.settings?.persona || {},
scale: Number(nextAppState.settings?.scale) || 1,
alwaysOnTop: nextAppState.settings?.alwaysOnTop !== false,
tts: {
@@ -247,6 +253,18 @@ function applyAppState(nextAppState = {}) {
talkButton.title = text("quick.toCharacter", { name: character.name });
document.documentElement.lang = activeLanguage;
applyPetLayout();
+ window.DesktopPetModel?.apply(character, settings, conversationActive || speechActive);
+
+ if (character.type === "live2d") {
+ ++spriteLoadToken;
+ spriteImage = null;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ window.clearInterval(animationTimer);
+ window.clearTimeout(idleTimer);
+ if (characterChanged || modeChanged) resetAfterSettingsChange();
+ scheduleStartupGreeting();
+ return;
+ }
if (characterChanged || !spriteImage) {
const token = ++spriteLoadToken;
@@ -278,6 +296,11 @@ function resetAfterSettingsChange() {
temporaryToken += 1;
state = "idle";
frameIndex = 0;
+ if (character.type === "live2d") {
+ window.DesktopPetModel?.expression(-1);
+ scheduleStartupGreeting();
+ return;
+ }
if (settings.expressionMode === "clickOnly") {
window.clearTimeout(idleTimer);
const currentIndex = character.staticExpressions.findIndex((expression) => expression.state === "idle" && expression.frame === 0);
@@ -298,10 +321,11 @@ function respondToInteraction(kind) {
: kind === "drop" ? ["tsundereProud", "happy", "waving", "idle"] : ["waving", "happy", "idle"];
const action = choices.find((name) => character.states[name]);
if (action) playTemporary(action, 1400);
+ if (character.type === "live2d") window.DesktopPetModel?.touch();
// Touches stay local and quiet; they neither replace a visible reply nor queue up.
if (bubbleVisible || performance.now() < interactionReadyAt) return;
interactionReadyAt = performance.now() + 6000;
- const style = character.id === "luna" ? "luna" : "default";
+ const style = settings.persona?.relationship === "partner" ? "partner" : character.id === "luna" ? "luna" : "default";
const variant = interactionVariants[kind]++ % 2;
const line = text(`pet.interaction.${style}.${kind}.${variant}`);
showBubble(settings.userName ? text("pet.interaction.address", { name: settings.userName, line }) : line);
@@ -310,11 +334,13 @@ function respondToInteraction(kind) {
function handleClick(kind = "greet") {
window.desktopPet.recordInteraction("click");
if (settings.expressionMode === "clickOnly") {
+ if (character.type === "live2d") { window.DesktopPetModel?.cycle(); return; }
const [nextState, nextFrame] = nextStaticExpression();
setStaticExpression(nextState, nextFrame);
} else {
if (conversationActive || speechActive) return;
if (settings.interactionResponses) { respondToInteraction(kind); return; }
+ if (character.type === "live2d") { window.DesktopPetModel?.touch(); return; }
const nextState = nextClickState();
playTemporary(nextState);
}
@@ -437,6 +463,7 @@ function dismissBubble() {
bubbleVisible = false;
conversationActive = false;
conversationAction = "idle";
+ window.DesktopPetModel?.busy(speechActive);
temporaryToken += 1;
if (settings.expressionMode === "automatic" && !dragStart) setState("idle");
scheduleIdleBehavior();
@@ -509,6 +536,8 @@ function chooseTtsVoice() {
function stopSpeech(status = "idle") {
speechToken += 1;
speechActive = false;
+ window.DesktopPetModel?.stopMouth();
+ window.DesktopPetModel?.busy(conversationActive);
if (speechId !== null) window.desktopPet.speechStateChanged({ id: speechId, status });
speechId = null;
window.clearTimeout(speechWatchdog);
@@ -527,6 +556,7 @@ async function speakText(text) {
const token = speechToken;
if (!settings.tts?.enabled || settings.tts.provider === "none" || !String(text || "").trim()) return;
speechActive = true;
+ window.DesktopPetModel?.busy(true);
window.clearTimeout(bubbleTimer);
try {
const id = await window.desktopPet.beginSpeech(speechGeneration);
@@ -555,6 +585,7 @@ async function speakText(text) {
activeAudio.onended = playNext;
activeAudio.onerror = () => finishSpeech(token, true);
await activeAudio.play();
+ if (token === speechToken) window.DesktopPetModel?.attachAudio(activeAudio);
if (token === speechToken) window.desktopPet.speechStateChanged({ id, status: "playing" });
} else {
if (!("speechSynthesis" in window)) { finishSpeech(token, true); return; }
@@ -603,6 +634,7 @@ function handlePetMessage(message = {}) {
if (isChat) {
if (!conversationActive || (!wasStreaming && bubbleStreaming)) conversationAction = "idle";
conversationActive = true;
+ window.DesktopPetModel?.busy(true);
if (action && character.states[action]) conversationAction = action;
window.clearTimeout(idleTimer);
temporaryToken += 1;
@@ -648,8 +680,16 @@ function loadSprite(spritePath) {
}
canvas.addEventListener("pointerdown", beginDrag);
+canvas.addEventListener("pointermove", (event) => {
+ if (dragStart || character.type !== "live2d") return;
+ const bounds = canvas.getBoundingClientRect();
+ window.DesktopPetModel?.gaze((event.clientX - bounds.left) / bounds.width * 0.7 - 0.35,
+ 0.25 - (event.clientY - bounds.top) / bounds.height * 0.5);
+});
+canvas.addEventListener("pointerleave", () => window.DesktopPetModel?.gaze(0, 0));
canvas.addEventListener("dblclick", handleDoubleClick);
canvas.addEventListener("pointerenter", () => {
+ if (character.type === "live2d") return;
if (settings.expressionMode !== "automatic" || conversationActive || dragStart || performance.now() < hoverReadyAt) return;
hoverReadyAt = performance.now() + 10000;
if (state === "idle") playTemporary("waving", 900);
diff --git a/src/settings.css b/src/settings.css
index 5179f2c..44e59de 100644
--- a/src/settings.css
+++ b/src/settings.css
@@ -330,3 +330,13 @@ button:focus-visible { outline: 2px solid #316e74; outline-offset: 2px; }
#import-preview { border-left: 3px solid #316e74; padding: 12px; }
#import-status { font-size: 12px; }
.settings-footer div { flex-shrink: 0; }
+.live2d-badge {
+ display: grid;
+ place-items: center;
+ width: 56px;
+ min-height: 90px;
+ color: #286770;
+ background: #eff8f8;
+ font-size: 14px;
+ border-radius: 4px;
+}
diff --git a/src/settings.html b/src/settings.html
index 1a3a8ad..d27f15f 100644
--- a/src/settings.html
+++ b/src/settings.html
@@ -2,6 +2,7 @@
+
Desktop Pet Settings
@@ -46,6 +47,7 @@ 角色
+
@@ -58,6 +60,31 @@
角色
选择角色后保存设置,桌宠窗口会立即切换。
+
+ Live2D
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 回复表情
+
+
+
+
+
+
+
+
人物设定
diff --git a/src/settings.js b/src/settings.js
index 8a61348..58573b2 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -137,6 +137,7 @@ async function importCharacter(kind) {
if (result.error) throw new Error();
importToken = result.token;
document.querySelector("#import-image").src = result.preview;
+ document.querySelector("#import-image").hidden = result.type === "live2d";
document.querySelector("#import-image").alt = result.name;
document.querySelector("#import-name").textContent = result.name;
document.querySelector("#import-description").textContent = result.description;
@@ -440,10 +441,15 @@ function renderCharacterCards() {
card.classList.toggle("selected", character.id === selectedCharacterId);
card.setAttribute("aria-pressed", character.id === selectedCharacterId ? "true" : "false");
- const image = document.createElement("img");
- image.src = character.previewPath || character.spritePath || "";
- image.alt = character.name;
- image.loading = "lazy";
+ const image = document.createElement(character.type === "live2d" ? "span" : "img");
+ if (character.type === "live2d") {
+ image.className = "live2d-badge";
+ image.textContent = "Live2D";
+ } else {
+ image.src = character.previewPath || character.spritePath || "";
+ image.alt = character.name;
+ image.loading = "lazy";
+ }
const content = document.createElement("span");
const name = document.createElement("b");
@@ -453,7 +459,7 @@ function renderCharacterCards() {
description.className = "description";
description.textContent = character.description || text("settings.character.noDescription");
meta.className = "meta";
- meta.textContent = text("settings.character.meta", {
+ meta.textContent = character.type === "live2d" ? text("live2d.modelMeta", { count: character.expressions?.length || 0 }) : text("settings.character.meta", {
actions: character.stateCount || 0,
click: character.clickActionCount || 0,
});
@@ -472,6 +478,45 @@ function renderCharacterCards() {
characterGridEl.append(card);
}
document.querySelector("#remove-character").hidden = !activeCharacter.imported;
+ renderLive2D(activeCharacter);
+}
+
+function fillModelExpressions(select, names, value = -1) {
+ select.textContent = "";
+ [text("live2d.neutral"), ...names].forEach((name, index) => {
+ const option = document.createElement("option");
+ option.value = String(index - 1); option.textContent = name; select.append(option);
+ });
+ select.value = String(value);
+}
+
+function renderLive2D(character) {
+ document.querySelector("#live2d-panel").hidden = character.type !== "live2d";
+ if (character.type !== "live2d") return;
+ document.querySelector("#live2d-gaze").checked = config.persona?.live2dGaze !== false;
+ const names = character.expressions || [];
+ fillModelExpressions(document.querySelector("#live2d-expression"), names);
+ for (const mood of ["happy", "shy", "review", "failed"]) {
+ const index = config.persona?.live2dExpressions?.[mood] ?? -1;
+ fillModelExpressions(document.querySelector(`#live2d-${mood}`), names, index < names.length ? index : -1);
+ }
+ const motionSelect = document.querySelector("#live2d-motion");
+ motionSelect.textContent = "";
+ for (const [group, count] of Object.entries(character.motions || {})) {
+ for (let index = 0; index < count; index++) {
+ const option = document.createElement("option");
+ option.value = JSON.stringify({ group, index }); option.textContent = `${group} ${index + 1}`; motionSelect.append(option);
+ }
+ }
+ document.querySelector("#preview-motion").disabled = !motionSelect.options.length;
+ refreshRuntimeStatus();
+}
+
+async function refreshRuntimeStatus() {
+ try {
+ const state = await window.desktopPet.getLive2DRuntime();
+ document.querySelector("#live2d-runtime-status").textContent = text(state.ready ? "live2d.runtimeReady" : "live2d.runtimeNeeded");
+ } catch { document.querySelector("#live2d-runtime-status").textContent = text("live2d.runtimeFailed"); }
}
function renderConfig(preserveDraft = false) {
@@ -644,6 +689,9 @@ function readConfigForm(includeEmptyKey = false) {
speakingStyle: personaSpeakingStyleEl.value.trim(),
background: personaBackgroundEl.value.trim(),
extraRules: personaExtraRulesEl.value.trim(),
+ live2dGaze: document.querySelector("#live2d-gaze").checked,
+ live2dExpressions: Object.fromEntries(["happy", "shy", "review", "failed"].map((mood) =>
+ [mood, Number(document.querySelector(`#live2d-${mood}`).value || -1)])),
};
const affection = {
enabled: affectionEnabledEl.checked,
@@ -894,6 +942,27 @@ window.addEventListener("beforeunload", (event) => {
});
document.querySelector("#import-folder").addEventListener("click", () => importCharacter("folder"));
document.querySelector("#import-zip").addEventListener("click", () => importCharacter("zip"));
+document.querySelector("#import-live2d").addEventListener("click", () => importCharacter("live2d"));
+document.querySelector("#download-core").addEventListener("click", () => window.desktopPet.openLive2DDownload());
+document.querySelector("#import-core").addEventListener("click", async () => {
+ try {
+ const result = await window.desktopPet.importLive2DRuntime();
+ if (result.cancelled) return;
+ if (!result.ok) throw new Error();
+ await refreshRuntimeStatus();
+ } catch { document.querySelector("#live2d-runtime-status").textContent = text("live2d.runtimeFailed"); }
+});
+document.querySelector("#preview-expression").addEventListener("click", async () => {
+ try { await window.desktopPet.performModelAction({ type: "expression", index: Number(document.querySelector("#live2d-expression").value) }); }
+ catch { document.querySelector("#live2d-action-status").textContent = text("live2d.actionFailed"); }
+});
+document.querySelector("#preview-motion").addEventListener("click", async () => {
+ try {
+ const action = JSON.parse(document.querySelector("#live2d-motion").value);
+ const result = await window.desktopPet.performModelAction({ type: "motion", ...action });
+ document.querySelector("#live2d-action-status").textContent = result ? "" : text("live2d.actionFailed");
+ } catch { document.querySelector("#live2d-action-status").textContent = text("live2d.actionFailed"); }
+});
document.querySelector("#cancel-import").addEventListener("click", () => { importToken = null; document.querySelector("#import-preview").hidden = true; });
document.querySelector("#confirm-import").addEventListener("click", async () => {
try {
diff --git a/src/styles.css b/src/styles.css
index a418178..5d281a9 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -30,6 +30,34 @@ body {
cursor: grabbing;
}
+#model-stage {
+ position: absolute;
+ left: 50%;
+ bottom: 0;
+ transform: translateX(-50%);
+ width: var(--pet-width, 192px);
+ height: var(--pet-height, 208px);
+ pointer-events: none;
+}
+#model-stage iframe { width: 100%; height: 100%; border: 0; }
+#model-status {
+ position: absolute;
+ z-index: 2;
+ bottom: 55px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(230px, calc(100% - 24px));
+ padding: 12px;
+ border: 1px solid #d8dfe0;
+ border-radius: 6px;
+ background: #fff;
+ color: #315d64;
+ font: 12px/1.5 system-ui, sans-serif;
+ pointer-events: auto;
+ cursor: pointer;
+}
+#model-status[hidden], #model-stage[hidden] { display: none; }
+
#speech-panel {
position: absolute;
top: 8px;
diff --git a/tests/live2d-boot.cjs b/tests/live2d-boot.cjs
new file mode 100644
index 0000000..e512973
--- /dev/null
+++ b/tests/live2d-boot.cjs
@@ -0,0 +1,4 @@
+const { app } = require("electron");
+if (!process.env.PET_TEST_DATA) throw new Error("Isolated test data required");
+app.setPath("userData", process.env.PET_TEST_DATA);
+require("../src/main");
diff --git a/tests/live2d-host.test.js b/tests/live2d-host.test.js
new file mode 100644
index 0000000..8d434ce
--- /dev/null
+++ b/tests/live2d-host.test.js
@@ -0,0 +1,45 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { createModelHandler, installCore, runtimeReady } = require("../src/live2d-host");
+
+test("model protocol serves only the active model's declared nonexecutable resources", async (t) => {
+ const userData = fs.mkdtempSync(path.join(os.tmpdir(), "pet-protocol-test-"));
+ t.after(() => fs.rmSync(userData, { recursive: true, force: true }));
+ const id = "imported-012345678901234567890123";
+ let active = { id, type: "live2d" };
+ const root = path.join(userData, "characters", id);
+ fs.mkdirSync(root, { recursive: true });
+ fs.writeFileSync(path.join(root, "character.json"), JSON.stringify({ resources: ["model.model3.json", "loader.js", "linked.json"] }));
+ fs.writeFileSync(path.join(root, "model.model3.json"), "{}");
+ fs.writeFileSync(path.join(root, "loader.js"), "alert('no')");
+ fs.writeFileSync(path.join(userData, "settings.json"), "private fixture");
+ fs.symlinkSync(path.join(userData, "settings.json"), path.join(root, "linked.json"));
+ const handler = createModelHandler({ userData, sourceDir: path.join(__dirname, "../src"), getActiveCharacter: () => active });
+ const request = (suffix, method = "GET") => handler({ url: `pet-live2d://viewer${suffix}`, method });
+ const valid = request(`/models/${id}/model.model3.json`);
+ assert.equal(valid.status, 200);
+ assert.equal(await valid.text(), "{}");
+ assert.match(valid.headers.get("Content-Security-Policy"), /connect-src 'self'/);
+ for (const suffix of [`/models/${id}/loader.js`, `/models/${id}/linked.json`, `/models/${id}/character.json`,
+ `/models/${id}/../settings.json`, `/models/${id}/%252e%252e/settings.json`, "/settings.json", "/index.html?token=private", "/core.js"]) {
+ const result = request(suffix);
+ assert.equal(result.status, 404, suffix);
+ assert.equal(await result.text(), "Unavailable");
+ }
+ assert.equal(request("/index.html", "POST").status, 404);
+ active = { id: "default", type: "sprite" };
+ assert.equal(request(`/models/${id}/model.model3.json`).status, 404);
+ assert.equal(request("/index.html").status, 200);
+});
+
+test("unknown runtime JavaScript is never installed or served", (t) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-core-test-"));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const source = path.join(root, "anything.js");
+ fs.writeFileSync(source, "console.log('untrusted')");
+ assert.throws(() => installCore(source, root), /unrecognized_runtime/);
+ assert.equal(runtimeReady(root), false);
+});
diff --git a/tests/live2d-import.test.js b/tests/live2d-import.test.js
new file mode 100644
index 0000000..e5229b5
--- /dev/null
+++ b/tests/live2d-import.test.js
@@ -0,0 +1,74 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { zipSync } = require("fflate");
+const { readLive2DPack, installLive2DPack, resourceName } = require("../src/live2d-import");
+const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=", "base64");
+
+function fixture(t, change = () => {}) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-model-test-"));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const raw = { Version: 3, FileReferences: {
+ Moc: "sample.moc3", Textures: ["textures/skin.png"],
+ Expressions: [{ Name: "Smile", File: "smile.exp3.json" }],
+ Motions: { Idle: [{ File: "rest.motion3.json", Sound: "private.wav" }] },
+ }, Groups: [{ Target: "Parameter", Name: "LipSync", Ids: ["ParamMouthOpenY"] }], url: "https://example.invalid/tracker" };
+ change(raw);
+ const moc = Buffer.alloc(64); moc.write("MOC3");
+ const files = { "sample.model3.json": Buffer.from(JSON.stringify(raw)), "sample.moc3": moc,
+ "textures/skin.png": png, "smile.exp3.json": Buffer.from('{"Parameters":[]}'),
+ "rest.motion3.json": Buffer.from('{"Version":3,"Curves":[]}'), "private.wav": Buffer.from("unused") };
+ for (const [name, bytes] of Object.entries(files)) {
+ const target = path.join(root, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, bytes);
+ }
+ return { root, files };
+}
+
+test("Live2D folder, selected model and wrapped ZIP install the same bounded resource graph", (t) => {
+ const { root, files } = fixture(t);
+ const pack = readLive2DPack(root);
+ const zipPath = path.join(root, "model.zip");
+ fs.writeFileSync(zipPath, zipSync(Object.fromEntries(Object.entries(files).map(([key, bytes]) => [`Model/${key}`, bytes]))));
+ assert.deepEqual(readLive2DPack(zipPath), pack);
+ assert.deepEqual(readLive2DPack(path.join(root, "sample.model3.json")), pack);
+ assert.equal(pack.manifest.type, "live2d");
+ assert.deepEqual(pack.manifest.expressions, ["Smile"]);
+ const model = JSON.parse(pack.files["model.model3.json"]);
+ assert.equal(model.url, undefined);
+ assert.equal(model.FileReferences.Motions.Idle[0].Sound, undefined);
+ assert.equal(pack.files["private.wav"], undefined);
+ installLive2DPack(pack, path.join(root, "installed"));
+ installLive2DPack(pack, path.join(root, "installed"));
+ assert.ok(fs.existsSync(path.join(root, "installed", pack.manifest.id, "textures/skin.png")));
+});
+
+for (const unsafe of ["../key.json", "/key.json", "https://example.invalid/a.png", "file:///key.json", "a\\b.png", "a/%2e%2e/b.png", "a.png?token=1", "CON.png", "a./b.png", "a//b.png"]) {
+ test(`Live2D rejects nonportable or escaping resource ${unsafe}`, () => assert.throws(() => resourceName(unsafe)));
+}
+for (const [label, change] of [
+ ["remote texture", (raw) => { raw.FileReferences.Textures = ["https://example.invalid/x.png"]; }],
+ ["executable reference", (raw) => { raw.FileReferences.Moc = "loader.js"; }],
+ ["too many textures", (raw) => { raw.FileReferences.Textures = Array(9).fill("textures/skin.png"); }],
+ ["invalid version", (raw) => { raw.Version = 2; }],
+]) test(`Live2D rejects ${label}`, (t) => assert.throws(() => readLive2DPack(fixture(t, change).root)));
+
+test("Live2D rejects missing resources, symlinked directories and malformed PNGs", (t) => {
+ const { root } = fixture(t);
+ assert.throws(() => readLive2DPack(root, () => false), /image_decode_failed/);
+ fs.renameSync(path.join(root, "textures"), path.join(root, "real-textures"));
+ fs.symlinkSync(path.join(root, "real-textures"), path.join(root, "textures"), "junction");
+ assert.throws(() => readLive2DPack(root), /symlink/);
+ fs.unlinkSync(path.join(root, "textures"));
+ assert.throws(() => readLive2DPack(root));
+});
+
+test("Live2D ZIP rejects ambiguous models, traversal and case-colliding paths", (t) => {
+ const { root, files } = fixture(t);
+ const target = path.join(root, "bad.zip");
+ for (const extra of [{ "second.model3.json": files["sample.model3.json"] }, { "../secret.json": Buffer.from("{}") }, { "textures/SKIN.png": png }]) {
+ fs.writeFileSync(target, zipSync({ ...files, ...extra }));
+ assert.throws(() => readLive2DPack(target));
+ }
+});
diff --git a/tests/live2d-runtime.test.js b/tests/live2d-runtime.test.js
new file mode 100644
index 0000000..c4fc270
--- /dev/null
+++ b/tests/live2d-runtime.test.js
@@ -0,0 +1,41 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const { createHash } = require("node:crypto");
+const { loadMain } = require("./helpers");
+const { loadPet } = require("./pet-helpers");
+
+test("vendored display bundles match the pinned upstream releases and omit Core/models", () => {
+ for (const [name, hash] of Object.entries({
+ "pixi.min.js": "403f2f2ee8145fa17f60c5c89403056efe2680e5096ec2762036486914ed19c5",
+ "cubism4.min.js": "af1267e6d52759b245766c578d905bfa025b532d5c3cc727c370957c4409e21b",
+ })) {
+ assert.equal(createHash("sha256").update(fs.readFileSync(path.join(__dirname, "../src/live2d/vendor", name))).digest("hex"), hash);
+ }
+ assert.equal(fs.readdirSync(path.join(__dirname, "../src/live2d/vendor")).some((name) => /core|\.moc3$|\.png$/i.test(name)), false);
+});
+
+test("model expression mappings and gaze normalize without enabling affection or voice", () => {
+ const app = loadMain();
+ const state = app.run('normalizeSettings({persona:{live2dGaze:false,live2dExpressions:{happy:0,shy:63,review:64,failed:"1"}}})');
+ assert.equal(state.persona.live2dGaze, false);
+ assert.deepEqual(JSON.parse(JSON.stringify(state.persona.live2dExpressions)), { happy: 0, shy: 63, review: -1, failed: -1 });
+ assert.equal(state.affection.enabled, false);
+ assert.equal(state.tts.enabled, false);
+});
+
+test("Live2D click-only advances once without scheduling sprite actions or network calls", () => {
+ const pet = loadPet();
+ pet.run(`globalThis.modelCalls=[]; window.DesktopPetModel={apply(){},expression(){},stopMouth(){},busy(){},cycle(){modelCalls.push("cycle")}};
+ applyAppState({settings:{expressionMode:"clickOnly"},activeCharacter:{id:"imported-fixture",type:"live2d",states:{idle:{row:0,frames:1,interval:1000}}}});
+ handleClick(); handleDoubleClick(); handleLongPress(); scheduleIdleBehavior();`);
+ assert.deepEqual(JSON.parse(pet.run('JSON.stringify(modelCalls)')), ["cycle"]);
+});
+
+test("partner touch phrases are opt-in, local and leave affection unchanged", () => {
+ const pet = loadPet();
+ pet.run('settings.persona = {relationship:"partner"}; settings.userName="Alex"; respondToInteraction("head")');
+ assert.match(pet.run('speechBubble.textContent'), /Alex/);
+ assert.equal(pet.run('speechBubble.textContent.includes("pet.interaction.")'), false);
+});
diff --git a/tests/live2d-smoke.cjs b/tests/live2d-smoke.cjs
new file mode 100644
index 0000000..efd53af
--- /dev/null
+++ b/tests/live2d-smoke.cjs
@@ -0,0 +1,162 @@
+const { _electron } = require("playwright");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const assert = require("node:assert/strict");
+const http = require("node:http");
+const project = path.join(__dirname, "..");
+const data = fs.mkdtempSync(path.join(os.tmpdir(), "pet-live2d-smoke-"));
+const output = path.join(project, "out/playwright/live2d");
+const env = { ...process.env, PET_TEST_DATA: data };
+delete env.ELECTRON_RUN_AS_NODE;
+const modelPath = process.env.PET_MODEL_FIXTURE;
+const corePath = process.env.PET_CORE_FIXTURE;
+if (!modelPath || !corePath) throw new Error("PET_MODEL_FIXTURE and PET_CORE_FIXTURE required; no model is bundled");
+
+const wav = Buffer.alloc(44 + 8000 * 2 * 5);
+wav.write("RIFF"); wav.writeUInt32LE(wav.length - 8, 4); wav.write("WAVEfmt ", 8);
+wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
+wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
+wav.write("data", 36); wav.writeUInt32LE(wav.length - 44, 40);
+for (let i = 0; i < (wav.length - 44) / 2; i++) wav.writeInt16LE(Math.round(Math.sin(i / 8000 * Math.PI * 440) * 6000), 44 + i * 2);
+let requests = 0;
+const server = http.createServer((req, res) => {
+ req.resume(); req.on("end", () => {
+ if (req.url === "/tts") { res.writeHead(200, { "content-type": "audio/wav" }); res.end(wav); }
+ else { requests++; res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ choices: [{ message: { content: "[expression:happy] It's good to see you." } }] })); }
+ });
+});
+
+async function run() {
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const endpoint = `http://127.0.0.1:${server.address().port}`;
+ const app = await _electron.launch({ executablePath: require("electron"), args: [path.join(__dirname, "live2d-boot.cjs")], env });
+ const errors = [];
+ app.on("window", (page) => {
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("console", (entry) => { if (entry.type() === "error") console.log("Renderer:", entry.text()); });
+ });
+ const find = async (file) => {
+ for (let i = 0; i < 150; i++) {
+ const page = app.windows().find((item) => item.url().endsWith(`/${file}`));
+ if (page) { await page.waitForLoadState(); return page; }
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new Error(`Window missing: ${file}`);
+ };
+ try {
+ fs.mkdirSync(output, { recursive: true });
+ const pet = await find("index.html");
+ const settings = await find("settings.html");
+ await app.evaluate(({ dialog }, file) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [file] }); }, modelPath);
+ await settings.locator("#import-live2d").click();
+ await settings.locator("#import-preview").waitFor({ state: "visible" });
+ await settings.locator("#confirm-import").click();
+ const id = await settings.evaluate(async () => (await window.desktopPet.getChatState()).characters.find((item) => item.type === "live2d").id);
+ await settings.evaluate((id) => window.desktopPet.saveChatConfig({ characterId: id, language: "en-US", persona: { setupDone: true } }), id);
+ await pet.waitForFunction(() => document.querySelector("#model-status").textContent.includes("not installed"));
+ await app.evaluate(({ dialog }, file) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [file] }); }, corePath);
+ await settings.locator("#import-core").click();
+ await pet.waitForFunction(() => document.querySelector("#model-status").hidden, { timeout: 30000 });
+ const frame = pet.frames().find((item) => item.url().startsWith("pet-live2d:"));
+ assert.ok(frame, "isolated model frame");
+ assert.equal(await frame.evaluate(() => typeof window.desktopPet), "undefined");
+ assert.equal(await frame.evaluate(() => typeof window.require), "undefined");
+ await frame.evaluate(() => {
+ const original = PIXI.live2d.Live2DModel.from;
+ PIXI.live2d.Live2DModel.from = async (...args) => { window.testModel = await original.apply(PIXI.live2d.Live2DModel, args); return window.testModel; };
+ });
+ await pet.evaluate((id) => {
+ document.querySelector("iframe").contentWindow.postMessage({ channel: "desktop-pet-model", type: "load", id }, "pet-live2d://viewer");
+ window.modelEvents = [];
+ window.addEventListener("message", (event) => { if (event.data?.channel === "desktop-pet-model") modelEvents.push(event.data); });
+ }, id);
+ await frame.waitForFunction(() => window.testModel?.internalModel);
+ await frame.evaluate(() => testModel.internalModel.on("beforeModelUpdate", () => {
+ const core = testModel.internalModel.coreModel;
+ window.testParameters = { eye: core.getParameterValueById("ParamEyeLOpen"), breath: core.getParameterValueById("ParamBreath"),
+ x: core.getParameterValueById("ParamAngleX"), mouth: core.getParameterValueById("ParamMouthOpenY"),
+ mouthForm: core.getParameterValueById("ParamMouthForm") };
+ window.testBlinkMinimum = Math.min(window.testBlinkMinimum ?? 1, testParameters.eye);
+ }));
+ const stats = () => frame.evaluate(() => {
+ const canvas = document.querySelector("canvas");
+ const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
+ const pixels = new Uint8Array(canvas.width * canvas.height * 4);
+ gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+ let visible = 0;
+ for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 10) visible++;
+ return { visible, ...window.testParameters };
+ });
+ await pet.waitForTimeout(500);
+ assert.ok((await stats()).visible > 1000);
+ const sampled = [];
+ for (let i = 0; i < 50; i++) { sampled.push(await stats()); await pet.waitForTimeout(100); }
+ console.log("Model parameters", { first: sampled[0], last: sampled.at(-1), eyeRange: [Math.min(...sampled.map((s) => s.eye)), Math.max(...sampled.map((s) => s.eye))], breathRange: [Math.min(...sampled.map((s) => s.breath)), Math.max(...sampled.map((s) => s.breath))] });
+ await frame.waitForFunction(() => window.testBlinkMinimum < 0.2, null, { timeout: 12000 });
+ assert.ok(Math.max(...sampled.map((s) => s.breath)) - Math.min(...sampled.map((s) => s.breath)) > 0.1, "native breathing");
+ await pet.evaluate(() => window.desktopPet.setSettings({ expressionMode: "clickOnly", scale: 2 }));
+ await pet.waitForTimeout(600);
+ const before = await pet.evaluate(() => modelEvents.filter((item) => item.type === "expression").length);
+ await pet.locator("#pet").click();
+ await pet.waitForTimeout(800);
+ const after = await pet.evaluate(() => modelEvents.filter((item) => item.type === "expression"));
+ assert.equal(after.length, before + 1, "one click, one expression");
+ assert.equal(after.at(-1).index, 0);
+ await pet.waitForTimeout(1200);
+ assert.equal(await pet.evaluate(() => modelEvents.filter((item) => item.type === "expression").length), after.length);
+ await pet.screenshot({ path: path.join(output, "haru-manual-200.png") });
+ await pet.evaluate(() => window.desktopPet.setSettings({ expressionMode: "automatic", scale: 0.75 }));
+ await pet.waitForTimeout(600);
+ const bounds = await pet.locator("#pet").boundingBox();
+ await pet.mouse.move(bounds.x + bounds.width * 0.95, bounds.y + bounds.height * 0.25);
+ await pet.waitForTimeout(800);
+ assert.ok(Math.abs((await stats()).x) > 0.5, "gaze follows pointer");
+ assert.ok(await frame.evaluate(() => testModel.internalModel.focusController.x > 0.1), "pointer input reaches the gaze controller");
+ assert.ok((await stats()).visible > 1000);
+ await pet.screenshot({ path: path.join(output, "haru-automatic-75.png") });
+ await settings.locator("#live2d-expression").selectOption("2");
+ await settings.locator("#preview-expression").click();
+ await pet.waitForFunction(() => modelEvents.filter((item) => item.type === "expression").at(-1)?.index === 2);
+ await frame.waitForFunction(() => window.testParameters.mouthForm < -0.5);
+ await settings.screenshot({ path: path.join(output, "settings-live2d.png") });
+ await settings.locator("#preview-motion").click();
+ await pet.waitForFunction(() => modelEvents.some((item) => item.type === "motion"));
+ await settings.locator("#live2d-happy").selectOption("1");
+ await settings.locator("#save-settings").click();
+ await pet.waitForFunction(() => settings.persona.live2dExpressions.happy === 1);
+ await settings.evaluate((endpoint) => window.desktopPet.saveChatConfig({
+ assistant: { baseUrl: `${endpoint}/v1`, model: "local-test", stream: false },
+ tts: { enabled: true, provider: "custom", endpoint: `${endpoint}/tts`, customBodyTemplate: '{"text":"{{text}}"}' },
+ persona: { relationship: "partner", userName: "Alex" },
+ }), endpoint);
+ await pet.locator("#talk").click();
+ const quick = await find("quick-chat.html");
+ await quick.locator("#quick-input").fill("Stay with me a little.");
+ await quick.locator("#send").click();
+ await pet.waitForFunction(() => speechActive && activeAudio && !activeAudio.paused);
+ await pet.waitForFunction(() => modelEvents.filter((item) => item.type === "expression").at(-1)?.index === 1);
+ await frame.waitForFunction(() => window.testParameters.mouth > 0.3);
+ await pet.waitForFunction(async () => (await window.desktopPet.getChatState()).voice.status === "playing");
+ await pet.screenshot({ path: path.join(output, "haru-speaking.png") });
+ await pet.locator("#voice-toggle").click();
+ await frame.waitForFunction(() => window.testParameters.mouth < 0.03);
+ assert.equal(requests, 1);
+ assert.equal(await pet.evaluate(() => {
+ const bubble = document.querySelector("#speech-panel").getBoundingClientRect();
+ const model = document.querySelector("#model-stage").getBoundingClientRect();
+ return bubble.bottom <= model.top;
+ }), true, "reply does not cover model");
+ await pet.evaluate(() => window.desktopPet.setSettings({ characterId: "luna" }));
+ await pet.waitForFunction(() => !document.querySelector("iframe") && spriteImage && character.id === "luna");
+ assert.equal(await pet.evaluate(() => ctx.getImageData(0, 0, 768, 832).data.some((value, index) => index % 4 === 3 && value > 0)), true);
+ assert.deepEqual(errors, []);
+ console.log("PASS: native Haru loading, isolated frame, blink, breath, gaze, manual expression, motion preview, chat expression mapping, audio-driven mouth/stop, 75%/200% scaling, sprite fallback; isolated local LLM/TTS only.");
+ } finally {
+ await app.evaluate(({ app }) => app.exit(0));
+ await app.close();
+ fs.rmSync(data, { recursive: true, force: true });
+ server.closeAllConnections(); server.close();
+ }
+}
+run().catch((error) => { console.error(error); server.closeAllConnections(); server.close(); process.exitCode = 1; });
diff --git a/tests/network-safety.test.js b/tests/network-safety.test.js
new file mode 100644
index 0000000..fc48af7
--- /dev/null
+++ b/tests/network-safety.test.js
@@ -0,0 +1,70 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { readResponseBytes, readResponseJson, returnedAudioUrl, audioDataUrl, redactServiceText } = require("../src/network-safety");
+const { loadMain } = require("./helpers");
+const { readChatResponse } = require("../src/chat-response");
+
+test("network readers reject both declared and streamed oversized bodies and cancel the source", async () => {
+ let cancelled = 0;
+ const response = () => new Response(new ReadableStream({
+ pull(controller) { controller.enqueue(new Uint8Array(16)); },
+ cancel() { cancelled++; },
+ }));
+ await assert.rejects(readResponseBytes(response(), 8), /response_too_large/);
+ const declared = response(); declared.headers.set("content-length", "99999");
+ await assert.rejects(readResponseBytes(declared, 8), /response_too_large/);
+ assert.equal(cancelled, 2);
+ assert.deepEqual(await readResponseJson(Response.json({ ok: true }), 100), { ok: true });
+});
+
+test("external TTS cannot request a loopback destination or follow a redirect", async () => {
+ const requests = [];
+ const app = loadMain({ fetch: async (url, options) => {
+ requests.push({ url, options });
+ return Response.json({ audio_url: "http://127.0.0.1:9880/private" });
+ } });
+ const result = await app.run('synthesizeSpeechWithSettings("hello", {tts:{enabled:true,provider:"custom",endpoint:"https://voice.example/tts",customBodyTemplate:"{}"}})');
+ assert.equal(result.ok, false);
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].options.redirect, "error");
+});
+
+test("chat JSON and SSE enforce a byte budget before accepting model output", async () => {
+ await assert.rejects(readChatResponse(new Response("{}", { headers: { "content-type": "application/json", "content-length": "999999999" } })), /response_too_large/);
+ const comment = ":" + "x".repeat(2100000) + "\n\n";
+ await assert.rejects(readChatResponse(new Response(comment, { headers: { "content-type": "text/event-stream" } })), /response_too_large/);
+});
+
+test("provider diagnostics cannot echo saved or unsaved test credentials into chat history", async () => {
+ const app = loadMain({ fetch: async () => Response.json({ error: { message: "Denied candidate-test-token and saved-test-token" } }, { status: 401 }) });
+ app.run('settings = normalizeSettings({assistant:{baseUrl:"https://llm.example/v1",model:"fixture",apiKey:"saved-test-token"}})');
+ const result = await app.run('testAssistantConnection(null,{assistant:{apiKey:"candidate-test-token"}})');
+ assert.equal(JSON.stringify(result).includes("candidate-test-token"), false);
+ await app.run('sendChatMessage(null,"Test")');
+ assert.equal(app.run('JSON.stringify(chatHistory)').includes("saved-test-token"), false);
+});
+
+test("returned TTS URLs stay on the exact user-configured origin", () => {
+ assert.equal(returnedAudioUrl("https://voice.example/audio/1.wav", "https://voice.example/tts"), "https://voice.example/audio/1.wav");
+ assert.equal(returnedAudioUrl("http://127.0.0.1:9880/audio.wav", "http://127.0.0.1:9880/tts"), "http://127.0.0.1:9880/audio.wav");
+ for (const target of ["http://127.0.0.1:9880/audio.wav", "http://169.254.169.254/latest", "https://cdn.example/audio.wav",
+ "https://voice.example:8443/audio.wav", "http://voice.example/audio.wav", "https://user:pass@voice.example/audio.wav", "file:///secret"]) {
+ assert.throws(() => returnedAudioUrl(target, "https://voice.example/tts"), /audio_origin_not_allowed/);
+ }
+});
+
+test("TTS data responses accept audio only", () => {
+ assert.equal(audioDataUrl("data:audio/wav;base64,AQID"), "data:audio/wav;base64,AQID");
+ for (const value of ["data:text/html;base64,AQID", "data:audio/wav,hello", "https://example.invalid/audio.wav", "data:audio/wav;base64,?"]) {
+ assert.throws(() => audioDataUrl(value), /invalid_audio/);
+ }
+});
+
+test("service diagnostics redact current, candidate and URL-encoded credentials before display", () => {
+ const token = "test-only-token/with+symbols";
+ const result = redactServiceText(`Denied ${token}; ${encodeURIComponent(token)}; Bearer example-secret`, [token]);
+ assert.equal(result.includes(token), false);
+ assert.equal(result.includes(encodeURIComponent(token)), false);
+ assert.equal(result.includes("example-secret"), false);
+ assert.equal(redactServiceText("Unknown model"), "Unknown model");
+});
diff --git a/tests/tts.test.js b/tests/tts.test.js
index 2d1fb87..1d57c05 100644
--- a/tests/tts.test.js
+++ b/tests/tts.test.js
@@ -10,10 +10,11 @@ test("voice URL download shares the synthesis timeout without forwarding credent
fetch: async (url, options) => {
requests.push({ url, options });
if (requests.length === 1) {
- return new Response(JSON.stringify({ audio_url: "https://audio.example/clip.wav" }), { headers: { "content-type": "application/json" } });
+ return new Response(JSON.stringify({ audio_url: "https://voice.example/clip.wav" }), { headers: { "content-type": "application/json" } });
}
assert.equal(options.signal, requests[0].options.signal);
assert.equal(options.headers?.Authorization, undefined);
+ assert.equal(options.redirect, "error");
const waiting = new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
diff --git a/tests/voice-controls.test.js b/tests/voice-controls.test.js
index 839da5f..c2172ba 100644
--- a/tests/voice-controls.test.js
+++ b/tests/voice-controls.test.js
@@ -65,7 +65,7 @@ test("a cancelled audio URL download also aborts without leaking the voice crede
const requests = [];
const app = voiceApp({ fetch: async (url, options) => {
requests.push({ url, options });
- if (requests.length === 1) return Response.json({ audio_url: "https://audio.example/file.wav" });
+ if (requests.length === 1) return Response.json({ audio_url: "https://voice.example/file.wav" });
return new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))));
} });
app.run('settings.tts.apiKey = "test-only-key"; beginSpeech(petEvent,speechGeneration)');
From 71d216cf16ca3a695a082d635dca14a53a545016 Mon Sep 17 00:00:00 2001
From: TonyNa-code <278977922+TonyNa-code@users.noreply.github.com>
Date: Mon, 26 Oct 2026 16:12:44 +0800
Subject: [PATCH 2/7] Add configurable Live2D reply gestures and strengthen
runtime checks
---
docs/live2d.md | 6 +++
src/i18n.js | 6 +++
src/live2d-bridge.js | 1 +
src/live2d/rig.js | 3 +-
src/live2d/viewer.js | 20 +++++++---
src/main.js | 15 ++++++++
src/renderer.js | 6 +++
src/settings.html | 7 ++++
src/settings.js | 32 ++++++++++++----
tests/live2d-runtime.test.js | 30 +++++++++++++++
tests/live2d-smoke.cjs | 16 ++++++--
tests/live2d-viewer.test.js | 72 ++++++++++++++++++++++++++++++++++++
tests/poll.cjs | 10 +++++
tests/poll.test.js | 15 ++++++++
14 files changed, 223 insertions(+), 16 deletions(-)
create mode 100644 tests/live2d-viewer.test.js
create mode 100644 tests/poll.cjs
create mode 100644 tests/poll.test.js
diff --git a/docs/live2d.md b/docs/live2d.md
index ca7d4e1..f163c5e 100644
--- a/docs/live2d.md
+++ b/docs/live2d.md
@@ -28,6 +28,8 @@ The speech icon opens a small input beside the character. Enter sends your messa
In automatic mode, a Live2D character gently centers its gaze while you type or wait for a reply. Its own blink and breath continue; random idle gestures wait until the conversation settles. Click expression mode keeps the expression you selected. External voice playback has a compact stop/replay control beside the character.
+Under **Reply gestures**, you can also assign a native motion to each reply mood. All start with **No gesture**. A completed reply can play one selected motion, not one per streamed text chunk. Gestures are spaced apart during quick exchanges, and a new message interrupts the previous gesture. Starting voice playback does not cancel the current reply gesture. Click expression mode disables reply gestures.
+
### Compatibility
- Cubism 3/4 `.model3.json` exports with `.moc3`, PNG textures, `.exp3.json`, `.motion3.json`, physics and pose files.
@@ -64,6 +66,8 @@ Chat and spoken text still go to the services you explicitly configure. Returned
点人物旁的对话图标即可展开小输入框,按回车发送后自动收起,回复显示在人物上方。按 Esc 可收起并保留草稿;点书本图标可看完整聊天记录,两处共用草稿。请求失败时也会保留未发送的文字。自动模式下,输入和等待回复时人物会轻轻看向你,保持眨眼、呼吸,暂缓待机动作。点击模式不改变手动选中的表情。朗读的停止、重播按钮在人物身旁。
+在 **回复动作** 中可以为各个情绪选择模型自带动作,默认均不播放。一条完整回复最多触发一次,逐字输出时不会重复触发;连续短回复也不会让动作连着播放。开始朗读会保留当前回复动作,发出新消息则会中止上一动作。点击表情模式不播放回复动作。
+
支持 Cubism 3/4 导出的模型、PNG、表情、动作、物理和姿态文件。不能把一张立绘自动变成 Live2D,也不支持 Cubism 2、编辑工程 `.cmo3`、PSD、VRM 或 Cubism 5.3 的扩展混合模式。单包上限 128 MiB、256 文件、8 张纹理,单张最长边 8192,纹理总像素不超过 6400 万,单个 JSON 不超过 2 MiB。
测试使用的是官方 Haru 示例,版权属于 Live2D Inc.,不是 MIT 角色素材;下载来源和许可见上方链接。Core 也不是开源库。允许用户加载任意模型的应用涉及额外发布条款,完成核实前不会将本功能发布为正式安装包。
@@ -86,6 +90,8 @@ Chat and spoken text still go to the services you explicitly configure. Returned
キャラクター横の会話アイコンで小さな入力欄が開きます。Enter で送信すると閉じ、返答はキャラクターのすぐ上に表示されます。Esc で閉じても下書きは残ります。本のアイコンから開く会話履歴とも下書きを共有します。送信失敗時も入力内容は保持されます。自動モードでは入力中や返答待ちに視線をそっと中央に戻し、まばたきと呼吸を続けます。クリック表情モードでは選んだ表情を保ちます。音声の停止・再生はキャラクター横の小さなボタンで操作できます。
+**返答時のモーション** では、感情ごとにモデルの動作を選べます。初期値はすべて「モーションなし」です。一つの返答につき一度だけ再生し、文字の逐次表示や短い返答の連続で動作を繰り返しません。読み上げ開始時も動作を保ち、新しいメッセージで前の動作を中断します。クリック表情モードでは再生しません。
+
Cubism 3/4 の書き出しモデルに対応します。Cubism 2、`.cmo3` 編集ファイル、PSD、VRM、Cubism 5.3 の拡張ブレンドは非対応です。128 MiB・256 ファイル・PNG 8 枚まで、各辺 8192 ピクセル以下、合計 6400 万ピクセル以下、JSON は一つ 2 MiB 以下です。
テストには Live2D Inc. の公式 Haru を使用しました。MIT 素材ではなく、上記の専用利用規約が適用されます。Core はプロプライエタリです。ユーザーモデルの読み込みに関する公開条件を確認するまでは、本機能を正式リリースに含めません。
diff --git a/src/i18n.js b/src/i18n.js
index 5fdf993..cafc179 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -32,6 +32,8 @@
"live2d.previewExpression": "预览表情",
"live2d.previewMotion": "预览动作",
"live2d.replyExpressions": "回复表情",
+ "live2d.replyMotions": "回复动作",
+ "live2d.noMotion": "不播放动作",
"live2d.happy": "开心",
"live2d.shy": "害羞",
"live2d.review": "思考",
@@ -405,6 +407,8 @@
"live2d.previewExpression": "Preview expression",
"live2d.previewMotion": "Preview motion",
"live2d.replyExpressions": "Reply expressions",
+ "live2d.replyMotions": "Reply gestures",
+ "live2d.noMotion": "No gesture",
"live2d.happy": "Happy",
"live2d.shy": "Shy",
"live2d.review": "Thoughtful",
@@ -778,6 +782,8 @@
"live2d.previewExpression": "表情を試す",
"live2d.previewMotion": "モーションを試す",
"live2d.replyExpressions": "返答時の表情",
+ "live2d.replyMotions": "返答時のモーション",
+ "live2d.noMotion": "モーションなし",
"live2d.happy": "うれしい",
"live2d.shy": "照れる",
"live2d.review": "考え中",
diff --git a/src/live2d-bridge.js b/src/live2d-bridge.js
index e289b8f..a723398 100644
--- a/src/live2d-bridge.js
+++ b/src/live2d-bridge.js
@@ -111,6 +111,7 @@
cycle: () => { if (loaded) send("cycle"); },
expression: (index) => { desiredExpression = index; if (loaded) send("expression", { index }); },
touch: () => { if (loaded) send("touch"); },
+ replyMotion: (motion) => { if (loaded) send("reply-motion", { group: motion.group, index: motion.index }); },
presence: (state) => { presence = state; if (loaded) send("presence", { state }); },
gaze: (x, y) => send("gaze", { x, y }),
busy: (busy) => { if (current) { current.busy = busy; send("options", current); } },
diff --git a/src/live2d/rig.js b/src/live2d/rig.js
index 7676db1..d8968a4 100644
--- a/src/live2d/rig.js
+++ b/src/live2d/rig.js
@@ -1,6 +1,7 @@
((root) => {
const aliases = { ParamAngleX: "PARAM_ANGLE_X", ParamAngleY: "PARAM_ANGLE_Y", ParamAngleZ: "PARAM_ANGLE_Z",
- ParamEyeBallX: "PARAM_EYE_BALL_X", ParamEyeBallY: "PARAM_EYE_BALL_Y", ParamBodyAngleX: "PARAM_BODY_ANGLE_X", ParamBreath: "PARAM_BREATH" };
+ ParamEyeBallX: "PARAM_EYE_BALL_X", ParamEyeBallY: "PARAM_EYE_BALL_Y", ParamBodyAngleX: "PARAM_BODY_ANGLE_X", ParamBreath: "PARAM_BREATH",
+ ParamMouthOpenY: "PARAM_MOUTH_OPEN_Y" };
function configure(internal) {
const ids = new Set(internal.coreModel.getModel().parameters.ids);
const resolve = (name) => ids.has(name) ? name : ids.has(aliases[name]) ? aliases[name] : name;
diff --git a/src/live2d/viewer.js b/src/live2d/viewer.js
index c2961e1..2f7061c 100644
--- a/src/live2d/viewer.js
+++ b/src/live2d/viewer.js
@@ -19,6 +19,8 @@
let mouthAt = 0;
let pettingUntil = 0;
let nextMotionAt = 0;
+ let replyMotionActive = false;
+ let replyMotionAt = -Infinity;
let metadata = { expressions: [], motions: {} };
function notify(type, extra = {}) { parent.postMessage({ channel: CHANNEL, type, ...extra }, "*"); }
@@ -36,6 +38,7 @@
function stopMotions() {
if (!model) return;
+ replyMotionActive = false;
model.internalModel.motionManager.stopAllMotions();
// Breathing and blinking remain active; large idle motions are explicitly paced below.
model.internalModel.motionManager.groups.idle = "__desktop_pet_no_automatic_motion__";
@@ -57,13 +60,15 @@
}).catch(() => notify("action-error"));
}
- function motion(group, index = 0) {
- if (!automatic || !model || !Object.hasOwn(metadata.motions, group)) return;
- if (!Number.isInteger(index) || index < 0 || index >= metadata.motions[group]) return;
+ function motion(group, index = 0, reply = false) {
+ if (!automatic || !model || !Object.hasOwn(metadata.motions, group)) return false;
+ if (!Number.isInteger(index) || index < 0 || index >= metadata.motions[group]) return false;
+ replyMotionActive = reply;
const target = model;
target.motion(group, index, 3).then((started) => { if (target === model) notify(started ? "motion" : "action-error", { group, index }); })
.catch(() => notify("action-error"));
nextMotionAt = performance.now() + 45000;
+ return true;
}
async function load(data) {
@@ -77,6 +82,7 @@
artBounds = null;
expressionIndex = -1;
mouthTarget = mouthValue = 0;
+ replyMotionAt = -Infinity;
model?.destroy({ children: true, texture: true, baseTexture: true });
model = null;
try {
@@ -105,7 +111,8 @@
artBounds = window.DesktopPetRig.measure(application, model);
fit();
nextMotionAt = performance.now() + 35000;
- const lips = descriptor.Groups?.find((group) => group.Name === "LipSync")?.Ids || ["ParamMouthOpenY"];
+ const lips = (descriptor.Groups?.find((group) => group.Name === "LipSync")?.Ids || []).filter(id => ids.has(id));
+ if (!lips.length && ids.has(resolve("ParamMouthOpenY"))) lips.push(resolve("ParamMouthOpenY"));
internal.on("beforeModelUpdate", () => {
const now = performance.now();
if (now - mouthAt > 300) mouthTarget = 0;
@@ -150,7 +157,7 @@
} else if (data.type === "presence" && ["idle", "attentive", "thinking", "speaking"].includes(data.state)) {
if (presence === data.state) return;
presence = data.state;
- if (presence !== "idle") stopMotions();
+ if (presence !== "idle" && !(presence === "speaking" && replyMotionActive)) stopMotions();
nextMotionAt = performance.now() + 45000;
if (automatic && gazeEnabled) model.internalModel.focusController.focus(0, presence === "idle" ? 0 : 0.08);
} else if (data.type === "gaze") {
@@ -164,6 +171,9 @@
expression(data.index);
} else if (data.type === "motion") {
motion(data.group, data.index);
+ } else if (data.type === "reply-motion") {
+ const now = performance.now();
+ if (now - replyMotionAt >= 12000 && motion(data.group, data.index, true)) replyMotionAt = now;
} else if (data.type === "touch" && automatic && !busy) {
pettingUntil = performance.now() + 1200;
const group = Object.keys(metadata.motions).find((key) => /^(tapbody|tap_body|touch|tap)$/i.test(key));
diff --git a/src/main.js b/src/main.js
index 6390ea1..fe33106 100644
--- a/src/main.js
+++ b/src/main.js
@@ -737,6 +737,12 @@ function normalizePersonaSettings(rawPersona = {}) {
const value = raw.live2dExpressions?.[key];
return [key, Number.isInteger(value) && value >= 0 && value < 64 ? value : -1];
})),
+ live2dMotions: Object.fromEntries(["happy", "shy", "review", "failed"].map((key) => {
+ const value = raw.live2dMotions?.[key];
+ const valid = value && typeof value.group === "string" && value.group.length <= 80
+ && !/[\u0000-\u001f\u007f]/.test(value.group) && Number.isInteger(value.index) && value.index >= 0 && value.index < 256;
+ return [key, valid ? { group: value.group, index: value.index } : null];
+ })),
};
}
@@ -1689,6 +1695,7 @@ async function sendChatMessage(_event, rawText, context) {
sendPetMessage(result.reply, result.action, {
...presentation,
speak: result.ok,
+ replyMotion: result.ok ? replyMotionFor(result.action) : null,
bubbleText: bubblePreview(result.reply),
});
if (result.ok) recordChatTimeProgress();
@@ -1703,6 +1710,14 @@ async function sendChatMessage(_event, rawText, context) {
}
}
+function replyMotionFor(action) {
+ const pack = getCharacterPack();
+ const motion = settings.persona?.live2dMotions?.[action];
+ if (settings.expressionMode !== "automatic" || pack?.type !== "live2d" || !motion
+ || !Object.hasOwn(pack.motions, motion.group) || motion.index >= pack.motions[motion.group]) return null;
+ return { group: motion.group, index: motion.index };
+}
+
function broadcastChatState() {
for (const window of [mainWindow, chatWindow, quickChatWindow]) {
if (window && !window.isDestroyed()) window.webContents.send("chat-state-updated", chatState());
diff --git a/src/renderer.js b/src/renderer.js
index 1b4973f..b44db3c 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -81,6 +81,7 @@ let speechActive = false;
let speechWatchdog;
let conversationActive = false;
let conversationAction = "idle";
+let lastReplyGestureId = null;
let queuedNotice = null;
let bubbleVisible = false;
let interactionReadyAt = 0;
@@ -729,6 +730,11 @@ function handlePetMessage(message = {}) {
if (settings.expressionMode === "automatic" && !dragStart) {
if (isChat) setState(conversationAction);
else if (action && character.states[action]) playTemporary(action, Math.max(temporaryDuration(action), 1200));
+ if (isChat && character.type === "live2d" && !message.streaming && message.replyMotion
+ && Number.isSafeInteger(message.presentationId) && message.presentationId !== lastReplyGestureId) {
+ lastReplyGestureId = message.presentationId;
+ window.DesktopPetModel?.replyMotion(message.replyMotion);
+ }
}
if (message.notification) window.desktopPet.presentNotification?.(message.notification);
}
diff --git a/src/settings.html b/src/settings.html
index d27f15f..d3d87cc 100644
--- a/src/settings.html
+++ b/src/settings.html
@@ -83,6 +83,13 @@ 回复表情
+ 回复动作
+
+
+
+
+
+
diff --git a/src/settings.js b/src/settings.js
index 94b2681..5cce8f3 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -490,6 +490,28 @@ function fillModelExpressions(select, names, value = -1) {
select.value = String(value);
}
+function fillModelMotions(select, motions, selected = null, optional = false) {
+ select.textContent = "";
+ if (optional) {
+ const option = document.createElement("option");
+ option.value = ""; option.textContent = text("live2d.noMotion"); select.append(option);
+ }
+ for (const [group, count] of Object.entries(motions || {})) {
+ for (let index = 0; index < count; index++) {
+ const option = document.createElement("option");
+ option.value = JSON.stringify({ group, index });
+ option.textContent = `${group || text("live2d.motion")} ${index + 1}`; select.append(option);
+ }
+ }
+ if (optional) select.value = selected && Object.hasOwn(motions || {}, selected.group) && selected.index < motions[selected.group]
+ ? JSON.stringify({ group: selected.group, index: selected.index }) : "";
+}
+
+function selectedReplyMotion(mood) {
+ try { return JSON.parse(document.querySelector(`#live2d-motion-${mood}`).value || "null"); }
+ catch { return null; }
+}
+
function renderLive2D(character) {
document.querySelector("#live2d-panel").hidden = character.type !== "live2d";
if (character.type !== "live2d") return;
@@ -499,15 +521,10 @@ function renderLive2D(character) {
for (const mood of ["happy", "shy", "review", "failed"]) {
const index = config.persona?.live2dExpressions?.[mood] ?? -1;
fillModelExpressions(document.querySelector(`#live2d-${mood}`), names, index < names.length ? index : -1);
+ fillModelMotions(document.querySelector(`#live2d-motion-${mood}`), character.motions, config.persona?.live2dMotions?.[mood], true);
}
const motionSelect = document.querySelector("#live2d-motion");
- motionSelect.textContent = "";
- for (const [group, count] of Object.entries(character.motions || {})) {
- for (let index = 0; index < count; index++) {
- const option = document.createElement("option");
- option.value = JSON.stringify({ group, index }); option.textContent = `${group} ${index + 1}`; motionSelect.append(option);
- }
- }
+ fillModelMotions(motionSelect, character.motions);
document.querySelector("#preview-motion").disabled = !motionSelect.options.length;
refreshRuntimeStatus();
}
@@ -692,6 +709,7 @@ function readConfigForm(includeEmptyKey = false) {
live2dGaze: document.querySelector("#live2d-gaze").checked,
live2dExpressions: Object.fromEntries(["happy", "shy", "review", "failed"].map((mood) =>
[mood, Number(document.querySelector(`#live2d-${mood}`).value || -1)])),
+ live2dMotions: Object.fromEntries(["happy", "shy", "review", "failed"].map((mood) => [mood, selectedReplyMotion(mood)])),
};
const affection = {
enabled: affectionEnabledEl.checked,
diff --git a/tests/live2d-runtime.test.js b/tests/live2d-runtime.test.js
index c4fc270..c2f002e 100644
--- a/tests/live2d-runtime.test.js
+++ b/tests/live2d-runtime.test.js
@@ -39,3 +39,33 @@ test("partner touch phrases are opt-in, local and leave affection unchanged", ()
assert.match(pet.run('speechBubble.textContent'), /Alex/);
assert.equal(pet.run('speechBubble.textContent.includes("pet.interaction.")'), false);
});
+
+test("reply gesture mappings are optional, normalized, and checked against the active model", () => {
+ const app = loadMain();
+ app.run(`settings = normalizeSettings({persona:{live2dMotions:{happy:{group:"Tap",index:1},shy:{group:"",index:0},review:{group:"Tap",index:-1},failed:{group:"Tap",index:256}}}});
+ getCharacterPack = () => ({type:"live2d",motions:{Tap:2,"":1}});`);
+ assert.equal(app.run('replyMotionFor("happy").index'), 1);
+ assert.equal(app.run('replyMotionFor("shy").group'), "");
+ assert.equal(app.run('replyMotionFor("review")'), null);
+ assert.equal(app.run('replyMotionFor("failed")'), null);
+ app.run('getCharacterPack = () => ({type:"live2d",motions:{Tap:1}})');
+ assert.equal(app.run('replyMotionFor("happy")'), null);
+ app.run('settings.expressionMode = "clickOnly"');
+ assert.equal(app.run('replyMotionFor("shy")'), null);
+ assert.equal(app.run('normalizeSettings({}).persona.live2dMotions.happy'), null);
+});
+
+test("completed replies trigger one gesture, never one per stream chunk or replay", () => {
+ const pet = loadPet();
+ pet.run(`globalThis.gestures=[]; window.DesktopPetModel={busy(){},stopMouth(){},expression(){},replyMotion(value){gestures.push(value)}};
+ character={...character,type:"live2d"}; settings.expressionMode="automatic";
+ globalThis.reply={source:"chat",action:"happy",text:"Hello",presentationId:1,replyMotion:{group:"Tap",index:0},speak:false};
+ handlePetMessage({...reply,streaming:true}); handlePetMessage({...reply,streaming:true});`);
+ assert.equal(pet.run('gestures.length'), 0);
+ pet.run('handlePetMessage(reply); handlePetMessage(reply)');
+ assert.equal(pet.run('gestures.length'), 1);
+ pet.run('handlePetMessage({...reply,presentationId:2,replyMotion:null})');
+ assert.equal(pet.run('gestures.length'), 1);
+ pet.run('settings.expressionMode="clickOnly"; handlePetMessage({...reply,presentationId:3})');
+ assert.equal(pet.run('gestures.length'), 1);
+});
diff --git a/tests/live2d-smoke.cjs b/tests/live2d-smoke.cjs
index d7a6802..121cc99 100644
--- a/tests/live2d-smoke.cjs
+++ b/tests/live2d-smoke.cjs
@@ -4,6 +4,7 @@ const os = require("node:os");
const path = require("node:path");
const assert = require("node:assert/strict");
const http = require("node:http");
+const { poll } = require("./poll.cjs");
const project = path.join(__dirname, "..");
const data = fs.mkdtempSync(path.join(os.tmpdir(), "pet-live2d-smoke-"));
const output = path.join(project, "out/playwright/live2d");
@@ -67,7 +68,7 @@ async function run() {
await pet.waitForFunction(() => document.querySelector("#model-status").textContent.includes("not installed"));
await app.evaluate(({ dialog }, file) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [file] }); }, corePath);
await settings.locator("#import-core").click();
- await pet.waitForFunction(() => document.querySelector("#model-status").hidden, { timeout: 30000 });
+ await pet.waitForFunction(() => document.querySelector("#model-status").hidden, null, { timeout: 30000 });
const frame = pet.frames().find((item) => item.url().startsWith("pet-live2d:"));
assert.ok(frame, "isolated model frame");
assert.equal(await frame.evaluate(() => typeof window.desktopPet), "undefined");
@@ -135,6 +136,13 @@ async function run() {
await settings.locator("#preview-motion").click();
await pet.waitForFunction(() => modelEvents.some((item) => item.type === "motion"));
await settings.locator("#live2d-happy").selectOption("1");
+ const replyMotion = await settings.locator("#live2d-motion-happy").evaluate(select => {
+ const options = [...select.options].filter(option => option.value);
+ return (options.find(option => !/^idle$/i.test(JSON.parse(option.value).group)) || options[0]).value;
+ });
+ await settings.locator("#live2d-motion-happy").selectOption(replyMotion);
+ await settings.locator("#live2d-motion-happy").scrollIntoViewIfNeeded();
+ await settings.screenshot({ path: path.join(output, "settings-reply-gestures.png") });
await settings.locator("#save-settings").click();
await pet.waitForFunction(() => settings.persona.live2dExpressions.happy === 1);
await settings.evaluate((endpoint) => window.desktopPet.saveChatConfig({
@@ -161,7 +169,8 @@ async function run() {
await pet.waitForFunction(() => speechActive && activeAudio && !activeAudio.paused);
await pet.waitForFunction(() => modelEvents.filter((item) => item.type === "expression").at(-1)?.index === 1);
await frame.waitForFunction(() => window.testParameters.mouth > 0.3);
- await pet.waitForFunction(async () => (await window.desktopPet.getChatState()).voice.status === "playing");
+ await poll(() => pet.evaluate(async () => (await window.desktopPet.getChatState()).voice.status === "playing"), { label: "playing voice" });
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "motion").length === 2);
await pet.screenshot({ path: path.join(output, "model-speaking.png") });
assert.equal(await pet.locator("#voice-toggle img").evaluate(img => img.complete && img.naturalWidth > 0 && img.src.endsWith("/square.svg")), true);
await pet.locator("#voice-toggle").click();
@@ -178,12 +187,13 @@ async function run() {
await pet.locator("#companion-text").fill("fail");
await pet.locator("#companion-send").click();
await pet.waitForFunction(() => document.querySelector("#companion-text").value === "fail");
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "motion").length), 2, "failed requests never trigger reply gestures");
assert.equal(await pet.locator("#companion-input").isHidden(), true, "failure does not steal focus");
await pet.locator("#talk").click();
await pet.locator("#companion-text").fill("cancel");
await pet.locator("#companion-send").click();
await pet.locator("#companion-stop").click();
- await pet.waitForFunction(async () => !(await window.desktopPet.getChatState()).busy);
+ await poll(() => pet.evaluate(async () => !(await window.desktopPet.getChatState()).busy), { label: "cancelled request" });
await pet.waitForTimeout(2200);
assert.equal(await pet.evaluate(() => bubbleVisible), false, "cancelled reply cannot return later");
await pet.locator("#talk").click();
diff --git a/tests/live2d-viewer.test.js b/tests/live2d-viewer.test.js
new file mode 100644
index 0000000..dbafe3e
--- /dev/null
+++ b/tests/live2d-viewer.test.js
@@ -0,0 +1,72 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const vm = require("node:vm");
+const rig = require("../src/live2d/rig");
+
+async function viewer() {
+ const events = {}, updates = {}, messages = [], motions = [], values = {};
+ let now = 100, stops = 0;
+ const internal = { width: 100, height: 200,
+ coreModel: { getModel: () => ({ parameters: { ids: ["PARAM_ANGLE_Z", "PARAM_MOUTH_OPEN_Y"] } }),
+ setParameterValueById: (id, value) => { values[id] = value; }, addParameterValueById() {} },
+ breath: { getParameters: () => [] },
+ motionManager: { groups: {}, stopAllMotions: () => { stops++; } },
+ focusController: { focus() {} }, on: (event, callback) => { updates[event] = callback; },
+ };
+ const model = { internalModel: internal, scale: { set() {} }, anchor: { set() {} }, position: { set() {} },
+ destroy() {}, update() {}, motion: async (group, index) => { motions.push({ group, index }); return true; } };
+ const parent = { postMessage: message => messages.push(message) };
+ const context = vm.createContext({ parent, innerWidth: 200, innerHeight: 400, devicePixelRatio: 1,
+ performance: { now: () => now },
+ document: { querySelector: () => ({}), addEventListener() {} },
+ window: { Live2DCubismCore: {}, DesktopPetRig: { ...rig, measure: () => ({ width: 100, height: 200 }) },
+ addEventListener: (name, callback) => { events[name] = callback; } },
+ fetch: async () => ({ json: async () => ({ FileReferences: { Motions: { Tap: [{}, {}] } } }) }),
+ PIXI: { Application: class {
+ constructor() { this.renderer = { resize() {} }; this.stage = { addChild() {} }; this.ticker = { add() {} }; }
+ }, live2d: { Live2DModel: { from: async () => model } } },
+ });
+ context.window.PIXI = context.PIXI;
+ vm.runInContext(fs.readFileSync(path.join(__dirname, "../src/live2d/viewer.js"), "utf8"), context);
+ const send = (type, extra = {}) => events.message({ source: parent, origin: "null",
+ data: { channel: "desktop-pet-model", id: "imported-123456789012345678901234", type, ...extra } });
+ send("load"); await new Promise(resolve => setImmediate(resolve));
+ assert.ok(messages.some(message => message.type === "loaded"));
+ return { send, motions, values, stops: () => stops, advance: milliseconds => { now += milliseconds; }, tick: () => updates.beforeModelUpdate() };
+}
+
+test("reply gestures are bounded and unavailable in click-only mode", async () => {
+ const ui = await viewer();
+ ui.send("reply-motion", { group: "Missing", index: 0 });
+ ui.send("reply-motion", { group: "Tap", index: 0 });
+ ui.send("reply-motion", { group: "Tap", index: 1 });
+ assert.equal(ui.motions.length, 1);
+ ui.advance(12000); ui.send("reply-motion", { group: "Tap", index: 1 });
+ assert.equal(ui.motions.length, 2);
+ ui.send("options", { automatic: false });
+ ui.advance(12000); ui.send("reply-motion", { group: "Tap", index: 0 });
+ assert.equal(ui.motions.length, 2);
+});
+
+test("speech preserves a reply gesture while the next user request interrupts it", async () => {
+ const ui = await viewer();
+ ui.send("presence", { state: "thinking" });
+ ui.send("reply-motion", { group: "Tap", index: 0 });
+ const before = ui.stops();
+ ui.send("presence", { state: "speaking" });
+ assert.equal(ui.stops(), before);
+ ui.send("presence", { state: "thinking" });
+ assert.equal(ui.stops(), before + 1);
+});
+
+test("legacy mouth IDs animate without a LipSync group and settle after audio stops", async () => {
+ const ui = await viewer();
+ ui.send("mouth", { value: 1 });
+ for (let i = 0; i < 10; i++) ui.tick();
+ assert.ok(ui.values.PARAM_MOUTH_OPEN_Y > 0.8);
+ ui.advance(400);
+ for (let i = 0; i < 30; i++) ui.tick();
+ assert.equal(ui.values.PARAM_MOUTH_OPEN_Y, 0);
+});
diff --git a/tests/poll.cjs b/tests/poll.cjs
new file mode 100644
index 0000000..0a490e3
--- /dev/null
+++ b/tests/poll.cjs
@@ -0,0 +1,10 @@
+async function poll(check, { timeout = 30000, interval = 50, label = "condition" } = {}) {
+ const deadline = Date.now() + timeout;
+ do {
+ const result = await check();
+ if (result) return result;
+ await new Promise(resolve => setTimeout(resolve, interval));
+ } while (Date.now() < deadline);
+ throw new Error(`Timed out waiting for ${label}`);
+}
+module.exports = { poll };
diff --git a/tests/poll.test.js b/tests/poll.test.js
new file mode 100644
index 0000000..3ce665b
--- /dev/null
+++ b/tests/poll.test.js
@@ -0,0 +1,15 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { poll } = require("./poll.cjs");
+
+test("async state polling waits for a true value, not a truthy Promise", async () => {
+ let calls = 0;
+ const result = await poll(async () => ++calls === 3 ? { reply: "Ready" } : false, { interval: 1 });
+ assert.equal(calls, 3);
+ assert.deepEqual(result, { reply: "Ready" });
+});
+
+test("async state polling reports timeouts and propagates real evaluation failures", async () => {
+ await assert.rejects(poll(async () => false, { timeout: 5, interval: 1, label: "reply" }), /waiting for reply/);
+ await assert.rejects(poll(async () => { throw new Error("Window closed"); }), /Window closed/);
+});
From e316e8cf051b43525fa71f96def447fab0f4ab9b Mon Sep 17 00:00:00 2001
From: TonyNa-code <278977922+TonyNa-code@users.noreply.github.com>
Date: Mon, 26 Oct 2026 17:11:17 +0800
Subject: [PATCH 3/7] Add gentle Live2D touch responses and quiet idle controls
---
docs/live2d.md | 24 ++++++--
src/i18n.js | 9 +++
src/live2d-bridge.js | 34 ++++++++++-
src/live2d-import.js | 14 +++++
src/live2d/vendor/NOTICE.md | 2 +-
src/live2d/viewer.js | 96 +++++++++++++++++++++++++-----
src/main.js | 29 ++++++----
src/renderer.js | 54 ++++++++++++-----
src/settings.html | 11 ++++
src/settings.js | 27 +++++++--
tests/live2d-bridge.test.js | 33 ++++++++++-
tests/live2d-import.test.js | 21 +++++++
tests/live2d-runtime.test.js | 61 +++++++++++++++++++-
tests/live2d-smoke.cjs | 109 ++++++++++++++++++++++++++++++++++-
tests/live2d-viewer.test.js | 100 +++++++++++++++++++++++++++++---
15 files changed, 557 insertions(+), 67 deletions(-)
diff --git a/docs/live2d.md b/docs/live2d.md
index f163c5e..32c2e7e 100644
--- a/docs/live2d.md
+++ b/docs/live2d.md
@@ -4,7 +4,7 @@
## English
-This local experimental integration is not part of the 0.4.x releases. Sprite characters remain available and keep their existing controls.
+This experimental source branch is not part of the 0.4.x releases. Sprite characters remain available and keep their existing controls.
### Import a Model
@@ -18,6 +18,10 @@ This local experimental integration is not part of the 0.4.x releases. Sprite ch
**Automatic mode:** subtle pointer tracking, local touch reactions and the model's idle motion at roughly 45-60 second intervals. A chat reply holds its assigned expression. Idle gestures pause while a reply is displayed or spoken. Turn off pointer tracking or interaction phrases independently in settings.
+**Head pats and gentle taps:** assign separate expressions and optional motions in Character settings. A touch eases in and out and returns to the previous expression; repeated taps do not stack. Typing, dragging and a new reply interrupt the reaction. Models with native hit areas use those regions. Other models use their visible silhouette and a height-based head/body split. Empty space does not trigger a reaction. Older imports need reimporting to gain their native hit areas; this adds a separate character without deleting the old one.
+
+Turn off **Occasional idle gestures** for quiet company. Blinking, breathing, touch reactions and reply gestures still work. Pointer tracking has its own switch. Touch phrases can be disabled without disabling touch animation, and touches do not call an API or increase affection.
+
**Voice:** external TTS audio drives mouth opening from its volume. This is not phoneme-level lip synchronization. System voices have no audio analyser and do not drive this mouth animation. A model needs mouth parameters for the effect to appear.
For a closer companion tone, choose **Relationship > Partner**, set your preferred name, and describe the character's personality. Partner touch phrases remain local and make no model request. Affection, saved chat history and shared memories remain off by default.
@@ -44,7 +48,7 @@ The local test uses **Haru**, an official Live2D sample from [CubismWebSamples 4
Local runtime checks also cover **Epsilon FREE** and **Mao PRO** samples: visible canvas content, native blinking and breathing, pointer tracking, expressions, motion preview, inline conversation, and audio-driven mouth movement. These sample assets are not included in this repository.
-Cubism Core is proprietary. The Framework also has its own license. User-loadable models bring additional publication considerations under the [official SDK guidance](https://help.live2d.com/zh-CHS/sdk/sdk_001/). The Live2D integration will not be included in a public release until its distribution terms are clarified. [Third-party notices](../src/live2d/vendor/NOTICE.md).
+Cubism Core is proprietary. The Framework and model assets also have their own licenses. Applications with user-loadable models are subject to the [official SDK publication terms](https://www.live2d.com/en/sdk/license/expandable/). The repository's MIT license does not cover those components or grant an SDK publication license. Neither Core nor sample models are bundled. [Third-party notices](../src/live2d/vendor/NOTICE.md).
### Privacy
@@ -54,7 +58,7 @@ Chat and spoken text still go to the services you explicitly configure. Returned
## 简体中文
-这是尚未发布的本地实验功能,不在 GitHub 0.4.x 安装包中。原有图片桌宠和设置保持不变。
+这是源码分支中的实验功能,不在 GitHub 0.4.x 安装包中。原有图片桌宠和设置保持不变。
1. 打开 **陪伴设置 > 角色 > 导入 Live2D**,选择模型的 `.model3.json`,或仅含一个模型的 ZIP。纹理、动作等文件需保留原有目录结构;单独的 `.moc3` 无法使用。
2. 确认导入并切换到新角色。首次使用时点 **下载 Core 5.2**,保存官方 `live2dcubismcore.min.js`,再通过 **安装 Core** 选择这个文件。程序只接受已核验的官方文件,运行库保存在本机数据目录。
@@ -64,13 +68,17 @@ Chat and spoken text still go to the services you explicitly configure. Returned
想要更亲近的口吻,可以将人物关系设为 **恋人**,填写希望被称呼的名字和人物性格。恋人关系的触碰短句在本地运行,不调用 API。好感度、保存聊天记录、共同记忆依旧默认关闭。
+角色设置中的 **摸摸头** 和 **轻轻碰一下** 可以分别指定表情、动作。触碰会平滑开始、结束并恢复原表情,连续点击不会叠加;开始输入、拖动或收到新回复时会结束触碰反馈。优先使用模型自带的触碰区域;没有区域的模型按可见轮廓和上下位置区分,点空白处不回应。旧版已导入的模型需重新导入才能使用原生区域,会新增一个角色,原角色和记录保留。
+
+关闭 **偶尔播放待机动作** 后,呼吸、眨眼、触碰和回复动作仍然保留。鼠标注视、互动短句各有独立开关,关掉短句也可以保留触碰动画。触碰不调用 API,也不增加好感度。
+
点人物旁的对话图标即可展开小输入框,按回车发送后自动收起,回复显示在人物上方。按 Esc 可收起并保留草稿;点书本图标可看完整聊天记录,两处共用草稿。请求失败时也会保留未发送的文字。自动模式下,输入和等待回复时人物会轻轻看向你,保持眨眼、呼吸,暂缓待机动作。点击模式不改变手动选中的表情。朗读的停止、重播按钮在人物身旁。
在 **回复动作** 中可以为各个情绪选择模型自带动作,默认均不播放。一条完整回复最多触发一次,逐字输出时不会重复触发;连续短回复也不会让动作连着播放。开始朗读会保留当前回复动作,发出新消息则会中止上一动作。点击表情模式不播放回复动作。
支持 Cubism 3/4 导出的模型、PNG、表情、动作、物理和姿态文件。不能把一张立绘自动变成 Live2D,也不支持 Cubism 2、编辑工程 `.cmo3`、PSD、VRM 或 Cubism 5.3 的扩展混合模式。单包上限 128 MiB、256 文件、8 张纹理,单张最长边 8192,纹理总像素不超过 6400 万,单个 JSON 不超过 2 MiB。
-测试使用的是官方 Haru 示例,版权属于 Live2D Inc.,不是 MIT 角色素材;下载来源和许可见上方链接。Core 也不是开源库。允许用户加载任意模型的应用涉及额外发布条款,完成核实前不会将本功能发布为正式安装包。
+测试使用的是官方 Haru 示例,版权属于 Live2D Inc.,不是 MIT 角色素材;下载来源和许可见上方链接。Core 也不是开源库。允许用户加载模型的应用适用 Live2D 的公开发布条款,仓库的 MIT 许可不替代这些条款。仓库和安装包均不附带 Core 或测试模型。
本地也已用 Epsilon FREE 和 Mao PRO 验证显示、眨眼、呼吸、注视、表情、动作、人物旁对话和音量口型。这些模型文件不会随仓库一起发布。
@@ -78,7 +86,7 @@ Chat and spoken text still go to the services you explicitly configure. Returned
## 日本語
-ローカルで試せる未公開機能です。公開版 0.4.x には含まれていません。既存のスプライトキャラクターはそのまま使えます。
+ソースブランチで試せる実験的な機能です。公開版 0.4.x には含まれていません。既存のスプライトキャラクターはそのまま使えます。
1. **付き添い設定 > キャラクター > Live2D を追加** から `.model3.json`、またはモデルが一つだけ入った ZIP を選びます。関連ファイルのフォルダー構成を保ってください。`.moc3` 単体では使えません。
2. 追加したキャラクターを選択します。初回は **Core 5.2 をダウンロード** から公式の `live2dcubismcore.min.js` を保存し、**Core を導入** で選びます。確認済みの公式ファイルのみ受け付けます。
@@ -88,13 +96,17 @@ Chat and spoken text still go to the services you explicitly configure. Returned
関係を「恋人」にし、呼び名と性格を設定すると、会話やローカルの触れ合い短文も親しみのある口調になります。好感度、履歴保存、共通の記憶は初期状態では無効です。
+**頭をなでる** と **そっと触れる** に別々の表情・モーションを設定できます。反応は滑らかに始まり、元の表情に戻ります。連打で重ならず、入力・ドラッグ・新しい返答で中断します。モデルの当たり判定を優先し、ない場合は表示部分と上下の位置で判定します。空白には反応しません。以前のインポートで当たり判定を利用するには再インポートが必要です。別のキャラクターとして追加され、元の設定や履歴は残ります。
+
+**ときどき待機モーションを再生** を無効にしても、まばたき・呼吸・触れ合い・返答時の動作は続きます。視線追従と触れ合いの一言は別々に切り替えられます。触れ合いでは API を呼び出さず、好感度も増えません。
+
キャラクター横の会話アイコンで小さな入力欄が開きます。Enter で送信すると閉じ、返答はキャラクターのすぐ上に表示されます。Esc で閉じても下書きは残ります。本のアイコンから開く会話履歴とも下書きを共有します。送信失敗時も入力内容は保持されます。自動モードでは入力中や返答待ちに視線をそっと中央に戻し、まばたきと呼吸を続けます。クリック表情モードでは選んだ表情を保ちます。音声の停止・再生はキャラクター横の小さなボタンで操作できます。
**返答時のモーション** では、感情ごとにモデルの動作を選べます。初期値はすべて「モーションなし」です。一つの返答につき一度だけ再生し、文字の逐次表示や短い返答の連続で動作を繰り返しません。読み上げ開始時も動作を保ち、新しいメッセージで前の動作を中断します。クリック表情モードでは再生しません。
Cubism 3/4 の書き出しモデルに対応します。Cubism 2、`.cmo3` 編集ファイル、PSD、VRM、Cubism 5.3 の拡張ブレンドは非対応です。128 MiB・256 ファイル・PNG 8 枚まで、各辺 8192 ピクセル以下、合計 6400 万ピクセル以下、JSON は一つ 2 MiB 以下です。
-テストには Live2D Inc. の公式 Haru を使用しました。MIT 素材ではなく、上記の専用利用規約が適用されます。Core はプロプライエタリです。ユーザーモデルの読み込みに関する公開条件を確認するまでは、本機能を正式リリースに含めません。
+テストには Live2D Inc. の公式 Haru を使用しました。MIT 素材ではなく、上記の専用利用規約が適用されます。Core はプロプライエタリです。ユーザーモデルを読み込むアプリには Live2D の公開条件が適用され、リポジトリの MIT ライセンスで代替できません。Core とテストモデルは同梱されません。
Epsilon FREE と Mao PRO でも表示、まばたき、呼吸、視線、表情、モーション、会話、音量による口の動きをローカルで確認しています。これらのモデルはリポジトリに含まれません。
diff --git a/src/i18n.js b/src/i18n.js
index cafc179..8238f7b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -27,6 +27,9 @@
"live2d.runtimeNeeded": "需要 Cubism Core 5.2",
"live2d.runtimeFailed": "文件不匹配,请选择官方 Core 5.2 文件",
"live2d.gaze": "自动模式下看向鼠标",
+ "live2d.idle": "偶尔播放待机动作",
+ "live2d.headTouch": "摸摸头",
+ "live2d.bodyTouch": "轻轻碰一下",
"live2d.expression": "表情",
"live2d.motion": "动作",
"live2d.previewExpression": "预览表情",
@@ -402,6 +405,9 @@
"live2d.runtimeNeeded": "Cubism Core 5.2 required",
"live2d.runtimeFailed": "File mismatch. Choose the official Core 5.2 file",
"live2d.gaze": "Follow the pointer in automatic mode",
+ "live2d.idle": "Occasional idle gestures",
+ "live2d.headTouch": "Head pats",
+ "live2d.bodyTouch": "Gentle taps",
"live2d.expression": "Expression",
"live2d.motion": "Motion",
"live2d.previewExpression": "Preview expression",
@@ -777,6 +783,9 @@
"live2d.runtimeNeeded": "Cubism Core 5.2 が必要です",
"live2d.runtimeFailed": "ファイルが一致しません。公式 Core 5.2 を選んでください",
"live2d.gaze": "自動モードでマウスを目で追う",
+ "live2d.idle": "ときどき待機モーションを再生",
+ "live2d.headTouch": "頭をなでる",
+ "live2d.bodyTouch": "そっと触れる",
"live2d.expression": "表情",
"live2d.motion": "モーション",
"live2d.previewExpression": "表情を試す",
diff --git a/src/live2d-bridge.js b/src/live2d-bridge.js
index a723398..034e92a 100644
--- a/src/live2d-bridge.js
+++ b/src/live2d-bridge.js
@@ -17,6 +17,27 @@
let analyser;
let audioTimer;
let loadTimer;
+ let locationRequest = 0;
+ const locations = new Map();
+
+ function settleLocation(request, kind = null) {
+ const pending = locations.get(request);
+ if (!pending) return;
+ clearTimeout(pending.timer);
+ locations.delete(request);
+ pending.resolve(kind);
+ }
+
+ function locate(x, y) {
+ if (!loaded || !Number.isFinite(x) || !Number.isFinite(y)) return Promise.resolve(null);
+ for (const request of locations.keys()) settleLocation(request);
+ const request = ++locationRequest;
+ return new Promise(resolve => {
+ const timer = setTimeout(() => settleLocation(request), 400);
+ locations.set(request, { resolve, timer });
+ send("locate", { x, y, request });
+ });
+ }
function send(type, extra = {}) {
if (frame && ready && current) frame.contentWindow.postMessage({ channel: CHANNEL, type, id: current.id, ...extra }, ORIGIN);
@@ -58,7 +79,8 @@
async function apply(character, settings, busy = false) {
const next = character.type === "live2d" ? {
id: character.id, automatic: settings.expressionMode === "automatic",
- gaze: settings.persona?.live2dGaze !== false, busy,
+ gaze: settings.persona?.live2dGaze !== false, idle: settings.persona?.live2dIdle !== false,
+ touches: settings.persona?.live2dTouches || {}, busy, dragging: current?.id === character.id && current.dragging === true,
} : null;
language = settings.resolvedLanguage;
if (next && current?.id === next.id && frame) {
@@ -67,6 +89,7 @@
return;
}
const token = ++ticket;
+ for (const request of locations.keys()) settleLocation(request);
stopMouth();
clearTimeout(loadTimer);
frame?.remove(); frame = null; ready = loaded = false;
@@ -98,6 +121,10 @@
send("presence", { state: presence });
}
if (event.data.type === "error") { clearTimeout(loadTimer); message("live2d.loadFailed"); }
+ if (event.data.type === "location") {
+ const kind = event.data.kind;
+ settleLocation(event.data.request, ["head", "greet"].includes(kind) ? kind : null);
+ }
});
window.desktopPet.onModelAction((action) => {
if (current?.id === action.id) {
@@ -107,13 +134,14 @@
});
status.addEventListener("click", () => window.desktopPet.openCompanionSettings());
window.DesktopPetModel = {
- apply, attachAudio, stopMouth,
+ apply, attachAudio, stopMouth, locate,
cycle: () => { if (loaded) send("cycle"); },
expression: (index) => { desiredExpression = index; if (loaded) send("expression", { index }); },
- touch: () => { if (loaded) send("touch"); },
+ touch: (kind = "greet") => { if (loaded) send("touch", { kind }); },
replyMotion: (motion) => { if (loaded) send("reply-motion", { group: motion.group, index: motion.index }); },
presence: (state) => { presence = state; if (loaded) send("presence", { state }); },
gaze: (x, y) => send("gaze", { x, y }),
busy: (busy) => { if (current) { current.busy = busy; send("options", current); } },
+ dragging: (dragging) => { if (current) { current.dragging = dragging; send("options", current); } },
};
})();
diff --git a/src/live2d-import.js b/src/live2d-import.js
index ff92730..932a99d 100644
--- a/src/live2d-import.js
+++ b/src/live2d-import.js
@@ -137,6 +137,20 @@ function readLive2DPack(source, decodeImage) {
}
}
const model = { Version: 3, FileReferences: cleanRefs };
+ if (raw.HitAreas !== undefined) {
+ if (!Array.isArray(raw.HitAreas) || raw.HitAreas.length > 64) throw new Error("invalid_hit_areas");
+ const usedNames = new Set();
+ model.HitAreas = raw.HitAreas.map((area, index) => {
+ if (!area || typeof area.Id !== "string" || !area.Id.length || area.Id.length > 128
+ || /[\u0000-\u001f\u007f]/.test(area.Id)) throw new Error("invalid_hit_areas");
+ let name = typeof area.Name === "string" ? area.Name.trim().slice(0, 80) : "";
+ if (!name || usedNames.has(name) || /[\u0000-\u001f\u007f]/.test(name)
+ || ["__proto__", "constructor", "prototype"].includes(name)) name = `${area.Id} ${index + 1}`;
+ while (usedNames.has(name)) name += "_";
+ usedNames.add(name);
+ return { Id: area.Id, Name: name };
+ });
+ }
if (raw.Groups) {
if (!Array.isArray(raw.Groups) || raw.Groups.length > 8) throw new Error("invalid_groups");
model.Groups = raw.Groups.filter((group) => group.Target === "Parameter" && ["EyeBlink", "LipSync"].includes(group.Name))
diff --git a/src/live2d/vendor/NOTICE.md b/src/live2d/vendor/NOTICE.md
index 01a9044..0fb45a1 100644
--- a/src/live2d/vendor/NOTICE.md
+++ b/src/live2d/vendor/NOTICE.md
@@ -15,6 +15,6 @@ The Cubism display bundle also contains Live2D Cubism Framework components, copy
Cubism Core is proprietary and is not included. The optional local importer recognizes the official Core 5.2 file at https://cubism.live2d.com/sdk-web/core/05/live2dcubismcore.min.js, SHA-256 `25ae938cb4fe282ce189b357bcc97e603d1e1f7ec78bf04150d401c23cdc792f`. Its use is subject to the [Live2D Proprietary Software License](https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html).
-An application which loads user-supplied models may fall within Live2D's expandable-application rules. A free application or an MIT source repository is not itself proof of a publication exemption. See the [official SDK licensing guidance](https://help.live2d.com/zh-CHS/sdk/sdk_001/). Public distribution of this experimental integration is pending licensing clarification.
+Applications which load user-supplied models are subject to Live2D's [expandable-application publication terms](https://www.live2d.com/en/sdk/license/expandable/). The repository's MIT license does not replace those terms or grant an SDK publication license.
No Live2D sample or user model is included in this directory or in the application's character assets.
diff --git a/src/live2d/viewer.js b/src/live2d/viewer.js
index 2f7061c..9f11568 100644
--- a/src/live2d/viewer.js
+++ b/src/live2d/viewer.js
@@ -11,13 +11,17 @@
let generation = 0;
let automatic = true;
let gazeEnabled = true;
+ let idleEnabled = true;
let busy = false;
+ let dragging = false;
let expressionIndex = -1;
let expressionQueue = Promise.resolve();
let mouthTarget = 0;
let mouthValue = 0;
let mouthAt = 0;
- let pettingUntil = 0;
+ let touchState = null;
+ let touchAt = -Infinity;
+ let touches = {};
let nextMotionAt = 0;
let replyMotionActive = false;
let replyMotionAt = -Infinity;
@@ -60,6 +64,45 @@
}).catch(() => notify("action-error"));
}
+ function cancelTouch(restore = true) {
+ const previous = touchState;
+ touchState = null;
+ if (restore && previous?.expressionChanged) expression(previous.expression);
+ }
+
+ function touch(kind) {
+ const now = performance.now();
+ if (!automatic || busy || dragging || presence !== "idle" || now - touchAt < 2400) return;
+ if (!["head", "greet", "drop"].includes(kind)) return;
+ cancelTouch();
+ stopMotions();
+ const mapping = touches[kind];
+ const index = mapping?.expression;
+ const expressionChanged = Number.isInteger(index) && index >= 0 && index < metadata.expressions.length;
+ touchState = { kind, start: now, expression: expressionIndex, expressionChanged };
+ touchAt = now;
+ if (expressionChanged) expression(index);
+ touchState.nativeMotion = mapping?.motion ? motion(mapping.motion.group, mapping.motion.index) : false;
+ nextMotionAt = now + 45000;
+ notify("touch", { kind });
+ }
+
+ function locate(x, y) {
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= 1 || y < 0 || y >= 1) return null;
+ const gl = application.renderer.gl;
+ const pixel = new Uint8Array(4);
+ gl.readPixels(Math.floor(x * canvas.width), canvas.height - 1 - Math.floor(y * canvas.height), 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
+ if (pixel[3] <= 8) return null;
+ const areas = model.internalModel.hitAreas;
+ if (areas && Object.keys(areas).length) {
+ const hits = model.hitTest(x * innerWidth, y * innerHeight);
+ if (hits.some(name => /head|face/i.test(`${name} ${areas[name]?.id || ""}`))) return "head";
+ return hits.length ? "greet" : null;
+ }
+ // Without named regions, use a height split inside the visible silhouette.
+ return y < 0.46 ? "head" : "greet";
+ }
+
function motion(group, index = 0, reply = false) {
if (!automatic || !model || !Object.hasOwn(metadata.motions, group)) return false;
if (!Number.isInteger(index) || index < 0 || index >= metadata.motions[group]) return false;
@@ -76,13 +119,18 @@
modelId = data.id;
automatic = data.automatic !== false;
gazeEnabled = data.gaze !== false;
+ idleEnabled = data.idle !== false;
+ touches = data.touches || {};
busy = data.busy === true;
+ dragging = data.dragging === true;
presence = "idle";
attentionWeight = 0;
artBounds = null;
expressionIndex = -1;
mouthTarget = mouthValue = 0;
replyMotionAt = -Infinity;
+ touchState = null;
+ touchAt = -Infinity;
model?.destroy({ children: true, texture: true, baseTexture: true });
model = null;
try {
@@ -106,6 +154,7 @@
application.stage.addChild(model);
stopMotions();
const internal = model.internalModel;
+ internal.motionManager.on("motionFinish", () => { if (internal === model?.internalModel) replyMotionActive = false; });
const core = internal.coreModel;
const { ids, resolve } = window.DesktopPetRig.configure(internal);
artBounds = window.DesktopPetRig.measure(application, model);
@@ -125,16 +174,21 @@
if (attentionWeight > 0.005) {
if (ids.has(resolve("ParamAngleZ"))) core.addParameterValueById(resolve("ParamAngleZ"), (presence === "thinking" ? 2 : -1.5) * attentionWeight);
}
- if (automatic && now < pettingUntil) {
- const weight = Math.min(1, (pettingUntil - now) / 400);
- if (ids.has(resolve("ParamAngleZ"))) core.addParameterValueById(resolve("ParamAngleZ"), -4 * weight);
+ if (touchState) {
+ const progress = Math.min(1, (now - touchState.start) / 2200);
+ const weight = Math.sin(Math.PI * progress) ** 2;
+ if (automatic && !touchState.nativeMotion && ids.has(resolve("ParamAngleZ"))) {
+ core.addParameterValueById(resolve("ParamAngleZ"), (touchState.kind === "head" ? -5 : 2) * weight);
+ }
+ if (progress >= 1) cancelTouch();
}
- if (automatic && !busy && presence === "idle" && now > nextMotionAt) {
+ if (automatic && idleEnabled && !busy && !dragging && presence === "idle" && now > nextMotionAt) {
const group = Object.keys(metadata.motions).find((key) => /^idle$/i.test(key));
nextMotionAt = now + 45000 + Math.random() * 15000;
if (group && metadata.motions[group]) model.motion(group, 0, 1).catch(() => {});
}
});
+ if (document.hidden) application.ticker.stop();
notify("loaded", { expressions: metadata.expressions, motions: metadata.motions });
} catch {
if (generation === token) notify("error");
@@ -150,40 +204,54 @@
const changed = automatic !== (data.automatic !== false);
automatic = data.automatic !== false;
gazeEnabled = data.gaze !== false;
- if (data.busy === true && !busy) stopMotions();
+ const interrupt = (data.busy === true && !busy) || (data.dragging === true && !dragging)
+ || (data.idle === false && idleEnabled) || changed;
+ if (interrupt) { cancelTouch(); stopMotions(); nextMotionAt = performance.now() + 45000; }
busy = data.busy === true;
- if (changed) { stopMotions(); nextMotionAt = performance.now() + 45000; }
+ dragging = data.dragging === true;
+ idleEnabled = data.idle !== false;
+ touches = data.touches || {};
if (!gazeEnabled || !automatic) model.internalModel.focusController.focus(0, 0);
} else if (data.type === "presence" && ["idle", "attentive", "thinking", "speaking"].includes(data.state)) {
if (presence === data.state) return;
presence = data.state;
+ if (presence !== "idle") cancelTouch();
if (presence !== "idle" && !(presence === "speaking" && replyMotionActive)) stopMotions();
nextMotionAt = performance.now() + 45000;
if (automatic && gazeEnabled) model.internalModel.focusController.focus(0, presence === "idle" ? 0 : 0.08);
} else if (data.type === "gaze") {
- if (!automatic || !gazeEnabled || presence !== "idle" || !Number.isFinite(data.x) || !Number.isFinite(data.y)) return;
+ if (!automatic || !gazeEnabled || dragging || presence !== "idle" || !Number.isFinite(data.x) || !Number.isFinite(data.y)) return;
model.internalModel.focusController.focus(Math.max(-0.35, Math.min(0.35, data.x)), Math.max(-0.25, Math.min(0.25, data.y)));
} else if (data.type === "mouth" && Number.isFinite(data.value)) {
mouthTarget = Math.max(0, Math.min(1, data.value)); mouthAt = performance.now();
} else if (data.type === "cycle") {
+ cancelTouch(false);
expression((expressionIndex + 2) % (metadata.expressions.length + 1) - 1);
} else if (data.type === "expression" && Number.isInteger(data.index) && data.index >= -1 && data.index < metadata.expressions.length) {
+ cancelTouch(false);
expression(data.index);
} else if (data.type === "motion") {
+ cancelTouch();
motion(data.group, data.index);
} else if (data.type === "reply-motion") {
const now = performance.now();
if (now - replyMotionAt >= 12000 && motion(data.group, data.index, true)) replyMotionAt = now;
- } else if (data.type === "touch" && automatic && !busy) {
- pettingUntil = performance.now() + 1200;
- const group = Object.keys(metadata.motions).find((key) => /^(tapbody|tap_body|touch|tap)$/i.test(key));
- if (group && metadata.motions[group]) motion(group, 0);
+ } else if (data.type === "touch") {
+ touch(data.kind);
+ } else if (data.type === "locate" && Number.isSafeInteger(data.request)) {
+ notify("location", { request: data.request, kind: locate(data.x, data.y) });
}
});
window.addEventListener("resize", fit);
document.addEventListener("visibilitychange", () => {
- if (document.hidden) application?.ticker.stop();
- else application?.ticker.start();
+ if (document.hidden) {
+ cancelTouch(); stopMotions();
+ mouthTarget = 0;
+ application?.ticker.stop();
+ } else {
+ nextMotionAt = performance.now() + 45000;
+ application?.ticker.start();
+ }
});
notify("boot");
})();
diff --git a/src/main.js b/src/main.js
index fe33106..c2833a7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -720,6 +720,16 @@ function normalizeTtsSettings(rawTts = {}) {
};
}
+function normalizeModelMotion(value) {
+ const valid = value && typeof value.group === "string" && value.group.length <= 80
+ && !/[\u0000-\u001f\u007f]/.test(value.group) && Number.isInteger(value.index) && value.index >= 0 && value.index < 256;
+ return valid ? { group: value.group, index: value.index } : null;
+}
+
+function normalizeModelExpression(value) {
+ return Number.isInteger(value) && value >= 0 && value < 64 ? value : -1;
+}
+
function normalizePersonaSettings(rawPersona = {}) {
const raw = { ...DEFAULT_SETTINGS.persona, ...(rawPersona || {}) };
return {
@@ -733,16 +743,15 @@ function normalizePersonaSettings(rawPersona = {}) {
background: normalizeLongText(raw.background, 1600),
extraRules: normalizeLongText(raw.extraRules, 1600),
live2dGaze: raw.live2dGaze !== false,
- live2dExpressions: Object.fromEntries(["happy", "shy", "review", "failed"].map((key) => {
- const value = raw.live2dExpressions?.[key];
- return [key, Number.isInteger(value) && value >= 0 && value < 64 ? value : -1];
- })),
- live2dMotions: Object.fromEntries(["happy", "shy", "review", "failed"].map((key) => {
- const value = raw.live2dMotions?.[key];
- const valid = value && typeof value.group === "string" && value.group.length <= 80
- && !/[\u0000-\u001f\u007f]/.test(value.group) && Number.isInteger(value.index) && value.index >= 0 && value.index < 256;
- return [key, valid ? { group: value.group, index: value.index } : null];
- })),
+ live2dIdle: raw.live2dIdle !== false,
+ live2dExpressions: Object.fromEntries(["happy", "shy", "review", "failed"].map((key) =>
+ [key, normalizeModelExpression(raw.live2dExpressions?.[key])])),
+ live2dMotions: Object.fromEntries(["happy", "shy", "review", "failed"].map((key) =>
+ [key, normalizeModelMotion(raw.live2dMotions?.[key])])),
+ live2dTouches: Object.fromEntries(["head", "greet"].map((key) => [key, {
+ expression: normalizeModelExpression(raw.live2dTouches?.[key]?.expression),
+ motion: normalizeModelMotion(raw.live2dTouches?.[key]?.motion),
+ }])),
};
}
diff --git a/src/renderer.js b/src/renderer.js
index b44db3c..7622762 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -68,6 +68,7 @@ let didDrag = false;
let clickTimer;
let clickOnlyReadyAt = 0;
let ignoreSingleClicksUntil = 0;
+let interactionTicket = 0;
let hoverReadyAt = 0;
let bubbleTimer;
let didShowStartupGreeting = false;
@@ -302,6 +303,7 @@ function applyAppState(nextAppState = {}) {
}
function resetAfterSettingsChange() {
+ interactionTicket++;
window.clearTimeout(clickTimer);
ignoreSingleClicksUntil = 0;
clickOnlyReadyAt = 0;
@@ -329,12 +331,12 @@ function resetAfterSettingsChange() {
function respondToInteraction(kind) {
if (settings.expressionMode !== "automatic" || !settings.interactionResponses
- || conversationActive || speechActive) return;
+ || conversationActive || speechActive || window.DesktopPetCompanionInput?.isOpen()) return;
const choices = kind === "head" ? ["shy", "happy", "review", "idle"]
: kind === "drop" ? ["tsundereProud", "happy", "waving", "idle"] : ["waving", "happy", "idle"];
const action = choices.find((name) => character.states[name]);
- if (action) playTemporary(action, 1400);
- if (character.type === "live2d") window.DesktopPetModel?.touch();
+ if (character.type === "live2d") window.DesktopPetModel?.touch(kind);
+ else if (action) playTemporary(action, 1400);
// Touches stay local and quiet; they neither replace a visible reply nor queue up.
if (bubbleVisible || performance.now() < interactionReadyAt) return;
interactionReadyAt = performance.now() + 6000;
@@ -351,40 +353,49 @@ function handleClick(kind = "greet") {
const [nextState, nextFrame] = nextStaticExpression();
setStaticExpression(nextState, nextFrame);
} else {
- if (conversationActive || speechActive) return;
+ if (conversationActive || speechActive || window.DesktopPetCompanionInput?.isOpen()) return;
if (settings.interactionResponses) { respondToInteraction(kind); return; }
- if (character.type === "live2d") { window.DesktopPetModel?.touch(); return; }
+ if (character.type === "live2d") { window.DesktopPetModel?.touch(kind); return; }
const nextState = nextClickState();
playTemporary(nextState);
}
}
function handleDoubleClick() {
+ interactionTicket++;
window.clearTimeout(clickTimer);
- if (settings.expressionMode === "clickOnly" || conversationActive || speechActive) return;
+ if (character.type === "live2d" || settings.expressionMode === "clickOnly" || conversationActive || speechActive) return;
ignoreSingleClicksUntil = performance.now() + 360;
window.desktopPet.recordInteraction("doubleClick");
playTemporary("jumping", 1200);
}
-function handleLongPress() {
+function handleLongPress(kind = "head") {
window.clearTimeout(clickTimer);
if (settings.expressionMode === "clickOnly" || conversationActive || speechActive) return;
window.desktopPet.recordInteraction("longPress");
if (settings.interactionResponses) {
- respondToInteraction("head");
+ respondToInteraction(kind);
+ } else if (character.type === "live2d") {
+ window.DesktopPetModel?.touch(kind);
} else {
playTemporary("review", 1400);
}
}
-function scheduleSingleClick(kind) {
+function scheduleSingleClick(kind, point, longPress = false) {
window.clearTimeout(clickTimer);
const scheduledAt = performance.now();
- clickTimer = window.setTimeout(() => {
+ const token = ++interactionTicket;
+ clickTimer = window.setTimeout(async () => {
if (scheduledAt < ignoreSingleClicksUntil) return;
- handleClick(kind);
- }, SINGLE_CLICK_DELAY);
+ if (character.type === "live2d" && point) {
+ kind = await window.DesktopPetModel?.locate(point.x, point.y);
+ if (!kind || token !== interactionTicket || dragStart || settings.expressionMode !== "automatic") return;
+ }
+ if (longPress) handleLongPress(kind);
+ else handleClick(kind);
+ }, longPress ? 0 : SINGLE_CLICK_DELAY);
}
function handleClickOnlyPointerClick() {
@@ -396,6 +407,7 @@ function handleClickOnlyPointerClick() {
function beginDrag(event) {
if (event.button !== 0) return;
+ interactionTicket++;
window.clearTimeout(clickTimer);
const bounds = canvas.getBoundingClientRect();
dragStart = {
@@ -403,6 +415,7 @@ function beginDrag(event) {
y: event.screenY,
time: performance.now(),
kind: (event.clientY - bounds.top) / bounds.height < 0.46 ? "head" : "greet",
+ point: { x: (event.clientX - bounds.left) / bounds.width, y: (event.clientY - bounds.top) / bounds.height },
expression: { state, frame: frameIndex },
};
didDrag = false;
@@ -420,6 +433,7 @@ function beginDrag(event) {
}
if (dragStart !== activeDrag) return;
if (Math.abs(movement.dx) > 2 || Math.abs(movement.dy) > 2) {
+ if (!didDrag) window.DesktopPetModel?.dragging(true);
didDrag = true;
if (Math.abs(movement.dx) > 4) {
setState(movement.dx > 0 ? "runningRight" : "runningLeft");
@@ -437,8 +451,9 @@ function endDrag(event) {
canvas.classList.remove("dragging");
const heldDuration = performance.now() - dragStart.time;
- const { kind, expression } = dragStart;
+ const { kind, point, expression, clickCount } = dragStart;
dragStart = null;
+ window.DesktopPetModel?.dragging(false);
if (didDrag) {
didDrag = false;
@@ -453,16 +468,19 @@ function endDrag(event) {
}
if (heldDuration > 450) {
- if (settings.expressionMode !== "clickOnly") handleLongPress();
+ if (settings.expressionMode !== "clickOnly") {
+ if (character.type === "live2d") scheduleSingleClick(kind, point, true);
+ else handleLongPress();
+ }
return;
}
if (settings.expressionMode === "clickOnly") {
- if (event.detail > 1) return;
+ if (event.detail > 1 || clickCount > 1) return;
handleClickOnlyPointerClick();
return;
}
if (event.detail > 1) return;
- scheduleSingleClick(kind);
+ scheduleSingleClick(kind, point);
}
let bubbleDeadline = 0;
@@ -768,6 +786,8 @@ function loadSprite(spritePath) {
}
canvas.addEventListener("pointerdown", beginDrag);
+// PointerEvent.detail can be zero; mousedown retains the platform's click count.
+canvas.addEventListener("mousedown", event => { if (dragStart) dragStart.clickCount = event.detail; });
canvas.addEventListener("pointermove", (event) => {
if (dragStart || character.type !== "live2d") return;
const bounds = canvas.getBoundingClientRect();
@@ -784,6 +804,8 @@ canvas.addEventListener("pointerenter", () => {
});
window.addEventListener("pointerup", endDrag);
function cancelDrag() {
+ interactionTicket++;
+ window.DesktopPetModel?.dragging(false);
if (!dragStart) return;
const expression = dragStart.expression;
dragStart = null;
diff --git a/src/settings.html b/src/settings.html
index d3d87cc..9545fa4 100644
--- a/src/settings.html
+++ b/src/settings.html
@@ -67,6 +67,7 @@ 角色
+
@@ -76,6 +77,16 @@
角色
+ 摸摸头
+
+
+
+
+ 轻轻碰一下
+
+
+
+
回复表情
diff --git a/src/settings.js b/src/settings.js
index 5cce8f3..8c3fd5c 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -130,7 +130,7 @@ async function mayLeave() {
}
async function importCharacter(kind) {
- document.querySelector("#import-status").textContent = "";
+ setImportStatus("");
try {
const result = await window.desktopPet.previewCharacterImport(kind);
if (result.cancelled) return;
@@ -142,7 +142,13 @@ async function importCharacter(kind) {
document.querySelector("#import-name").textContent = result.name;
document.querySelector("#import-description").textContent = result.description;
document.querySelector("#import-preview").hidden = false;
- } catch { document.querySelector("#import-status").textContent = text("settings.importFailed"); }
+ } catch { setImportStatus("settings.importFailed"); }
+}
+
+function setImportStatus(key) {
+ const status = document.querySelector("#import-status");
+ status.dataset.statusKey = key;
+ status.textContent = key ? text(key) : "";
}
const DEFAULT_CUSTOM_BODY = "{\"text\":\"{{text}}\"}";
@@ -282,6 +288,7 @@ function refreshLanguageOptions(selectedValue = "system") {
}
function applyStaticTranslations() {
+ setImportStatus(document.querySelector("#import-status").dataset.statusKey || "");
document.documentElement.lang = activeLanguage;
document.title = text("window.settingsTitle");
document.querySelector("h1").textContent = text("settings.title");
@@ -516,8 +523,15 @@ function renderLive2D(character) {
document.querySelector("#live2d-panel").hidden = character.type !== "live2d";
if (character.type !== "live2d") return;
document.querySelector("#live2d-gaze").checked = config.persona?.live2dGaze !== false;
+ document.querySelector("#live2d-idle").checked = config.persona?.live2dIdle !== false;
const names = character.expressions || [];
fillModelExpressions(document.querySelector("#live2d-expression"), names);
+ for (const kind of ["head", "greet"]) {
+ const touch = config.persona?.live2dTouches?.[kind];
+ const index = touch?.expression ?? -1;
+ fillModelExpressions(document.querySelector(`#live2d-touch-${kind}`), names, index < names.length ? index : -1);
+ fillModelMotions(document.querySelector(`#live2d-motion-touch-${kind}`), character.motions, touch?.motion, true);
+ }
for (const mood of ["happy", "shy", "review", "failed"]) {
const index = config.persona?.live2dExpressions?.[mood] ?? -1;
fillModelExpressions(document.querySelector(`#live2d-${mood}`), names, index < names.length ? index : -1);
@@ -707,6 +721,11 @@ function readConfigForm(includeEmptyKey = false) {
background: personaBackgroundEl.value.trim(),
extraRules: personaExtraRulesEl.value.trim(),
live2dGaze: document.querySelector("#live2d-gaze").checked,
+ live2dIdle: document.querySelector("#live2d-idle").checked,
+ live2dTouches: Object.fromEntries(["head", "greet"].map((kind) => [kind, {
+ expression: Number(document.querySelector(`#live2d-touch-${kind}`).value || -1),
+ motion: selectedReplyMotion(`touch-${kind}`),
+ }])),
live2dExpressions: Object.fromEntries(["happy", "shy", "review", "failed"].map((mood) =>
[mood, Number(document.querySelector(`#live2d-${mood}`).value || -1)])),
live2dMotions: Object.fromEntries(["happy", "shy", "review", "failed"].map((mood) => [mood, selectedReplyMotion(mood)])),
@@ -995,8 +1014,8 @@ document.querySelector("#confirm-import").addEventListener("click", async () =>
if (!result.ok) throw new Error();
applyChatState(result);
document.querySelector("#import-preview").hidden = true;
- document.querySelector("#import-status").textContent = text("settings.imported");
- } catch { document.querySelector("#import-status").textContent = text("settings.importFailed"); }
+ setImportStatus("settings.imported");
+ } catch { setImportStatus("settings.importFailed"); }
});
document.querySelector("#remove-character").addEventListener("click", async () => {
if (!(await mayLeave())) return;
diff --git a/tests/live2d-bridge.test.js b/tests/live2d-bridge.test.js
index b62aa8a..c681dc9 100644
--- a/tests/live2d-bridge.test.js
+++ b/tests/live2d-bridge.test.js
@@ -26,7 +26,7 @@ function setup() {
vm.runInContext(fs.readFileSync(path.join(__dirname, "../src/live2d-bridge.js"), "utf8"), context);
return { api: context.window.DesktopPetModel, sent, events, connections: () => connections,
resume: () => resume(),
- message: type => events.message({ source: frame.contentWindow, origin: "pet-live2d://viewer", data: { channel: "desktop-pet-model", type } }) };
+ message: (type, extra = {}) => events.message({ source: frame.contentWindow, origin: "pet-live2d://viewer", data: { channel: "desktop-pet-model", type, ...extra } }) };
}
test("model readiness replays only the latest expression and current attention", async () => {
@@ -40,6 +40,37 @@ test("model readiness replays only the latest expression and current attention",
assert.equal(ui.sent.at(-1).state, "thinking");
});
+test("hit queries accept only known regions and are cancelled on replacement or character switch", async () => {
+ const ui = setup();
+ await ui.api.apply({ id: "sample", type: "live2d" }, { expressionMode: "automatic" });
+ ui.message("boot"); ui.message("loaded");
+ let first = ui.api.locate(0.5, 0.2);
+ ui.message("location", { request: ui.sent.at(-1).request, kind: "head" });
+ assert.equal(await first, "head");
+ first = ui.api.locate(0.5, 0.2);
+ const second = ui.api.locate(0.5, 0.8);
+ assert.equal(await first, null);
+ ui.message("location", { request: ui.sent.at(-1).request, kind: "unknown" });
+ assert.equal(await second, null);
+ const pending = ui.api.locate(0.5, 0.2);
+ await ui.api.apply({ id: "sprite" }, {});
+ assert.equal(await pending, null);
+});
+
+test("drag interruption and quiet-idle settings survive ordinary state broadcasts", async () => {
+ const ui = setup();
+ const character = { id: "sample", type: "live2d" };
+ const settings = { expressionMode: "automatic", persona: { live2dIdle: false, live2dTouches: { head: { expression: 1 } } } };
+ await ui.api.apply(character, settings); ui.message("boot"); ui.message("loaded");
+ ui.api.dragging(true);
+ await ui.api.apply(character, settings);
+ assert.equal(ui.sent.at(-1).dragging, true);
+ assert.equal(ui.sent.at(-1).idle, false);
+ assert.equal(ui.sent.at(-1).touches.head.expression, 1);
+ ui.api.dragging(false);
+ assert.equal(ui.sent.at(-1).dragging, false);
+});
+
test("stopping or switching characters during audio resume cannot reconnect old speech", async () => {
for (const switchCharacter of [false, true]) {
const ui = setup();
diff --git a/tests/live2d-import.test.js b/tests/live2d-import.test.js
index e5229b5..d10ea05 100644
--- a/tests/live2d-import.test.js
+++ b/tests/live2d-import.test.js
@@ -64,6 +64,27 @@ test("Live2D rejects missing resources, symlinked directories and malformed PNGs
assert.throws(() => readLive2DPack(root));
});
+test("native hit areas survive import with unique names and no unrelated metadata", (t) => {
+ const { root } = fixture(t, raw => { raw.HitAreas = [
+ { Id: "HitAreaHead", Name: "", url: "https://example.invalid" },
+ { Id: "HitAreaBody", Name: "" }, { Id: "Hair", Name: "Head" }, { Id: "Hair2", Name: "Head" },
+ { Id: "Hands", Name: "__proto__" },
+ ]; });
+ const areas = JSON.parse(readLive2DPack(root).files["model.model3.json"]).HitAreas;
+ assert.equal(areas.length, 5);
+ assert.equal(new Set(areas.map(area => area.Name)).size, 5);
+ assert.equal(areas[0].Name, "HitAreaHead 1");
+ assert.equal(areas[1].Name, "HitAreaBody 2");
+ assert.equal(areas[0].url, undefined);
+ assert.equal(areas.some(area => area.Name === "__proto__"), false);
+});
+
+test("malformed or unbounded hit areas are rejected", t => {
+ for (const areas of [null, {}, [null], [{ Id: "" }], [{ Id: "x".repeat(129) }], Array(65).fill({ Id: "Head" })]) {
+ assert.throws(() => readLive2DPack(fixture(t, raw => { raw.HitAreas = areas; }).root), /invalid_hit_areas/);
+ }
+});
+
test("Live2D ZIP rejects ambiguous models, traversal and case-colliding paths", (t) => {
const { root, files } = fixture(t);
const target = path.join(root, "bad.zip");
diff --git a/tests/live2d-runtime.test.js b/tests/live2d-runtime.test.js
index c2f002e..75d8c64 100644
--- a/tests/live2d-runtime.test.js
+++ b/tests/live2d-runtime.test.js
@@ -3,7 +3,7 @@ const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { createHash } = require("node:crypto");
-const { loadMain } = require("./helpers");
+const { loadMain, loadRenderer } = require("./helpers");
const { loadPet } = require("./pet-helpers");
test("vendored display bundles match the pinned upstream releases and omit Core/models", () => {
@@ -27,12 +27,69 @@ test("model expression mappings and gaze normalize without enabling affection or
test("Live2D click-only advances once without scheduling sprite actions or network calls", () => {
const pet = loadPet();
- pet.run(`globalThis.modelCalls=[]; window.DesktopPetModel={apply(){},expression(){},stopMouth(){},busy(){},cycle(){modelCalls.push("cycle")}};
+ pet.run(`globalThis.modelCalls=[]; window.DesktopPetModel={apply(){},expression(){},stopMouth(){},busy(){},dragging(){},cycle(){modelCalls.push("cycle")}};
applyAppState({settings:{expressionMode:"clickOnly"},activeCharacter:{id:"imported-fixture",type:"live2d",states:{idle:{row:0,frames:1,interval:1000}}}});
handleClick(); handleDoubleClick(); handleLongPress(); scheduleIdleBehavior();`);
assert.deepEqual(JSON.parse(pet.run('JSON.stringify(modelCalls)')), ["cycle"]);
});
+test("slow double-clicks advance once even when pointer events have no click count", () => {
+ const pet = loadPet();
+ pet.run(`globalThis.cycles=0; window.DesktopPetModel={dragging(){},cycle(){cycles++}};
+ character={...character,type:"live2d"}; settings.expressionMode="clickOnly";`);
+ for (const detail of [1, 2]) {
+ pet.run('clickOnlyReadyAt=0; beginDrag({button:0,clientX:96,clientY:100,screenX:100,screenY:100})');
+ pet.events.mousedown({ detail });
+ pet.run('endDrag({detail:0})');
+ }
+ assert.equal(pet.run('cycles'), 1);
+});
+
+test("touch mappings validate indices, preserve motion group names, and keep quiet idle optional", () => {
+ const app = loadMain();
+ const persona = app.run(`normalizeSettings({persona:{live2dIdle:false,live2dTouches:{
+ head:{expression:0,motion:{group:"",index:0}},greet:{expression:64,motion:{group:"Tap",index:-1}}
+ }}}).persona`);
+ assert.equal(persona.live2dIdle, false);
+ assert.deepEqual(JSON.parse(JSON.stringify(persona.live2dTouches)), {
+ head: { expression: 0, motion: { group: "", index: 0 } }, greet: { expression: -1, motion: null },
+ });
+ assert.equal(app.run('normalizeSettings({}).persona.live2dIdle'), true);
+});
+
+test("a delayed hit test cannot trigger a touch after dragging, mode changes or focus loss", async () => {
+ for (const interrupt of ['cancelDrag()', 'settings.expressionMode="clickOnly"', 'dragStart={}']) {
+ const pet = loadPet();
+ pet.run(`globalThis.touches=[]; window.DesktopPetModel={dragging(){},locate(){return new Promise(resolve=>globalThis.resolveHit=resolve)},touch(kind){touches.push(kind)}};
+ character={...character,type:"live2d"}; scheduleSingleClick("greet",{x:0.5,y:0.2});`);
+ pet.fireTimer(pet.run('clickTimer'));
+ pet.run(interrupt);
+ pet.run('resolveHit("head")');
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(pet.run('touches.length'), 0);
+ }
+});
+
+test("native head hits select head responses and transparent hits do nothing", async () => {
+ for (const hit of ["head", "greet", null]) {
+ const pet = loadPet();
+ pet.context.hit = hit;
+ pet.run(`globalThis.touches=[]; window.DesktopPetModel={locate:async()=>hit,touch(kind){touches.push(kind)}};
+ character={...character,type:"live2d"}; scheduleSingleClick("greet",{x:0.5,y:0.2});`);
+ pet.fireTimer(pet.run('clickTimer'));
+ await new Promise(resolve => setImmediate(resolve));
+ assert.deepEqual(JSON.parse(pet.run('JSON.stringify(touches)')), hit ? [hit] : []);
+ }
+});
+
+test("import feedback follows settings language changes", () => {
+ const ui = loadRenderer("settings.js");
+ ui.run('setImportStatus("settings.imported"); activeLanguage="en-US"; applyStaticTranslations()');
+ assert.match(ui.nodes.get("#import-status").textContent, /Character/);
+ ui.run('activeLanguage="ja-JP"; applyStaticTranslations()');
+ assert.equal(ui.nodes.get("#import-status").textContent, require("../src/i18n").t("settings.imported", {}, "ja-JP"));
+});
+
test("partner touch phrases are opt-in, local and leave affection unchanged", () => {
const pet = loadPet();
pet.run('settings.persona = {relationship:"partner"}; settings.userName="Alex"; respondToInteraction("head")');
diff --git a/tests/live2d-smoke.cjs b/tests/live2d-smoke.cjs
index 121cc99..0ed084d 100644
--- a/tests/live2d-smoke.cjs
+++ b/tests/live2d-smoke.cjs
@@ -74,12 +74,24 @@ async function run() {
assert.equal(await frame.evaluate(() => typeof window.desktopPet), "undefined");
assert.equal(await frame.evaluate(() => typeof window.require), "undefined");
await frame.evaluate(() => {
+ window.modelCommands = [];
+ window.addEventListener("message", event => { if (event.data?.channel === "desktop-pet-model") modelCommands.push(event.data.type); });
const original = PIXI.live2d.Live2DModel.from;
PIXI.live2d.Live2DModel.from = async (...args) => { window.testModel = await original.apply(PIXI.live2d.Live2DModel, args); return window.testModel; };
});
await pet.evaluate((id) => {
document.querySelector("iframe").contentWindow.postMessage({ channel: "desktop-pet-model", type: "load", id }, "pet-live2d://viewer");
window.modelEvents = [];
+ window.expressionSources = [];
+ window.pointerEvents = [];
+ for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click", "dblclick"]) {
+ document.querySelector("#pet").addEventListener(type, event => pointerEvents.push({ type, detail: event.detail, time: Math.round(performance.now()) }));
+ }
+ const originalExpression = window.DesktopPetModel.expression;
+ window.DesktopPetModel.expression = index => {
+ expressionSources.push({ index, stack: new Error().stack.split("\n").slice(1, 5).map(line => line.replace(/file:\/\/[^)]+\//g, "")) });
+ originalExpression(index);
+ };
window.addEventListener("message", (event) => { if (event.data?.channel === "desktop-pet-model") modelEvents.push(event.data); });
}, id);
await frame.waitForFunction(() => window.testModel?.internalModel);
@@ -118,7 +130,44 @@ async function run() {
assert.equal(after.length, before + 1, "one click, one expression");
assert.equal(after.at(-1).index, 0);
await pet.waitForTimeout(1200);
- assert.equal(await pet.evaluate(() => modelEvents.filter((item) => item.type === "expression").length), after.length);
+ const heldExpressions = await pet.evaluate(() => modelEvents.filter(item => item.type === "expression"));
+ assert.equal(heldExpressions.length, after.length, JSON.stringify({ heldExpressions, commands: await frame.evaluate(() => modelCommands), sources: await pet.evaluate(() => expressionSources) }));
+ await pet.locator("#pet").dblclick();
+ await pet.waitForTimeout(600);
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "expression").length), after.length + 1, "double-click adds no second expression");
+ const slowClickBox = await pet.locator("#pet").boundingBox();
+ const slowClickX = slowClickBox.x + slowClickBox.width / 2;
+ const slowClickY = slowClickBox.y + slowClickBox.height / 2;
+ await pet.mouse.move(slowClickX, slowClickY);
+ await pet.mouse.down({ clickCount: 1 }); await pet.mouse.up({ clickCount: 1 });
+ await pet.waitForTimeout(320);
+ await pet.mouse.down({ clickCount: 2 }); await pet.mouse.up({ clickCount: 2 });
+ await pet.waitForTimeout(600);
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "expression").length), after.length + 2,
+ JSON.stringify({ reason: "slow double-click adds no second expression either", events: await pet.evaluate(() => pointerEvents), expressions: await pet.evaluate(() => modelEvents.filter(item => item.type === "expression")), sources: await pet.evaluate(() => expressionSources) }));
+ const manualBeforeHold = await pet.evaluate(() => modelEvents.filter(item => item.type === "expression").length);
+ await pet.mouse.down(); await pet.waitForTimeout(650); await pet.mouse.up();
+ await pet.waitForTimeout(400);
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "expression").length), manualBeforeHold, "long hold never advances manual expressions");
+ assert.equal(await pet.evaluate(() => settings.scale), 2, "double-click and hold never resize the pet");
+ await app.evaluate(({ screen }) => {
+ globalThis.testCursor = { x: 300, y: 300 };
+ globalThis.originalCursorReader = screen.getCursorScreenPoint;
+ screen.getCursorScreenPoint = () => globalThis.testCursor;
+ });
+ try {
+ await pet.mouse.down();
+ await pet.waitForTimeout(50);
+ await app.evaluate(() => { globalThis.testCursor = { x: 320, y: 320 }; });
+ await pet.waitForFunction(() => didDrag === true);
+ await pet.mouse.up();
+ await pet.waitForFunction(() => dragStart === null && didDrag === false);
+ await pet.waitForTimeout(350);
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "expression").length), manualBeforeHold, "manual dragging preserves the selected native expression");
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "touch").length), 0, "dragging never turns into a tap");
+ } finally {
+ await app.evaluate(({ screen }) => { screen.getCursorScreenPoint = globalThis.originalCursorReader; });
+ }
await pet.screenshot({ path: path.join(output, "model-manual-200.png") });
await pet.evaluate(() => window.desktopPet.setSettings({ expressionMode: "automatic", scale: 0.75 }));
await pet.waitForTimeout(600);
@@ -145,6 +194,62 @@ async function run() {
await settings.screenshot({ path: path.join(output, "settings-reply-gestures.png") });
await settings.locator("#save-settings").click();
await pet.waitForFunction(() => settings.persona.live2dExpressions.happy === 1);
+ await settings.locator("#live2d-idle").uncheck();
+ await settings.locator("#live2d-gaze").uncheck();
+ await settings.locator("#live2d-touch-head").selectOption("0");
+ await settings.locator("#live2d-touch-greet").selectOption("2");
+ await settings.locator("#save-settings").click();
+ await pet.waitForFunction(() => settings.persona.live2dIdle === false && settings.persona.live2dTouches.head.expression === 0);
+ await settings.locator("#live2d-touch-head").scrollIntoViewIfNeeded();
+ await settings.screenshot({ path: path.join(output, "settings-touch-responses.png") });
+ await pet.evaluate(() => { dismissBubble(); window.DesktopPetModel.expression(-1); });
+ await pet.waitForTimeout(600);
+ // Probe the same isolated bridge used by pointer events, including models with no hit areas.
+ const touchPoints = await pet.evaluate(async () => {
+ const points = {};
+ for (const y of [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]) {
+ for (const x of [0.5, 0.4, 0.6]) {
+ const kind = await window.DesktopPetModel.locate(x, y);
+ if (kind && !points[kind]) points[kind] = { x, y };
+ }
+ }
+ return points;
+ });
+ assert.ok(touchPoints.head && touchPoints.greet, "head and body hit regions are reachable");
+ const clickPoint = async point => {
+ const box = await pet.locator("#pet").boundingBox();
+ await pet.locator("#pet").click({ position: { x: point.x * box.width, y: point.y * box.height } });
+ };
+ await clickPoint(touchPoints.head);
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "touch").at(-1)?.kind === "head");
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "expression").at(-1)?.index === 0);
+ await pet.screenshot({ path: path.join(output, "head-pat.png") });
+ await clickPoint(touchPoints.head);
+ await pet.waitForTimeout(350);
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "touch").length), 1, "rapid pats never stack");
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "expression").at(-1)?.index === -1);
+ await pet.waitForTimeout(400);
+ await clickPoint(touchPoints.greet);
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "touch").at(-1)?.kind === "greet");
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "expression").at(-1)?.index === 2);
+ await pet.locator("#talk").click();
+ await pet.waitForFunction(() => modelEvents.filter(item => item.type === "expression").at(-1)?.index === -1);
+ await pet.locator("#companion-close").click();
+ await pet.waitForTimeout(2400);
+ await clickPoint({ x: 0.001, y: 0.001 });
+ await pet.waitForTimeout(450);
+ assert.equal(await pet.evaluate(() => modelEvents.filter(item => item.type === "touch").length), 2, "empty corners do not trigger interaction");
+ assert.equal(requests, 0, "touch interactions never call the LLM");
+ for (const language of ["ja-JP", "zh-CN", "en-US"]) {
+ await settings.evaluate(language => window.desktopPet.saveChatConfig({ language }), language);
+ await settings.waitForFunction(language => document.documentElement.lang === language, language);
+ await settings.locator("#live2d-motion-touch-greet").scrollIntoViewIfNeeded();
+ assert.equal(await settings.locator("#live2d-touch-head").inputValue(), "0");
+ assert.equal(await settings.locator("#live2d-touch-greet").inputValue(), "2");
+ assert.equal(await settings.locator("#live2d-idle").isChecked(), false);
+ assert.equal(await settings.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ await settings.screenshot({ path: path.join(output, `touch-settings-${language}.png`) });
+ }
await settings.evaluate((endpoint) => window.desktopPet.saveChatConfig({
assistant: { baseUrl: `${endpoint}/v1`, model: "local-test", stream: false },
tts: { enabled: true, provider: "custom", endpoint: `${endpoint}/tts`, customBodyTemplate: '{"text":"{{text}}"}' },
@@ -206,7 +311,7 @@ async function run() {
await pet.waitForFunction(() => !document.querySelector("iframe") && spriteImage && character.id === "luna");
assert.equal(await pet.evaluate(() => ctx.getImageData(0, 0, 768, 832).data.some((value, index) => index % 4 === 3 && value > 0)), true);
assert.deepEqual(errors, []);
- console.log("PASS: native sample loading, isolated frame, blink, breath, gaze, manual expression, motion, inline draft/submit/failure/cancel, history sync, chat expression, audio-driven mouth/stop, scaling, sprite fallback; isolated local LLM/TTS only.");
+ console.log("PASS: native sample loading, isolated frame, blink, breath, gaze, manual expression, motion, head/body touch mapping/restoration/interruption, empty hit rejection, inline draft/submit/failure/cancel, history sync, chat expression, audio-driven mouth/stop, scaling, sprite fallback; isolated local LLM/TTS only.");
} finally {
await app.evaluate(({ app }) => app.exit(0));
await app.close();
diff --git a/tests/live2d-viewer.test.js b/tests/live2d-viewer.test.js
index dbafe3e..44f98fd 100644
--- a/tests/live2d-viewer.test.js
+++ b/tests/live2d-viewer.test.js
@@ -6,26 +6,34 @@ const vm = require("node:vm");
const rig = require("../src/live2d/rig");
async function viewer() {
- const events = {}, updates = {}, messages = [], motions = [], values = {};
+ const events = {}, updates = {}, messages = [], motions = [], values = {}, expressions = [];
let now = 100, stops = 0;
+ let alpha = 255;
const internal = { width: 100, height: 200,
coreModel: { getModel: () => ({ parameters: { ids: ["PARAM_ANGLE_Z", "PARAM_MOUTH_OPEN_Y"] } }),
- setParameterValueById: (id, value) => { values[id] = value; }, addParameterValueById() {} },
+ setParameterValueById: (id, value) => { values[id] = value; }, addParameterValueById: (id, value) => { values[id] = value; } },
breath: { getParameters: () => [] },
- motionManager: { groups: {}, stopAllMotions: () => { stops++; } },
+ motionManager: { groups: {}, stopAllMotions: () => { stops++; }, on: (name, callback) => { events[name] = callback; },
+ expressionManager: { defaultExpression: {}, resetExpression: () => expressions.push(-1),
+ setExpression: async index => { expressions.push(index); return true; } } },
+ hitAreas: { Head: {}, Body: {} },
focusController: { focus() {} }, on: (event, callback) => { updates[event] = callback; },
};
const model = { internalModel: internal, scale: { set() {} }, anchor: { set() {} }, position: { set() {} },
- destroy() {}, update() {}, motion: async (group, index) => { motions.push({ group, index }); return true; } };
+ destroy() {}, update() {}, motion: async (group, index) => { motions.push({ group, index }); return true; },
+ hitTest: (x, y) => x < 10 ? [] : y < 150 ? ["Head", "Body"] : ["Body"] };
const parent = { postMessage: message => messages.push(message) };
const context = vm.createContext({ parent, innerWidth: 200, innerHeight: 400, devicePixelRatio: 1,
performance: { now: () => now },
- document: { querySelector: () => ({}), addEventListener() {} },
+ document: { hidden: false, querySelector: () => ({ width: 200, height: 400 }), addEventListener: (name, callback) => { events[name] = callback; } },
window: { Live2DCubismCore: {}, DesktopPetRig: { ...rig, measure: () => ({ width: 100, height: 200 }) },
addEventListener: (name, callback) => { events[name] = callback; } },
- fetch: async () => ({ json: async () => ({ FileReferences: { Motions: { Tap: [{}, {}] } } }) }),
+ fetch: async () => ({ json: async () => ({ FileReferences: { Expressions: [{ Name: "Smile" }, { Name: "Blush" }], Motions: { Tap: [{}, {}], Idle: [{}] } } }) }),
PIXI: { Application: class {
- constructor() { this.renderer = { resize() {} }; this.stage = { addChild() {} }; this.ticker = { add() {} }; }
+ constructor() {
+ this.renderer = { resize() {}, gl: { readPixels: (x, y, w, h, format, type, pixel) => { pixel[3] = alpha; } } };
+ this.stage = { addChild() {} }; this.ticker = { add() {}, stop() {}, start() {} };
+ }
}, live2d: { Live2DModel: { from: async () => model } } },
});
context.window.PIXI = context.PIXI;
@@ -34,7 +42,9 @@ async function viewer() {
data: { channel: "desktop-pet-model", id: "imported-123456789012345678901234", type, ...extra } });
send("load"); await new Promise(resolve => setImmediate(resolve));
assert.ok(messages.some(message => message.type === "loaded"));
- return { send, motions, values, stops: () => stops, advance: milliseconds => { now += milliseconds; }, tick: () => updates.beforeModelUpdate() };
+ return { send, motions, values, messages, expressions, events, context, stops: () => stops,
+ setAlpha: value => { alpha = value; }, internal, model,
+ advance: milliseconds => { now += milliseconds; }, tick: () => updates.beforeModelUpdate() };
}
test("reply gestures are bounded and unavailable in click-only mode", async () => {
@@ -50,6 +60,80 @@ test("reply gestures are bounded and unavailable in click-only mode", async () =
assert.equal(ui.motions.length, 2);
});
+const flush = () => new Promise(resolve => setImmediate(resolve));
+
+test("head pats ease in and out, do not stack, and restore the previous expression", async () => {
+ const ui = await viewer();
+ ui.send("expression", { index: 0 }); await flush();
+ ui.send("options", { touches: { head: { expression: 1 } } });
+ ui.send("touch", { kind: "head" }); await flush();
+ assert.equal(ui.expressions.at(-1), 1);
+ ui.tick(); assert.ok(Math.abs(ui.values.PARAM_ANGLE_Z) < 0.001);
+ ui.advance(1100); ui.tick(); assert.equal(ui.values.PARAM_ANGLE_Z, -5);
+ ui.send("touch", { kind: "head" });
+ assert.equal(ui.messages.filter(message => message.type === "touch").length, 1);
+ ui.advance(1100); ui.tick(); await flush();
+ assert.equal(ui.expressions.at(-1), 0);
+ assert.ok(Math.abs(ui.values.PARAM_ANGLE_Z) < 0.001);
+});
+
+test("touch expressions cannot overwrite a newer reply, drag or manual selection", async () => {
+ for (const interrupt of ["expression", "drag", "manual", "presence"]) {
+ const ui = await viewer();
+ ui.send("options", { touches: { head: { expression: 1 } } });
+ ui.send("touch", { kind: "head" }); await flush();
+ if (interrupt === "expression") ui.send("expression", { index: 0 });
+ if (interrupt === "drag") ui.send("options", { dragging: true });
+ if (interrupt === "manual") ui.send("options", { automatic: false });
+ if (interrupt === "presence") ui.send("presence", { state: "attentive" });
+ await flush();
+ const count = ui.expressions.length;
+ ui.advance(5000); ui.tick(); await flush();
+ assert.equal(ui.expressions.length, count);
+ assert.equal(ui.expressions.at(-1), interrupt === "expression" ? 0 : -1);
+ }
+});
+
+test("body touches use their own motion, while quiet idle never suppresses requested gestures", async () => {
+ const ui = await viewer();
+ ui.send("options", { idle: false, touches: { greet: { motion: { group: "Tap", index: 1 } } } });
+ ui.advance(90000); ui.tick(); assert.equal(ui.motions.length, 0);
+ ui.send("touch", { kind: "greet" }); ui.tick();
+ assert.deepEqual(ui.motions, [{ group: "Tap", index: 1 }]);
+ assert.equal(ui.values.PARAM_ANGLE_Z, undefined, "native gestures have no additional artificial tilt");
+ ui.send("options", { idle: true }); ui.advance(60000); ui.tick();
+ assert.equal(ui.motions.at(-1).group, "Idle");
+});
+
+test("hit tests prefer native head areas and reject blank or malformed coordinates", async () => {
+ const ui = await viewer();
+ for (const [x, y, kind] of [[0.5, 0.2, "head"], [0.5, 0.8, "greet"], [0, 0, null], [NaN, 0, null], [1, 1, null]]) {
+ ui.send("locate", { x, y, request: 1 });
+ assert.equal(ui.messages.at(-1).kind, kind);
+ }
+ ui.setAlpha(0); ui.send("locate", { x: 0.5, y: 0.2, request: 2 });
+ assert.equal(ui.messages.at(-1).kind, null, "transparent pixels inside native bounds do not react");
+ ui.setAlpha(255);
+ ui.internal.hitAreas = { localized: { id: "HitAreaHead" } };
+ ui.model.hitTest = () => ["localized"];
+ ui.send("locate", { x: 0.5, y: 0.2, request: 3 });
+ assert.equal(ui.messages.at(-1).kind, "head", "drawable IDs resolve non-English names");
+ ui.internal.hitAreas = {};
+ ui.send("locate", { x: 0.5, y: 0.8, request: 4 });
+ assert.equal(ui.messages.at(-1).kind, "greet", "models without native areas use visible fallback regions");
+});
+
+test("hiding cancels transient gestures and resuming never launches a queued idle motion", async () => {
+ const ui = await viewer();
+ ui.send("options", { touches: { head: { expression: 1 } } });
+ ui.send("touch", { kind: "head" }); await flush();
+ ui.context.document.hidden = true; ui.events.visibilitychange(); await flush();
+ assert.equal(ui.expressions.at(-1), -1);
+ ui.advance(120000);
+ ui.context.document.hidden = false; ui.events.visibilitychange(); ui.tick();
+ assert.equal(ui.motions.length, 0);
+});
+
test("speech preserves a reply gesture while the next user request interrupts it", async () => {
const ui = await viewer();
ui.send("presence", { state: "thinking" });
From f0b0503b9a12fc30b6b064c9fbc380d3e45a9f99 Mon Sep 17 00:00:00 2001
From: TonyNa-code <278977922+TonyNa-code@users.noreply.github.com>
Date: Mon, 26 Oct 2026 18:56:58 +0800
Subject: [PATCH 4/7] Preserve vendored runtime bytes on Windows checkout
---
.gitattributes | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 .gitattributes
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7b19f2d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+# Preserve the exact bytes checked against upstream SHA-256 hashes.
+/src/live2d/vendor/pixi.min.js -text
+/src/live2d/vendor/cubism4.min.js -text
From 4bd93332979e569c1972c8e751cf5d02e51e2859 Mon Sep 17 00:00:00 2001
From: TonyNa-code <278977922+TonyNa-code@users.noreply.github.com>
Date: Mon, 26 Oct 2026 19:41:20 +0800
Subject: [PATCH 5/7] Expose pet controls beside the character
---
docs/live2d.md | 6 ++++++
src/i18n.js | 3 +++
src/index.html | 1 +
src/renderer.js | 4 +++-
src/styles.css | 1 +
tests/bubble-controls.test.js | 14 +++++++++++++-
tests/electron-smoke.cjs | 22 ++++++++++++++++++++--
tests/live2d-smoke.cjs | 17 +++++++++++++++++
tests/pet-helpers.js | 4 ++--
9 files changed, 66 insertions(+), 6 deletions(-)
diff --git a/docs/live2d.md b/docs/live2d.md
index 32c2e7e..e488bc7 100644
--- a/docs/live2d.md
+++ b/docs/live2d.md
@@ -30,6 +30,8 @@ For a closer companion tone, choose **Relationship > Partner**, set your preferr
The speech icon opens a small input beside the character. Enter sends your message and folds the input away; replies appear just above the character. Escape closes the input without losing its draft. The book icon opens the full conversation, sharing the same draft and history. A failed request keeps your unsent text for another attempt.
+The menu button beside the speech icon opens character selection, size, full chat, chat settings, and **Quit**. The same menu is available by right-clicking the character.
+
In automatic mode, a Live2D character gently centers its gaze while you type or wait for a reply. Its own blink and breath continue; random idle gestures wait until the conversation settles. Click expression mode keeps the expression you selected. External voice playback has a compact stop/replay control beside the character.
Under **Reply gestures**, you can also assign a native motion to each reply mood. All start with **No gesture**. A completed reply can play one selected motion, not one per streamed text chunk. Gestures are spaced apart during quick exchanges, and a new message interrupts the previous gesture. Starting voice playback does not cancel the current reply gesture. Click expression mode disables reply gestures.
@@ -74,6 +76,8 @@ Chat and spoken text still go to the services you explicitly configure. Returned
点人物旁的对话图标即可展开小输入框,按回车发送后自动收起,回复显示在人物上方。按 Esc 可收起并保留草稿;点书本图标可看完整聊天记录,两处共用草稿。请求失败时也会保留未发送的文字。自动模式下,输入和等待回复时人物会轻轻看向你,保持眨眼、呼吸,暂缓待机动作。点击模式不改变手动选中的表情。朗读的停止、重播按钮在人物身旁。
+对话图标旁的菜单按钮可以切换角色、调整大小、打开完整聊天和对话设置,或选择 **退出**。右键点击人物也能打开同一个菜单。
+
在 **回复动作** 中可以为各个情绪选择模型自带动作,默认均不播放。一条完整回复最多触发一次,逐字输出时不会重复触发;连续短回复也不会让动作连着播放。开始朗读会保留当前回复动作,发出新消息则会中止上一动作。点击表情模式不播放回复动作。
支持 Cubism 3/4 导出的模型、PNG、表情、动作、物理和姿态文件。不能把一张立绘自动变成 Live2D,也不支持 Cubism 2、编辑工程 `.cmo3`、PSD、VRM 或 Cubism 5.3 的扩展混合模式。单包上限 128 MiB、256 文件、8 张纹理,单张最长边 8192,纹理总像素不超过 6400 万,单个 JSON 不超过 2 MiB。
@@ -102,6 +106,8 @@ Chat and spoken text still go to the services you explicitly configure. Returned
キャラクター横の会話アイコンで小さな入力欄が開きます。Enter で送信すると閉じ、返答はキャラクターのすぐ上に表示されます。Esc で閉じても下書きは残ります。本のアイコンから開く会話履歴とも下書きを共有します。送信失敗時も入力内容は保持されます。自動モードでは入力中や返答待ちに視線をそっと中央に戻し、まばたきと呼吸を続けます。クリック表情モードでは選んだ表情を保ちます。音声の停止・再生はキャラクター横の小さなボタンで操作できます。
+会話アイコン横のメニューボタンから、キャラクター・サイズの変更、チャット画面、会話設定、**終了**を選べます。キャラクターを右クリックしても同じメニューが開きます。
+
**返答時のモーション** では、感情ごとにモデルの動作を選べます。初期値はすべて「モーションなし」です。一つの返答につき一度だけ再生し、文字の逐次表示や短い返答の連続で動作を繰り返しません。読み上げ開始時も動作を保ち、新しいメッセージで前の動作を中断します。クリック表情モードでは再生しません。
Cubism 3/4 の書き出しモデルに対応します。Cubism 2、`.cmo3` 編集ファイル、PSD、VRM、Cubism 5.3 の拡張ブレンドは非対応です。128 MiB・256 ファイル・PNG 8 枚まで、各辺 8192 ピクセル以下、合計 6400 万ピクセル以下、JSON は一つ 2 MiB 以下です。
diff --git a/src/i18n.js b/src/i18n.js
index 8238f7b..81afb52 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -126,6 +126,7 @@
"voice.ready": "最近的回复",
"voice.failed": "语音未能播放",
"pet.talk": "对话",
+ "pet.menu": "桌宠菜单",
"quick.toCharacter": "和{name}说点什么…",
"quick.history": "记录",
"quick.responding": "正在回应…",
@@ -504,6 +505,7 @@
"voice.ready": "Latest reply",
"voice.failed": "Voice unavailable",
"pet.talk": "Talk",
+ "pet.menu": "Pet menu",
"quick.toCharacter": "Talk to {name}…",
"quick.history": "History",
"quick.responding": "Replying…",
@@ -882,6 +884,7 @@
"voice.ready": "直近の返信",
"voice.failed": "音声を再生できません",
"pet.talk": "話す",
+ "pet.menu": "デスクトップペットのメニュー",
"quick.toCharacter": "{name}に話しかける…",
"quick.history": "履歴",
"quick.responding": "返信中…",
diff --git a/src/index.html b/src/index.html
index f13ab40..852f0a4 100644
--- a/src/index.html
+++ b/src/index.html
@@ -33,6 +33,7 @@
+