=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 artBounds;
+ let presence = "idle";
+ let attentionWeight = 0;
+ let modelId = "";
+ 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 touchState = null;
+ let touchAt = -Infinity;
+ let touches = {};
+ let nextMotionAt = 0;
+ let replyMotionActive = false;
+ let replyMotionAt = -Infinity;
+ 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 = artBounds || model.internalModel;
+ const scale = Math.min(width / bounds.width, height / bounds.height) * 0.88;
+ model.scale.set(scale);
+ model.anchor.set(0.5, 0.5);
+ model.position.set(width / 2 - (bounds.x || 0) * scale, height / 2 - (bounds.y || 0) * scale);
+ }
+
+ 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__";
+ }
+
+ 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 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;
+ 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) {
+ const token = ++generation;
+ 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 {
+ 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 = 60;
+ application.ticker.add(() => model?.update(Math.min(50, application.ticker.elapsedMS)));
+ }
+ application.renderer.resize(Math.max(1, innerWidth), Math.max(1, innerHeight));
+ const url = `/models/${modelId}/model.model3.json`;
+ const descriptor = await (await fetch(url)).json();
+ const refs = descriptor.FileReferences;
+ const nextMetadata = { 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, { autoUpdate: false, autoInteract: false, idleMotionGroup: "__desktop_pet_no_automatic_motion__" });
+ if (generation !== token) { loaded.destroy({ children: true, texture: true, baseTexture: true }); return; }
+ model = loaded;
+ metadata = nextMetadata;
+ 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);
+ fit();
+ nextMotionAt = performance.now() + 35000;
+ 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;
+ const wasOpen = mouthValue > 0.005;
+ mouthValue += (mouthTarget - mouthValue) * 0.25;
+ if (mouthTarget > 0 || wasOpen) {
+ for (const id of lips) if (ids.has(id)) core.setParameterValueById(id, mouthValue < 0.005 ? 0 : mouthValue);
+ }
+ attentionWeight += ((automatic && presence !== "idle" ? 1 : 0) - attentionWeight) * 0.08;
+ if (attentionWeight > 0.005) {
+ if (ids.has(resolve("ParamAngleZ"))) core.addParameterValueById(resolve("ParamAngleZ"), (presence === "thinking" ? 2 : -1.5) * attentionWeight);
+ }
+ 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 && 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");
+ }
+ }
+
+ 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;
+ 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;
+ 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 || 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") {
+ 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) {
+ 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 a3c5cd4..c2833a7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,6 +10,9 @@ const {
dialog,
Tray,
powerMonitor,
+ protocol,
+ session,
+ shell,
} = require("electron");
const fs = require("node:fs");
const path = require("node:path");
@@ -23,10 +26,15 @@ const { normalizePlacement, fitBounds, capturePlacement, defaultBounds, restoreP
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 = 160;
const MIN_BUBBLE_WIDTH = 320;
+let companionInputOpen = false;
const DEFAULT_CHARACTER_ID = "default";
const CHARACTERS_DIR = path.join(__dirname, "..", "assets", "characters");
const SIZE_PRESETS = [0.75, 1, 1.25, 1.5, 1.75, 2];
@@ -245,28 +253,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), {
@@ -418,6 +452,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,
@@ -676,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 {
@@ -688,6 +742,16 @@ function normalizePersonaSettings(rawPersona = {}) {
speakingStyle: normalizeLongText(raw.speakingStyle, 1200),
background: normalizeLongText(raw.background, 1600),
extraRules: normalizeLongText(raw.extraRules, 1600),
+ live2dGaze: raw.live2dGaze !== false,
+ 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),
+ }])),
};
}
@@ -782,7 +846,7 @@ function nearestScale(scale) {
function displaySize(scale = settings.scale) {
return {
width: Math.max(MIN_BUBBLE_WIDTH, Math.round(BASE_SIZE.width * scale)),
- height: Math.round(BASE_SIZE.height * scale) + BUBBLE_HEIGHT,
+ height: Math.round(BASE_SIZE.height * scale) + BUBBLE_HEIGHT + (companionInputOpen ? 88 : 0),
};
}
@@ -796,6 +860,14 @@ function applyWindowSize(keepCenter = true) {
mainWindow.setBounds(fitBounds({ x, y, ...nextSize }, area));
}
+function setCompanionInputOpen(event, open) {
+ if (event.sender !== mainWindow?.webContents) return false;
+ companionInputOpen = open === true;
+ applyWindowSize();
+ if (companionInputOpen) { mainWindow.show(); mainWindow.focus(); }
+ return companionInputOpen;
+}
+
function createWindow() {
readSettings();
readProfile();
@@ -818,10 +890,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 });
enableInputMenu(mainWindow);
mainWindow.loadFile(path.join(__dirname, "index.html"));
@@ -834,6 +907,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) {
@@ -876,10 +961,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());
@@ -910,10 +996,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());
@@ -942,10 +1029,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());
@@ -1255,6 +1343,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,
@@ -1298,7 +1389,7 @@ function setChatDraft(event, draft = {}) {
else chatDrafts.delete(settings.characterId);
chatDraftVersion += 1;
// Mirror edits between composers without persisting unsent text.
- for (const window of [chatWindow, quickChatWindow]) {
+ for (const window of [mainWindow, chatWindow, quickChatWindow]) {
if (window && !window.isDestroyed()) {
window.webContents.send("chat-draft-updated", { ...chatDraftState(),
acknowledgedEdit: window.webContents === event?.sender ? draft.edit : undefined });
@@ -1613,6 +1704,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();
@@ -1627,6 +1719,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());
@@ -2095,44 +2195,62 @@ else {
if (ownsInstance) 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.on("set-chat-draft", setChatDraft);
- ipcMain.handle("confirm-discard", confirmDiscard);
- ipcMain.on("confirm-app-quit", confirmAppQuit);
- 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("show-last-reply", showLastReply);
- 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("set-companion-input-open", setCompanionInputOpen);
+ 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);
+ on("set-chat-draft", setChatDraft);
+ on("confirm-app-quit", confirmAppQuit);
+ 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("show-last-reply", showLastReply);
+ 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/preload.js b/src/preload.js
index 3d072b4..05646ae 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -1,7 +1,17 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("desktopPet", {
+ setCompanionInputOpen: (open) => ipcRenderer.invoke("set-companion-input-open", open),
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.css b/src/quick-chat.css
index ca496c2..ee4c552 100644
--- a/src/quick-chat.css
+++ b/src/quick-chat.css
@@ -5,7 +5,7 @@ body {
margin: 0;
background: transparent;
color: #25282d;
- font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font: 13px/1.4 var(--pet-font);
}
body {
diff --git a/src/quick-chat.html b/src/quick-chat.html
index 6ea5e9a..62ecb5f 100644
--- a/src/quick-chat.html
+++ b/src/quick-chat.html
@@ -2,8 +2,10 @@
+
Quick Companion Input
+
diff --git a/src/renderer.js b/src/renderer.js
index d14afb1..4808a52 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -8,6 +8,7 @@ const bubblePin = document.querySelector("#bubble-pin");
const bubbleDismiss = document.querySelector("#bubble-dismiss");
const lastReplyButton = document.querySelector("#last-reply");
const talkButton = document.querySelector("#talk");
+const petMenuButton = document.querySelector("#pet-menu");
const ctx = canvas.getContext("2d", { alpha: true });
const i18n = window.DesktopPetI18n;
const SINGLE_CLICK_DELAY = 280;
@@ -68,6 +69,7 @@ let didDrag = false;
let clickTimer;
let clickOnlyReadyAt = 0;
let ignoreSingleClicksUntil = 0;
+let interactionTicket = 0;
let hoverReadyAt = 0;
let bubbleTimer;
let didShowStartupGreeting = false;
@@ -81,6 +83,7 @@ let speechActive = false;
let speechWatchdog;
let conversationActive = false;
let conversationAction = "idle";
+let lastReplyGestureId = null;
let queuedNotice = null;
let bubbleVisible = false;
let interactionReadyAt = 0;
@@ -104,7 +107,7 @@ function draw() {
function applyPetLayout() {
const availableScale = Math.min(Number.isFinite(window.innerWidth) ? window.innerWidth / 192 : Infinity,
- Number.isFinite(window.innerHeight) ? Math.max(0, window.innerHeight - 160) / 208 : Infinity);
+ Number.isFinite(window.innerHeight) ? Math.max(0, window.innerHeight - 160 - (window.DesktopPetCompanionInput?.isOpen() ? 88 : 0)) / 208 : Infinity);
const scale = Math.min(settings.scale, availableScale);
const petWidth = Math.round(192 * scale);
const petHeight = Math.round(208 * scale);
@@ -114,6 +117,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) {
@@ -133,13 +137,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(() => {
@@ -230,6 +238,7 @@ function applyAppState(nextAppState = {}) {
interactionResponses: nextAppState.settings?.interactionResponses !== false,
userName: nextAppState.settings?.persona?.userName || "",
characterName: nextAppState.settings?.persona?.name || character.name,
+ persona: nextAppState.settings?.persona || {},
scale: Number(nextAppState.settings?.scale) || 1,
alwaysOnTop: nextAppState.settings?.alwaysOnTop !== false,
tts: {
@@ -254,11 +263,23 @@ function applyAppState(nextAppState = {}) {
if (bubbleVisible) hideBubbleLater(Math.max(3000, bubbleRemaining));
}
activeLanguage = settings.resolvedLanguage;
- talkButton.textContent = text("pet.talk");
talkButton.title = text("quick.toCharacter", { name: settings.characterName });
+ talkButton.setAttribute("aria-label", talkButton.title);
updateBubbleControls();
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;
@@ -283,6 +304,7 @@ function applyAppState(nextAppState = {}) {
}
function resetAfterSettingsChange() {
+ interactionTicket++;
window.clearTimeout(clickTimer);
ignoreSingleClicksUntil = 0;
clickOnlyReadyAt = 0;
@@ -290,6 +312,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);
@@ -305,15 +332,16 @@ 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(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;
- 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);
@@ -322,42 +350,53 @@ 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 (conversationActive || speechActive || window.DesktopPetCompanionInput?.isOpen()) return;
if (settings.interactionResponses) { respondToInteraction(kind); 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() {
@@ -369,6 +408,7 @@ function handleClickOnlyPointerClick() {
function beginDrag(event) {
if (event.button !== 0) return;
+ interactionTicket++;
window.clearTimeout(clickTimer);
const bounds = canvas.getBoundingClientRect();
dragStart = {
@@ -376,6 +416,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;
@@ -393,6 +434,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");
@@ -410,8 +452,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;
@@ -426,16 +469,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;
@@ -454,7 +500,7 @@ function updateBubbleControls() {
bubbleSpeaker.title = name;
for (const [button, key] of [[bubbleReply, "pet.reply"], [bubbleHistory, "pet.openChat"],
[bubblePin, bubblePinned ? "pet.unpinReply" : "pet.pinReply"], [bubbleDismiss, "pet.dismissReply"],
- [lastReplyButton, "pet.lastReply"]]) {
+ [lastReplyButton, "pet.lastReply"], [petMenuButton, "pet.menu"]]) {
button.title = text(key);
button.setAttribute("aria-label", button.title);
}
@@ -474,6 +520,7 @@ function dismissBubble() {
updateBubbleControls();
conversationActive = false;
conversationAction = "idle";
+ window.DesktopPetModel?.busy(speechActive);
temporaryToken += 1;
if (settings.expressionMode === "automatic" && !dragStart) setState("idle");
scheduleIdleBehavior();
@@ -555,10 +602,11 @@ bubblePin.addEventListener("click", () => {
updateBubbleControls();
hideBubbleLater(Math.max(3000, bubbleRemaining));
});
-bubbleReply.addEventListener("click", () => window.desktopPet.openQuickChatWindow());
+bubbleReply.addEventListener("click", () => window.DesktopPetCompanionInput?.open());
bubbleHistory.addEventListener("click", () => window.desktopPet.openChatWindow());
lastReplyButton.addEventListener("click", () => window.desktopPet.showLastReply());
-talkButton.addEventListener("click", () => window.desktopPet.openQuickChatWindow());
+talkButton.addEventListener("click", () => window.DesktopPetCompanionInput?.toggle());
+petMenuButton.addEventListener("click", () => window.desktopPet.showContextMenu());
function finishSpeech(token, failed = false) {
if (token !== speechToken) return;
@@ -584,6 +632,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);
@@ -602,6 +652,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);
@@ -630,6 +681,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; }
@@ -685,6 +737,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;
@@ -697,6 +750,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);
}
@@ -730,14 +788,26 @@ 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();
+ 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);
});
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.css b/src/settings.css
index 5179f2c..c2f9db6 100644
--- a/src/settings.css
+++ b/src/settings.css
@@ -5,7 +5,7 @@ body {
margin: 0;
background: #f6f7f9;
color: #25282d;
- font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font: 14px/1.5 var(--pet-font);
}
body {
@@ -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..96239b6 100644
--- a/src/settings.html
+++ b/src/settings.html
@@ -2,8 +2,10 @@
+
Desktop Pet Settings
+
@@ -46,6 +48,7 @@ 角色
导入文件夹
导入 ZIP
+ 导入 Live2D
移除导入的角色
@@ -58,6 +61,49 @@
角色
选择角色后保存设置,桌宠窗口会立即切换。
+
+
Live2D
+
+ 下载 Core 5.2
+ 安装 Core
+
+ 自动模式下看向鼠标
+ 偶尔播放待机动作
+
+ 表情
+ 动作
+
+
+ 预览表情
+ 预览动作
+
+
+ 摸摸头
+
+ 表情
+ 动作
+
+ 轻轻碰一下
+
+ 表情
+ 动作
+
+ 回复表情
+
+ 开心
+ 害羞
+ 思考
+ 难过
+
+ 回复动作
+
+ 开心
+ 害羞
+ 思考
+ 难过
+
+
+
人物设定
diff --git a/src/settings.js b/src/settings.js
index 43fb736..8c3fd5c 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -130,18 +130,25 @@ 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;
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;
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}}\"}";
@@ -281,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");
@@ -440,10 +448,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 +466,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 +485,69 @@ 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 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;
+ 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);
+ fillModelMotions(document.querySelector(`#live2d-motion-${mood}`), character.motions, config.persona?.live2dMotions?.[mood], true);
+ }
+ const motionSelect = document.querySelector("#live2d-motion");
+ fillModelMotions(motionSelect, character.motions);
+ 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 +720,15 @@ function readConfigForm(includeEmptyKey = false) {
speakingStyle: personaSpeakingStyleEl.value.trim(),
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)])),
};
const affection = {
enabled: affectionEnabledEl.checked,
@@ -901,6 +986,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 {
@@ -908,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/src/styles.css b/src/styles.css
index 5e36702..f712395 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -30,9 +30,37 @@ 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 var(--pet-font);
+ pointer-events: auto;
+ cursor: pointer;
+}
+#model-status[hidden], #model-stage[hidden] { display: none; }
+
#speech-panel {
position: absolute;
- top: 8px;
+ bottom: calc(var(--pet-height, 208px) + 10px);
left: 50%;
width: min(296px, calc(100vw - 18px));
transform: translateX(-50%);
@@ -40,6 +68,7 @@ body {
flex-direction: column;
gap: 6px;
}
+body[data-companion-open="true"] #speech-panel { bottom: calc(var(--pet-height, 208px) + 98px); }
#speech-card {
position: relative;
@@ -64,7 +93,7 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
color: #64716e;
- font: 500 11px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font: 500 11px/1.2 var(--pet-font);
}
.icon-button {
display: inline-flex;
@@ -91,7 +120,7 @@ body {
min-height: 34px;
padding: 3px 12px 9px;
color: #3d3730;
- font: 500 12.5px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font: 500 12.5px/1.35 var(--pet-font);
text-align: left;
white-space: pre-wrap;
overflow: auto;
@@ -126,27 +155,59 @@ body {
pointer-events: auto;
}
#last-reply { width: 30px; height: 30px; background: rgba(255, 255, 255, 0.95); border-radius: 6px; }
+#pet-menu { width: 30px; height: 30px; background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(49, 110, 116, 0.25); border-radius: 6px; }
#last-reply:disabled { visibility: hidden; }
#talk {
+ width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(49, 110, 116, 0.25);
border-radius: 8px;
background: rgba(255, 255, 255, 0.95);
color: #316e74;
- font: 500 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font: 500 12px/1 var(--pet-font);
pointer-events: auto;
cursor: pointer;
}
+#companion-input {
+ position: absolute;
+ left: 50%;
+ bottom: calc(var(--pet-height, 208px) + 10px);
+ transform: translateX(-50%);
+ box-sizing: border-box;
+ width: min(296px, calc(100vw - 18px));
+ height: 72px;
+ display: flex;
+ gap: 6px;
+ padding: 8px;
+ border: 1px solid #cbdcda;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, .97);
+ box-shadow: 0 4px 12px #273a3d18;
+ pointer-events: auto;
+}
+#companion-text { flex: 1; min-width: 0; border: 0; background: transparent; resize: none; padding: 3px; color: #293b3d; font: 13px/1.5 var(--pet-font); user-select: text; }
+#companion-text:focus { outline: none; }
+#companion-input:focus-within { border-color: #47877c; }
+.companion-input-actions { display: flex; flex-direction: column; justify-content: space-between; }
+#companion-send { background: #dceee9; }#companion-send img { transform: rotate(180deg); }
+#companion-feedback { position: absolute; bottom: 0; left: 12px; font: 10px/1.4 var(--pet-font); color: #9a434e; }
+#companion-presence { display: flex; gap: 3px; padding: 8px; border-radius: 6px; background: #ffffffed; }
+#companion-presence i { width: 4px; height: 4px; border-radius: 50%; background: #538478; animation: presence 1.5s ease-in-out infinite; }
+#companion-presence i:nth-child(2) { animation-delay: .15s; }#companion-presence i:nth-child(3) { animation-delay: .3s; }
+#companion-stop { background: #fff; width: 30px; height: 30px; }
+@keyframes presence { 0%, 80%, 100% { opacity: .35; } 40% { opacity: 1; } }
+@media (prefers-reduced-motion: reduce) { #companion-presence i { animation: none; } }
+[hidden] { display: none !important; }
#talk:hover { background: #edf6f5; }
#talk:focus-visible { outline: 2px solid #316e74; outline-offset: 2px; }
-.has-voice-controls #speech-bubble { max-height: 78px; }
#voice-controls {
box-sizing: border-box;
- height: 28px;
- padding: 0 4px 0 9px;
+ height: 30px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
pointer-events: auto;
}
+#voice-controls #voice-toggle { min-width: 30px; width: 30px; height: 30px; padding: 4px; border: 0; background: transparent; }
+#voice-controls #voice-status { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
diff --git a/src/voice-controls.css b/src/voice-controls.css
index 801e552..d55858a 100644
--- a/src/voice-controls.css
+++ b/src/voice-controls.css
@@ -5,7 +5,7 @@
gap: 8px;
min-width: 0;
color: #526368;
- font: 12px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font: 12px/1.3 var(--pet-font);
}
.voice-controls[hidden] { display: none !important; }
.voice-controls span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
diff --git a/src/voice-controls.js b/src/voice-controls.js
index 5b7d9d2..36cfa53 100644
--- a/src/voice-controls.js
+++ b/src/voice-controls.js
@@ -16,8 +16,11 @@
const wasNearBottom = messages && messages.scrollHeight - messages.scrollTop - messages.clientHeight < 60;
controls.hidden = !voice.enabled || (!active && !voice.canReplay);
document.body.classList.toggle("has-voice-controls", !controls.hidden);
- button.textContent = t(active ? "voice.stop" : "voice.replay");
+ if (controls.dataset.compact === "true") {
+ button.querySelector("img").src = `./icons/${active ? "square" : "volume-2"}.svg`;
+ } else button.textContent = t(active ? "voice.stop" : "voice.replay");
button.title = t(active ? "voice.stop" : "voice.replayTitle");
+ button.setAttribute("aria-label", button.title);
button.disabled = pending || (!active && !voice.canReplay);
status.textContent = t(active ? `voice.${voice.status}` : state.voiceError ? "voice.failed" : "voice.ready");
if (wasNearBottom) messages.scrollTop = messages.scrollHeight;
diff --git a/tests/bubble-controls.test.js b/tests/bubble-controls.test.js
index 1e0ebaf..7ce206d 100644
--- a/tests/bubble-controls.test.js
+++ b/tests/bubble-controls.test.js
@@ -78,11 +78,12 @@ test("mouse focus does not keep an unpinned reply open after the pointer leaves"
test("reply, history and recall controls are separate and labels are translated", () => {
const calls = [];
- const pet = loadPet({ openQuickChatWindow: () => calls.push("quick"), openChatWindow: () => calls.push("history"), showLastReply: () => calls.push("recall") });
+ const pet = loadPet({ openChatWindow: () => calls.push("history"), showLastReply: () => calls.push("recall") });
+ pet.context.window.DesktopPetCompanionInput = { open: () => calls.push("inline") };
assert.equal(pet.bubble.events.click, undefined);
for (const language of ["en-US", "zh-CN", "ja-JP"]) {
pet.run(`activeLanguage = ${JSON.stringify(language)}; updateBubbleControls()`);
- for (const id of ["bubble-reply", "bubble-history", "bubble-pin", "bubble-dismiss", "last-reply"]) {
+ for (const id of ["bubble-reply", "bubble-history", "bubble-pin", "bubble-dismiss", "last-reply", "pet-menu"]) {
const node = pet.nodes.get(`#${id}`);
assert.ok(node.title);
assert.doesNotMatch(node.title, /pet\./);
@@ -90,7 +91,19 @@ test("reply, history and recall controls are separate and labels are translated"
}
}
for (const id of ["bubble-reply", "bubble-history", "last-reply"]) pet.nodes.get(`#${id}`).events.click();
- assert.deepEqual(calls, ["quick", "history", "recall"]);
+ assert.deepEqual(calls, ["inline", "history", "recall"]);
+});
+
+test("the visible pet menu opens once without changing expressions or conversation state", () => {
+ let opened = 0;
+ const pet = loadPet({ showContextMenu: () => opened++ });
+ for (const type of ["sprite", "live2d"]) {
+ pet.run(`character = {...character, type:${JSON.stringify(type)}}; settings.expressionMode="clickOnly"`);
+ const before = pet.run('JSON.stringify({state,frameIndex,clickExpressionIndex,conversationActive})');
+ pet.nodes.get("#pet-menu").events.click();
+ assert.equal(pet.run('JSON.stringify({state,frameIndex,clickExpressionIndex,conversationActive})'), before);
+ }
+ assert.equal(opened, 2);
});
test("an old initial state cannot reenable recall during a newer request", async () => {
diff --git a/tests/chat-continuity.test.js b/tests/chat-continuity.test.js
index 801c23c..817a28b 100644
--- a/tests/chat-continuity.test.js
+++ b/tests/chat-continuity.test.js
@@ -37,13 +37,14 @@ test("draft edit broadcasts are bounded and acknowledge the originating composer
app.run(`globalThis.sent = []; globalThis.sender = {send:(topic,state)=>sent.push({topic,state})};
chatWindow = {isDestroyed:()=>false,webContents:sender};
quickChatWindow = {isDestroyed:()=>false,webContents:{send:(topic,state)=>sent.push({topic,state})}};
- mainWindow = {webContents:{send:()=>sent.push("pet")}};
+ mainWindow = {isDestroyed:()=>false,webContents:{send:(topic,state)=>sent.push({topic,state})}};
setChatDraft({sender},{characterId:"default",revision:0,text:"x".repeat(1500),edit:1});`);
- assert.equal(app.run("sent.length"), 2);
+ assert.equal(app.run("sent.length"), 3);
assert.equal(app.run("sent[0].state.text.length"), 1200);
assert.equal(app.run("sent[0].topic"), "chat-draft-updated");
- assert.equal(app.run("sent[0].state.acknowledgedEdit"), 1);
- assert.equal(app.run("sent[1].state.acknowledgedEdit"), undefined);
+ assert.equal(app.run("sent[0].state.acknowledgedEdit"), undefined);
+ assert.equal(app.run("sent[1].state.acknowledgedEdit"), 1);
+ assert.equal(app.run("sent[2].state.acknowledgedEdit"), undefined);
});
for (const file of files) {
diff --git a/tests/companion-input.test.js b/tests/companion-input.test.js
new file mode 100644
index 0000000..2d5416f
--- /dev/null
+++ b/tests/companion-input.test.js
@@ -0,0 +1,77 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { loadRenderer, loadMain } = require("./helpers");
+const flush = () => new Promise(resolve => setImmediate(resolve));
+const state = (text = "", characterId = "default", revision = 0) => ({
+ revision, draft: { text, characterId, revision, version: 0 }, character: { id: characterId, name: "Character" }, history: [],
+});
+function setup(api = {}) {
+ let receive;
+ const ui = loadRenderer("companion-input.js", {
+ setCompanionInputOpen: async open => open,
+ onChatStateUpdated: callback => { receive = callback; }, ...api,
+ }, "index.html");
+ return { ...ui, receive, input: ui.nodes.get("#companion-text"),
+ submit: () => ui.nodes.get("#companion-input").events.submit({ preventDefault() {} }) };
+}
+
+test("inline composer submits once, folds away, and restores failed drafts without stealing focus", async () => {
+ let finish, requests = 0;
+ const ui = setup({ sendChatMessage: () => { requests++; return new Promise(resolve => { finish = resolve; }); } });
+ ui.receive(state("A quiet evening"));
+ await ui.run("window.DesktopPetCompanionInput.open()");
+ assert.equal(ui.context.document.body.dataset.companionOpen, "true");
+ ui.submit(); ui.submit();
+ assert.equal(requests, 1);
+ assert.equal(ui.nodes.get("#companion-input").hidden, true);
+ assert.equal(ui.context.document.body.dataset.companionOpen, "false");
+ assert.equal(ui.input.value, "");
+ finish({ ok: false, revision: 0 }); await flush();
+ assert.equal(ui.input.value, "A quiet evening");
+ assert.equal(ui.nodes.get("#companion-input").hidden, true);
+});
+
+test("closing keeps a draft, while IME Enter and Shift+Enter never submit", async () => {
+ let requests = 0;
+ const ui = setup({ sendChatMessage: () => { requests++; } });
+ ui.receive(state("Unsent"));
+ await ui.run("window.DesktopPetCompanionInput.open()");
+ ui.input.events.keydown({ key: "Enter", isComposing: true });
+ ui.input.events.keydown({ key: "Enter", keyCode: 229 });
+ ui.input.events.keydown({ key: "Enter", shiftKey: true });
+ ui.input.events.keydown({ key: "Escape", preventDefault() {} });
+ assert.equal(requests, 0);
+ assert.equal(ui.input.value, "Unsent");
+ assert.equal(ui.nodes.get("#companion-input").hidden, true);
+});
+
+test("late request failures cannot replace another character's inline draft", async () => {
+ let reject;
+ const ui = setup({ sendChatMessage: () => new Promise((_, fail) => { reject = fail; }) });
+ ui.receive(state("Old")); ui.submit();
+ ui.receive(state("New character draft", "luna", 1));
+ reject(new Error("closed")); await flush();
+ assert.equal(ui.input.value, "New character draft");
+});
+
+test("opening attention and pending speech follow live state without additional requests", async () => {
+ const ui = setup();
+ ui.receive(state());
+ await ui.run("window.DesktopPetCompanionInput.open()");
+ assert.equal(ui.context.document.body.dataset.companionState, "attentive");
+ ui.receive({ ...state(), busy: true });
+ assert.equal(ui.context.document.body.dataset.companionState, "thinking");
+ assert.equal(ui.nodes.get("#companion-stop").hidden, false);
+ ui.receive({ ...state(), voice: { status: "playing" } });
+ assert.equal(ui.context.document.body.dataset.companionState, "speaking");
+ ui.receive(state());
+ assert.equal(ui.context.document.body.dataset.companionState, "attentive");
+});
+
+test("inline layout reserves input space without changing stored pet scale", () => {
+ const app = loadMain();
+ const before = app.run("displaySize().height");
+ app.run("companionInputOpen = true");
+ assert.equal(app.run("displaySize().height"), before + 88);
+ assert.equal(app.run("settings.scale"), 1);
+});
diff --git a/tests/electron-smoke.cjs b/tests/electron-smoke.cjs
index 260b625..fb36f5e 100644
--- a/tests/electron-smoke.cjs
+++ b/tests/electron-smoke.cjs
@@ -5,10 +5,11 @@ const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { spawn } = require("node:child_process");
-const { restorePlacement } = require("../src/window-placement");
+const appRoot = process.env.PET_SMOKE_APP || path.join(__dirname, "..");
+const { restorePlacement } = require(path.join(appRoot, "src/window-placement"));
const dataDir = process.env.PET_SMOKE_DATA || fs.mkdtempSync(path.join(os.tmpdir(), "desktop-pet-smoke-"));
-const screenshots = path.join(__dirname, "../out/smoke");
+const screenshots = path.join(__dirname, process.env.PET_SMOKE_APP ? "../out/smoke-packaged" : "../out/smoke");
app.setPath("userData", dataDir);
app.disableHardwareAcceleration();
@@ -66,6 +67,16 @@ const server = http.createServer((req, res) => {
});
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+async function menuFromButton(pet) {
+ const original = Menu.prototype.popup;
+ let menu;
+ Menu.prototype.popup = function (options) { assert.equal(options.window, pet); menu = this; };
+ try {
+ await evaluate(pet, () => document.querySelector("#pet-menu").click());
+ await until(() => menu, "visible pet menu");
+ return menu;
+ } finally { Menu.prototype.popup = original; }
+}
async function until(check, label, timeout = 15000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
@@ -89,19 +100,20 @@ async function findWindow(file) {
return window;
}
async function screenshot(window, name) {
+ await evaluate(window, () => document.fonts.ready.then(() => true));
fs.mkdirSync(screenshots, { recursive: true });
fs.writeFileSync(path.join(screenshots, `${name}.png`), (await window.webContents.capturePage()).toPNG());
}
async function run() {
if (process.env.PET_SMOKE_PHASE === "second-instance") {
- require("../src/main.js");
+ require(path.join(appRoot, "src/main.js"));
assert.equal(app.hasSingleInstanceLock(), false, "A second instance acquired the profile lock");
// Quit may finish after ready on Linux; the parent verifies the natural exit.
await new Promise((resolve, reject) => setTimeout(() => reject(new Error("Second instance did not quit")), 5000));
}
if (process.env.PET_SMOKE_PHASE === "restart") {
- require("../src/main.js");
+ require(path.join(appRoot, "src/main.js"));
await app.whenReady();
const pet = await findWindow("index.html");
const placement = JSON.parse(fs.readFileSync(path.join(dataDir, "window-placement.json"), "utf8"));
@@ -144,15 +156,34 @@ async function run() {
console.log("Electron restart PASS: position and saved settings restored; confirmed quit exits without saving discarded edits.");
} catch (error) { console.error(error); app.exit(1); }
});
- app.quit();
+ (await menuFromButton(pet)).items.find(item => item.accelerator === "CommandOrControl+Q").click();
await new Promise((resolve, reject) => setTimeout(() => reject(new Error("Confirmed quit did not exit")), 5000));
}
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
- require("../src/main.js");
+ require(path.join(appRoot, "src/main.js"));
await app.whenReady();
const pet = await findWindow("index.html");
const settings = await findWindow("settings.html");
+ for (const window of [pet, settings]) {
+ const loaded = await evaluate(window, async () => {
+ const results = [];
+ for (const [family, text] of [["Desktop Pet CJK SC", "角色对话"], ["Desktop Pet CJK JP", "会話かなカナ"]]) {
+ const faces = await document.fonts.load(`16px "${family}"`, text);
+ results.push(faces.length > 0 && faces.every(face => face.status === "loaded"));
+ }
+ return results;
+ });
+ assert.deepEqual(loaded, [true, true], "Bundled CJK fonts must load under the page CSP");
+ }
const endpoint = `http://127.0.0.1:${server.address().port}`;
+ const petMenu = await menuFromButton(pet);
+ assert.ok(petMenu.items.some(item => item.accelerator === "CommandOrControl+Q"));
+ assert.equal(await evaluate(pet, () => {
+ const button = document.querySelector("#pet-menu");
+ const rect = button.getBoundingClientRect();
+ return button.getAttribute("aria-haspopup") === "menu" && !!button.title && rect.right <= innerWidth
+ && button.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
+ }), true);
assert.ok(Menu.getApplicationMenu().items.some((item) => item.role?.toLowerCase() === "editmenu"));
const savedClipboard = clipboard.availableFormats().map((format) => [format, clipboard.readBuffer(format)]);
try {
@@ -275,7 +306,7 @@ async function run() {
});
await until(() => evaluate(chat, async () => (await window.desktopPet.getChatState()).config.memory.notes.length === 1), "memory ready for restart");
- await evaluate(pet, () => document.querySelector("#talk").click());
+ await evaluate(pet, () => window.desktopPet.openQuickChatWindow());
let quick = await findWindow("quick-chat.html");
assert.equal(await evaluate(quick, () => document.querySelector("#character-name").textContent), "Test Character");
const draftText = "An unsent thought\nA second line";
@@ -310,7 +341,7 @@ async function run() {
const beforeQuick = quick.getBounds();
pet.setPosition(originalPetBounds.x - 100, originalPetBounds.y - 40);
await until(() => quick.getBounds().x !== beforeQuick.x, "companion input follows pet");
- const expectedQuick = require("../src/companion-layout").companionBounds(pet.getBounds(), screen.getDisplayMatching(pet.getBounds()).workArea);
+ const expectedQuick = require(path.join(appRoot, "src/companion-layout")).companionBounds(pet.getBounds(), screen.getDisplayMatching(pet.getBounds()).workArea);
assert.deepEqual(quick.getBounds(), expectedQuick);
pet.setBounds(originalPetBounds);
const recovery = Menu.getApplicationMenu().getMenuItemById("show-pet");
@@ -331,7 +362,7 @@ async function run() {
return bounds.x >= area.x && bounds.y >= area.y && bounds.x + bounds.width <= area.x + area.width && bounds.y + bounds.height <= area.y + area.height;
}, "recover pet after display change");
await delay(400);
- assert.deepEqual(quick.getBounds(), require("../src/companion-layout").companionBounds(pet.getBounds(), screen.getDisplayMatching(pet.getBounds()).workArea));
+ assert.deepEqual(quick.getBounds(), require(path.join(appRoot, "src/companion-layout")).companionBounds(pet.getBounds(), screen.getDisplayMatching(pet.getBounds()).workArea));
assert.deepEqual((await evaluate(pet, () => window.desktopPet.getChatState())).history, profileBeforeRecovery.history);
const recoveredChat = chat.getBounds();
const recoveredArea = screen.getDisplayMatching(recoveredChat).workArea;
@@ -443,7 +474,8 @@ async function run() {
await evaluate(pet, () => document.querySelector("#bubble-history").click());
await until(() => chat.isVisible(), "bubble opens history");
await evaluate(pet, () => document.querySelector("#bubble-reply").click());
- await until(() => quick.isVisible(), "bubble opens quick reply");
+ await until(() => evaluate(pet, () => !document.querySelector("#companion-input").hidden), "bubble opens inline reply");
+ await evaluate(pet, () => document.querySelector("#companion-close").click());
const beforeRecallRequests = requests.length;
const beforeRecallHistory = (await evaluate(pet, () => window.desktopPet.getChatState())).history;
await evaluate(pet, () => document.querySelector("#bubble-dismiss").click());
@@ -561,7 +593,7 @@ async function run() {
assert.equal(await evaluate(pet, () => bubbleVisible), true);
for (const window of [pet, chat, quick]) {
assert.equal(await evaluate(window, () => document.querySelector("#voice-controls").hidden), false);
- assert.equal(await evaluate(window, () => document.querySelector("#voice-toggle").textContent), "Replay");
+ assert.equal(await evaluate(window, () => document.querySelector("#voice-toggle").getAttribute("aria-label")), "Read the latest reply again with the current voice service");
}
const beforeReplay = requests.length;
await evaluate(quick, () => { document.querySelector("#voice-toggle").click(); document.querySelector("#voice-toggle").click(); });
@@ -745,7 +777,7 @@ async function run() {
let leavePrompts = 0;
dialog.showMessageBox = async () => { leavePrompts++; return { response: 0 }; };
await evaluate(settings, () => { document.querySelector("#persona-name").value = "Unsaved name"; });
- app.quit();
+ (await menuFromButton(pet)).items.find(item => item.accelerator === "CommandOrControl+Q").click();
await until(() => leavePrompts === 1, "confirm before quitting with unsaved settings");
await delay(300);
for (const window of [pet, settings, chat, quick]) assert.equal(window.isDestroyed(), false, "Cancelling quit keeps every window alive");
diff --git a/tests/font-assets.test.js b/tests/font-assets.test.js
new file mode 100644
index 0000000..1cfa26c
--- /dev/null
+++ b/tests/font-assets.test.js
@@ -0,0 +1,31 @@
+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 root = path.join(__dirname, "..");
+
+test("bundled CJK fonts match the unmodified upstream files and retain their license", () => {
+ for (const [file, expected] of [
+ ["NotoSansSC-Regular.otf", "fc0fda9394367c16c1af7555a2ae4d5e2e6a6f02"],
+ ["NotoSansJP-Regular.otf", "4af556abb9cc42e585aab777b01857246a110746"],
+ ]) {
+ const data = fs.readFileSync(path.join(root, "assets/fonts", file));
+ assert.equal(data.subarray(0, 4).toString(), "OTTO");
+ const blob = createHash("sha1").update(`blob ${data.length}\0`).update(data).digest("hex");
+ assert.equal(blob, expected, file);
+ }
+ assert.match(fs.readFileSync(path.join(root, "assets/fonts/OFL.txt"), "utf8"), /SIL OPEN FONT LICENSE Version 1\.1/);
+});
+
+test("every user-facing window loads local fallback fonts under a self-only font policy", () => {
+ for (const page of ["index", "chat", "quick-chat", "settings"]) {
+ const html = fs.readFileSync(path.join(root, `src/${page}.html`), "utf8");
+ assert.match(html, /href="\.\/fonts\.css"/);
+ assert.match(html, /font-src 'self';/);
+ }
+ const css = fs.readFileSync(path.join(root, "src/fonts.css"), "utf8");
+ assert.match(css, /:root:lang\(ja\)/);
+ assert.equal((css.match(/url\("\.\.\/assets\/fonts\/NotoSans(?:SC|JP)-Regular\.otf"\)/g) || []).length, 2);
+ assert.doesNotMatch(css, /https?:/);
+});
diff --git a/tests/helpers.js b/tests/helpers.js
index a967867..e9d2fe3 100644
--- a/tests/helpers.js
+++ b/tests/helpers.js
@@ -62,7 +62,10 @@ function loadRenderer(file, api = {}, htmlFile = file.replace(/\.js$/, ".html"))
children: [], append(...children) { this.children.push(...children); }, replaceChildren(...children) { this.children = children; },
focus() {}, blur() {}, remove() {}, setAttribute() {}, classList: { toggle() {}, add() {}, remove() {} },
querySelector() { return element(); },
- addEventListener(name, callback) { this.events[name] = callback; },
+ addEventListener(name, callback) {
+ const previous = this.events[name];
+ this.events[name] = previous ? (...args) => { previous(...args); return callback(...args); } : callback;
+ },
};
}
const document = {
@@ -79,17 +82,17 @@ function loadRenderer(file, api = {}, htmlFile = file.replace(/\.js$/, ".html"))
createElement: element,
};
const context = vm.createContext({
- document, console, Intl, Date,
+ document, console, Intl, Date, Event,
window: {
DesktopPetI18n: i18n,
DesktopPetValidation: require("../src/config-validation"),
DesktopPetLlmPresets: require("../src/llm-presets"),
- addEventListener() {}, close() {},
+ addEventListener() {}, dispatchEvent() {}, close() {},
desktopPet: { getChatState: () => new Promise(() => {}), onChatStateUpdated() {},
setChatDraft() {}, onChatDraftUpdated() {}, onAppQuitRequested() {}, confirmAppQuit() {}, ...api },
},
});
- if (["chat.js", "quick-chat.js"].includes(file)) {
+ if (["chat.js", "quick-chat.js", "companion-input.js"].includes(file)) {
vm.runInContext(fs.readFileSync(path.join(sourceDir, "chat-draft.js"), "utf8"), context);
}
vm.runInContext(fs.readFileSync(path.join(sourceDir, file), "utf8"), context);
diff --git a/tests/live2d-boot.cjs b/tests/live2d-boot.cjs
new file mode 100644
index 0000000..f5f91b8
--- /dev/null
+++ b/tests/live2d-boot.cjs
@@ -0,0 +1,5 @@
+const { app } = require("electron");
+const path = require("node:path");
+if (!process.env.PET_TEST_DATA) throw new Error("Isolated test data required");
+app.setPath("userData", process.env.PET_TEST_DATA);
+require(path.join(process.env.PET_SMOKE_APP || path.join(__dirname, ".."), "src/main.js"));
diff --git a/tests/live2d-bridge.test.js b/tests/live2d-bridge.test.js
new file mode 100644
index 0000000..c681dc9
--- /dev/null
+++ b/tests/live2d-bridge.test.js
@@ -0,0 +1,84 @@
+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");
+
+function setup() {
+ const events = {};
+ const sent = [];
+ const stage = { replaceChildren() {} };
+ const status = { addEventListener() {} };
+ const frame = { setAttribute() {}, remove() {}, contentWindow: { postMessage: message => sent.push(message) } };
+ let resume;
+ let connections = 0;
+ const context = vm.createContext({
+ document: { querySelector: selector => selector === "#model-stage" ? stage : status, createElement: () => frame },
+ window: { DesktopPetI18n: { t: key => key }, addEventListener: (event, callback) => { events[event] = callback; },
+ desktopPet: { getLive2DRuntime: async () => ({ ready: true }), onModelAction: callback => { events.action = callback; } } },
+ setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
+ AudioContext: class {
+ resume() { return new Promise(resolve => { resume = resolve; }); }
+ createMediaElementSource() { connections++; return { connect() {}, disconnect() {} }; }
+ createAnalyser() { return { connect() {}, disconnect() {} }; }
+ },
+ });
+ 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, 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 () => {
+ const ui = setup();
+ await ui.api.apply({ id: "sample", type: "live2d" }, { expressionMode: "automatic" });
+ ui.api.expression(1); ui.api.expression(4); ui.api.presence("thinking"); ui.api.cycle();
+ assert.equal(ui.sent.length, 0);
+ ui.message("boot"); ui.message("loaded");
+ assert.deepEqual(ui.sent.map(message => message.type), ["load", "options", "expression", "presence"]);
+ assert.equal(ui.sent.find(message => message.type === "expression").index, 4);
+ 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();
+ await ui.api.apply({ id: "sample", type: "live2d" }, { expressionMode: "automatic" });
+ const pending = ui.api.attachAudio({ paused: false });
+ if (switchCharacter) await ui.api.apply({ id: "sprite" }, {});
+ else ui.api.stopMouth();
+ ui.resume(); await pending;
+ assert.equal(ui.connections(), 0);
+ }
+});
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..d10ea05
--- /dev/null
+++ b/tests/live2d-import.test.js
@@ -0,0 +1,95 @@
+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("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");
+ 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-rig.test.js b/tests/live2d-rig.test.js
new file mode 100644
index 0000000..1612d59
--- /dev/null
+++ b/tests/live2d-rig.test.js
@@ -0,0 +1,20 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { configure } = require("../src/live2d/rig");
+test("legacy exported models use their actual parameter IDs for gaze and breathing", () => {
+ const params = [{ parameterId: "ParamAngleX", peak: 15 }, { parameterId: "ParamBreath", peak: .5 }];
+ const internal = { coreModel: { getModel: () => ({ parameters: { ids: ["PARAM_ANGLE_X", "PARAM_BREATH"] } }) },
+ breath: { getParameters: () => params } };
+ configure(internal);
+ assert.equal(internal.idParamAngleX, "PARAM_ANGLE_X");
+ assert.equal(params[0].parameterId, "PARAM_ANGLE_X");
+ assert.equal(params[0].peak, 6.75);
+ assert.equal(params[1].parameterId, "PARAM_BREATH");
+ assert.equal(params[1].peak, .5);
+});
+test("modern parameter IDs take precedence when both conventions exist", () => {
+ const internal = { coreModel: { getModel: () => ({ parameters: { ids: ["ParamAngleX", "PARAM_ANGLE_X"] } }) }, breath: { getParameters: () => [] } };
+ const { resolve } = configure(internal);
+ assert.equal(resolve("ParamAngleX"), "ParamAngleX");
+ assert.equal(resolve("Missing"), "Missing");
+});
diff --git a/tests/live2d-runtime.test.js b/tests/live2d-runtime.test.js
new file mode 100644
index 0000000..75d8c64
--- /dev/null
+++ b/tests/live2d-runtime.test.js
@@ -0,0 +1,128 @@
+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, loadRenderer } = 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(){},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")');
+ 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
new file mode 100644
index 0000000..bc75c3d
--- /dev/null
+++ b/tests/live2d-smoke.cjs
@@ -0,0 +1,339 @@
+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 { 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");
+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) => {
+ let body = "";
+ req.on("data", chunk => { body += chunk; }); req.on("end", () => {
+ if (req.url === "/tts") { res.writeHead(200, { "content-type": "audio/wav" }); res.end(wav); }
+ else {
+ requests++;
+ const message = JSON.parse(body).messages.at(-1).content;
+ if (message === "fail") { res.writeHead(503); res.end("Test service unavailable"); return; }
+ const timer = setTimeout(() => {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ choices: [{ message: { content: "[expression:happy] It's good to see you. Sit beside me for a little while." } }] }));
+ }, message === "cancel" ? 2000 : 500);
+ res.on("close", () => clearTimeout(timer));
+ }
+ });
+});
+
+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, 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");
+ assert.equal(await frame.evaluate(() => typeof window.require), "undefined");
+ await app.evaluate(({ Menu }) => {
+ globalThis.originalMenuPopup = Menu.prototype.popup;
+ Menu.prototype.popup = function () { globalThis.petMenu = this; };
+ });
+ try {
+ await pet.locator("#pet-menu").click();
+ await poll(() => app.evaluate(() => !!globalThis.petMenu), { label: "Live2D pet menu" });
+ assert.equal(await app.evaluate(() => petMenu.items.some(item => item.accelerator === "CommandOrControl+Q")), true);
+ await app.evaluate(() => petMenu.items.flatMap(item => item.submenu?.items || []).find(item => item.label === "Full Chat").click());
+ const menuChat = await find("chat.html");
+ assert.equal(await menuChat.locator("#message-input").isVisible(), true);
+ await menuChat.close();
+ await app.evaluate(() => petMenu.items.flatMap(item => item.submenu?.items || []).find(item => item.label === "Chat Settings").click());
+ assert.equal(await settings.locator("#tab-character").isVisible(), true);
+ } finally {
+ await app.evaluate(({ Menu }) => { Menu.prototype.popup = globalThis.originalMenuPopup; });
+ }
+ 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);
+ await frame.evaluate(() => testModel.internalModel.on("beforeModelUpdate", () => {
+ const core = testModel.internalModel.coreModel;
+ const ids = core.getModel().parameters.ids;
+ const pick = (...names) => names.find(name => ids.includes(name));
+ window.testParameters = { eye: core.getParameterValueById(pick("ParamEyeLOpen", "PARAM_EYE_L_OPEN")),
+ breath: core.getParameterValueById(pick("ParamBreath", "PARAM_BREATH")),
+ x: core.getParameterValueById(pick("ParamAngleX", "PARAM_ANGLE_X")),
+ mouth: core.getParameterValueById(testModel.internalModel.settings.getLipSyncParameters()[0]) };
+ 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);
+ 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);
+ 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, "model-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 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");
+ 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.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}}"}' },
+ persona: { relationship: "partner", userName: "Alex" },
+ }), endpoint);
+ await pet.locator("#talk").click();
+ await pet.locator("#companion-text").fill("Stay with me a little.");
+ await pet.locator("#companion-text").press("Escape");
+ await pet.locator("#talk").click();
+ assert.equal(await pet.locator("#companion-text").inputValue(), "Stay with me a little.");
+ await pet.screenshot({ path: path.join(output, "inline-composer.png") });
+ const layout = await pet.evaluate(() => {
+ const input = document.querySelector("#companion-input").getBoundingClientRect();
+ const model = document.querySelector("#model-stage").getBoundingClientRect();
+ const bubble = document.querySelector("#speech-panel").getBoundingClientRect();
+ return { gap: model.top - input.bottom, bubbleGap: input.top - bubble.bottom };
+ });
+ assert.ok(layout.gap >= 8 && layout.bubbleGap >= 0, "composer, bubble and model never overlap");
+ await pet.locator("#companion-send").click();
+ await pet.waitForFunction(() => document.querySelector("#companion-input").hidden);
+ assert.equal(app.windows().some(page => page.url().endsWith("/quick-chat.html")), false, "no detached conversation window");
+ 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 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();
+ await frame.waitForFunction(() => window.testParameters.mouth < 0.03);
+ await pet.waitForFunction(() => document.querySelector("#voice-toggle img").src.endsWith("/volume-2.svg"));
+ assert.equal(await pet.locator("#voice-toggle img").evaluate(img => img.complete && img.naturalWidth > 0), true);
+ 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.locator("#talk").click();
+ 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 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();
+ await pet.locator("#companion-text").fill("Unsent companion thought");
+ await pet.evaluate(() => window.desktopPet.openChatWindow());
+ const chat = await find("chat.html");
+ await chat.waitForFunction(() => document.querySelector("#message-input").value === "Unsent companion thought");
+ await pet.locator("#companion-close").click();
+ 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 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();
+ 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/live2d-viewer.test.js b/tests/live2d-viewer.test.js
new file mode 100644
index 0000000..44f98fd
--- /dev/null
+++ b/tests/live2d-viewer.test.js
@@ -0,0 +1,156 @@
+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 = {}, 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: (id, value) => { values[id] = value; } },
+ breath: { getParameters: () => [] },
+ 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; },
+ 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: { 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: { Expressions: [{ Name: "Smile" }, { Name: "Blush" }], Motions: { Tap: [{}, {}], Idle: [{}] } } }) }),
+ PIXI: { Application: class {
+ 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;
+ 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, 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 () => {
+ 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);
+});
+
+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" });
+ 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/pet-helpers.js b/tests/pet-helpers.js
index 0a987d1..0c0b9fa 100644
--- a/tests/pet-helpers.js
+++ b/tests/pet-helpers.js
@@ -13,7 +13,7 @@ function loadPet(api = {}) {
const events = {};
let timerId = 0;
const nodes = new Map();
- for (const id of ["speech-bubble", "speech-card", "bubble-speaker", "bubble-reply", "bubble-history", "bubble-pin", "bubble-dismiss", "last-reply", "talk"]) {
+ for (const id of ["speech-bubble", "speech-card", "bubble-speaker", "bubble-reply", "bubble-history", "bubble-pin", "bubble-dismiss", "last-reply", "talk", "pet-menu"]) {
nodes.set(`#${id}`, { textContent: "", events: {}, attributes: {},
addEventListener(name, fn) { this.events[name] = fn; },
setAttribute(name, value) { this.attributes[name] = value; },
@@ -43,7 +43,7 @@ function loadPet(api = {}) {
getChatState: () => new Promise(() => {}), onChatStateUpdated() {},
recordInteraction() {}, dragStart() {}, dragEnd() {}, beginSpeech: async () => 1,
speechStateChanged() {}, onSpeechStopped() {}, openChatWindow() {}, openQuickChatWindow() {},
- stopVoice() {}, showLastReply() {}, ...api },
+ stopVoice() {}, showLastReply() {}, showContextMenu() {}, ...api },
addEventListener(name, handler) { events[name] = handler; },
setInterval(fn) { intervals.set(++timerId, fn); return timerId; },
clearInterval(id) { intervals.delete(id); },
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/);
+});
diff --git a/tests/run-electron-smoke.cjs b/tests/run-electron-smoke.cjs
index cbc6e09..6bc2de4 100644
--- a/tests/run-electron-smoke.cjs
+++ b/tests/run-electron-smoke.cjs
@@ -6,10 +6,21 @@ const { spawnSync } = require("node:child_process");
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "desktop-pet-smoke-"));
const env = { ...process.env, PET_SMOKE_DATA: dataDir };
delete env.ELECTRON_RUN_AS_NODE;
+const args = process.argv.slice(2).filter((arg) => arg !== "--packaged");
let status = 0;
try {
+ if (process.argv.includes("--packaged")) {
+ const output = path.resolve(process.env.PET_SMOKE_OUTPUT || path.join(__dirname, "../dist"));
+ const candidates = process.platform === "darwin"
+ ? ["mac", `mac-${process.arch}`].map((folder) => path.join(output, folder, "Desktop Pet.app/Contents/Resources/app.asar"))
+ : [path.join(output, process.platform === "win32" ? "win-unpacked" : "linux-unpacked", "resources/app.asar")];
+ const archive = candidates.find((file) => fs.existsSync(file));
+ if (!archive) throw new Error("Packaged app.asar missing; build before running --packaged");
+ env.PET_SMOKE_APP = archive;
+ console.log("Testing packaged app resources with an isolated profile");
+ }
for (const phase of ["initial", "restart"]) {
- const result = spawnSync(require("electron"), [path.join(__dirname, "electron-smoke.cjs"), ...process.argv.slice(2)], {
+ const result = spawnSync(require("electron"), [path.join(__dirname, "electron-smoke.cjs"), ...args], {
env: { ...env, PET_SMOKE_PHASE: phase }, stdio: "inherit", timeout: 150000,
});
if (result.error) console.error(result.error.message);
diff --git a/tests/voice-controls.test.js b/tests/voice-controls.test.js
index c2172ba..dc1d129 100644
--- a/tests/voice-controls.test.js
+++ b/tests/voice-controls.test.js
@@ -234,3 +234,31 @@ test("voice-only updates preserve message nodes and a reader's scroll position",
assert.equal(messages.children[0], first);
assert.equal(messages.scrollTop, 42);
});
+
+test("pet voice controls keep icons and accessible labels through stop and replay", async () => {
+ let update, stops = 0, replays = 0;
+ const ui = loadRenderer("voice-controls.js", {
+ onChatStateUpdated: callback => { update = callback; },
+ stopVoice: async () => { stops++; }, replaySpeech: async () => { replays++; },
+ }, "index.html");
+ const controls = ui.nodes.get("#voice-controls");
+ const button = ui.nodes.get("#voice-toggle");
+ const icon = {};
+ const attributes = {};
+ controls.dataset.compact = "true";
+ button.querySelector = () => icon;
+ button.setAttribute = (key, value) => { attributes[key] = value; };
+ update({ resolvedLanguage: "en-US", voice: { enabled: true, status: "playing", canReplay: true } });
+ assert.equal(icon.src, "./icons/square.svg");
+ assert.equal(attributes["aria-label"], button.title);
+ assert.ok(button.title.includes("Stop"));
+ assert.equal(button.textContent, "");
+ await button.events.click();
+ assert.equal(stops, 1);
+ update({ voice: { enabled: true, status: "idle", canReplay: true } });
+ assert.equal(icon.src, "./icons/volume-2.svg");
+ await button.events.click();
+ assert.equal(replays, 1);
+ update({ voice: { enabled: false, status: "idle", canReplay: false } });
+ assert.equal(controls.hidden, true);
+});