const t=!1,i=!0,e=new Float32Array(1),n=new Int32Array(e.buffer);class s{endianness=i;position=0;get length(){throw new Error("Deprecated, use 'capacity' instead")}set length(t){throw new Error("Deprecated, use 'capacity' instead")}capacity=1024;data=new ArrayBuffer(1024);dataView=new DataView(this.data);__data_uint8=new Uint8Array(this.data);__growFactor=1.1;get raw_bytes(){return this.__data_uint8}fromArrayBuffer(t){this.data=t,this.dataView=new DataView(t),this.__data_uint8=new Uint8Array(t),this.capacity=t.byteLength,this.position=0}trim(){return this.setCapacity(this.position),this}skip(t){this.position+=t}setCapacity(t){if(t<this.position)throw new Error(`Attempting to set capacity(=${t}) below current position(=${this.position})`);if(this.capacity===t)return;const i=this.__data_uint8,e=new Uint8Array(t);this.position>0&&function(t,i,e,n,s){let r,o;const a=function(t,i,e){const n=0|e;return 3&n?1&n?1:2:4}(0,0,s);4===a?(r=new Uint32Array(t,0,s>>>2),o=new Uint32Array(e,0,s>>>2)):2===a?(r=new Uint16Array(t,0,s>>>1),o=new Uint16Array(e,0,s>>>1)):(r=new Uint8Array(t,0,s),o=new Uint8Array(e,0,s)),o.set(r)}(i.buffer,0,e.buffer,0,Math.min(i.buffer.byteLength,e.buffer.byteLength,this.position)),this.data=e.buffer,this.__data_uint8=e,this.dataView=new DataView(this.data),this.capacity=t}ensureCapacity(t){const i=this.capacity;if(i>=t)return;const e=(n=Math.ceil(Math.max(t,i*this.__growFactor,i+1024)))+(4-n%4)%4;var n;this.setCapacity(e)}readFloat16(){return function(t){let i=0;const e=(31744&t)>>10,n=1023&t;let s=1;return t>>15&&(s=-1),i=0!==e?31===e?0!==n?NaN:Infinity*s:s*Math.pow(2,e-15)*(1+n/1024):s*(n/1024*6103515625e-14),i}(this.readUint16())}readFloat32(){const t=this.dataView.getFloat32(this.position,this.endianness);return this.position+=4,t}readFloat64(){const t=this.dataView.getFloat64(this.position,this.endianness);return this.position+=8,t}readInt8(){const t=this.dataView.getInt8(this.position);return this.position+=1,t}readInt16(){const t=this.dataView.getInt16(this.position,this.endianness);return this.position+=2,t}readInt16LE(){const t=this.dataView.getInt16(this.position,i);return this.position+=2,t}readInt16BE(){const i=this.dataView.getInt16(this.position,t);return this.position+=2,i}readInt32(){const t=this.dataView.getInt32(this.position,this.endianness);return this.position+=4,t}readInt32LE(){const t=this.dataView.getInt32(this.position,i);return this.position+=4,t}readInt32BE(){const i=this.dataView.getInt32(this.position,t);return this.position+=4,i}readInt64(){const t=this.dataView.getBigInt64(this.position,this.endianness);return this.position+=8,t}readUint8(){const t=this.dataView.getUint8(this.position);return this.position+=1,t}readUint16(){const t=this.dataView.getUint16(this.position,this.endianness);return this.position+=2,t}readUint16LE(){const t=this.dataView.getUint16(this.position,i);return this.position+=2,t}readUint16BE(){const i=this.dataView.getUint16(this.position,t);return this.position+=2,i}readUint24(){return this.endianness===t?this.readUint24BE():this.readUint24LE()}readUint24LE(){const t=this.dataView.getUint8(this.position),i=this.dataView.getUint8(this.position+1),e=this.dataView.getUint8(this.position+2);return this.position+=3,t|i<<8|e<<16}readUint24BE(){const t=this.dataView.getUint8(this.position),i=this.dataView.getUint8(this.position+1),e=this.dataView.getUint8(this.position+2);return this.position+=3,e|i<<8|t<<16}readUint32(){const t=this.dataView.getUint32(this.position,this.endianness);return this.position+=4,t}readUint32LE(){const t=this.dataView.getUint32(this.position,i);return this.position+=4,t}readUint32BE(){const i=this.dataView.getUint32(this.position,t);return this.position+=4,i}readUint64(){const t=this.dataView.getBigUint64(this.position,this.endianness);return this.position+=8,t}readUint8Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readUint8()}readUint16Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readUint16()}readUint32Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readUint32()}readInt8Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readInt8()}readInt16Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readInt16()}readInt32Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readInt32()}readFloat32Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readFloat32()}readFloat64Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readFloat64()}writeFloat32Array(t,i,e){for(let n=0;n<e;n++)this.writeFloat32(t[n+i])}writeFloat16Array(t,i,e){for(let n=0;n<e;n++)this.writeFloat16(t[n+i])}readFloat16Array(t,i,e){for(let n=0;n<e;n++)t[n+i]=this.readFloat16()}writeFloat16(t){const i=function(t){let i=t;Number.isFinite(i)&&(i>65504||i<-65504)&&(i=i>0?65504:-65504),e[0]=i;const s=n[0];let r=s>>16&32768,o=s>>12&2047;const a=s>>23&255;return a<103?r:a>142?(r|=31744,255===a&&8388607&s&&(r|=512),r):a<113?(o|=2048,r|=(o>>114-a)+(o>>113-a&1),r):(r|=a-112<<10|o>>1,r+=1&o,r)}(t);this.writeUint16(i)}writeFloat32(t){const i=this.position+4;this.ensureCapacity(i),this.dataView.setFloat32(this.position,t,this.endianness),this.position=i}writeFloat64(t){const i=this.position+8;this.ensureCapacity(i),this.dataView.setFloat64(this.position,t,this.endianness),this.position=i}writeInt8(t){const i=this.position+1;this.ensureCapacity(i),this.dataView.setInt8(this.position,t),this.position=i}writeInt16(t){const i=this.position+2;this.ensureCapacity(i),this.dataView.setInt16(this.position,t,this.endianness),this.position=i}writeInt16BE(i){const e=this.position+2;this.ensureCapacity(e),this.dataView.setInt16(this.position,i,t),this.position=e}writeInt16LE(t){const e=this.position+2;this.ensureCapacity(e),this.dataView.setInt16(this.position,t,i),this.position=e}writeInt32(t){const i=this.position+4;this.ensureCapacity(i),this.dataView.setInt32(this.position,t,this.endianness),this.position=i}writeInt32BE(i){const e=this.position+4;this.ensureCapacity(e),this.dataView.setInt32(this.position,i,t),this.position=e}writeInt32LE(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setInt32(this.position,t,i),this.position=e}writeInt64(t){const i=this.position+8;this.ensureCapacity(i),this.dataView.setBigInt64(this.position,t,this.endianness),this.position=i}writeInt8Array(t,i,e){this.ensureCapacity(this.position+e);for(let n=0;n<e;n++)this.writeInt8(t[i+n])}writeInt16Array(t,i,e){this.ensureCapacity(this.position+2*e);for(let n=0;n<e;n++)this.writeInt16(t[i+n])}writeInt32Array(t,i,e){this.ensureCapacity(this.position+4*e);for(let n=0;n<e;n++)this.writeInt32(t[i+n])}writeUint8(t){const i=this.position+1;this.ensureCapacity(i),this.dataView.setUint8(this.position,t),this.position=i}writeUint8Array(t,i,e){for(let n=0;n<e;n++)this.writeUint8(t[i+n])}writeUint16(t){const i=this.position+2;this.ensureCapacity(i),this.dataView.setUint16(this.position,t,this.endianness),this.position=i}writeUint16BE(i){const e=this.position+2;this.ensureCapacity(e),this.dataView.setUint16(this.position,i,t),this.position=e}writeUint16LE(t){const e=this.position+2;this.ensureCapacity(e),this.dataView.setUint16(this.position,t,i),this.position=e}writeUint16Array(t,i,e){for(let n=0;n<e;n++)this.writeUint16(t[i+n])}writeUint24(i){this.endianness===t?this.writeUint24BE(i):this.writeUint24LE(i)}writeUint24BE(t){const i=this.position+3;this.ensureCapacity(i);const e=255&t,n=t>>8&255,s=t>>16&255;this.dataView.setUint8(this.position,s),this.dataView.setUint8(this.position+1,n),this.dataView.setUint8(this.position+2,e),this.position=i}writeUint24LE(t){const i=this.position+3;this.ensureCapacity(i);const e=255&t,n=t>>8&255,s=t>>16&255;this.dataView.setUint8(this.position,e),this.dataView.setUint8(this.position+1,n),this.dataView.setUint8(this.position+2,s),this.position=i}writeUintVar(t){let i=!0,e=t;for(;i||0!==e;){i=!1;let t=127&e;e>>=7,e>0&&(t|=128),this.writeUint8(t)}}readUintVar(){let t=!0,i=0,e=0;for(;t;){let n=this.readUint8();t=!!(128&n),i|=(127&n)<<e,e+=7}return i}writeUint32(t){const i=this.position+4;this.ensureCapacity(i),this.dataView.setUint32(this.position,t,this.endianness),this.position=i}writeUint32BE(i){const e=this.position+4;this.ensureCapacity(e),this.dataView.setUint32(this.position,i,t),this.position=e}writeUint32LE(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setUint32(this.position,t,i),this.position=e}writeUint64(t){const i=this.position+8;this.ensureCapacity(i),this.dataView.setBigUint64(this.position,t,this.endianness),this.position=i}writeUint32Array(t,i,e){this.ensureCapacity(this.position+4*e);for(let n=0;n<e;n++)this.writeUint32(t[i+n])}writeBytes(t,i,e){const n=i+e,s=this.position,r=s+e;if(this.ensureCapacity(r),0===i&&t.length===e)this.__data_uint8.set(t,s);else if("function"==typeof t.subarray)this.__data_uint8.set(t.subarray(i,n),s);else for(let n=0;n<e;n++)this.__data_uint8[s+n]=t[i+n];this.position=r}readBytes(t,i,e){const n=this.position,s=n+e,r=this.__data_uint8;e<128?function(t,i,e,n,s){let r,o,a;for(a=0;a<s;a++)r=i+a,o=n+a,e[o]=t[r]}(r,n,t,i,e):t.set(r.subarray(n,s),i),this.position=s}writeUTF8String(t){if(null===t)return void this.writeUint32(4294967295);if(void 0===t)return void this.writeUint32(4294967294);let i=0;const e=t.length;if(e>=4294967294)throw new Error("String is too long");this.writeUint32(e);let n=this.position;const s=Math.max(32,e+(e>>1)+7);this.ensureCapacity(s+n);let r=this.__data_uint8,o=this.capacity;for(;i<e;){let s=t.charCodeAt(i++);if(s>=55296&&s<=56319){if(i<e){const e=t.charCodeAt(i);56320==(64512&e)&&(++i,s=((1023&s)<<10)+(1023&e)+65536)}s>=55296&&s<=56319&&(s=65533)}if(n+4>o&&(this.position=n,this.ensureCapacity(n+4),o=this.capacity,r=this.__data_uint8),4294967168&s){if(4294965248&s)if(4294901760&s){if(4292870144&s)continue;r[n++]=s>>18&7|240,r[n++]=s>>12&63|128,r[n++]=s>>6&63|128}else r[n++]=s>>12&15|224,r[n++]=s>>6&63|128;else r[n++]=s>>6&31|192;r[n++]=63&s|128}else r[n++]=s}this.position=n}readUTF8String(){const t=this.readUint32();if(4294967295===t)return null;if(4294967294===t)return;const i=this.__data_uint8;let e="",n=this.position,s=0;for(;n<this.capacity&&s<t;){const t=i[n++];let r;if(0===t)break;128&t?192==(224&t)?r=(31&t)<<6|63&i[n++]:224==(240&t)?r=(31&t)<<12|(63&i[n++])<<6|63&i[n++]:240==(248&t)&&(r=(7&t)<<18|(63&i[n++])<<12|(63&i[n++])<<6|63&i[n++],r>65535&&(r-=65536,e+=String.fromCharCode(r>>>10&1023|55296),s++,r=56320|1023&r)):r=t,s++,e+=String.fromCharCode(r)}return this.position=n,e}writeASCIIString(t){const i=t.length,e=this.position,n=e+i;this.ensureCapacity(n);for(let n=0;n<i;n++){const i=t.charCodeAt(n);if(i>127)throw new Error(`Character ${String.fromCharCode(i)} can't be represented by a US-ASCII byte.`);this.__data_uint8[e+n]=i}this.position=n}readASCIICharacters(t,i=!1){let e="";for(let n=0;n<t;n++){const t=this.readUint8();if(i&&0===t)break;e+=String.fromCharCode(t)}return e}toString(){return`BinaryBuffer[position=${this.position}, capacity=${this.capacity}, endianness=${this.endianness}]`}toHexString(){const t=this.__data_uint8,i=Math.min(t.length,this.position);let e="";for(let n=0;n<i;n++)e+=t[n].toString(16).padStart(2,"0").toUpperCase();return e}static fromEndianness(t){const i=new s;return i.endianness=t,i}static fromArrayBuffer(t){const i=new s;return i.fromArrayBuffer(t),i}static copyUTF8String(t,i){const e=t.readUTF8String();return i.writeUTF8String(e),e}static copyUintVar(t,i){const e=t.readUintVar();return i.writeUintVar(e),e}static copyUint8(t,i){const e=t.readUint8();return i.writeUint8(e),e}static copyUint16(t,i){const e=t.readUint16();return i.writeUint16(e),e}static copyUint32(t,i){const e=t.readUint32();return i.writeUint32(e),e}static copyFloat32(t,i){const e=t.readFloat32();return i.writeFloat32(e),e}static copyFloat64(t,i){const e=t.readFloat64();return i.writeFloat64(e),e}static copyBytes(t,i,e){const n=new Uint8Array(e);return t.readBytes(n,0,e),i.writeBytes(n,0,e),n}}s.prototype.isBinaryBuffer=!0;let r=i,o=!1;async function a(t,i="deflate"){let e=0;const n=new ReadableStream({pull(i){e===t.length?i.close():i.enqueue(t[e++])}}).pipeThrough(new DecompressionStream(i));return new Uint8Array(await new Response(n).arrayBuffer())}async function h(t,i=!1){try{return await a(t)}catch(e){if(i){const i=function(t){const i=t.length;if(1===i)return t[0];let e=0;for(let n=0;n<i;n++)e+=t[n].length;const n=new Uint8Array(e);let s=0;for(let e=0;e<i;e++)n.set(t[e],s),s+=t[e].length;return n}(t),e=i[0],n=i[1];if(i.length>=6&&8==(15&e)&&e>>>4<=7&&(e<<8|n)%31==0&&!(32&n))try{return await a([i.subarray(2,i.length-4)],"deflate-raw")}catch{}}throw new Error(`failed to inflate zlib stream: ${e?.message||"invalid or truncated data"}`)}}const c=new Uint32Array(256);for(let t=0;t<256;t++){let i=t;for(let t=0;t<8;t++)1&i?i=3988292384^i>>>1:i>>>=1;c[t]=i}const p=Object.freeze([function(t,i,e,n,s,r,o){for(let n=0;n<o;n++)e[s+n]=t[i+n]},function(t,i,e,n,s,r,o){let a=0;for(;a<n;a++)e[s+a]=t[a+i];for(;a<o;a++){const r=s+a;e[r]=t[a+i]+e[r-n]&255}},function(t,i,e,n,s,r,o){let a,h,c=0;if(r<0)for(;c<o;c++)e[s+c]=t[c+i];else for(;c<o;c++)a=t[c+i],h=e[r+c],e[s+c]=a+h&255},function(t,i,e,n,s,r,o){let a,h,c,p=0;if(r<0){for(;p<n;p++)e[s+p]=t[p+i];for(;p<o;p++){const r=s+p;e[r]=t[p+i]+(e[r-n]>>1)&255}}else{for(;p<n;p++)e[s+p]=t[p+i]+(e[r+p]>>1)&255;for(;p<o;p++)a=t[p+i],h=e[s+p-n],c=e[r+p],e[s+p]=a+(h+c>>1)&255}},function(t,i,e,n,s,r,o){let a,h,c,p,d,f,l,u,w,y=0;if(r<0){for(;y<n;y++)e[s+y]=t[y+i];for(;y<o;y++)e[s+y]=t[y+i]+e[s+y-n]&255}else{for(;y<n;y++)e[s+y]=t[y+i]+e[r+y]&255;for(;y<o;y++)a=t[y+i],p=e[r+y-n],c=e[r+y],h=e[s+y-n],d=h+c-p,f=Math.abs(d-h),l=Math.abs(d-c),u=Math.abs(d-p),w=f<=l&&f<=u?h:l<=u?c:p,e[s+y]=a+w&255}}]);function d(t,i,e,n,s,r,o,a){const h=p[t];if(void 0===h)throw new Error(`unknown filtered scanline type '${t}'`);h(i,e,n,s,r,o,a)}function f(t,i,e,n,s,r){if(1!==e&&2!==e&&4!==e)throw new Error(`only bit depths below 8 are packed, got ${e}`);const o=(1<<e)-1;for(let a=0;a<n;a++){const n=a*e,h=t[i+(n>>3)],c=8-e-(7&n);s[r+a]=h>>>c&o}}class l{width=0;height=0;bitDepth=0;colorType=0;compressionMethod=0;filterMethod=0;interlaceMethod=0;colors=0;alpha=!1;palette=null;pixels=null;transparency_lookup=null;text={};getWidth(){return this.width}setWidth(t){this.width=t}getHeight(){return this.height}setHeight(t){this.height=t}getBitDepth(){return this.bitDepth}setBitDepth(t){if(-1===[1,2,4,8,16].indexOf(t))throw new Error("invalid bith depth "+t);this.bitDepth=t}getColorType(){return this.colorType}setColorType(t){let i=0,e=!1;switch(t){case 0:case 3:i=1;break;case 2:i=3;break;case 4:i=2,e=!0;break;case 6:i=4,e=!0;break;default:throw new Error("invalid color type")}this.colors=i,this.alpha=e,this.colorType=t}setCompressionMethod(t){if(0!==t)throw new Error("invalid compression method "+t);this.compressionMethod=t}setFilterMethod(t){if(0!==t)throw new Error("invalid filter method "+t);this.filterMethod=t}getInterlaceMethod(){return this.interlaceMethod}setInterlaceMethod(t){if(0!==t&&1!==t)throw new Error("invalid interlace method "+t);this.interlaceMethod=t}setPalette(t){if(t.length%3!=0)throw new Error("incorrect PLTE chunk length");if(t.length>3*Math.pow(2,this.bitDepth))throw new Error("palette has more colors than 2^bitdepth");this.palette=t}getUint8Data_case3(){const t=this.width*this.height;let i;const e=this.transparency_lookup;i=null!==e?4:3;const n=new Uint8Array(t*i),s=this.pixels,r=this.palette,o=this.colors*Math.ceil(this.bitDepth/8);for(let e=0;e<t;e++){const t=e*i,a=3*s[e*o];n[t]=r[a],n[t+1]=r[a+1],n[t+2]=r[a+2]}if(null!==e){const i=e.length;for(let r=0;r<t;r++){const t=s[r*o];n[4*r+3]=t>=i?255:e[t]}}return{data:n,itemSize:i}}getUint8Data(){let t,i=0;switch(this.colorType){case 0:t=this.pixels,i=1;break;case 2:t=this.pixels,i=3;break;case 3:const e=this.getUint8Data_case3();t=e.data,i=e.itemSize;break;case 4:t=this.pixels,i=2;break;case 6:t=this.pixels,i=4;break;default:throw new Error("Unsupported color type")}return{data:t,itemSize:i}}}const u=[137,80,78,71,13,10,26,10];function w(t,i){return t[i]<<24|t[i+1]<<16|t[i+2]<<8|t[i+3]}function y(t,i){return t[i]}function U(i){this.png=new l,this.dataChunks=[],this.buffer=new s,this.buffer.endianness=t,this.buffer.fromArrayBuffer(i),this.crc_enabled=!1}U.prototype.readBytes=function(t){const i=this.buffer,e=new Uint8Array(i.data,i.position,t);return i.skip(t),e},U.prototype.decodeHeader=function(){const t=this.buffer,i=new Uint8Array(u.length);if(t.readBytes(i,0,i.length),!function(t,i){const e=t.length;if(e!==i.length)return!1;for(let n=0;n<e;n++)if(t[n]!==i[n])return!1;return!0}(i,u))throw new Error("invalid PNGReader file (bad signature)")},U.prototype.decodeChunk=async function(){const t=this.buffer,i=t.readUint32();if(i<0)throw new Error("Bad chunk length "+(4294967295&i));const e=t.position,n=t.readASCIICharacters(4),r=this.readBytes(i);switch(t.readUint32(),this.crc_enabled&&function(t,i=0,e=t.length){!function(t,i,e,n){let s=4294967295;const r=e+n;for(let t=e;t<r;t++){const e=i[t];s=c[255&(s^e)]^s>>>8}}(0,t,i,e)}(t.raw_bytes,e,i+4),n){case"IHDR":this.decodeIHDR(r);break;case"PLTE":this.decodePLTE(r);break;case"IDAT":this.decodeIDAT(r);break;case"tRNS":this.decodeTRNS(r);break;case"IEND":this.decodeIEND(r);break;case"sRGB":this.decodesRGB(r);break;case"tIME":this.decodetIME(r);break;case"zTXt":await async function(t){const i=t.byteLength,e=t.buffer,n=t.byteOffset,r=s.fromArrayBuffer(e);r.position=n;const o=r.readASCIICharacters(79,!0),a=r.readUint8();let c;if(0!==a)throw new Error(`Unsupported compression method '${a}'`);{const t=r.position-n,s=new Uint8Array(e,r.position,i-t),o=await h([s]);r.fromArrayBuffer(o.buffer),c=r.readASCIICharacters(o.byteLength)}return{keyword:o,text:c}}(r);break;case"iTXt":await async function(t){const i=t.byteLength,e=t.buffer,n=t.byteOffset,r=n+i,o=s.fromArrayBuffer(e);o.position=n;const a=o.readASCIICharacters(79,!0),c=o.readUint8(),p=o.readUint8(),d=o.readASCIICharacters(Number.MAX_SAFE_INTEGER,!0),f=o.readASCIICharacters(Number.MAX_SAFE_INTEGER,!0),l=r-o.position;let u;if(0===c)u=o.readASCIICharacters(l);else{if(1!==c)throw new Error(`Invalid compression flag value '${c}'`);{if(0!==p)throw new Error("only compression_method 0 is supported");const t=await h([new Uint8Array(o.data,o.position,l)]);o.fromArrayBuffer(t.buffer),u=o.readASCIICharacters(t.byteLength)}}return{keyword:a,language_tag:d,translated_keyword:f,text:u}}(r)}return n},U.prototype.decodesRGB=function(t){y(t,0)},U.prototype.decodetIME=function(t){y(t,0),y(t,1),y(t,2),y(t,3),y(t,4),y(t,5),y(t,6)},U.prototype.decodetEXt=function(t){const i=s.fromArrayBuffer(t.buffer),e=i.readASCIICharacters(Number.POSITIVE_INFINITY,!0),n=i.readASCIICharacters(e.length-1,!1);this.png.text[e]=n},U.prototype.decodeiEXt=function(t){const i=s.fromArrayBuffer(t.buffer),e=i.readASCIICharacters(Number.POSITIVE_INFINITY,!0);if(i.readUint8(),i.readUint8(),i.readASCIICharacters(Number.POSITIVE_INFINITY,!0),i.readUTF8String(),0!==i.readUint8())throw new Error("Expected Null Separator after Translated keyword");const n=i.readUTF8String();this.png.text[e]=n},U.prototype.decodeIHDR=function(t){const i=this.png;i.setWidth(w(t,0)),i.setHeight(w(t,4)),i.setBitDepth(y(t,8)),i.setColorType(y(t,9)),i.setCompressionMethod(y(t,10)),i.setFilterMethod(y(t,11)),i.setInterlaceMethod(y(t,12))},U.prototype.decodePLTE=function(t){this.png.setPalette(t)},U.prototype.decodeIDAT=function(t){this.dataChunks.push(t)},U.prototype.decodeTRNS=function(t){this.png.transparency_lookup=t},U.prototype.decodeIEND=function(){},U.prototype.decodePixels=async function(){const e=this.png,n=await h(this.dataChunks,!0);if(0===e.getInterlaceMethod()?this.png.pixels=this.interlaceNone(n):this.png.pixels=this.interlaceAdam7(n),16===e.bitDepth&&function(){if(o)return r;const e=new ArrayBuffer(2),n=new Uint8Array(e),s=new Uint16Array(e);return n[0]=19,r=19==(255&s[0])?i:t,o=!0,r}()===i){const t=this.png.pixels;for(let i=0;i<t.length;i+=2){const e=t[i];t[i]=t[i+1],t[i+1]=e}}},U.prototype.interlaceNone=function(t){const i=this.png,e=i.bitDepth,n=i.colors,s=i.width,r=i.height,o=Math.max(1,Math.ceil(n*e/8)),a=Math.ceil(n*e*s/8);if(e<8)return function(t,i,e,n,s,r){const o=new Uint8Array(e*r),a=new Uint8Array(2*s),h=Math.floor(t.length/(s+1)),c=r<h?r:h;let p=0,l=-1;for(let r=0;r<c;r++){const h=(1&r)*s;d(t[p++],t,p,a,n,h,l,s),p+=s,f(a,h,i,e,o,r*e),l=h}return o}(t,e,n*s,o,a,r);const h=new Uint8Array(a*r);let c=0;const p=t.length;for(let i=0;i<p;i+=a+1){const e=i+1;d(t[i],t,e,h,o,c,c-a,a),c+=a}return h},U.prototype.interlaceAdam7=function(t){const i=this.png,e=i.bitDepth,n=i.colors,s=i.width,r=i.height,o=e<8,a=Math.max(1,Math.ceil(n*e/8)),h=n*(16===e?2:1),c=new Uint8Array(h*s*r),p=[{x:0,y:0,xStep:8,yStep:8},{x:4,y:0,xStep:8,yStep:8},{x:0,y:4,xStep:4,yStep:8},{x:2,y:0,xStep:4,yStep:4},{x:0,y:2,xStep:2,yStep:4},{x:1,y:0,xStep:2,yStep:2},{x:0,y:1,xStep:1,yStep:2}];let l=0;const u=new Uint8Array(2*Math.ceil(n*e*s/8)),w=o?new Uint8Array(n*s):null;for(let i=0;i<7;i++){const y=p[i],U=Math.ceil((s-y.x)/y.xStep),g=Math.ceil((r-y.y)/y.yStep);if(U<=0||g<=0)continue;const I=n*U,b=Math.ceil(I*e/8);let A=-1;for(let i=0;i<g;i++){const n=i%2*b;d(t[l++],t,l,u,a,n,A,b),l+=b,A=n;let p=u,g=n;o&&(f(u,n,e,I,w,0),p=w,g=0);for(let t=0;t<U;t++){const e=y.x+t*y.xStep,n=y.y+i*y.yStep;if(!(e>=s||n>=r))for(let i=0;i<h;i++)c[(n*s+e)*h+i]=p[g+t*h+i]}}}return c},U.prototype.unFilter=function(t,i,e,n,s,r,o,a){d(t,i,e,n,s,r,o,a)},U.prototype.parse=async function(){for(this.decodeHeader();"IEND"!==await this.decodeChunk(););return await this.decodePixels(),this.png},self.Lib={decode:async function(t,i="png"){if(!(t instanceof ArrayBuffer))throw new Error(".data argument must be an ArrayBuffer, instead was something else");if("png"===i.toLowerCase())return await async function(t){const i=await new U(t).parse(),e=i.getUint8Data();return{data:e.data.buffer,width:i.getWidth(),height:i.getHeight(),itemSize:e.itemSize,bitDepth:i.getBitDepth()}}(t);throw new Error("Unsupported type")}};
// Chess vs MCTS — a Meep example.
//
// Sections:
// §1 Rules engine pure JS: board model, moves, check, mate
// §2 Engine bootstrap systems, loaders, harness
// §2b HDR environment background + image-based lighting from one octahedral map
// §3 World board, pieces, captured-piece graveyards
// §4 Move-arc animation per-axis AnimationCurves
// §5 Selection & input ray/plane read, Decal markers
// §5b AI-turn light tween AnimationCurve.easeInOut
// §6 MCTS AI wrapped in a Task on engine.executor
// §7 Move commit logical state + parallel piece animations
// §8 Banner / §9 HUD / §10 Kickoff
//
// Pawn promotion is fixed at queen — no piece-choice prompt. Standard FIDE
// rules otherwise: castling (both sides), en passant, stalemate as draw.
// ─── Imports ────────────────────────────────────────────────────────────────
import { plane3_compute_ray_intersection }
from "@woosh/meep-engine/src/core/geom/3d/plane/plane3_compute_ray_intersection.js";
import Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import Quaternion from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
import { randomGaussian } from "@woosh/meep-engine/src/core/math/random/randomGaussian.js";
import { randomFloatBetween } from "@woosh/meep-engine/src/core/math/random/randomFloatBetween.js";
import Task from "@woosh/meep-engine/src/core/process/task/Task.js";
import { TaskSignal } from "@woosh/meep-engine/src/core/process/task/TaskSignal.js";
import { AnimationCurve } from "@woosh/meep-engine/src/engine/animation/curve/AnimationCurve.js";
import { Keyframe } from "@woosh/meep-engine/src/engine/animation/curve/Keyframe.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { ArrayBufferLoader } from "@woosh/meep-engine/src/engine/asset/loaders/ArrayBufferLoader.js";
import { GLTFSceneBundleAssetLoader }
from "@woosh/meep-engine/src/engine/asset/loaders/GLTFSceneBundleAssetLoader.js";
import { ImageBitmapAssetLoader }
from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageBitmapAssetLoader.js";
import { load_model_scene_bundle } from "@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { ParentEntitySystem } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntitySystem.js";
import { TransformAttachmentSystem }
from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";
import { DecalSystem } from "@woosh/meep-engine/src/engine/graphics3/DecalSystem.js";
import Highlight from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/Highlight.js";
import {
HighlightOutlineSystem
} from "@woosh/meep-engine/src/engine/graphics3/HighlightOutlineSystem.js";
import {
SGMeshHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshHighlightSystem.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { MeshSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { MonteCarloTreeSearch } from "@woosh/meep-engine/src/engine/intelligence/mcts/MonteCarlo.js";
import { MoveEdge } from "@woosh/meep-engine/src/engine/intelligence/mcts/MoveEdge.js";
import { StateType } from "@woosh/meep-engine/src/engine/intelligence/mcts/StateNode.js";
import { load_environment_map, ProjectionMappingType }
from "@woosh/meep-engine/src/shade/renderer/light/environment/rgbe/load_environment_map.js";
// ─── §1 Rules engine ───────────────────────────────────────────────────────
//
// Pure JS, no engine. Shared between the player's UI (move validation) and
// the MCTS expansion — single source of truth for legality.
// Piece encoding — one byte per square:
// 0 empty
// 1..6 white (KING=1, QUEEN, ROOK, BISHOP, KNIGHT, PAWN=6)
// 9..14 black (same low 3 bits, bit 3 set)
const EMPTY = 0;
const KING = 1;
const QUEEN = 2;
const ROOK = 3;
const BISHOP = 4;
const KNIGHT = 5;
const PAWN = 6;
const WHITE = 0;
const BLACK = 8;
const TEAM_MASK = 8;
const TYPE_MASK = 7;
const pieceType = (code) => code & TYPE_MASK;
const pieceTeam = (code) => code & TEAM_MASK;
const isEmpty = (code) => code === EMPTY;
const opponent = (team) => team ^ TEAM_MASK;
// Square index 0..63 packs (rank, file) so consecutive ranks are contiguous.
const sq = (file, rank) => rank * 8 + file;
const fileOf = (idx) => idx & 7;
const rankOf = (idx) => (idx >> 3) & 7;
const inBoard = (f, r) => f >= 0 && f < 8 && r >= 0 && r < 8;
// --- Move-type tag --------------------------------------------------------
//
// Most moves are NORMAL — straight (from → to). The other three need apply
// and undo to do extra work, and commitMove() needs extra entity choreography
// (rook arc, displaced-pawn lookup, pawn-to-queen swap).
const MOVE_TYPE_NORMAL = 0;
const MOVE_TYPE_CASTLE = 1;
const MOVE_TYPE_EN_PASSANT = 2;
const MOVE_TYPE_PROMOTION = 3;
// --- Castling rights ------------------------------------------------------
//
// One bit per (colour, side). Cleared when the king moves, when a rook moves
// off its home square, or when a rook is captured on its home square.
const CR_WHITE_K = 1 << 0;
const CR_WHITE_Q = 1 << 1;
const CR_BLACK_K = 1 << 2;
const CR_BLACK_Q = 1 << 3;
const CR_ALL = CR_WHITE_K | CR_WHITE_Q | CR_BLACK_K | CR_BLACK_Q;
// Rook home squares, keyed by colour and side. Used both to seed castling
// rights and to clear them when a rook leaves or is captured at home.
const ROOK_HOME_WHITE_K = sq(7, 0);
const ROOK_HOME_WHITE_Q = sq(0, 0);
const ROOK_HOME_BLACK_K = sq(7, 7);
const ROOK_HOME_BLACK_Q = sq(0, 7);
// --- Initial position + cloning -------------------------------------------
function makeInitialState() {
const squares = new Uint8Array(64);
squares[sq(0, 0)] = WHITE | ROOK; squares[sq(1, 0)] = WHITE | KNIGHT;
squares[sq(2, 0)] = WHITE | BISHOP; squares[sq(3, 0)] = WHITE | QUEEN;
squares[sq(4, 0)] = WHITE | KING; squares[sq(5, 0)] = WHITE | BISHOP;
squares[sq(6, 0)] = WHITE | KNIGHT; squares[sq(7, 0)] = WHITE | ROOK;
for (let f = 0; f < 8; f++) squares[sq(f, 1)] = WHITE | PAWN;
for (let f = 0; f < 8; f++) squares[sq(f, 6)] = BLACK | PAWN;
squares[sq(0, 7)] = BLACK | ROOK; squares[sq(1, 7)] = BLACK | KNIGHT;
squares[sq(2, 7)] = BLACK | BISHOP; squares[sq(3, 7)] = BLACK | QUEEN;
squares[sq(4, 7)] = BLACK | KING; squares[sq(5, 7)] = BLACK | BISHOP;
squares[sq(6, 7)] = BLACK | KNIGHT; squares[sq(7, 7)] = BLACK | ROOK;
return {
squares,
sideToMove: WHITE,
// All four castles are available at game start.
castlingRights: CR_ALL,
// Square index where an en-passant capture *could* land this turn, or
// -1 if none. Set when a pawn double-pushes, cleared on every other
// move — the EP window is exactly one ply wide.
enPassantTarget: -1,
};
}
function cloneState(state) {
return {
squares: state.squares.slice(),
sideToMove: state.sideToMove,
castlingRights: state.castlingRights,
enPassantTarget: state.enPassantTarget,
};
}
// --- Move generation ------------------------------------------------------
//
// Pseudo-legal here means "ignores king-safety". The check filter is applied
// once in enumerateLegalMoves below. Moves are packed as a 14-bit int:
// move = (type << 12) | (from << 6) | to
// type ∈ [0..3] from, to ∈ [0..63]
const RAY_DIRECTIONS_ROOK = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const RAY_DIRECTIONS_BISHOP = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
const RAY_DIRECTIONS_QUEEN = RAY_DIRECTIONS_ROOK.concat(RAY_DIRECTIONS_BISHOP);
const KNIGHT_DELTAS = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]];
const KING_DELTAS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
const moveFrom = (move) => (move >> 6) & 0x3F;
const moveTo = (move) => move & 0x3F;
const moveType = (move) => (move >> 12) & 0x3;
const makeMove = (from, to, type = MOVE_TYPE_NORMAL) => (type << 12) | (from << 6) | to;
function pushPseudoMoves(out, state, from) {
const piece = state.squares[from];
const team = pieceTeam(piece);
const type = pieceType(piece);
const f0 = fileOf(from), r0 = rankOf(from);
switch (type) {
case PAWN: {
const dir = team === WHITE ? +1 : -1;
const startRank = team === WHITE ? 1 : 6;
const promoRank = team === WHITE ? 7 : 0;
const r1 = r0 + dir;
if (inBoard(f0, r1) && isEmpty(state.squares[sq(f0, r1)])) {
const dest = sq(f0, r1);
if (r1 === promoRank) {
// Push that crosses the back rank — auto-promote to queen.
out.push(makeMove(from, dest, MOVE_TYPE_PROMOTION));
} else {
out.push(makeMove(from, dest));
// Double push from starting rank. Cannot promote — the
// destination is two ranks short of the back rank.
if (r0 === startRank) {
const r2 = r0 + 2 * dir;
if (isEmpty(state.squares[sq(f0, r2)])) {
out.push(makeMove(from, sq(f0, r2)));
}
}
}
}
// Diagonal captures + en passant. En passant is the only way to
// legally land on an *empty* diagonal square — that's why we look
// it up explicitly.
for (const df of [-1, +1]) {
const fc = f0 + df, rc = r0 + dir;
if (!inBoard(fc, rc)) continue;
const dest = sq(fc, rc);
const target = state.squares[dest];
if (target !== EMPTY && pieceTeam(target) !== team) {
if (rc === promoRank) {
out.push(makeMove(from, dest, MOVE_TYPE_PROMOTION));
} else {
out.push(makeMove(from, dest));
}
} else if (target === EMPTY && dest === state.enPassantTarget) {
out.push(makeMove(from, dest, MOVE_TYPE_EN_PASSANT));
}
}
return;
}
case KNIGHT: {
for (const [df, dr] of KNIGHT_DELTAS) {
const f1 = f0 + df, r1 = r0 + dr;
if (!inBoard(f1, r1)) continue;
const target = state.squares[sq(f1, r1)];
if (target === EMPTY || pieceTeam(target) !== team) {
out.push(makeMove(from, sq(f1, r1)));
}
}
return;
}
case KING: {
for (const [df, dr] of KING_DELTAS) {
const f1 = f0 + df, r1 = r0 + dr;
if (!inBoard(f1, r1)) continue;
const target = state.squares[sq(f1, r1)];
if (target === EMPTY || pieceTeam(target) !== team) {
out.push(makeMove(from, sq(f1, r1)));
}
}
pushCastleMoves(out, state, from, team, r0);
return;
}
case BISHOP: pushRayMoves(out, state, from, team, RAY_DIRECTIONS_BISHOP); return;
case ROOK: pushRayMoves(out, state, from, team, RAY_DIRECTIONS_ROOK); return;
case QUEEN: pushRayMoves(out, state, from, team, RAY_DIRECTIONS_QUEEN); return;
}
}
// Walk each direction until the edge, our own piece (stop), or an opponent
// (capture + stop).
function pushRayMoves(out, state, from, team, directions) {
const f0 = fileOf(from), r0 = rankOf(from);
for (const [df, dr] of directions) {
let f = f0 + df, r = r0 + dr;
while (inBoard(f, r)) {
const idx = sq(f, r);
const target = state.squares[idx];
if (target === EMPTY) {
out.push(makeMove(from, idx));
} else {
if (pieceTeam(target) !== team) out.push(makeMove(from, idx));
break;
}
f += df; r += dr;
}
}
}
// Append castle moves (king-side and/or queen-side) if rights, geometry, and
// the no-passing-through-check rule all hold. We pre-check attacks here so
// the legal-move filter doesn't need to know about castling — it just sees a
// king move and asks "would my king be in check after this?".
function pushCastleMoves(out, state, from, team, rank) {
const rights = state.castlingRights;
const opp = opponent(team);
// Cannot castle out of check.
if (isInCheck(state, team)) return;
// King must be on its home square — sanity check, in case it somehow
// moved without losing its rights (it shouldn't, but defensive).
if (from !== sq(4, rank)) return;
const kBit = team === WHITE ? CR_WHITE_K : CR_BLACK_K;
const qBit = team === WHITE ? CR_WHITE_Q : CR_BLACK_Q;
// King-side: f, g must be empty; king walks through f to g, so neither
// may be attacked.
if ((rights & kBit) !== 0) {
const f = sq(5, rank), g = sq(6, rank);
if (
state.squares[f] === EMPTY &&
state.squares[g] === EMPTY &&
!isSquareAttackedBy(state, f, opp) &&
!isSquareAttackedBy(state, g, opp)
) {
out.push(makeMove(from, g, MOVE_TYPE_CASTLE));
}
}
// Queen-side: b, c, d must be empty (b is not on the king's path so it
// only needs to be empty, not safe); king walks through d to c.
if ((rights & qBit) !== 0) {
const b = sq(1, rank), c = sq(2, rank), d = sq(3, rank);
if (
state.squares[b] === EMPTY &&
state.squares[c] === EMPTY &&
state.squares[d] === EMPTY &&
!isSquareAttackedBy(state, c, opp) &&
!isSquareAttackedBy(state, d, opp)
) {
out.push(makeMove(from, c, MOVE_TYPE_CASTLE));
}
}
}
function pseudoLegalMovesFor(state, team) {
const out = [];
for (let i = 0; i < 64; i++) {
const piece = state.squares[i];
if (piece === EMPTY || pieceTeam(piece) !== team) continue;
pushPseudoMoves(out, state, i);
}
return out;
}
// --- Attack queries (for check detection) ---------------------------------
function findKing(state, team) {
const target = team | KING;
for (let i = 0; i < 64; i++) if (state.squares[i] === target) return i;
return -1;
}
// True if `attackerTeam` attacks `idx`. We sit on the target and look outward
// for each attacker piece type rather than building a full attack table.
function isSquareAttackedBy(state, idx, attackerTeam) {
const tf = fileOf(idx), tr = rankOf(idx);
// Pawn — attacks diagonally forward, so look one rank "back" from the
// attacker's POV.
const pawnDir = attackerTeam === WHITE ? +1 : -1;
for (const df of [-1, +1]) {
const f = tf + df, r = tr - pawnDir;
if (!inBoard(f, r)) continue;
if (state.squares[sq(f, r)] === (attackerTeam | PAWN)) return true;
}
// Knight.
for (const [df, dr] of KNIGHT_DELTAS) {
const f = tf + df, r = tr + dr;
if (!inBoard(f, r)) continue;
if (state.squares[sq(f, r)] === (attackerTeam | KNIGHT)) return true;
}
// King (only adjacent).
for (const [df, dr] of KING_DELTAS) {
const f = tf + df, r = tr + dr;
if (!inBoard(f, r)) continue;
if (state.squares[sq(f, r)] === (attackerTeam | KING)) return true;
}
// Sliders. Walk outward; first occupied square in each direction decides.
function rayHasAttacker(directions, type1, type2) {
for (const [df, dr] of directions) {
let f = tf + df, r = tr + dr;
while (inBoard(f, r)) {
const p = state.squares[sq(f, r)];
if (p !== EMPTY) {
if (pieceTeam(p) === attackerTeam) {
const t = pieceType(p);
if (t === type1 || t === type2) return true;
}
break;
}
f += df; r += dr;
}
}
return false;
}
if (rayHasAttacker(RAY_DIRECTIONS_ROOK, ROOK, QUEEN)) return true;
if (rayHasAttacker(RAY_DIRECTIONS_BISHOP, BISHOP, QUEEN)) return true;
return false;
}
function isInCheck(state, team) {
const kingIdx = findKing(state, team);
if (kingIdx === -1) return false;
return isSquareAttackedBy(state, kingIdx, opponent(team));
}
// --- Move application -----------------------------------------------------
//
// apply / undo work in place; the pair lets enumerateLegalMoves filter
// in-check moves without cloning the state. `apply` returns a restore record
// that `undo` consumes — castling, en passant, and promotion all need a
// little extra state captured so undo can reverse them exactly.
// Update castling rights given a move's from/to squares. Called from inside
// applyMoveInPlace once for any move type — losing rights is purely a
// function of whose squares are touched.
function updateCastlingRights(rights, movingType, movingTeam, from, to) {
if (movingType === KING) {
rights &= movingTeam === WHITE
? ~(CR_WHITE_K | CR_WHITE_Q)
: ~(CR_BLACK_K | CR_BLACK_Q);
}
if (movingType === ROOK) {
if (from === ROOK_HOME_WHITE_K) rights &= ~CR_WHITE_K;
if (from === ROOK_HOME_WHITE_Q) rights &= ~CR_WHITE_Q;
if (from === ROOK_HOME_BLACK_K) rights &= ~CR_BLACK_K;
if (from === ROOK_HOME_BLACK_Q) rights &= ~CR_BLACK_Q;
}
// Capturing on a rook's home square also clears that side's rights.
if (to === ROOK_HOME_WHITE_K) rights &= ~CR_WHITE_K;
if (to === ROOK_HOME_WHITE_Q) rights &= ~CR_WHITE_Q;
if (to === ROOK_HOME_BLACK_K) rights &= ~CR_BLACK_K;
if (to === ROOK_HOME_BLACK_Q) rights &= ~CR_BLACK_Q;
return rights;
}
function applyMoveInPlace(state, move) {
const from = moveFrom(move);
const to = moveTo(move);
const type = moveType(move);
const moving = state.squares[from];
const movingTeam = pieceTeam(moving);
const movingType = pieceType(moving);
// Restore record. `captured` holds whatever was vacated so undo can put
// it back; for en passant it's the displaced pawn at epCaptureSquare, not
// the value on `to` (which was already empty).
const restore = {
captured: state.squares[to],
epCaptureSquare: -1,
castlingRights: state.castlingRights,
enPassantTarget: state.enPassantTarget,
};
if (type === MOVE_TYPE_CASTLE) {
// King hop is the same as a normal move; the rook also slides to the
// king's other side. Path emptiness was checked at generation time.
const rank = rankOf(from);
const kingFile = fileOf(to);
const [rookFromSq, rookToSq] = kingFile === 6
? [sq(7, rank), sq(5, rank)] // king-side
: [sq(0, rank), sq(3, rank)]; // queen-side
state.squares[rookToSq] = state.squares[rookFromSq];
state.squares[rookFromSq] = EMPTY;
state.squares[to] = moving;
state.squares[from] = EMPTY;
} else if (type === MOVE_TYPE_EN_PASSANT) {
// Captured pawn sits behind the destination, on the moving pawn's
// original rank — that's where the EP target was created last ply.
const epCaptureSquare = sq(fileOf(to), rankOf(from));
restore.captured = state.squares[epCaptureSquare];
restore.epCaptureSquare = epCaptureSquare;
state.squares[epCaptureSquare] = EMPTY;
state.squares[to] = moving;
state.squares[from] = EMPTY;
} else if (type === MOVE_TYPE_PROMOTION) {
// Auto-queen. Undo restores a pawn — we look up the colour from
// sideToMove (which `opponent()` flips below, so cache the team).
state.squares[to] = movingTeam | QUEEN;
state.squares[from] = EMPTY;
} else {
state.squares[to] = moving;
state.squares[from] = EMPTY;
}
state.castlingRights = updateCastlingRights(
state.castlingRights, movingType, movingTeam, from, to,
);
// Set en-passant target on a pawn double-push, clear otherwise — the
// window for capturing en passant is exactly one ply.
if (movingType === PAWN && Math.abs(rankOf(to) - rankOf(from)) === 2) {
const midRank = (rankOf(from) + rankOf(to)) >> 1;
state.enPassantTarget = sq(fileOf(from), midRank);
} else {
state.enPassantTarget = -1;
}
state.sideToMove = opponent(state.sideToMove);
return restore;
}
function undoMoveInPlace(state, move, restore) {
const from = moveFrom(move);
const to = moveTo(move);
const type = moveType(move);
state.sideToMove = opponent(state.sideToMove);
state.castlingRights = restore.castlingRights;
state.enPassantTarget = restore.enPassantTarget;
if (type === MOVE_TYPE_CASTLE) {
state.squares[from] = state.squares[to];
state.squares[to] = EMPTY;
const rank = rankOf(from);
const kingFile = fileOf(to);
const [rookFromSq, rookToSq] = kingFile === 6
? [sq(7, rank), sq(5, rank)]
: [sq(0, rank), sq(3, rank)];
state.squares[rookFromSq] = state.squares[rookToSq];
state.squares[rookToSq] = EMPTY;
} else if (type === MOVE_TYPE_EN_PASSANT) {
state.squares[from] = state.squares[to];
state.squares[to] = EMPTY;
state.squares[restore.epCaptureSquare] = restore.captured;
} else if (type === MOVE_TYPE_PROMOTION) {
// Put the pawn back. sideToMove was just flipped above to point at
// the player who made the move.
state.squares[from] = state.sideToMove | PAWN;
state.squares[to] = restore.captured;
} else {
state.squares[from] = state.squares[to];
state.squares[to] = restore.captured;
}
}
function enumerateLegalMoves(state) {
const team = state.sideToMove;
const pseudo = pseudoLegalMovesFor(state, team);
const legal = [];
for (let i = 0; i < pseudo.length; i++) {
const move = pseudo[i];
const restore = applyMoveInPlace(state, move);
const safe = !isInCheck(state, team);
undoMoveInPlace(state, move, restore);
if (safe) legal.push(move);
}
return legal;
}
// Early-exit variant — cheaper when we only need yes/no.
function hasAnyLegalMove(state) {
const team = state.sideToMove;
const pseudo = pseudoLegalMovesFor(state, team);
for (let i = 0; i < pseudo.length; i++) {
const move = pseudo[i];
const restore = applyMoveInPlace(state, move);
const safe = !isInCheck(state, team);
undoMoveInPlace(state, move, restore);
if (safe) return true;
}
return false;
}
// Called after a move has been made. If the new side-to-move has no reply,
// the previous move was either mate (other side wins) or stalemate.
function computeOutcome(state) {
if (hasAnyLegalMove(state)) return { result: null };
if (isInCheck(state, state.sideToMove)) {
return { result: state.sideToMove === WHITE ? "BLACK_WINS" : "WHITE_WINS" };
}
return { result: "STALEMATE" };
}
// ─── §2 Engine bootstrap ───────────────────────────────────────────────────
// World scale — the board GLTF authors squares at ~0.062 world units. We
// shrink the placement grid and camera to match the model rather than
// scaling the model up. Bump if your assets are authored differently.
const WORLD_PER_SQUARE = 0.062;
// Camera: player's-eye view from the Black side (yaw = π faces -Z).
const CAMERA_PITCH = 1.05;
const CAMERA_YAW = Math.PI;
const CAMERA_DISTANCE = 14 * WORLD_PER_SQUARE;
const CAMERA_FOV = 38;
const CAMERA_FOCUS = new Vector3(
0,
0.3 * WORLD_PER_SQUARE,
0.65 * WORLD_PER_SQUARE,
);
// MeshSystem is needed by name below: HighlightSystem is constructed against
// it, because outlining an entity means knowing which rows of the frame its
// *model* drew into, and MeshSystem is what owns that.
let meshSystem = null;
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
const gltfLoader = new GLTFSceneBundleAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltfLoader);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltfLoader);
// The glTF parser asks the asset manager for each of a model's buffers
// and images rather than fetching them itself, so without these two the
// pieces load as untextured geometry.
config.addLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
// Every system that draws has to be handed the *same* Shade scene, or
// its content is simply missing from the frame. `shadeScene` hands out
// one per engine.
const scene = EngineHarness.shadeScene(engine);
// MeshSystem loads a GLTF into a Shade SceneBundle and expands it into
// per-primitive ShadedGeometry child entities; ShadedGeometrySystem is
// what puts those primitives in the scene.
meshSystem = new MeshSystem(
engine.graphics,
scene,
(url) => load_model_scene_bundle(engine.assetManager, url),
);
config.addSystem(meshSystem);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
// A model expanded into entities is a *hierarchy* of entities, and the
// two halves of that are separate components on purpose: ParentEntity
// is lifetime (children die with the parent), TransformAttachment is
// space (children are posed relative to it). Without the attachment
// system every primitive keeps its model-local transform, so all 32
// pieces pile up on the origin.
config.addSystem(new TransformAttachmentSystem());
config.addSystem(new ParentEntitySystem());
// Outline highlight on the selected piece. One system now: the outline
// is a neighbourhood test on the visibility buffer the frame already
// wrote, so there is nothing to propagate down to the child primitives
// — the parent's SGMesh names them all.
// Outlines are drawn by one pass (HighlightOutlineSystem) that takes its
// entries from a source system per kind of highlighted thing. The pieces
// are loaded models, so this board needs the SGMesh source only.
const highlightOutline = new HighlightOutlineSystem(engine.graphics);
config.addSystem(highlightOutline);
config.addSystem(new SGMeshHighlightSystem(highlightOutline, meshSystem));
// Legal-move marker decals.
config.addSystem(new DecalSystem(engine.graphics, engine.assetManager));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: false,
enableShadows: true,
focus: CAMERA_FOCUS,
distance: CAMERA_DISTANCE,
pitch: CAMERA_PITCH,
yaw: CAMERA_YAW,
cameraFieldOfView: CAMERA_FOV,
cameraFarDistance: 2,
cameraController: false,
showFps: false,
});
// Exposure and key light, the same in every demo on this site. Shade's automatic
// exposure is off: these scenes are lit to be looked at, not adapted to, and an
// adapting frame makes two screenshots of the same demo disagree. That leaves
// the light doing the work - see the sun's intensity below, in lux.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
await EngineHarness.buildLights({
engine,
sunDirection: new Vector3(-0.5, -1, -0.5),
// Lux, the same value every demo on this site uses. The harness's default of
// 0.9 is a three.js-era number that only looked lit because automatic
// exposure opened up to meet it, and that is off here.
sunIntensity: 6,
ambientIntensity: 0,
});
const ecd = engine.entityManager.dataset;
// Both component types are attached at runtime, so register them up front.
ecd.registerComponentType(Highlight);
ecd.registerComponentType(Decal);
// ─── §2b HDR environment ───────────────────────────────────────────────────
//
// One HDR does both jobs: it is the visible background *and* the image-based
// light every PBR surface reflects. There is no separate `background` texture
// and no prefilter step to run — Shade convolves the map into irradiance and
// roughness mips on the GPU itself, and redoes that work whenever the texture
// is swapped, so this is a single assignment.
//
// The map must be **octahedral**, which is the projection Shade samples the
// environment with; an equirectangular panorama handed over directly samples as
// garbage rather than failing. `load_environment_map` will convert one for you,
// but its equirectangular path is a conversion *tool* — it also downloads the
// result as a file — so this ships the already-converted map and loads it
// straight through. `load_cube_environment` is the other way in, for content
// authored as six cube faces.
//
// The engine installs a plausible sky of its own (`make_default_environment`)
// when nothing supplies one, which is why a scene is never unlit by accident.
const ENVIRONMENT_URL = "./photo_studio_loft_hall_2k_octahedral.hdr";
engine.graphics.set_environment_map(
await load_environment_map(ENVIRONMENT_URL, ProjectionMappingType.Octahedral),
);
// ─── §3 World ──────────────────────────────────────────────────────────────
const MODEL_URL_BASE = "./models/chess/";
const BOARD_TOP_Y = 0.02; // world Y of the playing surface
function squareWorld(file, rank, y = BOARD_TOP_Y) {
return [
(file - 3.5) * WORLD_PER_SQUARE,
y,
(rank - 3.5) * WORLD_PER_SQUARE,
];
}
// --- Board ----------------------------------------------------------------
//
// Single GLTF. Individual squares aren't separately pickable — every click is
// answered by intersecting the cursor ray with the playing-surface plane
// instead (see §5). The asset is authored 90° off from our world layout, so we
// rotate it.
// -π/2 around +Y → 90° clockwise viewed from above.
const BOARD_YAW_QUAT = { x: 0, y: -Math.SQRT1_2, z: 0, w: Math.SQRT1_2 };
new Entity()
.add(Transform64.fromJSON({
translation: { x: 0, y: 0, z: 0 },
rotation: BOARD_YAW_QUAT,
}))
.add(SGMesh.fromURL(MODEL_URL_BASE + "board.gltf"))
.build(ecd);
// --- Piece spawning -------------------------------------------------------
function pieceUrl(piece) {
const color = pieceTeam(piece) === WHITE ? "white" : "black";
const type = ({
[KING]: "king", [QUEEN]: "queen", [ROOK]: "rook",
[BISHOP]: "bishop", [KNIGHT]: "knight", [PAWN]: "pawn",
})[pieceType(piece)];
return `${MODEL_URL_BASE}${color}-${type}.gltf`;
}
// Half-turn so White's pieces face the Black side.
function pieceYawFor(piece) {
return pieceTeam(piece) === WHITE ? Math.PI : 0;
}
// Which entity stands on which square. -1 for an empty square, and for a
// captured piece: a graveyard piece is off the board and belongs to no square.
const pieceEntityBySquare = new Int32Array(64);
pieceEntityBySquare.fill(-1);
function spawnPieceAt(piece, squareIdx) {
if (piece === EMPTY) return -1;
const [x, , z] = squareWorld(fileOf(squareIdx), rankOf(squareIdx));
const halfYaw = pieceYawFor(piece) / 2;
const ent = new Entity()
.add(Transform64.fromJSON({
translation: { x, y: BOARD_TOP_Y, z },
rotation: { x: 0, y: Math.sin(halfYaw), z: 0, w: Math.cos(halfYaw) },
}))
.add(SGMesh.fromURL(pieceUrl(piece)))
.build(ecd);
pieceEntityBySquare[squareIdx] = ent;
return ent;
}
// --- Live game state ------------------------------------------------------
const game = makeInitialState();
let gameOver = false;
let inputLocked = false; // AI is thinking
let animationActive = false; // a piece is in flight
let selectedSquare = -1;
let selectedEntity = -1;
const moveMarkerEntities = [];
for (let i = 0; i < 64; i++) spawnPieceAt(game.squares[i], i);
// --- Captured-piece graveyards --------------------------------------------
//
// On capture the entity isn't destroyed — it flies to a row beside the
// board. White's losses go off the -X edge, Black's off +X.
const GRAVEYARD_OFFSET = 5.0 * WORLD_PER_SQUARE;
const GRAVEYARD_SPACING = 0.7 * WORLD_PER_SQUARE;
const nextGraveyardSlot = { [WHITE]: 0, [BLACK]: 0 };
function graveyardSlotPosition(team, slotIdx) {
const sideX = (team === WHITE ? -1 : +1) * GRAVEYARD_OFFSET;
const z = (slotIdx - 7.5) * GRAVEYARD_SPACING;
return [sideX, BOARD_TOP_Y, z];
}
// --- Human-error placement jitter -----------------------------------------
//
// A real player doesn't drop pieces perfectly on a square's centre, perfectly
// aligned to north — each placement is a little off, and the misses cluster
// near zero rather than spreading uniformly. We compose two engine helpers:
//
// randomGaussian(Math.random) → bell-shaped roll in [0, 1]
// randomFloatBetween(rng, min, max) → maps an rng's [0, 1] → [min, max]
//
// Feeding the gaussian as the `rng` makes randomFloatBetween emit a value
// centred on (min+max)/2 with most of its mass near the middle — exactly the
// shape we want for human error around a target. Initial placement skips
// this — only post-move and post-capture positions are nudged.
const PIECE_OFFSET_FRACTION = 0.10; // ±10% of a square's edge
const PIECE_YAW_JITTER_RAD = (30 * Math.PI) / 180; // ±20° around target yaw
const gaussianRng = () => randomGaussian(Math.random, 3);
function gaussianBetween(min, max) {
return randomFloatBetween(gaussianRng, min, max);
}
// Three independent draws — translation in X and Z plus a yaw twist. Heights
// stay clamped to the board surface; nobody drops a piece higher or lower
// than the square next to it.
function makePlacementJitter() {
const offsetMax = PIECE_OFFSET_FRACTION * WORLD_PER_SQUARE;
return {
dx: gaussianBetween(-offsetMax, +offsetMax),
dz: gaussianBetween(-offsetMax, +offsetMax),
dYaw: gaussianBetween(-PIECE_YAW_JITTER_RAD, +PIECE_YAW_JITTER_RAD),
};
}
// Target yaw = the piece's "natural" facing (π for White, 0 for Black) plus a
// jitter. The constructed quaternion is exactly unit-length by sin²+cos²=1
// identity, but we normalize anyway — the same helper feeds slerp endpoints
// downstream and normalize() costs nothing for a single allocation.
function jitteredYawQuat(piece, dYaw) {
const yaw = pieceYawFor(piece) + dYaw;
const half = yaw * 0.5;
return new Quaternion(0, Math.sin(half), 0, Math.cos(half)).normalize();
}
// ─── §4 Move-arc animation ─────────────────────────────────────────────────
//
// 3-segment trajectory built from three cubic-Hermite AnimationCurves
// (one per axis):
//
// Y (height)
// ▲ ╭────────────╮ KF1, KF2 sit at apex
// │ ╱ ╲
// │ ╱ ╲
// base ──┼────╯ ╰────► time
// KF0 KF3
// lift glide touchdown
//
// X,Z hold steady through lift + touchdown (tangents = 0) and smoothstep
// across the glide. Y has positive outTangent at KF0 and negative inTangent
// at KF3; both apex tangents are 0 so the piece hovers briefly at the top.
const ARC_DURATION_SEC = 0.45;
const ARC_LIFT_HEIGHT = 0.06; // apex above board, world units
const ARC_LIFT_FRACTION = 0.2; // fraction of total time spent lifting / touching down
const ARC_LIFT_DURATION = ARC_LIFT_FRACTION * ARC_DURATION_SEC;
const ARC_TRAVEL_END = ARC_DURATION_SEC - ARC_LIFT_DURATION;
const ARC_LIFT_SLOPE = ARC_LIFT_HEIGHT / ARC_LIFT_DURATION; // dY/dT
/** Scratch for the slerp in {@link animateEntityAlongArc}; one animation runs per tick. */
const arcRotation = new Quaternion();
function makeArcCurves(fromX, fromZ, toX, toZ) {
const apexY = BOARD_TOP_Y + ARC_LIFT_HEIGHT;
const xCurve = AnimationCurve.from([
Keyframe.from(0, fromX, 0, 0),
Keyframe.from(ARC_LIFT_DURATION, fromX, 0, 0),
Keyframe.from(ARC_TRAVEL_END, toX, 0, 0),
Keyframe.from(ARC_DURATION_SEC, toX, 0, 0),
]);
const zCurve = AnimationCurve.from([
Keyframe.from(0, fromZ, 0, 0),
Keyframe.from(ARC_LIFT_DURATION, fromZ, 0, 0),
Keyframe.from(ARC_TRAVEL_END, toZ, 0, 0),
Keyframe.from(ARC_DURATION_SEC, toZ, 0, 0),
]);
const yCurve = AnimationCurve.from([
Keyframe.from(0, BOARD_TOP_Y, 0, ARC_LIFT_SLOPE),
Keyframe.from(ARC_LIFT_DURATION, apexY, 0, 0),
Keyframe.from(ARC_TRAVEL_END, apexY, 0, 0),
Keyframe.from(ARC_DURATION_SEC, BOARD_TOP_Y, -ARC_LIFT_SLOPE, 0),
]);
return { xCurve, yCurve, zCurve };
}
// Drive one entity along a freshly-built arc; resolves when done. Each call
// attaches its own postRender listener and detaches on completion, so
// concurrent animations don't share state.
//
// If `endQuat` is non-null the rotation is slerped from the entity's current
// orientation (captured at entry — the transform is mutated each tick, so it
// can't be re-read as the "from" later) to the target. We renormalise every
// frame against numerical drift, which is the conventional safety net even
// though slerp's analytic form keeps unit length.
//
// A Transform64 has no signals, so each tick ends by announcing the write: that
// is what moves the piece's model, and the marker decals attached to it.
function animateEntityAlongArc(entity, fromX, fromZ, toX, toZ, endQuat = null) {
return new Promise((resolve) => {
const transform = ecd.getComponent(entity, Transform64);
if (transform === undefined) { resolve(); return; }
const { xCurve, yCurve, zCurve } = makeArcCurves(fromX, fromZ, toX, toZ);
const startQuat = endQuat !== null
? new Quaternion(
transform.rotation_x, transform.rotation_y,
transform.rotation_z, transform.rotation_w
)
: null;
const startTimeMs = performance.now();
const tick = () => {
const elapsedSec = (performance.now() - startTimeMs) / 1000;
const t = Math.min(elapsedSec, ARC_DURATION_SEC);
transform.setTranslation(
xCurve.evaluate(t),
yCurve.evaluate(t),
zCurve.evaluate(t),
);
if (endQuat !== null) {
const u = t / ARC_DURATION_SEC;
arcRotation.slerpQuaternions(startQuat, endQuat, u);
arcRotation.normalize();
transform.setRotation(arcRotation.x, arcRotation.y, arcRotation.z, arcRotation.w);
transform.updateMatrix();
}
t64_announce_change(ecd, entity);
if (elapsedSec >= ARC_DURATION_SEC) {
engine.graphics.on.postRender.remove(tick);
resolve();
}
};
engine.graphics.on.postRender.add(tick);
});
}
// ─── §5 Selection & input ──────────────────────────────────────────────────
// --- Move-marker decals ---------------------------------------------------
//
// Procedural soft-green dot rendered to a canvas, exposed as a blob: URL.
// One Decal per legal destination square.
function makeMarkerTextureURL() {
return new Promise((resolve) => {
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
const cx = size / 2, cy = size / 2;
const r = size * 0.42;
const grad = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, r);
grad.addColorStop(0, "rgba(78, 240, 168, 0.95)");
grad.addColorStop(0.65, "rgba(78, 240, 168, 0.75)");
grad.addColorStop(1, "rgba(78, 240, 168, 0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
canvas.toBlob((blob) => resolve(URL.createObjectURL(blob)), "image/png");
});
}
const MARKER_URI = await makeMarkerTextureURL();
// A decal projects along its own local +Z, and the surface it lands on faces
// the other way. Rotate 90° around X so local +Z → world -Y: straight down onto
// the board, whose normal is +Y. Local X and Y are the decal's footprint, and
// local Z is how deep it reaches — all three come from the entity's scale.
const DECAL_DOWN_QUAT = {
x: Math.sin(Math.PI / 4),
y: 0,
z: 0,
w: Math.cos(Math.PI / 4),
};
const MARKER_WIDTH_FRACTION = 0.85;
const MARKER_DEPTH_FRACTION = 0.6;
function placeMoveMarker(squareIdx) {
const [x, , z] = squareWorld(fileOf(squareIdx), rankOf(squareIdx));
const decal = Decal.fromJSON({ uri: MARKER_URI, priority: 1, color: "#FFFFFF" });
const ent = new Entity()
.add(Transform64.fromJSON({
translation: { x, y: BOARD_TOP_Y + 0.01 * WORLD_PER_SQUARE, z },
rotation: DECAL_DOWN_QUAT,
scale: {
x: MARKER_WIDTH_FRACTION * WORLD_PER_SQUARE,
y: MARKER_WIDTH_FRACTION * WORLD_PER_SQUARE,
z: MARKER_DEPTH_FRACTION * WORLD_PER_SQUARE,
},
}))
.add(decal)
.build(ecd);
moveMarkerEntities.push(ent);
}
function clearMoveMarkers() {
for (const ent of moveMarkerEntities) {
if (ecd.entityExists(ent)) ecd.removeEntity(ent);
}
moveMarkerEntities.length = 0;
}
// --- Selection / highlight ------------------------------------------------
const HIGHLIGHT_COLOR = Highlight.fromOne(0.31, 0.94, 0.66, 1);
function setSelectedSquare(idx) {
if (idx === selectedSquare) return;
if (selectedEntity !== -1 && ecd.entityExists(selectedEntity)) {
ecd.removeComponentFromEntity(selectedEntity, Highlight);
}
clearMoveMarkers();
selectedSquare = idx;
selectedEntity = idx === -1 ? -1 : pieceEntityBySquare[idx];
if (selectedEntity !== -1) {
ecd.addComponentToEntity(selectedEntity, HIGHLIGHT_COLOR);
const legal = enumerateLegalMoves(game);
for (const m of legal) {
if (moveFrom(m) === idx) placeMoveMarker(moveTo(m));
}
}
}
// --- Click → square -------------------------------------------------------
//
// One read: the board square where the cursor's world ray crosses the playing
// surface. The board *is* a plane and every piece stands on a square, so that
// intersection is exact, and it is the whole answer.
//
// A chess move is chosen by **square**, not by piece: "move to e4" is a legal
// move whether or not anything is standing on e4. `PickingSystem` answers a
// different question — which entity the renderer drew at a pixel, read back off
// the visibility buffer — so it cannot name an empty square at all, and it
// answers a frame or two later because a readback is asynchronous. A plane
// crossing answers every click, inside the same tick that asked.
//
// The cost is small and worth naming: a click on the upper half of a piece
// lands on the square its head overhangs rather than on the piece. Clicking at
// the base — where a piece visibly sits — is exact.
const PLAYER_TEAM = BLACK;
const pickViewport = new Vector2();
const pickOrigin = new Vector3();
const pickDirection = new Vector3();
const planeHitPoint = new Vector3();
function projectPlaneSquare() {
const ok = plane3_compute_ray_intersection(
planeHitPoint,
pickOrigin.x, pickOrigin.y, pickOrigin.z,
pickDirection.x, pickDirection.y, pickDirection.z,
0, 1, 0, -BOARD_TOP_Y,
);
if (!ok) return -1;
const f = Math.round(planeHitPoint.x / WORLD_PER_SQUARE + 3.5);
const r = Math.round(planeHitPoint.z / WORLD_PER_SQUARE + 3.5);
if (!inBoard(f, r)) return -1;
return sq(f, r);
}
function isPlayerPieceAt(squareIdx) {
if (squareIdx === -1) return false;
const p = game.squares[squareIdx];
return p !== EMPTY && pieceTeam(p) === PLAYER_TEAM;
}
engine.devices.pointer.on.tap.add(async (position) => {
if (inputLocked || gameOver || animationActive) return;
// Viewport pixels → clip space → a world ray from the eye.
engine.graphics.normalizeViewportPoint(position, pickViewport);
engine.graphics.viewportProjectionRay(
pickViewport.x, pickViewport.y, pickOrigin, pickDirection,
);
const clickedSquare = projectPlaneSquare();
// Selection candidate: the clicked square, when it holds one of the
// player's own pieces.
const selectCandidate = isPlayerPieceAt(clickedSquare) ? clickedSquare : -1;
// Phase 1 — no piece in hand → try to pick one up.
if (selectedSquare === -1) {
if (selectCandidate !== -1) setSelectedSquare(selectCandidate);
return;
}
// Click on the already-selected piece → deselect (toggle behaviour).
// We catch this before Phase 2 so we don't burn cycles enumerating moves
// looking for a self → self destination that doesn't exist.
if (selectCandidate === selectedSquare) {
setSelectedSquare(-1);
return;
}
// Phase 2 — piece is selected → maybe execute a move. The destination is
// the clicked square, occupied or not: clicking an opponent piece and
// clicking the square it stands on are the same click, and both capture.
const moveTarget = clickedSquare;
if (moveTarget !== -1) {
const legalMoves = enumerateLegalMoves(game).filter(
(m) => moveFrom(m) === selectedSquare && moveTo(m) === moveTarget,
);
if (legalMoves.length > 0) {
const move = legalMoves[0];
setSelectedSquare(-1);
await commitMove(move);
startAITurn();
return;
}
}
// Phase 3 — click wasn't a legal destination → re-select (same rules as
// Phase 1) or deselect.
if (selectCandidate !== -1) setSelectedSquare(selectCandidate);
else setSelectedSquare(-1);
});
// ─── §5b AI-turn light tween ───────────────────────────────────────────────
//
// Dim the directional light to a fraction of its base intensity while the
// AI thinks; restore on the player's turn. AnimationCurve.easeInOut handles
// the soft start/end on both ramps.
const LIGHT_DIM_FRACTION = 0.5;
const LIGHT_TWEEN_DURATION_SEC = 0.3;
// Pull the directional light's intensity (a Vector1) out of the lights
// EngineHarness.buildLights spawned for us.
let directionalLightIntensity = null;
let directionalLightBaseIntensity = 1;
ecd.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION && directionalLightIntensity === null) {
directionalLightIntensity = light.intensity;
directionalLightBaseIntensity = light.intensity.getValue();
}
});
let activeLightTween = null;
function tweenDirectionalLightTo(targetIntensity) {
if (directionalLightIntensity === null) return;
// Cancel any in-flight tween so consecutive transitions chain cleanly.
if (activeLightTween !== null) {
engine.graphics.on.postRender.remove(activeLightTween);
activeLightTween = null;
}
const startIntensity = directionalLightIntensity.getValue();
if (startIntensity === targetIntensity) return;
const curve = AnimationCurve.easeInOut(
0, startIntensity,
LIGHT_TWEEN_DURATION_SEC, targetIntensity,
);
const startTimeMs = performance.now();
const tick = () => {
const elapsedSec = (performance.now() - startTimeMs) / 1000;
const t = Math.min(elapsedSec, LIGHT_TWEEN_DURATION_SEC);
directionalLightIntensity.set(curve.evaluate(t));
if (elapsedSec >= LIGHT_TWEEN_DURATION_SEC) {
engine.graphics.on.postRender.remove(tick);
if (activeLightTween === tick) activeLightTween = null;
}
};
activeLightTween = tick;
engine.graphics.on.postRender.add(tick);
}
const dimLightForAI = () => tweenDirectionalLightTo(directionalLightBaseIntensity * LIGHT_DIM_FRACTION);
const restoreLightForPlayer = () => tweenDirectionalLightTo(directionalLightBaseIntensity);
// ─── §6 MCTS AI ────────────────────────────────────────────────────────────
//
// MonteCarloTreeSearch tracks per-player payoffs as a Float64Array vector.
// We declare two players (White = 0, Black = 1) and feed the search five
// callbacks: a MoveEdge factory, a terminal flag, an active-player resolver,
// a terminal-outcome vector, and a heuristic — plus a state cloner. Chess
// is zero-sum, so every vector here is just [+s, -s].
const AI_TIME_BUDGET_MS = 3300;
const AI_PLAYOUT_BUDGET = 80_000;
const AI_BATCH_PER_CYCLE = 32; // playouts per Task slice
const AI_MAX_DEPTH = 16; // beyond this MCTS falls back on heuristic
// MCTS player IDs are dense 0..numPlayers-1. Our piece-team constants (WHITE=0,
// BLACK=8) are bitmasks for piece encoding, so they can't double as player IDs.
const PLAYER_WHITE = 0;
const PLAYER_BLACK = 1;
// Reused terminal payoff vectors — the engine copies these into the node's own
// buffer via `.set()`, so a single shared instance per outcome is safe.
const OUTCOME_WHITE_WIN = new Float64Array([+1, -1]);
const OUTCOME_BLACK_WIN = new Float64Array([-1, +1]);
const OUTCOME_TIE = new Float64Array([ 0, 0]);
function computeValidMoves(state) {
const moves = enumerateLegalMoves(state);
const edges = new Array(moves.length);
for (let i = 0; i < moves.length; i++) {
const m = moves[i];
const edge = new MoveEdge();
edge.move = (s) => { applyMoveInPlace(s, m); return s; };
edge.__chessMove = m; // stash so we can read the chosen move back
edges[i] = edge;
}
return edges;
}
function computeActivePlayer(state) {
return state.sideToMove === WHITE ? PLAYER_WHITE : PLAYER_BLACK;
}
// Terminal iff the side to move has no legal reply. We don't classify Win /
// Loss / Tie here — that's what computeMctsOutcome is for.
function computeTerminalFlag(state) {
return hasAnyLegalMove(state) ? StateType.Undecided : StateType.Terminal;
}
// Called when the search materializes a terminal node: zero legal replies for
// side-to-move. In check → checkmate, their opponent wins. Otherwise →
// stalemate. Distinct from the rules-engine `computeOutcome` (§1) which
// returns a banner-friendly tag — MCTS wants a per-player payoff vector.
function computeMctsOutcome(state) {
if (!isInCheck(state, state.sideToMove)) return OUTCOME_TIE;
return state.sideToMove === WHITE ? OUTCOME_BLACK_WIN : OUTCOME_WHITE_WIN;
}
// --- Piece-Square Tables --------------------------------------------------
//
// PSTs give a static positional bonus per (piece type, square). Tomasz
// Michniewski's "Simplified Evaluation Function" set (Chess Programming
// Wiki, public domain). Values are centipawns; each table is from White's
// POV with row 0 = rank 8. For Black we mirror by reading bottom-up — a
// crude approximation, but enough to teach the AI basic piece-square
// instincts (knights centralised, pawns advance, kings hide on rank 1, …).
const PST_PAWN = new Int8Array([
0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10,
5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, -5,-10, 0, 0,-10, -5, 5,
5, 10, 10,-20,-20, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0,
]);
const PST_KNIGHT = new Int8Array([
-50,-40,-30,-30,-30,-30,-40,-50,
-40,-20, 0, 0, 0, 0,-20,-40,
-30, 0, 10, 15, 15, 10, 0,-30,
-30, 5, 15, 20, 20, 15, 5,-30,
-30, 0, 15, 20, 20, 15, 0,-30,
-30, 5, 10, 15, 15, 10, 5,-30,
-40,-20, 0, 5, 5, 0,-20,-40,
-50,-40,-30,-30,-30,-30,-40,-50,
]);
const PST_BISHOP = new Int8Array([
-20,-10,-10,-10,-10,-10,-10,-20,
-10, 0, 0, 0, 0, 0, 0,-10,
-10, 0, 5, 10, 10, 5, 0,-10,
-10, 5, 5, 10, 10, 5, 5,-10,
-10, 0, 10, 10, 10, 10, 0,-10,
-10, 10, 10, 10, 10, 10, 10,-10,
-10, 5, 0, 0, 0, 0, 5,-10,
-20,-10,-10,-10,-10,-10,-10,-20,
]);
const PST_ROOK = new Int8Array([
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
0, 0, 0, 5, 5, 0, 0, 0,
]);
const PST_QUEEN = new Int8Array([
-20,-10,-10, -5, -5,-10,-10,-20,
-10, 0, 0, 0, 0, 0, 0,-10,
-10, 0, 5, 5, 5, 5, 0,-10,
-5, 0, 5, 5, 5, 5, 0, -5,
0, 0, 5, 5, 5, 5, 0, -5,
-10, 5, 5, 5, 5, 5, 0,-10,
-10, 0, 5, 0, 0, 0, 0,-10,
-20,-10,-10, -5, -5,-10,-10,-20,
]);
// Middle-game king table — phase distinction skipped.
const PST_KING = new Int8Array([
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-20,-30,-30,-40,-40,-30,-30,-20,
-10,-20,-20,-20,-20,-20,-20,-10,
20, 20, 0, 0, 0, 0, 20, 20,
20, 30, 10, 0, 0, 10, 30, 20,
]);
const PST_BY_TYPE = new Array(8);
PST_BY_TYPE[PAWN] = PST_PAWN;
PST_BY_TYPE[KNIGHT] = PST_KNIGHT;
PST_BY_TYPE[BISHOP] = PST_BISHOP;
PST_BY_TYPE[ROOK] = PST_ROOK;
PST_BY_TYPE[QUEEN] = PST_QUEEN;
PST_BY_TYPE[KING] = PST_KING;
// Material in centipawns so it sums with PST values directly.
const PIECE_VALUE_CP = new Int16Array(8);
PIECE_VALUE_CP[PAWN] = 100;
PIECE_VALUE_CP[KNIGHT] = 320;
PIECE_VALUE_CP[BISHOP] = 330;
PIECE_VALUE_CP[ROOK] = 500;
PIECE_VALUE_CP[QUEEN] = 900;
PIECE_VALUE_CP[KING] = 0; // king is terminal — counted via mate flag
// All material on one side ≈ 3900 + a healthy PST swing ≈ 500. Normalising
// by 4500 keeps output mostly inside [-1, 1] for typical middlegames.
const PST_EVAL_NORM = 1 / 4500;
// Material + PST evaluation, White-positive, normalised. White reads each
// table top-down (row = 7 - rank); Black is mirrored (row = rank) and
// negated. Returns a [White, Black] per-player vector — zero-sum, so the
// scalar score is written positively to White's slot and negated for Black.
// The engine copies our buffer on receipt, so a single reused instance is
// safe.
const heuristicVector = new Float64Array(2);
function pstHeuristic(_node, state) {
let score = 0;
for (let i = 0; i < 64; i++) {
const p = state.squares[i];
if (p === EMPTY) continue;
const team = pieceTeam(p);
const type = pieceType(p);
const row = team === WHITE ? (7 - rankOf(i)) : rankOf(i);
const positional = PST_BY_TYPE[type][row * 8 + fileOf(i)];
const material = PIECE_VALUE_CP[type];
const contribution = material + positional;
score += team === WHITE ? contribution : -contribution;
}
const normalised = score * PST_EVAL_NORM;
heuristicVector[PLAYER_WHITE] = +normalised;
heuristicVector[PLAYER_BLACK] = -normalised;
return heuristicVector;
}
// --- AI move task ---------------------------------------------------------
const aiProgressEl = document.getElementById("ai-progress");
const turnEl = document.getElementById("turn");
function buildSearch() {
const search = new MonteCarloTreeSearch();
search.maxExplorationDepth = AI_MAX_DEPTH;
search.initialize({
rootState: cloneState(game),
numPlayers: 2,
computeValidMoves,
computeTerminalFlag,
computeActivePlayer,
computeOutcome: computeMctsOutcome,
cloneState,
heuristic: pstHeuristic,
});
return search;
}
// engine.executor time-slices the cycleFunction against rendering. We stop
// when either the wall-clock budget or playout budget is reached.
function runAISearchAsTask(search, onDone) {
const deadlineMs = performance.now() + AI_TIME_BUDGET_MS;
let playouts = 0;
const cycle = () => {
for (let i = 0; i < AI_BATCH_PER_CYCLE; i++) {
search.playout();
playouts++;
}
aiProgressEl.textContent = `${playouts} playouts`;
const timeUp = performance.now() >= deadlineMs;
const playoutsUp = playouts >= AI_PLAYOUT_BUDGET;
return (timeUp || playoutsUp) ? TaskSignal.EndSuccess : TaskSignal.Continue;
};
const task = new Task({
name: "chess.ai.mcts",
cycleFunction: cycle,
computeProgress: () => Math.min(1, playouts / AI_PLAYOUT_BUDGET),
estimatedDuration: AI_TIME_BUDGET_MS / 1000,
});
task.on.completed.add(() => onDone(playouts));
engine.executor.run(task);
}
function startAITurn() {
// Player might have just delivered mate.
const outcomeAfterPlayer = computeOutcome(game);
if (outcomeAfterPlayer.result !== null) {
showBanner(outcomeAfterPlayer.result);
gameOver = true;
return;
}
inputLocked = true;
turnEl.textContent = "White (AI)";
turnEl.className = "value thinking";
dimLightForAI();
const search = buildSearch();
// Defensive — no legal moves means the game already ended.
if (computeValidMoves(search.rootState).length === 0) {
inputLocked = false;
return;
}
runAISearchAsTask(search, async (playouts) => {
const best = search.root.pickBestMoves();
if (best.length === 0) {
aiProgressEl.textContent = "no move";
finalizeAITurn();
return;
}
// Random tie-break so MCTS doesn't always play the same line.
const chosen = best[(Math.random() * best.length) | 0];
aiProgressEl.textContent = `done (${playouts})`;
await commitMove(chosen.__chessMove);
finalizeAITurn();
});
}
function finalizeAITurn() {
inputLocked = false;
restoreLightForPlayer();
const outcome = computeOutcome(game);
if (outcome.result !== null) {
showBanner(outcome.result);
gameOver = true;
turnEl.textContent = "—";
turnEl.className = "value";
aiProgressEl.textContent = "—";
return;
}
turnEl.textContent = "Black (you)";
turnEl.className = "value you";
aiProgressEl.textContent = "idle";
}
// ─── §7 Move commit ────────────────────────────────────────────────────────
//
// Only function that mutates BOTH the logical board AND the visual world.
// Three phases:
// 1. Bookkeeping: update rules state + entity-tracking maps synchronously
// so any code after `await commitMove(...)` sees the new board.
// 2. Animations: build mover + (optional) captured-piece arcs, run in
// parallel via Promise.all.
// 3. Release the animation lock.
//
// Captured pieces aren't destroyed — they're animated to a graveyard slot.
// Build one arc from an entity's current world position to (centreX, centreZ)
// plus a fresh gaussian jitter, slerping rotation to the piece's natural yaw
// plus a yaw jitter. Pushed into `animations` for Promise.all parallelism.
function pushArcAnimation(animations, entity, centreX, centreZ, pieceCode) {
const transform = ecd.getComponent(entity, Transform64);
const j = makePlacementJitter();
animations.push(animateEntityAlongArc(
entity,
transform.translation_x, transform.translation_z,
centreX + j.dx, centreZ + j.dz,
jitteredYawQuat(pieceCode, j.dYaw),
));
}
// Replace a pawn entity with a freshly-spawned queen at the same transform.
// Called after the move-arc lands the pawn on the back rank, so the swap
// looks like a transformation in place rather than a snap mid-flight.
function promotePawnToQueen(pawnEntity, team, squareIdx) {
const t = ecd.getComponent(pawnEntity, Transform64);
const queenEnt = new Entity()
.add(Transform64.fromJSON({
translation: { x: t.translation_x, y: t.translation_y, z: t.translation_z },
rotation: { x: t.rotation_x, y: t.rotation_y, z: t.rotation_z, w: t.rotation_w },
}))
.add(SGMesh.fromURL(pieceUrl(team | QUEEN)))
.build(ecd);
pieceEntityBySquare[squareIdx] = queenEnt;
ecd.removeEntity(pawnEntity);
}
async function commitMove(move) {
const from = moveFrom(move);
const to = moveTo(move);
const type = moveType(move);
// ── Snapshot piece codes BEFORE applyMoveInPlace mutates the board ───
const movingPieceCode = game.squares[from];
const movingTeam = pieceTeam(movingPieceCode);
// For en passant the captured pawn sits on the moving pawn's rank, not
// on the move's destination square (which was empty by definition).
const captureSq =
type === MOVE_TYPE_EN_PASSANT
? sq(fileOf(to), rankOf(from))
: to;
const capturedPieceCode = game.squares[captureSq];
// Castling moves a rook in addition to the king. Compute the rook's
// home/destination so we can animate it alongside.
let rookFromSq = -1, rookToSq = -1, rookEnt = -1, rookPieceCode = EMPTY;
if (type === MOVE_TYPE_CASTLE) {
const rank = rankOf(from);
const kingFile = fileOf(to);
[rookFromSq, rookToSq] = kingFile === 6
? [sq(7, rank), sq(5, rank)]
: [sq(0, rank), sq(3, rank)];
rookEnt = pieceEntityBySquare[rookFromSq];
rookPieceCode = game.squares[rookFromSq];
}
const movingEnt = pieceEntityBySquare[from];
const capturedEnt = capturedPieceCode !== EMPTY
? pieceEntityBySquare[captureSq]
: -1;
// 1 — visual bookkeeping (mirrors what applyMoveInPlace does to the
// logical board, but on the entity-tracking side).
if (capturedEnt !== -1) {
pieceEntityBySquare[captureSq] = -1;
}
pieceEntityBySquare[to] = movingEnt;
pieceEntityBySquare[from] = -1;
if (rookEnt !== -1) {
pieceEntityBySquare[rookToSq] = rookEnt;
pieceEntityBySquare[rookFromSq] = -1;
}
applyMoveInPlace(game, move);
// 2 — animations
//
// Source positions are read from each piece's live transform so the arc
// begins exactly where the piece currently sits — preserving any jitter
// applied by an earlier move instead of snapping back to grid centre on
// every commit. Destinations are nudged with a fresh gaussian-distributed
// jitter, and rotations are slerped into a similarly jittered yaw.
animationActive = true;
const animations = [];
if (movingEnt !== -1) {
const [cx, , cz] = squareWorld(fileOf(to), rankOf(to));
pushArcAnimation(animations, movingEnt, cx, cz, movingPieceCode);
}
if (rookEnt !== -1) {
const [cx, , cz] = squareWorld(fileOf(rookToSq), rankOf(rookToSq));
pushArcAnimation(animations, rookEnt, cx, cz, rookPieceCode);
}
if (capturedEnt !== -1 && capturedPieceCode !== EMPTY) {
const capTeam = pieceTeam(capturedPieceCode);
const slot = nextGraveyardSlot[capTeam]++;
const [cx, , cz] = graveyardSlotPosition(capTeam, slot);
pushArcAnimation(animations, capturedEnt, cx, cz, capturedPieceCode);
}
await Promise.all(animations);
// 3 — post-animation: promotion swap. Doing the swap after the arc means
// the player watches a pawn land on the back rank, then transform —
// rather than seeing a queen mid-flight.
if (type === MOVE_TYPE_PROMOTION && movingEnt !== -1) {
promotePawnToQueen(movingEnt, movingTeam, to);
}
// 4 — release the lock
animationActive = false;
}
// ─── §8 End-game banner ────────────────────────────────────────────────────
function showBanner(result) {
const banner = document.getElementById("banner");
const who = document.getElementById("banner-who");
if (result === "WHITE_WINS") who.textContent = "White won";
else if (result === "BLACK_WINS") who.textContent = "Black won";
else who.textContent = "Stalemate — draw";
banner.hidden = false;
}
// ─── §9 HUD ────────────────────────────────────────────────────────────────
const fpsEl = document.getElementById("fps");
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
// ─── §10 Kickoff ───────────────────────────────────────────────────────────
//
// White (AI) moves first. Wait one frame so the engine has finished system
// startup (notably DecalSystem registering its render extension) before we hand
// it a Task.
requestAnimationFrame(() => { startAITurn(); });