function t(t){let e=t.length;for(;--e>=0;)t[e]=0}const e=new Uint8Array([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0]),i=new Uint8Array([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13]),n=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]),r=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=new Array(576);t(a);const s=new Array(60);t(s);const o=new Array(512);t(o);const h=new Array(256);t(h);const l=new Array(29);t(l);const d=new Array(30);function c(t,e,i,n,r){this.static_tree=t,this.extra_bits=e,this.extra_base=i,this.elems=n,this.max_length=r,this.has_stree=t&&t.length}let f,u,_;function p(t,e){this.dyn_tree=t,this.max_code=0,this.stat_desc=e}t(d);const w=t=>t<256?o[t]:o[256+(t>>>7)],g=(t,e)=>{t.pending_buf[t.pending++]=255&e,t.pending_buf[t.pending++]=e>>>8&255},b=(t,e,i)=>{t.bi_valid>16-i?(t.bi_buf|=e<<t.bi_valid&65535,g(t,t.bi_buf),t.bi_buf=e>>16-t.bi_valid,t.bi_valid+=i-16):(t.bi_buf|=e<<t.bi_valid&65535,t.bi_valid+=i)},y=(t,e,i)=>{b(t,i[2*e],i[2*e+1])},m=(t,e)=>{let i=0;do{i|=1&t,t>>>=1,i<<=1}while(--e>0);return i>>>1},k=(t,e,i)=>{const n=new Array(16);let r,a,s=0;for(r=1;r<=15;r++)n[r]=s=s+i[r-1]<<1;for(a=0;a<=e;a++){let e=t[2*a+1];0!==e&&(t[2*a]=m(n[e]++,e))}},v=t=>{let e;for(e=0;e<286;e++)t.dyn_ltree[2*e]=0;for(e=0;e<30;e++)t.dyn_dtree[2*e]=0;for(e=0;e<19;e++)t.bl_tree[2*e]=0;t.dyn_ltree[512]=1,t.opt_len=t.static_len=0,t.last_lit=t.matches=0},x=t=>{t.bi_valid>8?g(t,t.bi_buf):t.bi_valid>0&&(t.pending_buf[t.pending++]=t.bi_buf),t.bi_buf=0,t.bi_valid=0},A=(t,e,i,n)=>{const r=2*e,a=2*i;return t[r]<t[a]||t[r]===t[a]&&n[e]<=n[i]},U=(t,e,i)=>{const n=t.heap[i];let r=i<<1;for(;r<=t.heap_len&&(r<t.heap_len&&A(e,t.heap[r+1],t.heap[r],t.depth)&&r++,!A(e,n,t.heap[r],t.depth));)t.heap[i]=t.heap[r],i=r,r<<=1;t.heap[i]=n},E=(t,n,r)=>{let a,s,o,c,f=0;if(0!==t.last_lit)do{a=t.pending_buf[t.d_buf+2*f]<<8|t.pending_buf[t.d_buf+2*f+1],s=t.pending_buf[t.l_buf+f],f++,0===a?y(t,s,n):(o=h[s],y(t,o+256+1,n),c=e[o],0!==c&&(s-=l[o],b(t,s,c)),a--,o=w(a),y(t,o,r),c=i[o],0!==c&&(a-=d[o],b(t,a,c)))}while(f<t.last_lit);y(t,256,n)},I=(t,e)=>{const i=e.dyn_tree,n=e.stat_desc.static_tree,r=e.stat_desc.has_stree,a=e.stat_desc.elems;let s,o,h,l=-1;for(t.heap_len=0,t.heap_max=573,s=0;s<a;s++)0!==i[2*s]?(t.heap[++t.heap_len]=l=s,t.depth[s]=0):i[2*s+1]=0;for(;t.heap_len<2;)h=t.heap[++t.heap_len]=l<2?++l:0,i[2*h]=1,t.depth[h]=0,t.opt_len--,r&&(t.static_len-=n[2*h+1]);for(e.max_code=l,s=t.heap_len>>1;s>=1;s--)U(t,i,s);h=a;do{s=t.heap[1],t.heap[1]=t.heap[t.heap_len--],U(t,i,1),o=t.heap[1],t.heap[--t.heap_max]=s,t.heap[--t.heap_max]=o,i[2*h]=i[2*s]+i[2*o],t.depth[h]=(t.depth[s]>=t.depth[o]?t.depth[s]:t.depth[o])+1,i[2*s+1]=i[2*o+1]=h,t.heap[1]=h++,U(t,i,1)}while(t.heap_len>=2);t.heap[--t.heap_max]=t.heap[1],((t,e)=>{const i=e.dyn_tree,n=e.max_code,r=e.stat_desc.static_tree,a=e.stat_desc.has_stree,s=e.stat_desc.extra_bits,o=e.stat_desc.extra_base,h=e.stat_desc.max_length;let l,d,c,f,u,_,p=0;for(f=0;f<=15;f++)t.bl_count[f]=0;for(i[2*t.heap[t.heap_max]+1]=0,l=t.heap_max+1;l<573;l++)d=t.heap[l],f=i[2*i[2*d+1]+1]+1,f>h&&(f=h,p++),i[2*d+1]=f,d>n||(t.bl_count[f]++,u=0,d>=o&&(u=s[d-o]),_=i[2*d],t.opt_len+=_*(f+u),a&&(t.static_len+=_*(r[2*d+1]+u)));if(0!==p){do{for(f=h-1;0===t.bl_count[f];)f--;t.bl_count[f]--,t.bl_count[f+1]+=2,t.bl_count[h]--,p-=2}while(p>0);for(f=h;0!==f;f--)for(d=t.bl_count[f];0!==d;)c=t.heap[--l],c>n||(i[2*c+1]!==f&&(t.opt_len+=(f-i[2*c+1])*i[2*c],i[2*c+1]=f),d--)}})(t,e),k(i,l,t.bl_count)},S=(t,e,i)=>{let n,r,a=-1,s=e[1],o=0,h=7,l=4;for(0===s&&(h=138,l=3),e[2*(i+1)+1]=65535,n=0;n<=i;n++)r=s,s=e[2*(n+1)+1],++o<h&&r===s||(o<l?t.bl_tree[2*r]+=o:0!==r?(r!==a&&t.bl_tree[2*r]++,t.bl_tree[32]++):o<=10?t.bl_tree[34]++:t.bl_tree[36]++,o=0,a=r,0===s?(h=138,l=3):r===s?(h=6,l=3):(h=7,l=4))},z=(t,e,i)=>{let n,r,a=-1,s=e[1],o=0,h=7,l=4;for(0===s&&(h=138,l=3),n=0;n<=i;n++)if(r=s,s=e[2*(n+1)+1],!(++o<h&&r===s)){if(o<l)do{y(t,r,t.bl_tree)}while(0!=--o);else 0!==r?(r!==a&&(y(t,r,t.bl_tree),o--),y(t,16,t.bl_tree),b(t,o-3,2)):o<=10?(y(t,17,t.bl_tree),b(t,o-3,3)):(y(t,18,t.bl_tree),b(t,o-11,7));o=0,a=r,0===s?(h=138,l=3):r===s?(h=6,l=3):(h=7,l=4)}};let C=!1;const T=(t,e,i,n)=>{b(t,0+(n?1:0),3),((t,e,i,n)=>{x(t),g(t,i),g(t,~i),t.pending_buf.set(t.window.subarray(e,e+i),t.pending),t.pending+=i})(t,e,i)};var R={_tr_init:t=>{C||((()=>{let t,r,p,w,g;const b=new Array(16);for(p=0,w=0;w<28;w++)for(l[w]=p,t=0;t<1<<e[w];t++)h[p++]=w;for(h[p-1]=w,g=0,w=0;w<16;w++)for(d[w]=g,t=0;t<1<<i[w];t++)o[g++]=w;for(g>>=7;w<30;w++)for(d[w]=g<<7,t=0;t<1<<i[w]-7;t++)o[256+g++]=w;for(r=0;r<=15;r++)b[r]=0;for(t=0;t<=143;)a[2*t+1]=8,t++,b[8]++;for(;t<=255;)a[2*t+1]=9,t++,b[9]++;for(;t<=279;)a[2*t+1]=7,t++,b[7]++;for(;t<=287;)a[2*t+1]=8,t++,b[8]++;for(k(a,287,b),t=0;t<30;t++)s[2*t+1]=5,s[2*t]=m(t,5);f=new c(a,e,257,286,15),u=new c(s,i,0,30,15),_=new c(new Array(0),n,0,19,7)})(),C=!0),t.l_desc=new p(t.dyn_ltree,f),t.d_desc=new p(t.dyn_dtree,u),t.bl_desc=new p(t.bl_tree,_),t.bi_buf=0,t.bi_valid=0,v(t)},_tr_stored_block:T,_tr_flush_block:(t,e,i,n)=>{let o,h,l=0;t.level>0?(2===t.strm.data_type&&(t.strm.data_type=(t=>{let e,i=4093624447;for(e=0;e<=31;e++,i>>>=1)if(1&i&&0!==t.dyn_ltree[2*e])return 0;if(0!==t.dyn_ltree[18]||0!==t.dyn_ltree[20]||0!==t.dyn_ltree[26])return 1;for(e=32;e<256;e++)if(0!==t.dyn_ltree[2*e])return 1;return 0})(t)),I(t,t.l_desc),I(t,t.d_desc),l=(t=>{let e;for(S(t,t.dyn_ltree,t.l_desc.max_code),S(t,t.dyn_dtree,t.d_desc.max_code),I(t,t.bl_desc),e=18;e>=3&&0===t.bl_tree[2*r[e]+1];e--);return t.opt_len+=3*(e+1)+5+5+4,e})(t),o=t.opt_len+3+7>>>3,h=t.static_len+3+7>>>3,h<=o&&(o=h)):o=h=i+5,i+4<=o&&-1!==e?T(t,e,i,n):4===t.strategy||h===o?(b(t,2+(n?1:0),3),E(t,a,s)):(b(t,4+(n?1:0),3),((t,e,i,n)=>{let a;for(b(t,e-257,5),b(t,i-1,5),b(t,n-4,4),a=0;a<n;a++)b(t,t.bl_tree[2*r[a]+1],3);z(t,t.dyn_ltree,e-1),z(t,t.dyn_dtree,i-1)})(t,t.l_desc.max_code+1,t.d_desc.max_code+1,l+1),E(t,t.dyn_ltree,t.dyn_dtree)),v(t),n&&x(t)},_tr_tally:(t,e,i)=>(t.pending_buf[t.d_buf+2*t.last_lit]=e>>>8&255,t.pending_buf[t.d_buf+2*t.last_lit+1]=255&e,t.pending_buf[t.l_buf+t.last_lit]=255&i,t.last_lit++,0===e?t.dyn_ltree[2*i]++:(t.matches++,e--,t.dyn_ltree[2*(h[i]+256+1)]++,t.dyn_dtree[2*w(e)]++),t.last_lit===t.lit_bufsize-1),_tr_align:t=>{b(t,2,3),y(t,256,a),(t=>{16===t.bi_valid?(g(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):t.bi_valid>=8&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)})(t)}},F=(t,e,i,n)=>{let r=65535&t|0,a=t>>>16&65535|0,s=0;for(;0!==i;){s=i>2e3?2e3:i,i-=s;do{r=r+e[n++]|0,a=a+r|0}while(--s);r%=65521,a%=65521}return r|a<<16|0};const B=new Uint32Array((()=>{let t,e=[];for(var i=0;i<256;i++){t=i;for(var n=0;n<8;n++)t=1&t?3988292384^t>>>1:t>>>1;e[i]=t}return e})());var N=(t,e,i,n)=>{const r=B,a=n+i;t^=-1;for(let i=n;i<a;i++)t=t>>>8^r[255&(t^e[i])];return-1^t},D={2:"need dictionary",1:"stream end",0:"","-1":"file error","-2":"stream error","-3":"data error","-4":"insufficient memory","-5":"buffer error","-6":"incompatible version"},Z={Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_TREES:6,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,Z_BINARY:0,Z_TEXT:1,Z_UNKNOWN:2,Z_DEFLATED:8};const{_tr_init:L,_tr_stored_block:O,_tr_flush_block:$,_tr_tally:V,_tr_align:M}=R,{Z_NO_FLUSH:H,Z_PARTIAL_FLUSH:P,Z_FULL_FLUSH:j,Z_FINISH:G,Z_BLOCK:K,Z_OK:Y,Z_STREAM_END:X,Z_STREAM_ERROR:q,Z_DATA_ERROR:W,Z_BUF_ERROR:J,Z_DEFAULT_COMPRESSION:Q,Z_FILTERED:tt,Z_HUFFMAN_ONLY:et,Z_RLE:it,Z_FIXED:nt,Z_DEFAULT_STRATEGY:rt,Z_UNKNOWN:at,Z_DEFLATED:st}=Z,ot=258,ht=262,lt=103,dt=113,ct=666,ft=(t,e)=>(t.msg=D[e],e),ut=t=>(t<<1)-(t>4?9:0),_t=t=>{let e=t.length;for(;--e>=0;)t[e]=0};let pt=(t,e,i)=>(e<<t.hash_shift^i)&t.hash_mask;const wt=t=>{const e=t.state;let i=e.pending;i>t.avail_out&&(i=t.avail_out),0!==i&&(t.output.set(e.pending_buf.subarray(e.pending_out,e.pending_out+i),t.next_out),t.next_out+=i,e.pending_out+=i,t.total_out+=i,t.avail_out-=i,e.pending-=i,0===e.pending&&(e.pending_out=0))},gt=(t,e)=>{$(t,t.block_start>=0?t.block_start:-1,t.strstart-t.block_start,e),t.block_start=t.strstart,wt(t.strm)},bt=(t,e)=>{t.pending_buf[t.pending++]=e},yt=(t,e)=>{t.pending_buf[t.pending++]=e>>>8&255,t.pending_buf[t.pending++]=255&e},mt=(t,e,i,n)=>{let r=t.avail_in;return r>n&&(r=n),0===r?0:(t.avail_in-=r,e.set(t.input.subarray(t.next_in,t.next_in+r),i),1===t.state.wrap?t.adler=F(t.adler,e,r,i):2===t.state.wrap&&(t.adler=N(t.adler,e,r,i)),t.next_in+=r,t.total_in+=r,r)},kt=(t,e)=>{let i,n,r=t.max_chain_length,a=t.strstart,s=t.prev_length,o=t.nice_match;const h=t.strstart>t.w_size-ht?t.strstart-(t.w_size-ht):0,l=t.window,d=t.w_mask,c=t.prev,f=t.strstart+ot;let u=l[a+s-1],_=l[a+s];t.prev_length>=t.good_match&&(r>>=2),o>t.lookahead&&(o=t.lookahead);do{if(i=e,l[i+s]===_&&l[i+s-1]===u&&l[i]===l[a]&&l[++i]===l[a+1]){a+=2,i++;do{}while(l[++a]===l[++i]&&l[++a]===l[++i]&&l[++a]===l[++i]&&l[++a]===l[++i]&&l[++a]===l[++i]&&l[++a]===l[++i]&&l[++a]===l[++i]&&l[++a]===l[++i]&&a<f);if(n=ot-(f-a),a=f-ot,n>s){if(t.match_start=e,s=n,n>=o)break;u=l[a+s-1],_=l[a+s]}}}while((e=c[e&d])>h&&0!=--r);return s<=t.lookahead?s:t.lookahead},vt=t=>{const e=t.w_size;let i,n,r,a,s;do{if(a=t.window_size-t.lookahead-t.strstart,t.strstart>=e+(e-ht)){t.window.set(t.window.subarray(e,e+e),0),t.match_start-=e,t.strstart-=e,t.block_start-=e,n=t.hash_size,i=n;do{r=t.head[--i],t.head[i]=r>=e?r-e:0}while(--n);n=e,i=n;do{r=t.prev[--i],t.prev[i]=r>=e?r-e:0}while(--n);a+=e}if(0===t.strm.avail_in)break;if(n=mt(t.strm,t.window,t.strstart+t.lookahead,a),t.lookahead+=n,t.lookahead+t.insert>=3)for(s=t.strstart-t.insert,t.ins_h=t.window[s],t.ins_h=pt(t,t.ins_h,t.window[s+1]);t.insert&&(t.ins_h=pt(t,t.ins_h,t.window[s+3-1]),t.prev[s&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=s,s++,t.insert--,!(t.lookahead+t.insert<3)););}while(t.lookahead<ht&&0!==t.strm.avail_in)},xt=(t,e)=>{let i,n;for(;;){if(t.lookahead<ht){if(vt(t),t.lookahead<ht&&e===H)return 1;if(0===t.lookahead)break}if(i=0,t.lookahead>=3&&(t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!==i&&t.strstart-i<=t.w_size-ht&&(t.match_length=kt(t,i)),t.match_length>=3)if(n=V(t,t.strstart-t.match_start,t.match_length-3),t.lookahead-=t.match_length,t.match_length<=t.max_lazy_match&&t.lookahead>=3){t.match_length--;do{t.strstart++,t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart}while(0!=--t.match_length);t.strstart++}else t.strstart+=t.match_length,t.match_length=0,t.ins_h=t.window[t.strstart],t.ins_h=pt(t,t.ins_h,t.window[t.strstart+1]);else n=V(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++;if(n&&(gt(t,!1),0===t.strm.avail_out))return 1}return t.insert=t.strstart<2?t.strstart:2,e===G?(gt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(gt(t,!1),0===t.strm.avail_out)?1:2},At=(t,e)=>{let i,n,r;for(;;){if(t.lookahead<ht){if(vt(t),t.lookahead<ht&&e===H)return 1;if(0===t.lookahead)break}if(i=0,t.lookahead>=3&&(t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),t.prev_length=t.match_length,t.prev_match=t.match_start,t.match_length=2,0!==i&&t.prev_length<t.max_lazy_match&&t.strstart-i<=t.w_size-ht&&(t.match_length=kt(t,i),t.match_length<=5&&(t.strategy===tt||3===t.match_length&&t.strstart-t.match_start>4096)&&(t.match_length=2)),t.prev_length>=3&&t.match_length<=t.prev_length){r=t.strstart+t.lookahead-3,n=V(t,t.strstart-1-t.prev_match,t.prev_length-3),t.lookahead-=t.prev_length-1,t.prev_length-=2;do{++t.strstart<=r&&(t.ins_h=pt(t,t.ins_h,t.window[t.strstart+3-1]),i=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart)}while(0!=--t.prev_length);if(t.match_available=0,t.match_length=2,t.strstart++,n&&(gt(t,!1),0===t.strm.avail_out))return 1}else if(t.match_available){if(n=V(t,0,t.window[t.strstart-1]),n&>(t,!1),t.strstart++,t.lookahead--,0===t.strm.avail_out)return 1}else t.match_available=1,t.strstart++,t.lookahead--}return t.match_available&&(n=V(t,0,t.window[t.strstart-1]),t.match_available=0),t.insert=t.strstart<2?t.strstart:2,e===G?(gt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(gt(t,!1),0===t.strm.avail_out)?1:2};function Ut(t,e,i,n,r){this.good_length=t,this.max_lazy=e,this.nice_length=i,this.max_chain=n,this.func=r}const Et=[new Ut(0,0,0,0,((t,e)=>{let i=65535;for(i>t.pending_buf_size-5&&(i=t.pending_buf_size-5);;){if(t.lookahead<=1){if(vt(t),0===t.lookahead&&e===H)return 1;if(0===t.lookahead)break}t.strstart+=t.lookahead,t.lookahead=0;const n=t.block_start+i;if((0===t.strstart||t.strstart>=n)&&(t.lookahead=t.strstart-n,t.strstart=n,gt(t,!1),0===t.strm.avail_out))return 1;if(t.strstart-t.block_start>=t.w_size-ht&&(gt(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===G?(gt(t,!0),0===t.strm.avail_out?3:4):(t.strstart>t.block_start&&(gt(t,!1),t.strm.avail_out),1)})),new Ut(4,4,8,4,xt),new Ut(4,5,16,8,xt),new Ut(4,6,32,32,xt),new Ut(4,4,16,16,At),new Ut(8,16,32,32,At),new Ut(8,16,128,128,At),new Ut(8,32,128,256,At),new Ut(32,128,258,1024,At),new Ut(32,258,258,4096,At)];function It(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=st,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new Uint16Array(1146),this.dyn_dtree=new Uint16Array(122),this.bl_tree=new Uint16Array(78),_t(this.dyn_ltree),_t(this.dyn_dtree),_t(this.bl_tree),this.l_desc=null,this.d_desc=null,this.bl_desc=null,this.bl_count=new Uint16Array(16),this.heap=new Uint16Array(573),_t(this.heap),this.heap_len=0,this.heap_max=0,this.depth=new Uint16Array(573),_t(this.depth),this.l_buf=0,this.lit_bufsize=0,this.last_lit=0,this.d_buf=0,this.opt_len=0,this.static_len=0,this.matches=0,this.insert=0,this.bi_buf=0,this.bi_valid=0}const St=t=>{if(!t||!t.state)return ft(t,q);t.total_in=t.total_out=0,t.data_type=at;const e=t.state;return e.pending=0,e.pending_out=0,e.wrap<0&&(e.wrap=-e.wrap),e.status=e.wrap?42:dt,t.adler=2===e.wrap?0:1,e.last_flush=H,L(e),Y},zt=t=>{const e=St(t);var i;return e===Y&&((i=t.state).window_size=2*i.w_size,_t(i.head),i.max_lazy_match=Et[i.level].max_lazy,i.good_match=Et[i.level].good_length,i.nice_match=Et[i.level].nice_length,i.max_chain_length=Et[i.level].max_chain,i.strstart=0,i.block_start=0,i.lookahead=0,i.insert=0,i.match_length=i.prev_length=2,i.match_available=0,i.ins_h=0),e},Ct=(t,e,i,n,r,a)=>{if(!t)return q;let s=1;if(e===Q&&(e=6),n<0?(s=0,n=-n):n>15&&(s=2,n-=16),r<1||r>9||i!==st||n<8||n>15||e<0||e>9||a<0||a>nt)return ft(t,q);8===n&&(n=9);const o=new It;return t.state=o,o.strm=t,o.wrap=s,o.gzhead=null,o.w_bits=n,o.w_size=1<<o.w_bits,o.w_mask=o.w_size-1,o.hash_bits=r+7,o.hash_size=1<<o.hash_bits,o.hash_mask=o.hash_size-1,o.hash_shift=~~((o.hash_bits+3-1)/3),o.window=new Uint8Array(2*o.w_size),o.head=new Uint16Array(o.hash_size),o.prev=new Uint16Array(o.w_size),o.lit_bufsize=1<<r+6,o.pending_buf_size=4*o.lit_bufsize,o.pending_buf=new Uint8Array(o.pending_buf_size),o.d_buf=1*o.lit_bufsize,o.l_buf=3*o.lit_bufsize,o.level=e,o.strategy=a,o.method=i,zt(t)};var Tt={deflateInit:(t,e)=>Ct(t,e,st,15,8,rt),deflateInit2:Ct,deflateReset:zt,deflateResetKeep:St,deflateSetHeader:(t,e)=>t&&t.state?2!==t.state.wrap?q:(t.state.gzhead=e,Y):q,deflate:(t,e)=>{let i,n;if(!t||!t.state||e>K||e<0)return t?ft(t,q):q;const r=t.state;if(!t.output||!t.input&&0!==t.avail_in||r.status===ct&&e!==G)return ft(t,0===t.avail_out?J:q);r.strm=t;const a=r.last_flush;if(r.last_flush=e,42===r.status)if(2===r.wrap)t.adler=0,bt(r,31),bt(r,139),bt(r,8),r.gzhead?(bt(r,(r.gzhead.text?1:0)+(r.gzhead.hcrc?2:0)+(r.gzhead.extra?4:0)+(r.gzhead.name?8:0)+(r.gzhead.comment?16:0)),bt(r,255&r.gzhead.time),bt(r,r.gzhead.time>>8&255),bt(r,r.gzhead.time>>16&255),bt(r,r.gzhead.time>>24&255),bt(r,9===r.level?2:r.strategy>=et||r.level<2?4:0),bt(r,255&r.gzhead.os),r.gzhead.extra&&r.gzhead.extra.length&&(bt(r,255&r.gzhead.extra.length),bt(r,r.gzhead.extra.length>>8&255)),r.gzhead.hcrc&&(t.adler=N(t.adler,r.pending_buf,r.pending,0)),r.gzindex=0,r.status=69):(bt(r,0),bt(r,0),bt(r,0),bt(r,0),bt(r,0),bt(r,9===r.level?2:r.strategy>=et||r.level<2?4:0),bt(r,3),r.status=dt);else{let e=st+(r.w_bits-8<<4)<<8,i=-1;i=r.strategy>=et||r.level<2?0:r.level<6?1:6===r.level?2:3,e|=i<<6,0!==r.strstart&&(e|=32),e+=31-e%31,r.status=dt,yt(r,e),0!==r.strstart&&(yt(r,t.adler>>>16),yt(r,65535&t.adler)),t.adler=1}if(69===r.status)if(r.gzhead.extra){for(i=r.pending;r.gzindex<(65535&r.gzhead.extra.length)&&(r.pending!==r.pending_buf_size||(r.gzhead.hcrc&&r.pending>i&&(t.adler=N(t.adler,r.pending_buf,r.pending-i,i)),wt(t),i=r.pending,r.pending!==r.pending_buf_size));)bt(r,255&r.gzhead.extra[r.gzindex]),r.gzindex++;r.gzhead.hcrc&&r.pending>i&&(t.adler=N(t.adler,r.pending_buf,r.pending-i,i)),r.gzindex===r.gzhead.extra.length&&(r.gzindex=0,r.status=73)}else r.status=73;if(73===r.status)if(r.gzhead.name){i=r.pending;do{if(r.pending===r.pending_buf_size&&(r.gzhead.hcrc&&r.pending>i&&(t.adler=N(t.adler,r.pending_buf,r.pending-i,i)),wt(t),i=r.pending,r.pending===r.pending_buf_size)){n=1;break}n=r.gzindex<r.gzhead.name.length?255&r.gzhead.name.charCodeAt(r.gzindex++):0,bt(r,n)}while(0!==n);r.gzhead.hcrc&&r.pending>i&&(t.adler=N(t.adler,r.pending_buf,r.pending-i,i)),0===n&&(r.gzindex=0,r.status=91)}else r.status=91;if(91===r.status)if(r.gzhead.comment){i=r.pending;do{if(r.pending===r.pending_buf_size&&(r.gzhead.hcrc&&r.pending>i&&(t.adler=N(t.adler,r.pending_buf,r.pending-i,i)),wt(t),i=r.pending,r.pending===r.pending_buf_size)){n=1;break}n=r.gzindex<r.gzhead.comment.length?255&r.gzhead.comment.charCodeAt(r.gzindex++):0,bt(r,n)}while(0!==n);r.gzhead.hcrc&&r.pending>i&&(t.adler=N(t.adler,r.pending_buf,r.pending-i,i)),0===n&&(r.status=lt)}else r.status=lt;if(r.status===lt&&(r.gzhead.hcrc?(r.pending+2>r.pending_buf_size&&wt(t),r.pending+2<=r.pending_buf_size&&(bt(r,255&t.adler),bt(r,t.adler>>8&255),t.adler=0,r.status=dt)):r.status=dt),0!==r.pending){if(wt(t),0===t.avail_out)return r.last_flush=-1,Y}else if(0===t.avail_in&&ut(e)<=ut(a)&&e!==G)return ft(t,J);if(r.status===ct&&0!==t.avail_in)return ft(t,J);if(0!==t.avail_in||0!==r.lookahead||e!==H&&r.status!==ct){let i=r.strategy===et?((t,e)=>{let i;for(;;){if(0===t.lookahead&&(vt(t),0===t.lookahead)){if(e===H)return 1;break}if(t.match_length=0,i=V(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++,i&&(gt(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===G?(gt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(gt(t,!1),0===t.strm.avail_out)?1:2})(r,e):r.strategy===it?((t,e)=>{let i,n,r,a;const s=t.window;for(;;){if(t.lookahead<=ot){if(vt(t),t.lookahead<=ot&&e===H)return 1;if(0===t.lookahead)break}if(t.match_length=0,t.lookahead>=3&&t.strstart>0&&(r=t.strstart-1,n=s[r],n===s[++r]&&n===s[++r]&&n===s[++r])){a=t.strstart+ot;do{}while(n===s[++r]&&n===s[++r]&&n===s[++r]&&n===s[++r]&&n===s[++r]&&n===s[++r]&&n===s[++r]&&n===s[++r]&&r<a);t.match_length=ot-(a-r),t.match_length>t.lookahead&&(t.match_length=t.lookahead)}if(t.match_length>=3?(i=V(t,1,t.match_length-3),t.lookahead-=t.match_length,t.strstart+=t.match_length,t.match_length=0):(i=V(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++),i&&(gt(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===G?(gt(t,!0),0===t.strm.avail_out?3:4):t.last_lit&&(gt(t,!1),0===t.strm.avail_out)?1:2})(r,e):Et[r.level].func(r,e);if(3!==i&&4!==i||(r.status=ct),1===i||3===i)return 0===t.avail_out&&(r.last_flush=-1),Y;if(2===i&&(e===P?M(r):e!==K&&(O(r,0,0,!1),e===j&&(_t(r.head),0===r.lookahead&&(r.strstart=0,r.block_start=0,r.insert=0))),wt(t),0===t.avail_out))return r.last_flush=-1,Y}return e!==G?Y:r.wrap<=0?X:(2===r.wrap?(bt(r,255&t.adler),bt(r,t.adler>>8&255),bt(r,t.adler>>16&255),bt(r,t.adler>>24&255),bt(r,255&t.total_in),bt(r,t.total_in>>8&255),bt(r,t.total_in>>16&255),bt(r,t.total_in>>24&255)):(yt(r,t.adler>>>16),yt(r,65535&t.adler)),wt(t),r.wrap>0&&(r.wrap=-r.wrap),0!==r.pending?Y:X)},deflateEnd:t=>{if(!t||!t.state)return q;const e=t.state.status;return 42!==e&&69!==e&&73!==e&&91!==e&&e!==lt&&e!==dt&&e!==ct?ft(t,q):(t.state=null,e===dt?ft(t,W):Y)},deflateSetDictionary:(t,e)=>{let i=e.length;if(!t||!t.state)return q;const n=t.state,r=n.wrap;if(2===r||1===r&&42!==n.status||n.lookahead)return q;if(1===r&&(t.adler=F(t.adler,e,i,0)),n.wrap=0,i>=n.w_size){0===r&&(_t(n.head),n.strstart=0,n.block_start=0,n.insert=0);let t=new Uint8Array(n.w_size);t.set(e.subarray(i-n.w_size,i),0),e=t,i=n.w_size}const a=t.avail_in,s=t.next_in,o=t.input;for(t.avail_in=i,t.next_in=0,t.input=e,vt(n);n.lookahead>=3;){let t=n.strstart,e=n.lookahead-2;do{n.ins_h=pt(n,n.ins_h,n.window[t+3-1]),n.prev[t&n.w_mask]=n.head[n.ins_h],n.head[n.ins_h]=t,t++}while(--e);n.strstart=t,n.lookahead=2,vt(n)}return n.strstart+=n.lookahead,n.block_start=n.strstart,n.insert=n.lookahead,n.lookahead=0,n.match_length=n.prev_length=2,n.match_available=0,t.next_in=s,t.input=o,t.avail_in=a,n.wrap=r,Y},deflateInfo:"pako deflate (from Nodeca project)"};const Rt=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var Ft={assign:function(t){const e=Array.prototype.slice.call(arguments,1);for(;e.length;){const i=e.shift();if(i){if("object"!=typeof i)throw new TypeError(i+"must be non-object");for(const e in i)Rt(i,e)&&(t[e]=i[e])}}return t},flattenChunks:t=>{let e=0;for(let i=0,n=t.length;i<n;i++)e+=t[i].length;const i=new Uint8Array(e);for(let e=0,n=0,r=t.length;e<r;e++){let r=t[e];i.set(r,n),n+=r.length}return i}};let Bt=!0;try{String.fromCharCode.apply(null,new Uint8Array(1))}catch(t){Bt=!1}const Nt=new Uint8Array(256);for(let t=0;t<256;t++)Nt[t]=t>=252?6:t>=248?5:t>=240?4:t>=224?3:t>=192?2:1;Nt[254]=Nt[254]=1;var Dt={string2buf:t=>{let e,i,n,r,a,s=t.length,o=0;for(r=0;r<s;r++)i=t.charCodeAt(r),55296==(64512&i)&&r+1<s&&(n=t.charCodeAt(r+1),56320==(64512&n)&&(i=65536+(i-55296<<10)+(n-56320),r++)),o+=i<128?1:i<2048?2:i<65536?3:4;for(e=new Uint8Array(o),a=0,r=0;a<o;r++)i=t.charCodeAt(r),55296==(64512&i)&&r+1<s&&(n=t.charCodeAt(r+1),56320==(64512&n)&&(i=65536+(i-55296<<10)+(n-56320),r++)),i<128?e[a++]=i:i<2048?(e[a++]=192|i>>>6,e[a++]=128|63&i):i<65536?(e[a++]=224|i>>>12,e[a++]=128|i>>>6&63,e[a++]=128|63&i):(e[a++]=240|i>>>18,e[a++]=128|i>>>12&63,e[a++]=128|i>>>6&63,e[a++]=128|63&i);return e},buf2string:(t,e)=>{let i,n;const r=e||t.length,a=new Array(2*r);for(n=0,i=0;i<r;){let e=t[i++];if(e<128){a[n++]=e;continue}let s=Nt[e];if(s>4)a[n++]=65533,i+=s-1;else{for(e&=2===s?31:3===s?15:7;s>1&&i<r;)e=e<<6|63&t[i++],s--;s>1?a[n++]=65533:e<65536?a[n++]=e:(e-=65536,a[n++]=55296|e>>10&1023,a[n++]=56320|1023&e)}}return((t,e)=>{if(e<65534&&t.subarray&&Bt)return String.fromCharCode.apply(null,t.length===e?t:t.subarray(0,e));let i="";for(let n=0;n<e;n++)i+=String.fromCharCode(t[n]);return i})(a,n)},utf8border:(t,e)=>{(e=e||t.length)>t.length&&(e=t.length);let i=e-1;for(;i>=0&&128==(192&t[i]);)i--;return i<0||0===i?e:i+Nt[t[i]]>e?i:e}},Zt=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0};const Lt=Object.prototype.toString,{Z_NO_FLUSH:Ot,Z_SYNC_FLUSH:$t,Z_FULL_FLUSH:Vt,Z_FINISH:Mt,Z_OK:Ht,Z_STREAM_END:Pt,Z_DEFAULT_COMPRESSION:jt,Z_DEFAULT_STRATEGY:Gt,Z_DEFLATED:Kt}=Z;function Yt(t){this.options=Ft.assign({level:jt,method:Kt,chunkSize:16384,windowBits:15,memLevel:8,strategy:Gt},t||{});let e=this.options;e.raw&&e.windowBits>0?e.windowBits=-e.windowBits:e.gzip&&e.windowBits>0&&e.windowBits<16&&(e.windowBits+=16),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Zt,this.strm.avail_out=0;let i=Tt.deflateInit2(this.strm,e.level,e.method,e.windowBits,e.memLevel,e.strategy);if(i!==Ht)throw new Error(D[i]);if(e.header&&Tt.deflateSetHeader(this.strm,e.header),e.dictionary){let t;if(t="string"==typeof e.dictionary?Dt.string2buf(e.dictionary):"[object ArrayBuffer]"===Lt.call(e.dictionary)?new Uint8Array(e.dictionary):e.dictionary,i=Tt.deflateSetDictionary(this.strm,t),i!==Ht)throw new Error(D[i]);this._dict_set=!0}}function Xt(t,e){const i=new Yt(e);if(i.push(t,!0),i.err)throw i.msg||D[i.err];return i.result}Yt.prototype.push=function(t,e){const i=this.strm,n=this.options.chunkSize;let r,a;if(this.ended)return!1;for(a=e===~~e?e:!0===e?Mt:Ot,"string"==typeof t?i.input=Dt.string2buf(t):"[object ArrayBuffer]"===Lt.call(t)?i.input=new Uint8Array(t):i.input=t,i.next_in=0,i.avail_in=i.input.length;;)if(0===i.avail_out&&(i.output=new Uint8Array(n),i.next_out=0,i.avail_out=n),(a===$t||a===Vt)&&i.avail_out<=6)this.onData(i.output.subarray(0,i.next_out)),i.avail_out=0;else{if(r=Tt.deflate(i,a),r===Pt)return i.next_out>0&&this.onData(i.output.subarray(0,i.next_out)),r=Tt.deflateEnd(this.strm),this.onEnd(r),this.ended=!0,r===Ht;if(0!==i.avail_out){if(a>0&&i.next_out>0)this.onData(i.output.subarray(0,i.next_out)),i.avail_out=0;else if(0===i.avail_in)break}else this.onData(i.output)}return!0},Yt.prototype.onData=function(t){this.chunks.push(t)},Yt.prototype.onEnd=function(t){t===Ht&&(this.result=Ft.flattenChunks(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};var qt={Deflate:Yt,deflate:Xt,deflateRaw:function(t,e){return(e=e||{}).raw=!0,Xt(t,e)},gzip:function(t,e){return(e=e||{}).gzip=!0,Xt(t,e)},constants:Z},Wt=function(t,e){let i,n,r,a,s,o,h,l,d,c,f,u,_,p,w,g,b,y,m,k,v,x,A,U;const E=t.state;i=t.next_in,A=t.input,n=i+(t.avail_in-5),r=t.next_out,U=t.output,a=r-(e-t.avail_out),s=r+(t.avail_out-257),o=E.dmax,h=E.wsize,l=E.whave,d=E.wnext,c=E.window,f=E.hold,u=E.bits,_=E.lencode,p=E.distcode,w=(1<<E.lenbits)-1,g=(1<<E.distbits)-1;t:do{u<15&&(f+=A[i++]<<u,u+=8,f+=A[i++]<<u,u+=8),b=_[f&w];e:for(;;){if(y=b>>>24,f>>>=y,u-=y,y=b>>>16&255,0===y)U[r++]=65535&b;else{if(!(16&y)){if(0==(64&y)){b=_[(65535&b)+(f&(1<<y)-1)];continue e}if(32&y){E.mode=12;break t}t.msg="invalid literal/length code",E.mode=30;break t}m=65535&b,y&=15,y&&(u<y&&(f+=A[i++]<<u,u+=8),m+=f&(1<<y)-1,f>>>=y,u-=y),u<15&&(f+=A[i++]<<u,u+=8,f+=A[i++]<<u,u+=8),b=p[f&g];i:for(;;){if(y=b>>>24,f>>>=y,u-=y,y=b>>>16&255,!(16&y)){if(0==(64&y)){b=p[(65535&b)+(f&(1<<y)-1)];continue i}t.msg="invalid distance code",E.mode=30;break t}if(k=65535&b,y&=15,u<y&&(f+=A[i++]<<u,u+=8,u<y&&(f+=A[i++]<<u,u+=8)),k+=f&(1<<y)-1,k>o){t.msg="invalid distance too far back",E.mode=30;break t}if(f>>>=y,u-=y,y=r-a,k>y){if(y=k-y,y>l&&E.sane){t.msg="invalid distance too far back",E.mode=30;break t}if(v=0,x=c,0===d){if(v+=h-y,y<m){m-=y;do{U[r++]=c[v++]}while(--y);v=r-k,x=U}}else if(d<y){if(v+=h+d-y,y-=d,y<m){m-=y;do{U[r++]=c[v++]}while(--y);if(v=0,d<m){y=d,m-=y;do{U[r++]=c[v++]}while(--y);v=r-k,x=U}}}else if(v+=d-y,y<m){m-=y;do{U[r++]=c[v++]}while(--y);v=r-k,x=U}for(;m>2;)U[r++]=x[v++],U[r++]=x[v++],U[r++]=x[v++],m-=3;m&&(U[r++]=x[v++],m>1&&(U[r++]=x[v++]))}else{v=r-k;do{U[r++]=U[v++],U[r++]=U[v++],U[r++]=U[v++],m-=3}while(m>2);m&&(U[r++]=U[v++],m>1&&(U[r++]=U[v++]))}break}}break}}while(i<n&&r<s);m=u>>3,i-=m,u-=m<<3,f&=(1<<u)-1,t.next_in=i,t.next_out=r,t.avail_in=i<n?n-i+5:5-(i-n),t.avail_out=r<s?s-r+257:257-(r-s),E.hold=f,E.bits=u};const Jt=new Uint16Array([3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0]),Qt=new Uint8Array([16,16,16,16,16,16,16,16,17,17,17,17,18,18,18,18,19,19,19,19,20,20,20,20,21,21,21,21,16,72,78]),te=new Uint16Array([1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,0,0]),ee=new Uint8Array([16,16,16,16,17,17,18,18,19,19,20,20,21,21,22,22,23,23,24,24,25,25,26,26,27,27,28,28,29,29,64,64]);var ie=(t,e,i,n,r,a,s,o)=>{const h=o.bits;let l,d,c,f,u,_,p=0,w=0,g=0,b=0,y=0,m=0,k=0,v=0,x=0,A=0,U=null,E=0;const I=new Uint16Array(16),S=new Uint16Array(16);let z,C,T,R=null,F=0;for(p=0;p<=15;p++)I[p]=0;for(w=0;w<n;w++)I[e[i+w]]++;for(y=h,b=15;b>=1&&0===I[b];b--);if(y>b&&(y=b),0===b)return r[a++]=20971520,r[a++]=20971520,o.bits=1,0;for(g=1;g<b&&0===I[g];g++);for(y<g&&(y=g),v=1,p=1;p<=15;p++)if(v<<=1,v-=I[p],v<0)return-1;if(v>0&&(0===t||1!==b))return-1;for(S[1]=0,p=1;p<15;p++)S[p+1]=S[p]+I[p];for(w=0;w<n;w++)0!==e[i+w]&&(s[S[e[i+w]]++]=w);if(0===t?(U=R=s,_=19):1===t?(U=Jt,E-=257,R=Qt,F-=257,_=256):(U=te,R=ee,_=-1),A=0,w=0,p=g,u=a,m=y,k=0,c=-1,x=1<<y,f=x-1,1===t&&x>852||2===t&&x>592)return 1;for(;;){z=p-k,s[w]<_?(C=0,T=s[w]):s[w]>_?(C=R[F+s[w]],T=U[E+s[w]]):(C=96,T=0),l=1<<p-k,d=1<<m,g=d;do{d-=l,r[u+(A>>k)+d]=z<<24|C<<16|T|0}while(0!==d);for(l=1<<p-1;A&l;)l>>=1;if(0!==l?(A&=l-1,A+=l):A=0,w++,0==--I[p]){if(p===b)break;p=e[i+s[w]]}if(p>y&&(A&f)!==c){for(0===k&&(k=y),u+=g,m=p-k,v=1<<m;m+k<b&&(v-=I[m+k],!(v<=0));)m++,v<<=1;if(x+=1<<m,1===t&&x>852||2===t&&x>592)return 1;c=A&f,r[c]=y<<24|m<<16|u-a|0}}return 0!==A&&(r[u+A]=p-k<<24|64<<16|0),o.bits=y,0};const{Z_FINISH:ne,Z_BLOCK:re,Z_TREES:ae,Z_OK:se,Z_STREAM_END:oe,Z_NEED_DICT:he,Z_STREAM_ERROR:le,Z_DATA_ERROR:de,Z_MEM_ERROR:ce,Z_BUF_ERROR:fe,Z_DEFLATED:ue}=Z,_e=12,pe=30,we=t=>(t>>>24&255)+(t>>>8&65280)+((65280&t)<<8)+((255&t)<<24);function ge(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}const be=t=>{if(!t||!t.state)return le;const e=t.state;return t.total_in=t.total_out=e.total=0,t.msg="",e.wrap&&(t.adler=1&e.wrap),e.mode=1,e.last=0,e.havedict=0,e.dmax=32768,e.head=null,e.hold=0,e.bits=0,e.lencode=e.lendyn=new Int32Array(852),e.distcode=e.distdyn=new Int32Array(592),e.sane=1,e.back=-1,se},ye=t=>{if(!t||!t.state)return le;const e=t.state;return e.wsize=0,e.whave=0,e.wnext=0,be(t)},me=(t,e)=>{let i;if(!t||!t.state)return le;const n=t.state;return e<0?(i=0,e=-e):(i=1+(e>>4),e<48&&(e&=15)),e&&(e<8||e>15)?le:(null!==n.window&&n.wbits!==e&&(n.window=null),n.wrap=i,n.wbits=e,ye(t))},ke=(t,e)=>{if(!t)return le;const i=new ge;t.state=i,i.window=null;const n=me(t,e);return n!==se&&(t.state=null),n};let ve,xe,Ae=!0;const Ue=t=>{if(Ae){ve=new Int32Array(512),xe=new Int32Array(32);let e=0;for(;e<144;)t.lens[e++]=8;for(;e<256;)t.lens[e++]=9;for(;e<280;)t.lens[e++]=7;for(;e<288;)t.lens[e++]=8;for(ie(1,t.lens,0,288,ve,0,t.work,{bits:9}),e=0;e<32;)t.lens[e++]=5;ie(2,t.lens,0,32,xe,0,t.work,{bits:5}),Ae=!1}t.lencode=ve,t.lenbits=9,t.distcode=xe,t.distbits=5},Ee=(t,e,i,n)=>{let r;const a=t.state;return null===a.window&&(a.wsize=1<<a.wbits,a.wnext=0,a.whave=0,a.window=new Uint8Array(a.wsize)),n>=a.wsize?(a.window.set(e.subarray(i-a.wsize,i),0),a.wnext=0,a.whave=a.wsize):(r=a.wsize-a.wnext,r>n&&(r=n),a.window.set(e.subarray(i-n,i-n+r),a.wnext),(n-=r)?(a.window.set(e.subarray(i-n,i),0),a.wnext=n,a.whave=a.wsize):(a.wnext+=r,a.wnext===a.wsize&&(a.wnext=0),a.whave<a.wsize&&(a.whave+=r))),0};var Ie={inflateReset:ye,inflateReset2:me,inflateResetKeep:be,inflateInit:t=>ke(t,15),inflateInit2:ke,inflate:(t,e)=>{let i,n,r,a,s,o,h,l,d,c,f,u,_,p,w,g,b,y,m,k,v,x,A=0;const U=new Uint8Array(4);let E,I;const S=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(!t||!t.state||!t.output||!t.input&&0!==t.avail_in)return le;i=t.state,i.mode===_e&&(i.mode=13),s=t.next_out,r=t.output,h=t.avail_out,a=t.next_in,n=t.input,o=t.avail_in,l=i.hold,d=i.bits,c=o,f=h,x=se;t:for(;;)switch(i.mode){case 1:if(0===i.wrap){i.mode=13;break}for(;d<16;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(2&i.wrap&&35615===l){i.check=0,U[0]=255&l,U[1]=l>>>8&255,i.check=N(i.check,U,2,0),l=0,d=0,i.mode=2;break}if(i.flags=0,i.head&&(i.head.done=!1),!(1&i.wrap)||(((255&l)<<8)+(l>>8))%31){t.msg="incorrect header check",i.mode=pe;break}if((15&l)!==ue){t.msg="unknown compression method",i.mode=pe;break}if(l>>>=4,d-=4,v=8+(15&l),0===i.wbits)i.wbits=v;else if(v>i.wbits){t.msg="invalid window size",i.mode=pe;break}i.dmax=1<<i.wbits,t.adler=i.check=1,i.mode=512&l?10:_e,l=0,d=0;break;case 2:for(;d<16;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(i.flags=l,(255&i.flags)!==ue){t.msg="unknown compression method",i.mode=pe;break}if(57344&i.flags){t.msg="unknown header flags set",i.mode=pe;break}i.head&&(i.head.text=l>>8&1),512&i.flags&&(U[0]=255&l,U[1]=l>>>8&255,i.check=N(i.check,U,2,0)),l=0,d=0,i.mode=3;case 3:for(;d<32;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}i.head&&(i.head.time=l),512&i.flags&&(U[0]=255&l,U[1]=l>>>8&255,U[2]=l>>>16&255,U[3]=l>>>24&255,i.check=N(i.check,U,4,0)),l=0,d=0,i.mode=4;case 4:for(;d<16;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}i.head&&(i.head.xflags=255&l,i.head.os=l>>8),512&i.flags&&(U[0]=255&l,U[1]=l>>>8&255,i.check=N(i.check,U,2,0)),l=0,d=0,i.mode=5;case 5:if(1024&i.flags){for(;d<16;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}i.length=l,i.head&&(i.head.extra_len=l),512&i.flags&&(U[0]=255&l,U[1]=l>>>8&255,i.check=N(i.check,U,2,0)),l=0,d=0}else i.head&&(i.head.extra=null);i.mode=6;case 6:if(1024&i.flags&&(u=i.length,u>o&&(u=o),u&&(i.head&&(v=i.head.extra_len-i.length,i.head.extra||(i.head.extra=new Uint8Array(i.head.extra_len)),i.head.extra.set(n.subarray(a,a+u),v)),512&i.flags&&(i.check=N(i.check,n,u,a)),o-=u,a+=u,i.length-=u),i.length))break t;i.length=0,i.mode=7;case 7:if(2048&i.flags){if(0===o)break t;u=0;do{v=n[a+u++],i.head&&v&&i.length<65536&&(i.head.name+=String.fromCharCode(v))}while(v&&u<o);if(512&i.flags&&(i.check=N(i.check,n,u,a)),o-=u,a+=u,v)break t}else i.head&&(i.head.name=null);i.length=0,i.mode=8;case 8:if(4096&i.flags){if(0===o)break t;u=0;do{v=n[a+u++],i.head&&v&&i.length<65536&&(i.head.comment+=String.fromCharCode(v))}while(v&&u<o);if(512&i.flags&&(i.check=N(i.check,n,u,a)),o-=u,a+=u,v)break t}else i.head&&(i.head.comment=null);i.mode=9;case 9:if(512&i.flags){for(;d<16;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(l!==(65535&i.check)){t.msg="header crc mismatch",i.mode=pe;break}l=0,d=0}i.head&&(i.head.hcrc=i.flags>>9&1,i.head.done=!0),t.adler=i.check=0,i.mode=_e;break;case 10:for(;d<32;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}t.adler=i.check=we(l),l=0,d=0,i.mode=11;case 11:if(0===i.havedict)return t.next_out=s,t.avail_out=h,t.next_in=a,t.avail_in=o,i.hold=l,i.bits=d,he;t.adler=i.check=1,i.mode=_e;case _e:if(e===re||e===ae)break t;case 13:if(i.last){l>>>=7&d,d-=7&d,i.mode=27;break}for(;d<3;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}switch(i.last=1&l,l>>>=1,d-=1,3&l){case 0:i.mode=14;break;case 1:if(Ue(i),i.mode=20,e===ae){l>>>=2,d-=2;break t}break;case 2:i.mode=17;break;case 3:t.msg="invalid block type",i.mode=pe}l>>>=2,d-=2;break;case 14:for(l>>>=7&d,d-=7&d;d<32;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if((65535&l)!=(l>>>16^65535)){t.msg="invalid stored block lengths",i.mode=pe;break}if(i.length=65535&l,l=0,d=0,i.mode=15,e===ae)break t;case 15:i.mode=16;case 16:if(u=i.length,u){if(u>o&&(u=o),u>h&&(u=h),0===u)break t;r.set(n.subarray(a,a+u),s),o-=u,a+=u,h-=u,s+=u,i.length-=u;break}i.mode=_e;break;case 17:for(;d<14;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(i.nlen=257+(31&l),l>>>=5,d-=5,i.ndist=1+(31&l),l>>>=5,d-=5,i.ncode=4+(15&l),l>>>=4,d-=4,i.nlen>286||i.ndist>30){t.msg="too many length or distance symbols",i.mode=pe;break}i.have=0,i.mode=18;case 18:for(;i.have<i.ncode;){for(;d<3;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}i.lens[S[i.have++]]=7&l,l>>>=3,d-=3}for(;i.have<19;)i.lens[S[i.have++]]=0;if(i.lencode=i.lendyn,i.lenbits=7,E={bits:i.lenbits},x=ie(0,i.lens,0,19,i.lencode,0,i.work,E),i.lenbits=E.bits,x){t.msg="invalid code lengths set",i.mode=pe;break}i.have=0,i.mode=19;case 19:for(;i.have<i.nlen+i.ndist;){for(;A=i.lencode[l&(1<<i.lenbits)-1],w=A>>>24,g=A>>>16&255,b=65535&A,!(w<=d);){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(b<16)l>>>=w,d-=w,i.lens[i.have++]=b;else{if(16===b){for(I=w+2;d<I;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(l>>>=w,d-=w,0===i.have){t.msg="invalid bit length repeat",i.mode=pe;break}v=i.lens[i.have-1],u=3+(3&l),l>>>=2,d-=2}else if(17===b){for(I=w+3;d<I;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}l>>>=w,d-=w,v=0,u=3+(7&l),l>>>=3,d-=3}else{for(I=w+7;d<I;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}l>>>=w,d-=w,v=0,u=11+(127&l),l>>>=7,d-=7}if(i.have+u>i.nlen+i.ndist){t.msg="invalid bit length repeat",i.mode=pe;break}for(;u--;)i.lens[i.have++]=v}}if(i.mode===pe)break;if(0===i.lens[256]){t.msg="invalid code -- missing end-of-block",i.mode=pe;break}if(i.lenbits=9,E={bits:i.lenbits},x=ie(1,i.lens,0,i.nlen,i.lencode,0,i.work,E),i.lenbits=E.bits,x){t.msg="invalid literal/lengths set",i.mode=pe;break}if(i.distbits=6,i.distcode=i.distdyn,E={bits:i.distbits},x=ie(2,i.lens,i.nlen,i.ndist,i.distcode,0,i.work,E),i.distbits=E.bits,x){t.msg="invalid distances set",i.mode=pe;break}if(i.mode=20,e===ae)break t;case 20:i.mode=21;case 21:if(o>=6&&h>=258){t.next_out=s,t.avail_out=h,t.next_in=a,t.avail_in=o,i.hold=l,i.bits=d,Wt(t,f),s=t.next_out,r=t.output,h=t.avail_out,a=t.next_in,n=t.input,o=t.avail_in,l=i.hold,d=i.bits,i.mode===_e&&(i.back=-1);break}for(i.back=0;A=i.lencode[l&(1<<i.lenbits)-1],w=A>>>24,g=A>>>16&255,b=65535&A,!(w<=d);){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(g&&0==(240&g)){for(y=w,m=g,k=b;A=i.lencode[k+((l&(1<<y+m)-1)>>y)],w=A>>>24,g=A>>>16&255,b=65535&A,!(y+w<=d);){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}l>>>=y,d-=y,i.back+=y}if(l>>>=w,d-=w,i.back+=w,i.length=b,0===g){i.mode=26;break}if(32&g){i.back=-1,i.mode=_e;break}if(64&g){t.msg="invalid literal/length code",i.mode=pe;break}i.extra=15&g,i.mode=22;case 22:if(i.extra){for(I=i.extra;d<I;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}i.length+=l&(1<<i.extra)-1,l>>>=i.extra,d-=i.extra,i.back+=i.extra}i.was=i.length,i.mode=23;case 23:for(;A=i.distcode[l&(1<<i.distbits)-1],w=A>>>24,g=A>>>16&255,b=65535&A,!(w<=d);){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(0==(240&g)){for(y=w,m=g,k=b;A=i.distcode[k+((l&(1<<y+m)-1)>>y)],w=A>>>24,g=A>>>16&255,b=65535&A,!(y+w<=d);){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}l>>>=y,d-=y,i.back+=y}if(l>>>=w,d-=w,i.back+=w,64&g){t.msg="invalid distance code",i.mode=pe;break}i.offset=b,i.extra=15&g,i.mode=24;case 24:if(i.extra){for(I=i.extra;d<I;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}i.offset+=l&(1<<i.extra)-1,l>>>=i.extra,d-=i.extra,i.back+=i.extra}if(i.offset>i.dmax){t.msg="invalid distance too far back",i.mode=pe;break}i.mode=25;case 25:if(0===h)break t;if(u=f-h,i.offset>u){if(u=i.offset-u,u>i.whave&&i.sane){t.msg="invalid distance too far back",i.mode=pe;break}u>i.wnext?(u-=i.wnext,_=i.wsize-u):_=i.wnext-u,u>i.length&&(u=i.length),p=i.window}else p=r,_=s-i.offset,u=i.length;u>h&&(u=h),h-=u,i.length-=u;do{r[s++]=p[_++]}while(--u);0===i.length&&(i.mode=21);break;case 26:if(0===h)break t;r[s++]=i.length,h--,i.mode=21;break;case 27:if(i.wrap){for(;d<32;){if(0===o)break t;o--,l|=n[a++]<<d,d+=8}if(f-=h,t.total_out+=f,i.total+=f,f&&(t.adler=i.check=i.flags?N(i.check,r,f,s-f):F(i.check,r,f,s-f)),f=h,(i.flags?l:we(l))!==i.check){t.msg="incorrect data check",i.mode=pe;break}l=0,d=0}i.mode=28;case 28:if(i.wrap&&i.flags){for(;d<32;){if(0===o)break t;o--,l+=n[a++]<<d,d+=8}if(l!==(4294967295&i.total)){t.msg="incorrect length check",i.mode=pe;break}l=0,d=0}i.mode=29;case 29:x=oe;break t;case pe:x=de;break t;case 31:return ce;default:return le}return t.next_out=s,t.avail_out=h,t.next_in=a,t.avail_in=o,i.hold=l,i.bits=d,(i.wsize||f!==t.avail_out&&i.mode<pe&&(i.mode<27||e!==ne))&&Ee(t,t.output,t.next_out,f-t.avail_out),c-=t.avail_in,f-=t.avail_out,t.total_in+=c,t.total_out+=f,i.total+=f,i.wrap&&f&&(t.adler=i.check=i.flags?N(i.check,r,f,t.next_out-f):F(i.check,r,f,t.next_out-f)),t.data_type=i.bits+(i.last?64:0)+(i.mode===_e?128:0)+(20===i.mode||15===i.mode?256:0),(0===c&&0===f||e===ne)&&x===se&&(x=fe),x},inflateEnd:t=>{if(!t||!t.state)return le;let e=t.state;return e.window&&(e.window=null),t.state=null,se},inflateGetHeader:(t,e)=>{if(!t||!t.state)return le;const i=t.state;return 0==(2&i.wrap)?le:(i.head=e,e.done=!1,se)},inflateSetDictionary:(t,e)=>{const i=e.length;let n,r,a;return t&&t.state?(n=t.state,0!==n.wrap&&11!==n.mode?le:11===n.mode&&(r=1,r=F(r,e,i,0),r!==n.check)?de:(a=Ee(t,e,i,i),a?(n.mode=31,ce):(n.havedict=1,se))):le},inflateInfo:"pako inflate (from Nodeca project)"},Se=function(){this.text=0,this.time=0,this.xflags=0,this.os=0,this.extra=null,this.extra_len=0,this.name="",this.comment="",this.hcrc=0,this.done=!1};const ze=Object.prototype.toString,{Z_NO_FLUSH:Ce,Z_FINISH:Te,Z_OK:Re,Z_STREAM_END:Fe,Z_NEED_DICT:Be,Z_STREAM_ERROR:Ne,Z_DATA_ERROR:De,Z_MEM_ERROR:Ze}=Z;function Le(t){this.options=Ft.assign({chunkSize:65536,windowBits:15,to:""},t||{});const e=this.options;e.raw&&e.windowBits>=0&&e.windowBits<16&&(e.windowBits=-e.windowBits,0===e.windowBits&&(e.windowBits=-15)),!(e.windowBits>=0&&e.windowBits<16)||t&&t.windowBits||(e.windowBits+=32),e.windowBits>15&&e.windowBits<48&&0==(15&e.windowBits)&&(e.windowBits|=15),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Zt,this.strm.avail_out=0;let i=Ie.inflateInit2(this.strm,e.windowBits);if(i!==Re)throw new Error(D[i]);if(this.header=new Se,Ie.inflateGetHeader(this.strm,this.header),e.dictionary&&("string"==typeof e.dictionary?e.dictionary=Dt.string2buf(e.dictionary):"[object ArrayBuffer]"===ze.call(e.dictionary)&&(e.dictionary=new Uint8Array(e.dictionary)),e.raw&&(i=Ie.inflateSetDictionary(this.strm,e.dictionary),i!==Re)))throw new Error(D[i])}function Oe(t,e){const i=new Le(e);if(i.push(t),i.err)throw i.msg||D[i.err];return i.result}Le.prototype.push=function(t,e){const i=this.strm,n=this.options.chunkSize,r=this.options.dictionary;let a,s,o;if(this.ended)return!1;for(s=e===~~e?e:!0===e?Te:Ce,"[object ArrayBuffer]"===ze.call(t)?i.input=new Uint8Array(t):i.input=t,i.next_in=0,i.avail_in=i.input.length;;){for(0===i.avail_out&&(i.output=new Uint8Array(n),i.next_out=0,i.avail_out=n),a=Ie.inflate(i,s),a===Be&&r&&(a=Ie.inflateSetDictionary(i,r),a===Re?a=Ie.inflate(i,s):a===De&&(a=Be));i.avail_in>0&&a===Fe&&i.state.wrap>0&&0!==t[i.next_in];)Ie.inflateReset(i),a=Ie.inflate(i,s);switch(a){case Ne:case De:case Be:case Ze:return this.onEnd(a),this.ended=!0,!1}if(o=i.avail_out,i.next_out&&(0===i.avail_out||a===Fe))if("string"===this.options.to){let t=Dt.utf8border(i.output,i.next_out),e=i.next_out-t,r=Dt.buf2string(i.output,t);i.next_out=e,i.avail_out=n-e,e&&i.output.set(i.output.subarray(t,t+e),0),this.onData(r)}else this.onData(i.output.length===i.next_out?i.output:i.output.subarray(0,i.next_out));if(a!==Re||0!==o){if(a===Fe)return a=Ie.inflateEnd(this.strm),this.onEnd(a),this.ended=!0,!0;if(0===i.avail_in)break}}return!0},Le.prototype.onData=function(t){this.chunks.push(t)},Le.prototype.onEnd=function(t){t===Re&&("string"===this.options.to?this.result=this.chunks.join(""):this.result=Ft.flattenChunks(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};var $e={Inflate:Le,inflate:Oe,inflateRaw:function(t,e){return(e=e||{}).raw=!0,Oe(t,e)},ungzip:Oe,constants:Z};const{Deflate:Ve,deflate:Me,deflateRaw:He,gzip:Pe}=qt,{Inflate:je,inflate:Ge,inflateRaw:Ke,ungzip:Ye}=$e;var Xe={Deflate:Ve,deflate:Me,deflateRaw:He,gzip:Pe,Inflate:je,inflate:Ge,inflateRaw:Ke,ungzip:Ye,constants:Z};function qe(t,e){const i=t.length;if(i!==e.length)return!1;for(let n=0;n<i;n++)if(t[n]!==e[n])return!1;return!0}class We{appendDescriptionOf(t){throw new Error("Not Implemented")}appendList(t,e,i,n){throw new Error("Not Implemented")}appendText(t){throw new Error("Not Implemented")}appendValue(t){throw new Error("Not Implemented")}appendValueList(t,e,i,n){throw new Error("Not Implemented")}}class Je{describeTo(t){throw new Error("Not Implemented")}}class Qe extends Je{value;constructor(t){super(),this.value=t}describeTo(t){t.appendValue(this.value)}}class ti{#t;constructor(t){this.#t=t}*[Symbol.iterator](){for(const t of this.#t)yield new Qe(t)}}class ei extends We{appendDescriptionOf(t){return t.describeTo(this),this}#e(t){this.appendText('"'),this.appendText(t),this.appendText('"')}appendValue(t){return null==t?this.appendText("null"):void 0===t?this.appendText("undefined"):"string"==typeof t?this.#e(t):"number"==typeof t?this.appendText(function(t){return String(t)}(t)):Array.isArray(t)?this.appendValueList("[",", ","]",t):(this.appendText("<"),this.appendText(function(t){try{return String(t)}catch(t){return"VALUE@0"}}(t)),this.appendText(">")),this}appendValueList(t,e,i,n){return this.appendList(t,e,i,new ti(n)),this}appendList(t,e,i,n){let r=!1;this.appendText(t);const a=n[Symbol.iterator]();for(let t=a.next();!0!==t.done;t=a.next())r&&this.appendText(e),this.appendDescriptionOf(t.value),r=!0;return this.appendText(i),this}}class ii extends ei{value="";appendText(t){return this.value+=t,this}}function ni(t,e){if(!t)throw new Error(e||"AssertionError")}function ri(t,e,i="value"){if(-1===e.indexOf(t))throw new Error(`${i} must be one of [${e.join(", ")}], instead was '${t}'`)}const ai=["string","boolean","number","object","undefined","function","symbol"];ni.enum=function(t,e,i="value"){for(let i in e)if(e[i]===t)return;throw new Error(`${i}(=${t}) is not a valid enumerable value, valid values are: [${Object.values(e).join(", ")}]`)},ni.notEqual=function(t,e,i){ni(t!==e,i)},ni.notOk=function(t,e){ni(!t,e)},ni.equal=function(t,e,i){if(t!==e){const n=`${t} !== ${e}`;throw new Error(void 0!==i&&""!==i?`${i}. ${n}`:n)}},ni.logicalyEqual=function(t,e,i){},ni.ok=ni,ni.greaterThan=function(t,e,i){if(!(t>e)){let n="";throw void 0!==i&&(n+=i+". "),n+=`Expected ${t} > ${e}.`,new Error(n)}},ni.greaterThanOrEqual=function(t,e,i){if(!(t>=e)){let n="";throw void 0!==i&&(n+=i+". "),n+=`Expected ${t} >= ${e}.`,new Error(n)}},ni.lessThan=function(t,e,i){if(!(t<e)){let n="";throw void 0!==i&&(n+=i+". "),n+=`Expected ${t} < ${e}.`,new Error(n)}},ni.lessThanOrEqual=function(t,e,i){if(!(t<=e)){let n="";throw void 0!==i&&(n+=i+". "),n+=`Expected ${t} <= ${e}.`,new Error(n)}},ni.typeOf=function(t,e,i="value"){ri(typeof t,ai);const n=typeof t;if(n!==e)throw new Error(`expected ${i} to be ${e}, instead was '${n}'(=${t})`)},ni.arrayHas=function(t,e,i="Array does not contain the item"){},ni.arrayHasNo=function(t,e,i="Array contains the item"){},ni.arrayEqual=function(t,e,i="Arrays are not equal"){if(!qe(t,e))throw new Error(i)},ni.isOneOf=ri,ni.isInstanceOf=function(t,e,i="value",n=e.name){},ni.isNumber=function(t,e="value"){const i=typeof t;if("number"!==i)throw new Error(`expected ${e} to be a number, instead was '${i}'(=${t})`)},ni.isString=function(t,e="value"){const i=typeof t;if("string"!==i)throw new Error(`expected ${e} to be a string, instead was '${i}'(=${t})`)},ni.isBoolean=function(t,e="value"){const i=typeof t;if("boolean"!==i)throw new Error(`expected ${e} to be a boolean, instead was '${i}'(=${t})`)},ni.isFunction=function(t,e="value"){const i=typeof t;if("function"!==i)throw new Error(`expected ${e} to be a function, instead was '${i}'(=${t})`)},ni.isObject=function(t,e="value"){const i=typeof t;if("object"!==i)throw new Error(`expected ${e} to be an object, instead was '${i}'(=${t})`)},ni.isInteger=function(t,e="value"){if(!Number.isInteger(t))throw new Error(`${e} must be an integer, instead was ${t}`)},ni.isNonNegativeInteger=function(t,e="value"){if(t<0)throw new Error(`${e} must be >= 0, instead was ${t}`)},ni.isPositiveInteger=function(t,e="value"){if(t<=0)throw new Error(`${e} must be > 0, instead was ${t}`)},ni.isArray=function(t,e="value"){if(!Array.isArray(t))throw new Error(`expected ${e} to be an array, instead was something else (typeof ='${typeof t}')`)},ni.isArrayLike=function(t,e="value"){if(!function(t){if(Array.isArray(t))return!0;if("object"!=typeof t)return!1;if(null===t)return!1;if(function(t){if("object"!=typeof t)return!1;if(null===t)return!1;const e=t.constructor;return e===Uint8Array||e===Uint8ClampedArray||e===Uint16Array||e===Uint32Array||e===Int8Array||e===Int16Array||e===Int32Array||e===Float16Array||e===Float32Array||e===Float64Array||e===BigUint64Array||e===BigInt64Array}(t))return!0;const e=t.length;return!("number"!=typeof e&&!Number.isInteger(e)||e<0)}(t))throw new Error(`expected ${e} to be an array-like structure, instead was something else (typeof ='${typeof t}')`)},ni.defined=function(t,e="value"){if(void 0===t)throw new Error(`${e} is undefined`)},ni.undefined=function(t,e="value"){if(void 0!==t)throw new Error(`${e} is not undefined`)},ni.isNull=function(t,e){if(null!==t)throw new Error(`${e} is NOT null`)},ni.notNull=function(t,e="value"){if(null===t)throw new Error(`${e} is null`)},ni.notNaN=function(t,e="value"){if(Number.isNaN(t))throw new Error(`${e} must be a valid number, instead was NaN`)},ni.isFinite=function(t,e="value"){if(!Number.isFinite(t))throw new Error(`${e} must be a finite number, instead was ${t}`)},ni.that=function(t,e,i){if(i.matches(t))return;const n=new ii;throw n.appendText(`Expected ${e} to be `),i.describeTo(n),n.appendText(" instead "),i.describeMismatch(t,n),new Error(n.value)};const si=!1,oi=!0,hi=new Float32Array(1),li=new Int32Array(hi.buffer);class di{endianness=oi;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 e=this.__data_uint8,i=new Uint8Array(t);this.position>0&&function(t,e,i,n,r){let a,s;const o=function(t,e,i){const n=0|i;return 0==(3&n)?4:0==(1&n)?2:1}(0,0,r);4===o?(a=new Uint32Array(t,0,r>>>2),s=new Uint32Array(i,0,r>>>2)):2===o?(a=new Uint16Array(t,0,r>>>1),s=new Uint16Array(i,0,r>>>1)):(a=new Uint8Array(t,0,r),s=new Uint8Array(i,0,r)),s.set(a)}(e.buffer,0,i.buffer,0,Math.min(e.buffer.byteLength,i.buffer.byteLength,this.position)),this.data=i.buffer,this.__data_uint8=i,this.dataView=new DataView(this.data),this.capacity=t}ensureCapacity(t){const e=this.capacity;if(e>=t)return;const i=Math.ceil(Math.max(t,e*this.__growFactor,e+1024))+3>>2<<2;this.setCapacity(i)}readFloat16(){return function(t){let e=0;const i=(31744&t)>>10,n=1023&t;let r=1;return t>>15!=0&&(r=-1),e=0!==i?31===i?0!==n?NaN:Infinity*r:r*Math.pow(2,i-15)*(1+n/1024):r*(n/1024*6103515625e-14),e}(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}readInt32(){const t=this.dataView.getInt32(this.position,this.endianness);return this.position+=4,t}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,oi);return this.position+=2,t}readUint16BE(){const t=this.dataView.getUint16(this.position,si);return this.position+=2,t}readUint24(){return this.endianness===si?this.readUint24BE():this.readUint24LE()}readUint24LE(){const t=this.dataView.getUint8(this.position),e=this.dataView.getUint8(this.position+1),i=this.dataView.getUint8(this.position+2);return this.position+=3,t|e<<8|i<<16}readUint24BE(){const t=this.dataView.getUint8(this.position),e=this.dataView.getUint8(this.position+1),i=this.dataView.getUint8(this.position+2);return this.position+=3,i|e<<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,oi);return this.position+=4,t}readUint32BE(){const t=this.dataView.getUint32(this.position,si);return this.position+=4,t}readUint64(){const t=this.dataView.getBigUint64(this.position,this.endianness);return this.position+=8,t}readUint8Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readUint8()}readUint16Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readUint16()}readUint32Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readUint32()}readInt8Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readInt8()}readInt16Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readInt16()}readInt32Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readInt32()}readFloat32Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readFloat32()}readFloat64Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readFloat64()}writeFloat32Array(t,e,i){for(let n=0;n<i;n++)this.writeFloat32(t[n+e])}writeFloat16Array(t,e,i){for(let n=0;n<i;n++)this.writeFloat16(t[n+e])}readFloat16Array(t,e,i){for(let n=0;n<i;n++)t[n+e]=this.readFloat16()}writeFloat16(t){const e=function(t){let e=t;Number.isFinite(e)&&e>65504&&(e=65504),hi[0]=e;const i=li[0];let n=i>>16&32768,r=i>>12&2047;const a=i>>23&255;return a<103?n:a>142?(n|=31744,255===a&&0!=(8388607&i)&&(n|=512),n):a<113?(r|=2048,n|=(r>>114-a)+(r>>113-a&1),n):(n|=a-112<<10|r>>1,n+=1&r,n)}(t);this.writeUint16(e)}writeFloat32(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setFloat32(this.position,t,this.endianness),this.position=e}writeFloat64(t){const e=this.position+8;this.ensureCapacity(e),this.dataView.setFloat64(this.position,t,this.endianness),this.position=e}writeInt8(t){const e=this.position+1;this.ensureCapacity(e),this.dataView.setInt8(this.position,t),this.position=e}writeInt16(t){const e=this.position+2;this.ensureCapacity(e),this.dataView.setInt16(this.position,t,this.endianness),this.position=e}writeInt32(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setInt32(this.position,t,this.endianness),this.position=e}writeInt64(t){const e=this.position+8;this.ensureCapacity(e),this.dataView.setBigInt64(this.position,t,this.endianness),this.position=e}writeInt8Array(t,e,i){this.ensureCapacity(this.position+i);for(let n=0;n<i;n++)this.writeInt8(t[e+n])}writeInt16Array(t,e,i){this.ensureCapacity(this.position+2*i);for(let n=0;n<i;n++)this.writeInt16(t[e+n])}writeInt32Array(t,e,i){this.ensureCapacity(this.position+4*i);for(let n=0;n<i;n++)this.writeInt32(t[e+n])}writeUint8(t){const e=this.position+1;this.ensureCapacity(e),this.dataView.setUint8(this.position,t),this.position=e}writeUint8Array(t,e,i){for(let n=0;n<i;n++)this.writeUint8(t[e+n])}writeUint16(t){const e=this.position+2;this.ensureCapacity(e),this.dataView.setUint16(this.position,t,this.endianness),this.position=e}writeUint16BE(t){const e=this.position+2;this.ensureCapacity(e),this.dataView.setUint16(this.position,t,si),this.position=e}writeUint16LE(t){const e=this.position+2;this.ensureCapacity(e),this.dataView.setUint16(this.position,t,oi),this.position=e}writeUint16Array(t,e,i){for(let n=0;n<i;n++)this.writeUint16(t[e+n])}writeUint24(t){this.endianness===si?this.writeUint24BE(t):this.writeUint24LE(t)}writeUint24BE(t){const e=this.position+3;this.ensureCapacity(e);const i=255&t,n=t>>8&255,r=t>>16&255;this.dataView.setUint8(this.position,r),this.dataView.setUint8(this.position+1,n),this.dataView.setUint8(this.position+2,i),this.position=e}writeUint24LE(t){const e=this.position+3;this.ensureCapacity(e);const i=255&t,n=t>>8&255,r=t>>16&255;this.dataView.setUint8(this.position,i),this.dataView.setUint8(this.position+1,n),this.dataView.setUint8(this.position+2,r),this.position=e}writeUintVar(t){let e=!0,i=t;for(;e||0!==i;){e=!1;let t=127&i;i>>=7,i>0&&(t|=128),this.writeUint8(t)}}readUintVar(){let t=!0,e=0,i=0;for(;t;){let n=this.readUint8();t=0!=(128&n),e|=(127&n)<<i,i+=7}return e}writeUint32(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setUint32(this.position,t,this.endianness),this.position=e}writeUint32BE(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setUint32(this.position,t,si),this.position=e}writeUint32LE(t){const e=this.position+4;this.ensureCapacity(e),this.dataView.setUint32(this.position,t,oi),this.position=e}writeUint64(t){const e=this.position+8;this.ensureCapacity(e),this.dataView.setBigUint64(this.position,t,this.endianness),this.position=e}writeUint32Array(t,e,i){this.ensureCapacity(this.position+4*i);for(let n=0;n<i;n++)this.writeUint32(t[e+n])}writeBytes(t,e,i){const n=e+i,r=this.position,a=r+i;if(this.ensureCapacity(a),0===e&&t.length===i)this.__data_uint8.set(t,r);else if("function"==typeof t.subarray)this.__data_uint8.set(t.subarray(e,n),r);else for(let n=0;n<i;n++)this.__data_uint8[r+n]=t[e+n];this.position=a}readBytes(t,e,i){const n=this.position,r=n+i,a=this.__data_uint8;i<128?function(t,e,i,n,r){let a,s,o;for(o=0;o<r;o++)a=e+o,s=n+o,i[s]=t[a]}(a,n,t,e,i):t.set(a.subarray(n,r),e),this.position=r}writeUTF8String(t){if(null===t)return void this.writeUint32(4294967295);if(void 0===t)return void this.writeUint32(4294967294);let e=0;const i=t.length;if(i>=4294967294)throw new Error("String is too long");this.writeUint32(i);let n=this.position;const r=Math.max(32,i+(i>>1)+7);this.ensureCapacity(r+n);let a=this.__data_uint8,s=this.capacity;for(;e<i;){let r=t.charCodeAt(e++);if(r>=55296&&r<=56319){if(e<i){const i=t.charCodeAt(e);56320==(64512&i)&&(++e,r=((1023&r)<<10)+(1023&i)+65536)}if(r>=55296&&r<=56319)continue}if(n+4>s&&(this.ensureCapacity(n+4),s=this.capacity,a=this.__data_uint8),0!=(4294967168&r)){if(0==(4294965248&r))a[n++]=r>>6&31|192;else if(0==(4294901760&r))a[n++]=r>>12&15|224,a[n++]=r>>6&63|128;else{if(0!=(4292870144&r))continue;a[n++]=r>>18&7|240,a[n++]=r>>12&63|128,a[n++]=r>>6&63|128}a[n++]=63&r|128}else a[n++]=r}this.position=n}readUTF8String(){const t=this.readUint32();if(4294967295===t)return null;if(4294967294===t)return;const e=this.__data_uint8;let i="",n=this.position,r=0;for(;n<this.capacity&&r<t;){const t=e[n++];let a;if(0===t)break;0==(128&t)?a=t:192==(224&t)?a=(31&t)<<6|63&e[n++]:224==(240&t)?a=(31&t)<<12|(63&e[n++])<<6|63&e[n++]:240==(248&t)&&(a=(7&t)<<18|(63&e[n++])<<12|(63&e[n++])<<6|63&e[n++],a>65535&&(a-=65536,i+=String.fromCharCode(a>>>10&1023|55296),r++,a=56320|1023&a)),r++,i+=String.fromCharCode(a)}return this.position=n,i}writeASCIIString(t){const e=t.length,i=this.position,n=i+e;this.ensureCapacity(n);for(let n=0;n<e;n++){const e=t.charCodeAt(n);if(e>128)throw new Error(`Character ${String.fromCharCode(e)} can't be represented by a US-ASCII byte.`);this.__data_uint8[i+n]=e}this.position=n}readASCIICharacters(t,e=!1){let i="";for(let n=0;n<t;n++){const t=this.readUint8();if(e&&0===t)break;i+=String.fromCharCode(t)}return i}toString(){return`BinaryBuffer[position=${this.position}, capacity=${this.capacity}, endianness=${this.endianness}]`}toHexString(){const t=this.__data_uint8,e=Math.min(t.length,this.position);let i="";for(let n=0;n<e;n++)i+=t[n].toString(16).padStart(2,"0").toUpperCase();return i}static fromEndianness(t){const e=new di;return e.endianness=t,e}static fromArrayBuffer(t){const e=new di;return e.fromArrayBuffer(t),e}static copyUTF8String(t,e){const i=t.readUTF8String();return e.writeUTF8String(i),i}static copyUintVar(t,e){const i=t.readUintVar();return e.writeUintVar(i),i}static copyUint8(t,e){const i=t.readUint8();return e.writeUint8(i),i}static copyUint16(t,e){const i=t.readUint16();return e.writeUint16(i),i}static copyUint32(t,e){const i=t.readUint32();return e.writeUint32(i),i}static copyFloat32(t,e){const i=t.readFloat32();return e.writeFloat32(i),i}static copyFloat64(t,e){const i=t.readFloat64();return e.writeFloat64(i),i}static copyBytes(t,e,i){const n=new Uint8Array(i);return t.readBytes(n,0,i),e.writeBytes(n,0,i),n}}di.prototype.isBinaryBuffer=!0;let ci=oi,fi=!1;function ui(t){const e=new Xe.Inflate;if(e.push(t),e.err)throw new Error(e.err);return e.result.buffer}const _i=new Uint32Array(256);for(let t=0;t<256;t++){let e=t;for(let t=0;t<8;t++)0!=(1&e)?e=3988292384^e>>>1:e>>>=1;_i[t]=e}class pi{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 e=0,i=!1;switch(t){case 0:case 3:e=1;break;case 2:e=3;break;case 4:e=2,i=!0;break;case 6:e=4,i=!0;break;default:throw new Error("invalid color type")}this.colors=e,this.alpha=i,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}getPixel(t,e,i,n){const r=this.pixels;if(!r)throw new Error("pixel data is empty");if(i>=this.width||n>=this.height)throw new Error("x,y position out of bound");const a=this.colors*this.bitDepth/8*(n*this.width+i);let s,o,h,l;switch(this.colorType){case 0:s=r[a],o=s,h=s,l=255;break;case 2:s=r[a],o=r[a+1],h=r[a+2],l=255;break;case 3:l=255,null!=this.transparency_lookup&&(l=this.transparency_lookup[r[a]]);const t=3*r[a],e=this.palette;s=e[t],o=e[t+1],h=e[t+2];break;case 4:s=r[a],o=s,h=s,l=r[a+1];break;case 6:s=r[a],o=r[a+1],h=r[a+2],l=r[a+3];break;default:throw new Error("Unsupported color type")}t[e+0]=s,t[e+1]=o,t[e+2]=h,t[e+3]=l}getRGBA8Array_fromRGB(t){const e=this.height,i=this.width*e,n=this.pixels;for(let e=0;e<i;e++){const i=3*e,r=i+e;t[r]=n[i],t[r+1]=n[i+1],t[r+2]=n[i+2],t[r+3]=255}}getRGBA8Array_generic(t){const e=this.height,i=this.width;for(let n=0;n<e;n++){const e=n*i;for(let r=0;r<i;r++){const i=4*(e+r);this.getPixel(t,i,r,n)}}}getRGBA8Array(){if(6===this.colorType)return this.pixels;const t=this.height,e=this.width,i=new Uint8Array(e*t*4);return 2===this.colorType?this.getRGBA8Array_fromRGB(i):this.getRGBA8Array_generic(i),i}getUint8Data_case3(){const t=this.width*this.height;let e;const i=this.transparency_lookup;e=null!==i?4:3;const n=new Uint8Array(t*e),r=this.pixels,a=this.palette,s=this.colors*Math.ceil(this.bitDepth/8);for(let i=0;i<t;i++){const t=i*e,o=3*r[i*s];n[t]=a[o],n[t+1]=a[o+1],n[t+2]=a[o+2]}if(null!==i){const e=i.length;for(let a=0;a<t;a++){const t=r[a*s];n[4*a+3]=t>=e?255:i[t]}}return{data:n,itemSize:e}}getUint8Data(){let t,e=0;switch(this.colorType){case 0:t=this.pixels,e=1;break;case 2:t=this.pixels,e=3;break;case 3:const i=this.getUint8Data_case3();t=i.data,e=i.itemSize;break;case 4:t=this.pixels,e=2;break;case 6:t=this.pixels,e=4;break;default:throw new Error("Unsupported color type")}return{data:t,itemSize:e}}}const wi=[137,80,78,71,13,10,26,10];function gi(t,e){return t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3]}function bi(t,e){return t[e]}function yi(t){this.i=0,this.bytes=new Uint8Array(t),this.png=new pi,this.dataChunks=[],this.buffer=new di,this.buffer.endianness=si,this.buffer.fromArrayBuffer(t),this.crc_enabled=!1,this.header=new Uint8Array(8)}yi.prototype.readBytes=function(t){const e=this.buffer,i=new Uint8Array(e.data,e.position,t);return e.skip(t),i},yi.prototype.decodeHeader=function(){if(0!==this.i)throw new Error("file pointer should be at 0 to read the header");const t=this.buffer,e=this.header;if(t.readBytes(e,0,8),!qe(e,wi))throw new Error("invalid PNGReader file (bad signature)")},yi.prototype.decodeChunk=function(){const t=this.buffer,e=t.readUint32();if(e<0)throw new Error("Bad chunk length "+(4294967295&e));const i=t.position,n=t.readASCIICharacters(4),r=this.readBytes(e);switch(t.readUint32(),this.crc_enabled&&function(t,e=0,i=t.length){!function(t,e,i,n){let r=4294967295;const a=i+n;for(let t=i;t<a;t++){const i=e[t];r=_i[255&(r^i)]^r>>>8}}(0,t,e,i)}(t.raw_bytes,i,e+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":!function(t){const e=t.byteLength,i=t.buffer,n=t.byteOffset,r=di.fromArrayBuffer(i);r.position=n,r.readASCIICharacters(79,!0);const a=r.readUint8();let s;if(0!==a)throw new Error(`Unsupported compression method '${a}'`);{const t=r.position-n,a=ui(new Uint8Array(i,r.position,e-t));r.fromArrayBuffer(a),s=r.readASCIICharacters(a.byteLength)}}(r);break;case"iTXt":!function(t){const e=t.byteLength,i=t.buffer,n=t.byteOffset,r=n+e,a=di.fromArrayBuffer(i);a.position=n,a.readASCIICharacters(79,!0);const s=a.readUint8(),o=a.readUint8(),h=(a.readASCIICharacters(Number.MAX_SAFE_INTEGER,!0),a.readASCIICharacters(Number.MAX_SAFE_INTEGER,!0),r-a.position);let l;if(0===s)l=a.readASCIICharacters(h);else{if(1!==s)throw new Error(`Invalid compression flag value '${s}'`);{if(0!==o)throw new Error("only compression_method 0 is supported");const t=ui(new Uint8Array(a.data,a.position,h));a.fromArrayBuffer(t),l=a.readASCIICharacters(t.byteLength)}}}(r)}return n},yi.prototype.decodesRGB=function(t){bi(t,0)},yi.prototype.decodetIME=function(t){bi(t,0),bi(t,1),bi(t,2),bi(t,3),bi(t,4),bi(t,5),bi(t,6)},yi.prototype.decodetEXt=function(t){const e=di.fromArrayBuffer(t.buffer),i=e.readASCIICharacters(Number.POSITIVE_INFINITY,!0),n=e.readASCIICharacters(i.length-1,!1);this.png.text[i]=n},yi.prototype.decodeiEXt=function(t){const e=di.fromArrayBuffer(t.buffer),i=e.readASCIICharacters(Number.POSITIVE_INFINITY,!0);if(e.readUint8(),e.readUint8(),e.readASCIICharacters(Number.POSITIVE_INFINITY,!0),e.readUTF8String(),0!==e.readUint8())throw new Error("Expected Null Separator after Translated keyword");const n=e.readUTF8String();this.png.text[i]=n},yi.prototype.decodeIHDR=function(t){const e=this.png;e.setWidth(gi(t,0)),e.setHeight(gi(t,4)),e.setBitDepth(bi(t,8)),e.setColorType(bi(t,9)),e.setCompressionMethod(bi(t,10)),e.setFilterMethod(bi(t,11)),e.setInterlaceMethod(bi(t,12))},yi.prototype.decodePLTE=function(t){this.png.setPalette(t)},yi.prototype.decodeIDAT=function(t){this.dataChunks.push(t)},yi.prototype.decodeTRNS=function(t){this.png.transparency_lookup=t},yi.prototype.decodeIEND=function(){},yi.prototype.decodePixels=function(){const t=this.png,e=this.dataChunks,i=e.length,n=new Xe.Inflate;for(let t=0;t<i;t++)n.push(e[t]);let r;if(0===n.err)r=n.result;else{let t=0;for(let n=0;n<i;n++)t+=e[n].length;const a=new Uint8Array(t);let s=0;for(let t=0;t<i;t++)a.set(e[t],s),s+=e[t].length;try{r=Xe.inflateRaw(a.subarray(2))}catch(t){throw new Error(`Failed to inflate IDAT stream: ${n.msg||n.err}`)}}if(0===t.getInterlaceMethod()?this.png.pixels=this.interlaceNone(r):this.png.pixels=this.interlaceAdam7(r),16===t.bitDepth&&function(){if(fi)return ci;const t=new ArrayBuffer(2),e=new Uint8Array(t),i=new Uint16Array(t);return e[0]=19,ci=19==(255&i[0])?oi:si,fi=!0,ci}()===oi){const t=this.png.pixels;for(let e=0;e<t.length;e+=2){const i=t[e];t[e]=t[e+1],t[e+1]=i}}},yi.prototype.interlaceNone=function(t){const e=this.png,i=e.bitDepth,n=e.colors*i/8,r=e.width,a=e.height,s=Math.ceil(n*r),o=new Uint8Array(s*a);let h=0;const l=t.length;for(let e=0;e<l;e+=s+1){const i=e+1,r=bi(t,e);this.unFilter(r,t,i,o,Math.max(1,Math.ceil(n)),h,h-s,s),h+=s}return o},yi.prototype.interlaceAdam7=function(t){const e=this.png,i=e.colors*e.bitDepth/8,n=new Uint8Array(i*e.width*e.height),r=[{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}],a=e.width,s=e.height;let o=0;const h=new Uint8Array(a*i);for(let e=0;e<7;e++){const l=r[e],d=Math.ceil((a-l.x)/l.xStep),c=Math.ceil((s-l.y)/l.yStep);if(d<=0||c<=0)continue;const f=d*i;let u=-1;for(let e=0;e<c;e++){const r=t[o++],c=e%2*f;this.unFilter(r,t,o,h,i,c,u,f),o+=f,u=c;for(let t=0;t<d;t++){const r=l.x+t*l.xStep,o=l.y+e*l.yStep;if(!(r>=a||o>=s))for(let e=0;e<i;e++)n[(o*a+r)*i+e]=h[c+t*i+e]}}}return n},yi.prototype.unFilter=function(t,e,i,n,r,a,s,o){switch(t){case 0:!function(t,e,i,n,r,a){if(1===a)for(let a=0;a<r;a++){const r=t[(a>>>4)+e]>>>(7&a)&1;i[n+a]=r}else if(2===a)for(let a=0;a<r;a++){const r=t[(a>>>2)+e]>>>((3&~a)<<1)&3;i[n+a]=r}else if(4===a)for(let a=0;a<r;a++){const r=t[(a>>>1)+e]>>>((1&~a)<<2)&15;i[n+a]=r}else{if(8!==a&&16!==a)throw new Error(`unsupported bit depth ${a}`);for(let a=0;a<r;a++)i[n+a]=t[a+e]}}(e,i,n,a,o,this.png.bitDepth);break;case 1:!function(t,e,i,n,r,a){let s=0;for(;s<n;s++)i[r+s]=t[s+e];for(;s<a;s++){const a=r+s;i[a]=t[s+e]+i[a-n]&255}}(e,i,n,r,a,o);break;case 2:!function(t,e,i,n,r,a){let s,o,h=0;if(r<0)for(;h<a;h++)i[n+h]=t[h+e];else for(;h<a;h++)s=t[h+e],o=i[r+h],i[n+h]=s+o&255}(e,i,n,a,s,o);break;case 3:!function(t,e,i,n,r,a,s){let o,h,l,d=0;if(a<0){for(;d<n;d++)i[r+d]=t[d+e];for(;d<s;d++){const a=r+d;i[a]=t[d+e]+(i[a-n]>>1)&255}}else{for(;d<n;d++)i[r+d]=t[d+e]+(i[a+d]>>1)&255;for(;d<s;d++)o=t[d+e],h=i[r+d-n],l=i[a+d],i[r+d]=o+(h+l>>1)&255}}(e,i,n,r,a,s,o);break;case 4:!function(t,e,i,n,r,a,s){let o,h,l,d,c,f,u,_,p,w=0;if(a<0){for(;w<n;w++)i[r+w]=t[w+e];for(;w<s;w++)i[r+w]=t[w+e]+i[r+w-n]&255}else{for(;w<n;w++)i[r+w]=t[w+e]+i[a+w]&255;for(;w<s;w++)o=t[w+e],d=i[a+w-n],l=i[a+w],h=i[r+w-n],c=h+l-d,f=Math.abs(c-h),u=Math.abs(c-l),_=Math.abs(c-d),p=f<=u&&f<=_?h:u<=_?l:d,i[r+w]=o+p&255}}(e,i,n,r,a,s,o);break;default:throw new Error(`unknown filtered scanline type '${t}'`)}},yi.prototype.parse=function(){for(this.decodeHeader();"IEND"!==this.decodeChunk(););return this.decodePixels(),this.png},self.Lib={decode:async function(t,e="png"){if(!(t instanceof ArrayBuffer))throw new Error(".data argument must be an ArrayBuffer, instead was something else");if("png"===e.toLowerCase())return await async function(t){return new Promise(((e,i)=>{const n=new yi(t).parse(),r=n.getUint8Data(),a=n.getWidth(),s=n.getHeight();e({data:r.data.buffer,width:a,height:s,itemSize:r.itemSize,bitDepth:n.getBitDepth()})}))}(t);throw new Error("Unsupported type")}};
// Positional sound, on the sopra-backed AudioEmitter system.
//
// Each car carries several looping AudioEmitters — one per engine/tyre layer,
// each riding a child EntityNode so it follows the car — and we modulate their
// volumes per frame ("gear shifting" is faked by cross-fading three engine
// loops by speed; sopra's BlendContainer reads its parameter once at trigger,
// so live cross-fades stay on per-emitter volume). One-shots (jumps, dodges,
// impacts, the goal horn) are transient entities with a non-looping autoplay
// emitter: the system's direct path plays them on link and they self-release.
// The audio listener is wired to the camera by EngineHarness, so everything
// is heard from the chase camera's point of view.
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { EntityNode } from "@woosh/meep-engine/src/engine/ecs/parent/EntityNode.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { AudioEmitter } from "@woosh/meep-engine/src/engine/sound/ecs/audio/AudioEmitter.js";
import { SampleAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/SampleAudioClip.js";
import { clamp } from "@woosh/meep-engine/src/core/math/clamp.js";
const S = "./sounds";
const tri = (r, c) => Math.max(0, 1 - Math.abs(r - c));
const approach = (cur, target, dt, rate) => cur + (target - cur) * Math.min(1, dt * rate);
// A spatial emitter around one sample. `loop: true` + autoplay puts it on the
// system's MANAGED path (distance-promoted against the live budget); a finite
// one-shot takes the DIRECT path and plays immediately on link.
function sampleEmitter(url, { loop = false, volume = 1, min = 6, max = 130 } = {}) {
const e = new AudioEmitter();
e.event.label = url;
e.event.is3D = true;
e.event.busId = "effects";
e.event.distanceMin = min;
e.event.distanceMax = max;
e.event.rootClip = SampleAudioClip.from(url, { loop });
e.volume.set(volume);
return e;
}
// ── per-car engine / tyre loops ──────────────────────────────────────────────
export class CarAudio {
constructor(ctx, controller) {
this.c = controller;
const node = controller.node;
// One looping emitter per layer, each on a child node that follows the
// car. (AudioEmitter is one-per-entity; volume is the live channel.)
const loop = (file, volume = 0) => {
const emitter = sampleEmitter(`${S}/${file}`, { loop: true, volume });
node.addChild(EntityNode.fromComponents(new Transform64(), emitter));
return emitter;
};
this.low = loop("engine_low.wav", 0.25);
this.mid = loop("engine_mid.wav");
this.high = loop("engine_high.wav");
this.boost = loop("boost.wav");
this.roll = loop("roll.wav");
this.screech = loop("screech.wav");
}
update(dt) {
const st = this.c.state;
const sn = clamp(st.speed / 40, 0, 1);
const r = sn * 2; // 0..2 across three "gears"
const eng = st.throttling ? 0.7 : 0.32; // idle vs under power
const fade = (e, target, rate) => e.volume.set(approach(e.volume.getValue(), target, dt, rate));
fade(this.low, tri(r, 0) * eng, 8);
fade(this.mid, tri(r, 1) * eng, 8);
fade(this.high, tri(r, 2) * eng, 8);
fade(this.boost, st.boosting ? 0.5 : 0, 12);
fade(this.roll, st.grounded ? clamp(st.speed / 16, 0, 0.32) : 0, 6);
fade(this.screech, st.sliding ? 0.45 : 0, 14);
}
}
// ── positional one-shots ─────────────────────────────────────────────────────
export class Sfx {
constructor(ctx) {
this.ecd = ctx.ecd;
this._transient = []; // { entity, ttl }
}
play(url, x, y, z, volume = 1, life = 2.0) {
const e = sampleEmitter(url, { volume, min: 6, max: 110 });
const tr = new Transform64();
tr.setTranslation(x, y, z);
const entity = new Entity().add(tr).add(e).build(this.ecd);
this._transient.push({ entity, ttl: life });
}
jump(x, y, z) { this.play(`${S}/jump.wav`, x, y, z, 0.7, 1.0); }
dodge(x, y, z) { this.play(`${S}/dodge.wav`, x, y, z, 0.8, 1.0); }
impact(x, y, z, strength) { this.play(`${S}/${strength > 0.5 ? "hit2" : "hit1"}.wav`, x, y, z, clamp(0.4 + strength, 0.4, 1), 1.0); }
goal(x, y, z) { this.play(`${S}/goal.wav`, x, y, z, 0.9, 2.0); }
update(dt) {
for (let i = this._transient.length - 1; i >= 0; i--) {
const e = this._transient[i];
e.ttl -= dt;
if (e.ttl <= 0) { this.ecd.removeEntity(e.entity); this._transient.splice(i, 1); }
}
}
}
// chaseCamera — a Rocket-League-style spring chase camera.
//
// Two modes, toggled at runtime:
// car-cam : trails behind the car's LINEAR MOTION (the direction it's actually
// travelling), not its orientation — so a spin on the ground or a
// tumble in the air doesn't whip the camera around. The heading eases
// toward the velocity on a spring, so a hard collision doesn't snap
// the angle. Below a small speed it holds its heading.
// ball-cam : stays behind the car on the car→ball line and keeps the ball in
// view — the default Rocket League camera.
//
// On top of the position spring there's a small speed-based FOV widening so going
// fast "feels" faster.
//
// It drops buildBasics' orbital controller and writes the camera Transform64 each
// preRender, reading the car's (interpolated) Transform64 so the motion is smooth
// at the full display refresh rate. It then synchronizes CameraSystem so this
// frame's pose and lens reach the renderer before the frame is drawn.
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { Camera } from "@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js";
import { CameraSystem } from "@woosh/meep-engine/src/engine/graphics3/CameraSystem.js";
import TopDownCameraController from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { CAMERA } from "../tuning.js";
const WORLD_UP = new Vector3(0, 1, 0);
/**
* @param {object} opts
* @param {Engine} opts.engine
* @param {EntityComponentDataset} opts.ecd
* @param {() => Transform64} opts.getCarTransform
* @param {() => ArrayLike<number>} [opts.getCarVelocity] car linear velocity
* @param {() => (Transform64|null)} [opts.getBall] the ball's world transform, or null
* @param {() => boolean} [opts.isBallCam]
*/
export function createChaseCamera({
engine, ecd, getCarTransform,
getCarVelocity = () => [0, 0, 0],
getBall = () => null,
isBallCam = () => true,
}) {
const cameraEntity = ecd.getAnyComponent(Camera).entity;
ecd.removeComponentFromEntity(cameraEntity, TopDownCameraController);
const camT = ecd.getComponent(cameraEntity, Transform64);
const camComponent = ecd.getComponent(cameraEntity, Camera);
const cameraSystem = engine.entityManager.getSystem(CameraSystem);
const camPos = new Vector3();
const desired = new Vector3();
const focus = new Vector3();
const heading = new Vector3(0, 0, 1);
const fwd = new Vector3();
const carPos = new Vector3();
const ballPos = new Vector3();
const carRot = new Quaternion();
let started = false;
let currentFov = CAMERA.fov;
camComponent.fov.set(CAMERA.fov); // set the base FOV once via the component
let lastMs = performance.now();
// heading pointing the way the car FACES — used to seed the heading and to
// realign it behind the car on a teleport (kickoff / respawn).
function headingFromFacing(tr) {
carRot.set(tr.rotation_x, tr.rotation_y, tr.rotation_z, tr.rotation_w);
fwd.set(0, 0, 1).applyQuaternion(carRot); fwd.y = 0;
if (fwd.lengthSqr() > 0.01) heading.copy(fwd).normalize();
}
function setDesired() {
desired.set(
carPos.x - heading.x * CAMERA.back,
carPos.y + CAMERA.up,
carPos.z - heading.z * CAMERA.back,
);
}
function frame() {
const tr = getCarTransform();
if (tr === null || tr === undefined) return;
const nowMs = performance.now();
const dt = Math.min((nowMs - lastMs) / 1000, 0.1);
lastMs = nowMs;
carPos.set(tr.translation_x, tr.translation_y, tr.translation_z);
const vel = getCarVelocity();
const ball = getBall();
const ballCam = isBallCam() && ball !== null;
// ── heading ─────────────────────────────────────────────────────────
if (!started) {
headingFromFacing(tr);
} else if (ballCam) {
ballPos.set(ball.translation_x, ball.translation_y, ball.translation_z);
fwd.copy(ballPos).sub(carPos); fwd.y = 0;
if (fwd.lengthSqr() > 0.04) heading.copy(fwd).normalize();
} else {
// car-cam: ease the heading toward the car's HORIZONTAL velocity. The
// lerp eases (no snap on a hard hit) and naturally refuses to flip on a
// dead reverse; below minHeadingSpeed the heading just holds.
fwd.set(vel[0], 0, vel[2]);
const groundSpeed = fwd.length();
if (groundSpeed > CAMERA.minHeadingSpeed) {
fwd.multiplyScalar(1 / groundSpeed);
heading.lerp(fwd, 1 - Math.exp(-CAMERA.headingStiffness * dt));
if (heading.lengthSqr() > 1e-9) heading.normalize();
}
}
// ── position (spring, with a hard snap on big teleports) ─────────────
setDesired();
if (!started) {
camPos.copy(desired);
started = true;
} else if (camPos.distanceTo(desired) > CAMERA.snapDist) {
if (!ballCam) headingFromFacing(tr); // realign behind the car on kickoff/respawn
setDesired();
camPos.copy(desired);
} else {
camPos.lerp(desired, 1 - Math.exp(-CAMERA.stiffness * dt));
}
// ── look target + orientation ────────────────────────────────────────
if (ballCam) {
focus.copy(carPos).lerp(ballPos, 0.5);
focus.y += CAMERA.ballCamHeightBias;
} else {
focus.set(
carPos.x + heading.x * CAMERA.lookAhead,
carPos.y + CAMERA.lookUp,
carPos.z + heading.z * CAMERA.lookAhead,
);
}
// CameraSystem copies this orientation directly to Shade. Set the
// position first; lookAt reads it.
camT.setTranslation(camPos.x, camPos.y, camPos.z);
camT.lookAt(focus, WORLD_UP);
// ── speed FOV: widen a touch when fast so it "feels" faster ──────────
// Straight onto the Camera COMPONENT. Under Shade the facade owns one
// camera for the whole engine and CameraSystem copies the active entity's
// lens onto it every tick, so writing the component is both the only thing
// that sticks and free — nothing is rebuilt. (The v2 code had to bypass the
// component and poke the three camera, because a Camera.fov change there
// replaced the whole PerspectiveCamera and hitched the post-process chain.)
const speed = Math.hypot(vel[0], vel[1], vel[2]);
const targetFov = CAMERA.fov + CAMERA.fovBoost * Math.min(speed / CAMERA.fovSpeedRef, 1);
currentFov += (targetFov - currentFov) * (1 - Math.exp(-CAMERA.fovStiffness * dt));
if (Math.abs(camComponent.fov.getValue() - currentFov) > 1e-3) {
camComponent.fov.set(currentFov);
}
// CameraSystem's regular ECS update already ran before preRender. Copy
// the final pose, lens and frustum now so the draw and later preRender
// consumers (such as distance highlights) use this frame's camera.
cameraSystem.update(dt);
}
engine.graphics.on.preRender.add(frame);
return {
snap() { started = false; },
};
}
// carDefIO — pure (framework-free) conversion between a car-def JSON and the
// editor's internal model, plus the wheel-axle maths. Kept out of editor.js so it
// can be unit-tested under `node --test` with no renderer / DOM.
//
// Every attachment is edited as a Transform64: { position[3], rotation[4] (xyzw),
// scale[3] }. Base marker size is 1, so the scale alone sets the visible size.
// Wheels additionally carry their GLTF node name(s); a wheel's `rotation` IS its
// spin quaternion — the rolling AXLE is `rotation · +X̂`.
const IDENTITY = [0, 0, 0, 1];
/** Rotate vector v (array[3]) by quaternion q (array[4] xyzw). Returns array[3]. */
export function applyQuat(q, v) {
const [x, y, z, w] = q;
const [vx, vy, vz] = v;
// t = 2 * cross(q.xyz, v)
const tx = 2 * (y * vz - z * vy);
const ty = 2 * (z * vx - x * vz);
const tz = 2 * (x * vy - y * vx);
// v' = v + w*t + cross(q.xyz, t)
return [
vx + w * tx + (y * tz - z * ty),
vy + w * ty + (z * tx - x * tz),
vz + w * tz + (x * ty - y * tx),
];
}
/** The rolling axle (unit array[3]) a wheel `rotation` quaternion defines: q · +X̂. */
export function axleFromQuat(q) {
const a = applyQuat(q, [1, 0, 0]);
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
}
const v3 = (a, d = [0, 0, 0]) => (Array.isArray(a) && a.length >= 3 ? [a[0], a[1], a[2]] : d.slice());
const v4 = (a, d = IDENTITY) => (Array.isArray(a) && a.length >= 4 ? [a[0], a[1], a[2], a[3]] : d.slice());
/**
* Convert a loaded car-def (the existing carDefs.js shape OR an already-Transform64
* shape this tool exported) into a flat, editable model.
* @returns {{ id, model, body, attachments: Array }}
* each attachment: { kind, label, position, rotation, scale, nodes? }
*/
export function defToModel(def) {
const attachments = [];
// wheels: position = suspension-ray mount (car-local); rotation = spin quat;
// nodes = the GLTF parts it drives.
const LABELS = ["FL", "FR", "BL", "BR"];
const rawWheels = def.wheels || [];
const wheelNodes = def.wheelNodes || [];
// New (this tool's export) shape: wheels = [{ nodes, spin, position }].
const newWheelShape = rawWheels.length > 0 && typeof rawWheels[0] === "object" && !Array.isArray(rawWheels[0]);
if (newWheelShape) {
rawWheels.forEach((w, i) => {
attachments.push({
kind: "wheel", label: `wheel ${LABELS[i] || i}`,
position: v3(w.position), rotation: v4(w.spin), scale: [1, 1, 1],
nodes: (w.nodes || []).slice(),
});
});
} else {
// Old (carDefs.js) shape: wheels = [[x,y,z]], wheelNodes = [[{name,spin}|"name"]].
const wheelCount = Math.max(wheelNodes.length, rawWheels.length);
for (let i = 0; i < wheelCount; i++) {
const parts = wheelNodes[i] || [];
let nodes, spin;
if (parts.length > 0 && typeof parts[0] === "object") {
nodes = parts.map((p) => p.name);
spin = v4(parts[0].spin);
} else {
nodes = parts.slice();
spin = IDENTITY.slice();
}
attachments.push({
kind: "wheel", label: `wheel ${LABELS[i] || i}`,
position: v3(rawWheels[i]), rotation: spin, scale: [1, 1, 1], nodes,
});
}
}
// mount-style attachments. Accept both the old shapes (position/size or bare
// [x,y,z]) and the Transform64 shape ({position,rotation,scale}).
const asTransform = (entry, fallbackScale = 1) => {
if (Array.isArray(entry)) return { position: v3(entry), rotation: IDENTITY.slice(), scale: [fallbackScale, fallbackScale, fallbackScale] };
const s = entry.scale !== undefined ? v3(entry.scale) : (entry.size !== undefined ? [entry.size, entry.size, entry.size] : [fallbackScale, fallbackScale, fallbackScale]);
return { position: v3(entry.position), rotation: v4(entry.rotation), scale: s };
};
const pushList = (list, kind, label) => {
(list || []).forEach((entry, i) => {
const t = asTransform(entry);
attachments.push({ kind, label: `${label} ${i}`, position: t.position, rotation: t.rotation, scale: t.scale });
});
};
pushList(def.exhausts, "exhaust", "exhaust");
pushList(def.trails || def.trailMounts, "trail", "trail");
pushList(def.headlights, "headlight", "headlight");
if (def.boost || def.boostMount) {
const t = asTransform(def.boost || def.boostMount);
attachments.push({ kind: "boost", label: "boost", position: t.position, rotation: t.rotation, scale: t.scale });
}
return {
id: def.id || "car",
name: def.name || def.id || "Car",
model: def.model || { scale: 1, yaw: 0, offset: [0, 0, 0] },
body: def.body || { half: [1, 0.5, 2], mass: 180 },
wheelSpin: def.wheelSpin !== undefined ? def.wheelSpin : 1,
attachments,
};
}
const round = (n, p = 5) => {
const f = Math.pow(10, p);
return Math.round(n * f) / f;
};
const r3 = (a) => a.map((n) => round(n));
const r4 = (a) => a.map((n) => round(n));
/**
* Serialize the editable model back to a car-def JSON object, in the RUNTIME
* carDefs.js shape so it pastes straight in: `wheels` (suspension positions) +
* `wheelNodes` ([[{name, spin}]], every part of a wheel gets that wheel's edited
* spin quaternion) + `wheelSpin`. Mount attachments are Transforms
* ({position, rotation, scale}; base box size is 1, so scale sets the size).
*/
export function modelToDef(model) {
const wheels = model.attachments.filter((a) => a.kind === "wheel");
const pick = (kind) => model.attachments.filter((a) => a.kind === kind);
const xf = (a) => ({ position: r3(a.position), rotation: r4(a.rotation), scale: r3(a.scale) });
const out = {
id: model.id,
name: model.name,
model: model.model,
body: model.body,
wheels: wheels.map((w) => r3(w.position)),
wheelNodes: wheels.map((w) => w.nodes.map((name) => ({ name, spin: r4(w.rotation) }))),
wheelSpin: model.wheelSpin !== undefined ? model.wheelSpin : 1,
exhausts: pick("exhaust").map(xf),
trails: pick("trail").map(xf),
headlights: pick("headlight").map(xf),
};
const boost = pick("boost")[0];
if (boost) out.boost = xf(boost);
return out;
}
// CarVfx — the per-car effect kit.
//
// Exhaust smoke is a persistent always-on emitter mounted on the car (it stays
// populated, so the particle system never culls + sleeps it). Boost flame and
// tyre dust are spawned as short-lived transient burst puffs at their mount
// world-positions while active — a persistent emitter that drops to a low rate
// loses its particles, gets culled/slept, and never restarts, whereas a burst
// puff bootstraps with particles every time. A boost point-light and a speed/boost
// trail round out the kit. update(dt) reads the controller's runtime state to gate
// everything.
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { EntityNode } from "@woosh/meep-engine/src/engine/ecs/parent/EntityNode.js";
import { ParticleEmitter } from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js";
import { ParticleEmitterFlag } from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitterFlag.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 Trail3D from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3D.js";
import { linearFromHex } from "../lib/shade.js";
import { exhaustSmokeSpec, tireDustSpec, boostFlameSpec } from "./particleSpecs.js";
import { attachmentPosition, attachmentRotation, attachmentScale } from "../vehicles/carDefs.js";
const _q = new Quaternion();
const _qMount = new Quaternion();
const _qWorld = new Quaternion();
const _fwd = new Vector3();
const _up = new Vector3();
const _right = new Vector3();
const _w = new Vector3();
export class CarVfx {
/**
* @param {import("../vehicles/CarController.js").CarController} controller
* @param {import("./vfx.js").Vfx} vfx shared transient-effect spawner
*/
constructor(controller, vfx) {
this.c = controller;
this.vfx = vfx;
const def = controller.def;
const node = controller.node;
this.flameSpec = () => boostFlameSpec(controller.team.color);
// persistent exhaust smoke (always emitting, just rate-modulated).
// attachmentPosition reads either the legacy {position,size} or the
// editor's {position,rotation,scale} Transform64 shape.
this.exhaustEmitters = (def.exhausts || []).map((e) => {
const p = attachmentPosition(e);
const emitter = new ParticleEmitter();
emitter.fromJSON(exhaustSmokeSpec());
emitter.position.set(0, 0, 0);
const child = EntityNode.fromComponents(new Transform64(), emitter);
child.transform.setTranslation(p[0], p[1], p[2]);
node.addChild(child);
return emitter;
});
this.exhaustLayers = this.exhaustEmitters.map((e) => e.layers.get(0));
// mount (car-local) for the boost puff; tyre dust is spawned per-wheel at
// the RaycastVehicle contact points in update(). `boost` (Transform64) is the
// editor's key; `boostMount` ([x,y,z]) is the legacy one. The Transform64's
// rotation orients the emission box and its scale sizes it (box-volume).
const boostEntry = def.boost || def.boostMount;
this.boostMount = attachmentPosition(boostEntry, [0, 0, -1.7]);
this.boostRot = attachmentRotation(boostEntry); // [x,y,z,w] (identity if legacy)
this.boostScale = attachmentScale(boostEntry); // [x,y,z] box size, or null (legacy → spec default)
// boost point-light: lit only while boosting
this.boostLight = new Light();
this.boostLight.type.set(LightType.POINT);
this.boostLight.color.copy(linearFromHex(controller.team.color));
this.boostLight.intensity.set(0);
this.boostLight.distance.set(11);
const lightNode = EntityNode.fromComponents(new Transform64(), this.boostLight);
lightNode.transform.setTranslation(this.boostMount[0], this.boostMount[1] + 0.2, this.boostMount[2]);
node.addChild(lightNode);
// speed/boost trails — one per rear mount point (behind the body), not the
// car centre, so they stream from the back of the car.
// trail width is in world units; a mount's box is normalised (base 1), so
// the width is the largest axis of its scale (legacy mounts → 0.55 default).
const trailEntries = def.trails || def.trailMounts || [[0, 0.25, -1.6]];
const mounts = trailEntries.map((e) => attachmentPosition(e));
this.trailWidths = trailEntries.map((e) => {
const s = attachmentScale(e);
return s ? Math.max(s[0], s[1], s[2]) : 0.55;
});
// Trail3D is a volumetric tube extruded around the mount's path. Knot alpha
// is aged out by the tube simulator (color.a only seeds the build), so the
// trail is shown/hidden by animating `width` — a zero-diameter tube is
// invisible. Untextured: the tube reads as a solid ribbon of team colour.
this.trails = mounts.map((m) => {
const trail = new Trail3D();
trail.maxAge = 0.5;
trail.width = 0;
trail.color.copy(linearFromHex(controller.team.color));
const child = EntityNode.fromComponents(new Transform64(), trail);
child.transform.setTranslation(m[0], m[1], m[2]);
node.addChild(child);
return trail;
});
// let the controller clear these on teleport (kickoff / respawn) so they
// don't draw a streak from the car's old position to its reset pose.
controller.trails.push(...this.trails);
// keep exhaust awake when the fast car flies far from its link-time bounds
this._flameTimer = 0;
this._dustTimer = 0;
}
/** car-local point → world (into _w) using the current pose. */
_toWorld(local) {
const tr = this.c.transform;
_q.set(tr.rotation_x, tr.rotation_y, tr.rotation_z, tr.rotation_w);
_fwd.set(0, 0, 1).applyQuaternion(_q);
_up.set(0, 1, 0).applyQuaternion(_q);
_right.set(1, 0, 0).applyQuaternion(_q);
_w.set(tr.translation_x, tr.translation_y, tr.translation_z)
._add(_right.x * local[0], _right.y * local[0], _right.z * local[0])
._add(_up.x * local[1], _up.y * local[1], _up.z * local[1])
._add(_fwd.x * local[2], _fwd.y * local[2], _fwd.z * local[2]);
return _w;
}
update(dt) {
const st = this.c.state;
// keep exhaust simulating even when the car is off-screen (it moves far
// from its link-time bounds, where the culler would otherwise sleep it)
for (const e of this.exhaustEmitters) e.clearFlag(ParticleEmitterFlag.Sleeping);
// exhaust: idle wisp, thicker under throttle/boost
const exRate = st.boosting ? 30 : (st.throttling ? 24 : 9);
for (const l of this.exhaustLayers) l.emissionRate = exRate;
// boost flame puffs (additive blending) + boost light
if (st.boosting) {
this._flameTimer -= dt;
if (this._flameTimer <= 0) {
this._flameTimer = 0.03;
const w = this._toWorld(this.boostMount); // sets _q=car rot, _fwd=car forward
// box-volume flame: orient the box by car·mount rotation + size it
// by the mount scale; aim the velocity car-backward (−forward) so it
// always trails behind regardless of how the mount is rotated.
_qMount.set(this.boostRot[0], this.boostRot[1], this.boostRot[2], this.boostRot[3]);
_qWorld.copy(_q).multiply(_qMount);
const spec = this.flameSpec();
const L = spec.layers[0];
if (this.boostScale) L.scale = { x: this.boostScale[0], y: this.boostScale[1], z: this.boostScale[2] };
L.particleVelocityDirection.direction = { x: -_fwd.x, y: -_fwd.y, z: -_fwd.z };
this.vfx.puff(spec, w.x, w.y, w.z, 0.35, _qWorld);
}
this.boostLight.intensity.set(5.5);
} else {
this.boostLight.intensity.set(0);
}
// tyre dust — per wheel: a wheel only kicks dust if IT is in contact, and
// the car is braking / sliding / boosting hard. Spawned at each wheel's
// actual RaycastVehicle contact point (4WD → up to four sources).
const dusting = st.sliding || st.braking || (st.boosting && st.speed > 8);
if (dusting) {
this._dustTimer -= dt;
if (this._dustTimer <= 0) {
this._dustTimer = 0.045;
for (const wheel of this.c.vehicle.wheels) {
if (!wheel.inContact) continue;
const cp = wheel.contactPoint;
this.vfx.puff(tireDustSpec(), cp[0], cp[1] + 0.05, cp[2], 0.65);
}
}
}
// Trails: grow at speed / while boosting, taper away otherwise. Trail3D
// writes `width` into each new head knot, so lerping it tapers the tube.
const fast = st.boosting || st.speed > 17;
for (let i = 0; i < this.trails.length; i++) {
const trail = this.trails[i];
const targetW = fast ? this.trailWidths[i] : 0; // width = mount's largest scale axis
trail.width += (targetW - trail.width) * Math.min(1, dt * 8);
}
}
}
// DistanceHighlights — modulates a mesh outline's opacity by how big the thing is
// on screen, so the ball + opponent get a highlight that fades IN as they recede
// into the distance (and off when they're close, where you can already see them).
//
// Each target is approximated as a sphere (the ball's real radius; the car's body
// bounding sphere). meep's `sphere_project` gives its projected screen area (an
// exact perspective-sphere projection — Inigo Quilez's formula), so we just feed it
// the world-space sphere + the camera's world→view matrix + focal length and lerp
// opacity off the result: full at HIGHLIGHT.areaFadeFull, zero at .areaFadeStart.
//
// (We build the view matrix from the camera entity Transform64 — set fresh this frame
// by the chase camera — rather than the renderer camera's own matrix, which lags a
// frame in preRender. The projection uses z² and |centre|², both sign-symmetric, so
// meep's vs the renderer's camera-forward convention doesn't matter.)
import { sphere_project } from "@woosh/meep-engine/src/core/geom/3d/sphere/sphere_project.js";
import { m4_invert } from "@woosh/meep-engine/src/core/geom/3d/mat4/m4_invert.js";
import { clamp } from "@woosh/meep-engine/src/core/math/clamp.js";
import { HIGHLIGHT } from "../tuning.js";
export class DistanceHighlights {
/**
* @param {Engine} engine
* @param {import("@woosh/meep-engine/src/engine/ecs/transform/Transform64.js").Transform64} cameraTransform
* the camera entity's Transform64 (its world pose this frame).
*/
constructor(engine, cameraTransform) {
this.engine = engine;
this.cameraTransform = cameraTransform;
/** @type {Array<{def:object, getCenter:Function, radius:number}>} */
this.targets = [];
this._view = new Float32Array(16); // world → camera
this._sph = [0, 0, 0, 0]; // scratch sphere [x,y,z,r]
}
/**
* @param {import("@woosh/meep-engine/src/engine/graphics/ecs/highlight/HighlightDefinition.js").HighlightDefinition} def
* the highlight element whose `color.a` we drive each frame.
* @param {() => import("@woosh/meep-engine/src/engine/ecs/transform/Transform64.js").Transform64} getCenter
* the target's world transform; its translation is the sphere centre.
* @param {number} radius approximate sphere radius of the target.
*/
track(def, getCenter, radius) {
this.targets.push({ def, getCenter, radius });
return this;
}
/** Call once per rendered frame (preRender), after the camera pose is set. */
update() {
// `graphics.camera` is a thin adapter over Shade's camera in the shape the
// engine's older consumers read; `.camera` is the real one, whose `fov` is
// in RADIANS (the ECS Camera component is the one that speaks degrees).
const cam = this.engine.graphics.camera.camera;
if (cam === null || cam === undefined || this.targets.length === 0) {
return;
}
// current world→camera matrix from the (just-updated) camera Transform64
this.cameraTransform.updateMatrix();
m4_invert(this._view, this.cameraTransform.matrix);
const fl = 1 / Math.tan(cam.fov / 2);
const start = HIGHLIGHT.areaFadeStart;
const span = Math.max(1e-6, start - HIGHLIGHT.areaFadeFull);
for (let i = 0; i < this.targets.length; i++) {
const t = this.targets[i];
const c = t.getCenter();
this._sph[0] = c.translation_x;
this._sph[1] = c.translation_y;
this._sph[2] = c.translation_z;
this._sph[3] = t.radius;
const area = sphere_project(this._sph, this._view, fl); // projected screen area
t.def.color.a = clamp((start - area) / span, 0, 1) * HIGHLIGHT.maxOpacity;
}
}
}
// Particle-emitter spec factories.
//
// Each returns a plain spec object for ParticleEmitter.fromJSON (same schema as
// the `particles` example). Factories (not static JSON) so colours can be tinted
// per team. Emission shape/from/blending use the engine enums:
// EmissionShapeType.Sphere=0 EmissionFromType.Volume=1 BlendingType.Add=1
//
// All emitters are authored at the origin; the entity Transform64 (an EntityNode
// child mounted on the car) places them. New particles spawn at the emitter's
// current world position then drift in world space, so they trail behind a
// moving car for free.
const PT = "./textures/particle";
function rgb(hex) {
return [((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255];
}
// scale-over-life track (size multiplier)
const scaleTrack = (data, positions) => ({ name: "scale", track: { itemSize: 1, data, positions } });
// colour-over-life track from [ {rgb:[r,g,b], a, at} ... ]
function colorTrack(stops) {
const data = [];
const positions = [];
for (const s of stops) { data.push(s.rgb[0], s.rgb[1], s.rgb[2], s.a); positions.push(s.at); }
return { name: "color", track: { itemSize: 4, data, positions } };
}
function spec(blendingMode, layer) {
return {
position: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 },
rotation: { x: 0, y: 0, z: 0, w: 1 },
parameters: [
{ name: "scale", itemSize: 1, defaultTrackValue: { itemSize: 1, data: [1], positions: [0] } },
{ name: "color", itemSize: 4, defaultTrackValue: { itemSize: 4, data: [1, 1, 1, 1], positions: [0] } },
],
blendingMode,
layers: [layer],
};
}
// Exhaust smoke — thin grey puffs drifting up and back.
export function exhaustSmokeSpec() {
return spec(0, {
imageURL: `${PT}/Smoke_08.png`,
particleLife: { min: 0.45, max: 0.85 },
particleSize: { min: 0.22, max: 0.4 },
particleRotation: { min: 0, max: 6.28 },
particleRotationSpeed: { min: -1.2, max: 1.2 },
emissionShape: 0, emissionFrom: 1,
emissionRate: 16, emissionImmediate: 0,
position: { x: 0, y: 0, z: 0 }, scale: { x: 0.12, y: 0.12, z: 0.12 },
particleVelocityDirection: { direction: { x: 0, y: 0.70710678, z: -0.70710678 }, angle: 0.6 },
particleSpeed: { min: 0.8, max: 1.8 },
parameterTracks: [
scaleTrack([1, 2.2, 3.2], [0, 0.5, 1]),
colorTrack([
{ rgb: [0.55, 0.56, 0.6], a: 0, at: 0 },
{ rgb: [0.5, 0.5, 0.54], a: 0.34, at: 0.25 },
{ rgb: [0.45, 0.45, 0.48], a: 0.2, at: 0.6 },
{ rgb: [0.4, 0.4, 0.43], a: 0, at: 1 },
]),
],
});
}
// Tyre dust — tan dust kicked up while sliding / braking. Used as a transient
// burst puff (emissionImmediate): persistent gated emitters that drop to a low
// rate get culled + slept by the particle system and never restart, so each puff
// is its own short-lived emitter that bursts on spawn and is then removed.
export function tireDustSpec() {
return spec(0, {
imageURL: `${PT}/Smoke_14.png`,
particleLife: { min: 0.35, max: 0.6 },
particleSize: { min: 0.3, max: 0.55 },
particleRotation: { min: 0, max: 6.28 },
particleRotationSpeed: { min: -2, max: 2 },
emissionShape: 0, emissionFrom: 1,
emissionRate: 0, emissionImmediate: 5,
position: { x: 0, y: 0, z: 0 }, scale: { x: 0.15, y: 0.05, z: 0.15 },
particleVelocityDirection: { direction: { x: 0, y: 1, z: 0 }, angle: 0.85 },
particleSpeed: { min: 1.2, max: 3 },
parameterTracks: [
scaleTrack([0.8, 1.8, 2.6], [0, 0.5, 1]),
colorTrack([
{ rgb: [0.72, 0.64, 0.48], a: 0, at: 0 },
{ rgb: [0.7, 0.62, 0.46], a: 0.35, at: 0.2 },
{ rgb: [0.66, 0.58, 0.42], a: 0.175, at: 0.6 },
{ rgb: [0.6, 0.52, 0.38], a: 0, at: 1 },
]),
],
});
}
// Boost flame — additive, team-tinted, short and fast out the back. Transient
// burst puff (see tireDustSpec) spawned repeatedly while boosting. Emitted from a
// BOX volume (emissionShape 1); CarVfx overrides the box `scale` from the boost
// mount's Transform64 and aims the velocity car-backward at spawn time.
export function boostFlameSpec(teamHex) {
const c = rgb(teamHex);
return spec(1, {
imageURL: `${PT}/Circle_02.png`,
particleLife: { min: 0.16, max: 0.32 },
particleSize: { min: 0.45, max: 0.85 },
particleRotation: { min: 0, max: 6.28 },
particleRotationSpeed: { min: 0, max: 0 },
emissionShape: 1, emissionFrom: 1, // box, from its volume
emissionRate: 0, emissionImmediate: 5,
position: { x: 0, y: 0, z: 0 }, scale: { x: 0.2, y: 0.2, z: 0.2 },
particleVelocityDirection: { direction: { x: 0, y: 0, z: -1 }, angle: 0.28 },
particleSpeed: { min: 6, max: 11 },
parameterTracks: [
scaleTrack([1.5, 1.0, 0.25], [0, 0.5, 1]),
colorTrack([
{ rgb: [1, 0.95, 0.8], a: 0.9, at: 0 },
{ rgb: [c[0] * 1.2, c[1] * 1.2, c[2] * 1.2], a: 0.8, at: 0.35 },
{ rgb: c, a: 0.3, at: 0.75 },
{ rgb: c, a: 0, at: 1 },
]),
],
});
}
// Collision dust — a one-shot burst (used transiently at an impact point).
export function collisionDustSpec() {
return spec(0, {
imageURL: `${PT}/Smoke_14.png`,
particleLife: { min: 0.35, max: 0.7 },
particleSize: { min: 0.4, max: 0.9 },
particleRotation: { min: 0, max: 6.28 },
particleRotationSpeed: { min: -2.5, max: 2.5 },
emissionShape: 0, emissionFrom: 1,
emissionRate: 0, emissionImmediate: 22, // burst on spawn
position: { x: 0, y: 0, z: 0 }, scale: { x: 0.3, y: 0.3, z: 0.3 },
particleVelocityDirection: { direction: { x: 0, y: 1, z: 0 }, angle: 3.14 },
particleSpeed: { min: 2, max: 6 },
parameterTracks: [
scaleTrack([0.9, 1.8, 2.6], [0, 0.5, 1]),
colorTrack([
{ rgb: [0.78, 0.74, 0.66], a: 0.6, at: 0 },
{ rgb: [0.72, 0.68, 0.6], a: 0.35, at: 0.4 },
{ rgb: [0.66, 0.62, 0.54], a: 0, at: 1 },
]),
],
});
}
// Vfx — short-lived world effects: point-light flashes and dust bursts for
// impacts, and a goal celebration. Shade bins lights into froxels and applies
// them deferred, so dozens of short-lived POINT lights cost the frame very little;
// each one is removed once it has faded.
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.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 { ParticleEmitter } from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js";
import { linearFromHex } from "../lib/shade.js";
import { collisionDustSpec } from "./particleSpecs.js";
export class Vfx {
constructor(ctx) {
this.ecd = ctx.ecd;
this._lights = []; // { entity, light, ttl, life, peak }
this._emitters = []; // { entity, ttl }
}
/** A point-light that flares then fades over `life` seconds. */
flash(x, y, z, colorHex, peak = 5, distance = 16, life = 0.35) {
const light = new Light();
light.type.set(LightType.POINT);
light.color.copy(linearFromHex(colorHex)); // light colours are linear too
light.intensity.set(peak);
light.distance.set(distance);
const t = new Transform64();
t.setTranslation(x, y, z);
const entity = new Entity().add(t).add(light).build(this.ecd);
this._lights.push({ entity, light, ttl: life, life, peak });
}
/**
* Spawn a transient burst emitter from a spec at a point (removed after ttl).
* Optional `rot` {x,y,z,w} orients the emitter (e.g. a box emission volume).
*/
puff(spec, x, y, z, ttl = 0.9, rot = null) {
const emitter = new ParticleEmitter();
emitter.fromJSON(spec);
emitter.position.set(0, 0, 0);
const t = new Transform64();
t.setTranslation(x, y, z);
if (rot) {
t.setRotation(rot.x, rot.y, rot.z, rot.w);
t.updateMatrix();
}
const entity = new Entity().add(t).add(emitter).build(this.ecd);
this._emitters.push({ entity, ttl });
}
/** Impact: a dust puff + a quick white flash, scaled by strength (0..1). */
impact(x, y, z, strength) {
this.puff(collisionDustSpec(), x, y, z, 1.0);
this.flash(x, y, z, 0xffffff, 2 + strength * 5, 10 + strength * 12, 0.28);
}
/** Goal celebration: a ring of team-coloured flashes at the net. */
goalCelebration(x, y, z, colorHex) {
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2;
this.flash(x + Math.cos(a) * 9, y + 2 + (i % 3) * 2.5, z + Math.sin(a) * 5, colorHex, 7, 20, 1.1 + (i % 4) * 0.2);
}
this.flash(x, y + 4, z, colorHex, 12, 34, 1.6);
}
update(dt) {
for (let i = this._lights.length - 1; i >= 0; i--) {
const e = this._lights[i];
e.ttl -= dt;
if (e.ttl <= 0) {
this.ecd.removeEntity(e.entity);
this._lights.splice(i, 1);
} else {
// ease out
const k = e.ttl / e.life;
e.light.intensity.set(e.peak * k * k);
}
}
for (let i = this._emitters.length - 1; i >= 0; i--) {
const e = this._emitters[i];
e.ttl -= dt;
if (e.ttl <= 0) {
this.ecd.removeEntity(e.entity);
this._emitters.splice(i, 1);
}
}
}
}
// Off-screen ball tracker.
//
// A HeadsUpDisplay + ViewportPosition + GUIElement attached to the ball. The
// marker is hidden while the ball is on screen and, via stickToScreenEdge,
// clamps to the screen edge as a chevron when the ball leaves the view — rotated
// to point at the (off-screen) ball. The component types must already be
// registered on the dataset.
import HeadsUpDisplay from "@woosh/meep-engine/src/engine/ecs/gui/hud/HeadsUpDisplay.js";
import ViewportPosition from "@woosh/meep-engine/src/engine/ecs/gui/position/ViewportPosition.js";
import GUIElement from "@woosh/meep-engine/src/engine/ecs/gui/GUIElement.js";
import EmptyView from "@woosh/meep-engine/src/view/elements/EmptyView.js";
/**
* @param {EntityComponentDataset} ecd
* @param {number} ballEntityId
* @param {Engine} engine
*/
export function attachBallTracker(ecd, ballEntityId, engine) {
const view = new EmptyView({ classList: ["ball-tracker"] });
view.size.set(34, 34);
const hud = new HeadsUpDisplay();
// Centre the marker ON the ball. worldOffset is rotated by the entity's
// Transform64 (HeadsUpDisplayFlag.TransformWorldOffset), so any non-zero offset
// would swing around as the ball spins — (0,0,0) tracks the centre cleanly.
hud.worldOffset.set(0, 0, 0);
const vp = new ViewportPosition();
vp.anchor.set(0.5, 0.5);
vp.stickToScreenEdge = true; // clamp to the edge (+ sticky CSS class) when off-screen
vp.screenEdgeWidth = 26;
ecd.addComponentToEntity(ballEntityId, hud);
ecd.addComponentToEntity(ballEntityId, vp);
ecd.addComponentToEntity(ballEntityId, GUIElement.fromView(view));
// Aim the edge chevron at the ball. `vp.position` is the ball's normalized
// screen position (0.5,0.5 = centre, Y down), written by HeadsUpDisplaySystem
// each preRender; the direction from centre to it is where the ball is. We
// feed the angle to a CSS variable the chevron (::after) rotates by.
const el = view.el;
engine.graphics.on.postRender.add(() => {
const dx = vp.position.x - 0.5;
const dy = vp.position.y - 0.5;
if (dx === 0 && dy === 0) return;
const deg = Math.atan2(dy, dx) * 180 / Math.PI;
el.style.setProperty("--ball-arrow-angle", `${deg.toFixed(1)}deg`);
});
return view;
}
// Arena level builder.
//
// "Inside a large open-topped bowl with a flat floor": a flat floor, a rounded-
// rectangle ring of vertical walls (long sides + chamfered corners), two goal
// mouths in the end walls with recessed nets, and a polyhedral dome that is now
// COLLISION-ONLY (invisible) — the roof is removed visually so the arena opens to
// the sky/environment map, but the dome still keeps the ball + cars inside.
// Everything is a static box body so cars and the ball can drive/bounce on any
// surface (walls and the invisible ceiling included). Goal detection uses a box
// IsSensor inside each net.
//
// buildArena(ctx, def) is self-contained and returns the spawn points + goal
// sensors, so adding another level later is just another def + a call here.
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { ColliderFlags } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderFlags.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometryFlags } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
import { make_plane_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_plane_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { boxGeometry, linearFromHex, standardMaterial } from "../lib/shade.js";
import { ARENA, TEAM } from "../tuning.js";
import { makeFieldTexture } from "./fieldTexture.js";
import { buildFieldDecals } from "./fieldDecals.js";
const _q = new Quaternion();
const _x = new Vector3();
const _y = new Vector3();
const _z = new Vector3();
const wallMat = (color = 0x4a515b) => standardMaterial({ color, roughness: 0.92, metalness: 0.04 });
/**
* Add a static box body + matching visual. `rot` is an optional world rotation
* quaternion {x,y,z,w}; half is local half-extents [hx,hy,hz]. With `sensor`,
* the collider is a trigger and no mesh is drawn.
*/
// Arena structure (walls, chamfers, dome panels, roof) neither casts nor
// receives shadows by default — those near-vertical / overhanging / overhead
// surfaces gain nothing from shadows and pick up shadow-map artifacts out at the
// edge of the tightened sun frustum. Only the flat floor (built separately)
// receives, so car shadows still land on the pitch.
function addBox(ctx, { pos, rot, half, color = 0x4a515b, mat, friction = 0.8, restitution = 0, sensor = false, visible = true, receiveShadow = false, castShadow = false }) {
const { ecd } = ctx;
const t = new Transform64();
t.setTranslation(pos[0], pos[1], pos[2]);
if (rot) {
t.setRotation(rot.x, rot.y, rot.z, rot.w);
// rotation is not in the matrix until it is composed
t.updateMatrix();
}
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = BoxShape3D.from(half[0], half[1], half[2]);
collider.friction = friction;
collider.restitution = restitution;
if (sensor) collider.setFlag(ColliderFlags.IsSensor);
const e = new Entity().add(t).add(body).add(collider);
if (visible && !sensor) {
// Shade wants a meshlet-clustered geometry; `boxGeometry` builds one per
// distinct size and shares it, the way the v2 code shared a BufferGeometry.
const mesh = ShadedGeometry.from(
boxGeometry(half[0] * 2, half[1] * 2, half[2] * 2),
mat || wallMat(color),
);
if (receiveShadow) mesh.setFlag(ShadedGeometryFlags.ReceiveShadow);
if (castShadow) mesh.setFlag(ShadedGeometryFlags.CastShadow);
e.add(mesh);
}
return e.build(ctx.ecd); // entity id
}
/**
* Quaternion whose local +X→x, +Y→y, +Z→z (orthonormal basis), as {x,y,z,w}.
* meep has no Matrix4, so this is expressed via Quaternion.lookRotation: it maps
* local +Z→forward and local +X→(up×forward). Feeding forward=z and up=(z×x)
* reproduces the basis exactly (the `y` arg is recomputed, as before).
*/
function basisQuat(x, y, z) {
_x.copy(x).normalize();
_z.copy(z).normalize();
_y.crossVectors(_z, _x).normalize(); // y = z × x
_q.lookRotation(_z, _y);
return { x: _q.x, y: _q.y, z: _q.z, w: _q.w };
}
/**
* @param {object} ctx { ecd, physics }
* @param {object} def arena definition (defaults to ARENA)
* @returns {{ goalSensors: Array, kickoffSpawns: object, ballSpawn:number[], dims: object }}
*/
export function buildArena(ctx, def = ARENA) {
const W = def.width, L = def.length;
const hw = W / 2, hl = L / 2;
const c = def.cornerChamfer;
const wh = def.wallHeight, dh = def.domeHeight;
const th = def.wallThickness;
const gw = def.goalWidth, gh = def.goalHeight, gd = def.goalDepth;
// ── floor: invisible box collider + a textured visual plane ───────────────
// (the pitch markings are baked into the plane's albedo texture; the boost-pad
// motifs are projected Decals.) Decals reach OPAQUE surfaces only — they are
// written into the G-buffer — so the floor material stays opaque.
addBox(ctx, {
pos: [0, -1, 0], half: [hw + gd, 1, hl + gd],
friction: def.floorFriction, visible: false,
});
{
// `make_plane_geometry` builds three's plane: the XY plane facing +Z. There
// is no geometry-space `rotateX` on a Shade `Geometry`, so the quarter turn
// that lays it flat lives on the entity Transform64 instead — the same result,
// one fewer copy of the vertex data.
const geo = meshlet_geometry_build_from_geometry(make_plane_geometry(W + gd * 2, L + gd * 2));
// Nothing special is needed to make the floor receive the pad lights and the
// projected decals any more: Shade bins lights into froxels and applies them
// deferred, and a decal is written into the G-BUFFER before anything is lit
// — so every opaque surface gets both, with no per-material opt-in. (The old
// renderer only lit and decalled materials that had been through its own
// material manager, so this one had to be pushed through it by hand.)
const floorMat = standardMaterial({ map: makeFieldTexture(def), roughness: 0.95, metalness: 0.0 });
const mesh = ShadedGeometry.from(geo, floorMat);
mesh.setFlag(ShadedGeometryFlags.ReceiveShadow);
const t = new Transform64();
t.setTranslation(0, 0.01, 0);
_q.fromAxisAngle(Vector3.right, -Math.PI / 2); // +Z normal → +Y (up)
t.setRotation(_q.x, _q.y, _q.z, _q.w);
t.updateMatrix();
new Entity().add(t).add(mesh).build(ctx.ecd);
}
// ── pitch markings projected onto the floor as decals ─────────────────────
// (the kickoff circles — high-res — and scattered tyre scuffs; the rest of the
// markings stay baked into the floor texture above.)
buildFieldDecals(ctx, def);
// ── long side walls (±X), inset from the corners by the chamfer ───────────
for (const sx of [-1, 1]) {
addBox(ctx, {
pos: [sx * (hw + th / 2), wh / 2, 0],
half: [th / 2, wh / 2, hl - c],
color: 0x474e58, restitution: def.wallRestitution,
});
}
// ── chamfered corners (45° panels) ────────────────────────────────────────
const cornerLen = c * Math.SQRT2;
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
// panel tangent runs along the diagonal; normal points inward
_x.set(-sx, 0, sz).normalize(); // along the chamfer
const rot = basisQuat(_x, new Vector3(0, 1, 0), new Vector3(sx, 0, sz).normalize());
addBox(ctx, {
pos: [sx * (hw - c / 2), wh / 2, sz * (hl - c / 2)],
rot, half: [cornerLen / 2, wh / 2, th / 2],
color: 0x515862, restitution: def.wallRestitution,
});
}
}
// ── end walls (±Z) with a goal opening ────────────────────────────────────
const sideRun = (hw - c) - gw / 2; // width of each post segment
for (const sz of [-1, 1]) {
const zWall = sz * (hl + th / 2);
// left & right posts
for (const side of [-1, 1]) {
const cx = side * (gw / 2 + sideRun / 2);
addBox(ctx, {
pos: [cx, wh / 2, zWall],
half: [sideRun / 2, wh / 2, th / 2],
color: 0x474e58, restitution: def.wallRestitution,
});
}
// lintel above the goal mouth
addBox(ctx, {
pos: [0, (gh + wh) / 2, zWall],
half: [gw / 2, (wh - gh) / 2, th / 2],
color: 0x474e58, restitution: def.wallRestitution,
});
}
// ── goal recesses + sensors ───────────────────────────────────────────────
// Blue defends −Z (its net is at −Z); a ball in the −Z net scores for Orange.
const goalSensors = [];
for (const sz of [-1, 1]) {
const team = sz < 0 ? TEAM.blue : TEAM.orange; // whose net this is
const scoringTeam = sz < 0 ? "orange" : "blue"; // who scores here
const zBack = sz * (hl + gd);
const tint = wallMat(team.color);
// Shade's emissive is a linear colour multiplied into the material's output;
// `standardMaterial` decodes the hex, so the 0.18 is applied to the result.
tint.emissive_factor.copy(linearFromHex(team.color));
tint.emissive_factor.multiplyScalar(0.18);
// Net interior, sunk one wall-thickness BEHIND the goal line so its tinted
// faces never sit coplanar with the gray end wall (which would z-fight at
// the opening edges). The gray wall frames the mouth; the tinted net recedes
// behind it. Front at sz·(hl+th), back at the back wall (sz·(hl+gd)).
const netDepth = gd - th;
const netZc = sz * (hl + (gd + th) / 2); // centre of the recessed span
// back wall of the net
addBox(ctx, { pos: [0, gh / 2, zBack], half: [gw / 2 + th, gh / 2, th / 2], mat: tint, restitution: 0.2 });
// net side walls
for (const side of [-1, 1]) {
addBox(ctx, { pos: [side * (gw / 2), gh / 2, netZc], half: [th / 2, gh / 2, netDepth / 2], mat: tint, restitution: 0.2 });
}
// net ceiling
addBox(ctx, { pos: [0, gh, netZc], half: [gw / 2, th / 2, netDepth / 2], mat: tint, restitution: 0.2 });
// goal sensor: a trigger box sitting inside the net mouth
const sensorId = addBox(ctx, {
pos: [0, gh / 2, sz * (hl + gd * 0.55)],
half: [gw / 2 - 0.5, gh / 2 - 0.3, gd * 0.4],
sensor: true,
});
goalSensors.push({ entity: sensorId, zSign: sz, scoringTeam, defendingTeam: team.id });
}
// ── dome: a ring of inward-slanted panels + a ceiling cap ──────────────────
buildDome(ctx, def);
// ── spawns ────────────────────────────────────────────────────────────────
// Start on diagonally-opposite side boost-pads (not dead-centre in front of a
// goal), each facing the centre ball — so simply driving forward goes for the
// ball, not straight into a goal.
const rideY = 1.0;
const px = hw * 0.7, pz = hl * 0.4;
const yawToCentre = (x, z) => Math.atan2(-x, -z); // face from (x,z) toward the ball at origin
const kickoffSpawns = {
blue: { position: [px, rideY, -pz], yaw: yawToCentre(px, -pz) }, // blue half (−Z), right side
orange: { position: [-px, rideY, pz], yaw: yawToCentre(-px, pz) }, // orange half (+Z), left side
};
const ballSpawn = [0, def && def.ballSpawnY ? def.ballSpawnY : 3.0, 0];
return {
goalSensors,
kickoffSpawns,
ballSpawn,
dims: { W, L, hw, hl, wallHeight: wh, domeHeight: dh, goalDepth: gd },
};
}
// A polyhedral dome: four side slabs + four corner slabs slanting inward from
// the wall top, then a flat rectangular ceiling cap.
//
// The dome is COLLISION-ONLY (every panel is `visible:false`): the roof is
// removed visually so the arena opens to the sky / environment map above the
// walls, but the invisible slanted panels + cap still keep the ball and cars
// inside. (Removing the colliders too would let a big boost or aerial launch the
// ball clean out of the arena.)
function buildDome(ctx, def) {
const hw = def.width / 2, hl = def.length / 2;
const c = def.cornerChamfer;
const wh = def.wallHeight, dh = def.domeHeight;
const th = def.wallThickness;
// how far the dome rim pulls inward by the time it reaches the apex height
const inset = Math.min(hw, hl) * 0.55;
const rise = dh - wh;
const slopeLen = Math.hypot(inset, rise);
const slopeHalf = slopeLen / 2;
const UP = new Vector3(0, 1, 0);
// four straight sides: outward normal o, tangent t
const sides = [
{ o: new Vector3(1, 0, 0), t: new Vector3(0, 0, 1), dist: hw, halfLen: hl - c },
{ o: new Vector3(-1, 0, 0), t: new Vector3(0, 0, 1), dist: hw, halfLen: hl - c },
{ o: new Vector3(0, 0, 1), t: new Vector3(1, 0, 0), dist: hl, halfLen: hw - c },
{ o: new Vector3(0, 0, -1), t: new Vector3(1, 0, 0), dist: hl, halfLen: hw - c },
];
for (const s of sides) {
// slope direction d goes inward (−o) and up
_z.copy(s.o).multiplyScalar(-inset)._add(UP.x * rise, UP.y * rise, UP.z * rise).normalize();
const rot = basisQuat(s.t, UP /* placeholder, recomputed in basisQuat */, _z);
const center = new Vector3()
.copy(s.o).multiplyScalar(s.dist)
._add(UP.x * wh, UP.y * wh, UP.z * wh)
._add(_z.x * slopeHalf, _z.y * slopeHalf, _z.z * slopeHalf);
addBox(ctx, {
pos: [center.x, center.y, center.z], rot,
half: [s.halfLen, th / 2, slopeHalf],
color: 0x3c424b, restitution: def.wallRestitution, visible: false,
});
}
// four corner slabs
const cornerLen = c * Math.SQRT2;
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const o = new Vector3(sx, 0, sz).normalize();
const t = new Vector3(-sx, 0, sz).normalize();
_z.copy(o).multiplyScalar(-inset)._add(UP.x * rise, UP.y * rise, UP.z * rise).normalize();
const rot = basisQuat(t, UP, _z);
const center = new Vector3()
.set(sx * (hw - c / 2), wh, sz * (hl - c / 2))
._add(_z.x * slopeHalf, _z.y * slopeHalf, _z.z * slopeHalf);
addBox(ctx, {
pos: [center.x, center.y, center.z], rot,
half: [cornerLen / 2, th / 2, slopeHalf],
color: 0x434a54, restitution: def.wallRestitution, visible: false,
});
}
}
// ceiling cap over the shrunken top
addBox(ctx, {
pos: [0, dh, 0], half: [hw - inset + c, th / 2, hl - inset + c],
color: 0x343a42, restitution: def.wallRestitution, visible: false,
});
}
// fieldDecals — projects the pitch's painted markings onto the floor as decals
// instead of baking them into the floor's diffuse texture:
// • centre markings: one high-res (512²) decal of the two kickoff circles, so
// the big circle stays crisp instead of being limited by the floor's texels,
// • tyre scuffs: many dark grungy streaks scattered + rotated across the pitch.
// Both are projected Decals — the same path the boost pads use. Under Shade a
// decal is written into the G-BUFFER before anything is lit, so it lands on any
// opaque surface with no per-material opt-in; its tint is therefore a LINEAR
// albedo, and an sRGB hex has to be decoded before it gets there (an undecoded
// near-black reads roughly ten times too bright against this very dark pitch).
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { linearFromHex } from "../lib/shade.js";
import { FIELD } from "../tuning.js";
const CENTER_URL = "./textures/decals/center_markings.png";
const SCUFF_URL = "./textures/decals/scuff.png";
// Project a decal's local +Z onto world −Y (straight down onto the floor); the
// scuffs reuse the same projection axis with an extra roll so they can point any
// way on the pitch.
const PROJECT_DOWN = new Quaternion();
PROJECT_DOWN.lookRotation(Vector3.down, Vector3.forward);
/**
* @param {{ecd}} ctx
* @param {{width:number, length:number}} def arena def (uses width / length)
*/
export function buildFieldDecals(ctx, def) {
const { ecd } = ctx;
// ── centre markings: the two kickoff circles, one crisp 512² decal ─────────
// Footprint is sized so the texture's big ring (at centerOuterFrac of the
// half-texture) lands exactly on the world kickoff circle.
{
const footprint = 2 * FIELD.centerKickoffRadius / FIELD.centerOuterFrac;
const decal = new Decal();
decal.uri = CENTER_URL;
decal.color.copy(linearFromHex(FIELD.centerColor));
decal.color.multiplyScalar(FIELD.centerBrightness); // dim the line tint so it reads bright, not blown
decal.color.a = 1; // multiplyScalar also scaled alpha; keep it opaque
// DecalSystem reads the transform's matrix, and a Transform64 leaves the
// rotation and scale out of it until updateMatrix composes them.
const t = new Transform64();
t.setTranslation(0, 0, 0); // centred on the ground → thin slab, won't catch the cars
t.setRotation(PROJECT_DOWN.x, PROJECT_DOWN.y, PROJECT_DOWN.z, PROJECT_DOWN.w);
t.setScale(footprint, footprint, FIELD.decalDepth);
t.updateMatrix();
new Entity().add(t).add(decal).build(ecd);
}
// ── tyre scuffs: scatter dark grungy streaks across the pitch ──────────────
let seed = FIELD.scuffSeed >>> 0;
const rnd = () => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; return (seed >>> 0) / 0xFFFFFFFF; };
const hx = def.width / 2 - FIELD.scuffMargin;
const hz = def.length / 2 - FIELD.scuffMargin;
const _dir = new Vector3();
const _scuffRotation = new Quaternion();
for (let i = 0; i < FIELD.scuffCount; i++) {
const x = (rnd() * 2 - 1) * hx;
const z = (rnd() * 2 - 1) * hz;
const size = FIELD.scuffSizeMin + rnd() * (FIELD.scuffSizeMax - FIELD.scuffSizeMin);
const op = FIELD.scuffOpacityMin + rnd() * (FIELD.scuffOpacityMax - FIELD.scuffOpacityMin);
const ang = rnd() * Math.PI * 2;
const decal = new Decal();
decal.uri = SCUFF_URL;
decal.color.copy(linearFromHex(FIELD.scuffColor));
decal.color.a = op; // tint.a scales the decal's coverage → per-scuff strength
const t = new Transform64();
t.setTranslation(x, 0, z); // centred on the ground (thin slab → won't catch the cars)
_dir.set(Math.sin(ang), 0, Math.cos(ang));
_scuffRotation.lookRotation(Vector3.down, _dir); // project down, rolled by `ang`
t.setRotation(_scuffRotation.x, _scuffRotation.y, _scuffRotation.z, _scuffRotation.w);
t.setScale(size, size, FIELD.decalDepth);
t.updateMatrix();
new Entity().add(t).add(decal).build(ecd);
}
}
// Field-floor texture.
//
// The broad strokes of the pitch — panel grid, halfway line, goal lines and areas
// — are baked straight into the floor's albedo texture with a 2D canvas, and only
// the marks that want to stay crisp at any zoom (the kickoff circles, the tyre
// scuffs) are projected decals on top. Reliable, and it reads like a painted pitch.
//
// Shade has no `CanvasTexture`: the canvas' pixels become a `ShadeImage` and then
// a `ShadeTexture` (see lib/shade.js), which is the same one-liner in the end.
import { textureFromCanvas } from "../lib/shade.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import { color_desaturate } from "@woosh/meep-engine/src/core/color/operations/color_desaturate.js";
/**
* @param {object} def arena def (uses width / length / goalWidth)
* @returns {import("@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js").ShadeTexture}
*/
export function makeFieldTexture(def) {
const W = def.width, L = def.length;
const PXU = 12; // canvas px per world unit
const cw = Math.round(W * PXU), ch = Math.round(L * PXU);
const cv = document.createElement("canvas");
cv.width = cw; cv.height = ch;
const g = cv.getContext("2d");
// world (x,z) → canvas (px,py); x∈[-W/2,W/2], z∈[-L/2,L/2]
const X = (x) => (x / W + 0.5) * cw;
const Y = (z) => (z / L + 0.5) * ch;
const R = (u) => u * PXU;
// base — kept dark on purpose. The flat, up-facing floor catches the full
// HDR-sky image-based light, so it's specular-bright regardless of albedo; a
// dark diffuse base keeps the pitch from going pale and lets the bright painted
// markings below pop as near-white lines. (Shade tonemaps and auto-exposes the
// frame itself — see RENDER in tuning.js — so this no longer has to be
// near-black, but a dark pitch reads well, so we keep it.)
g.fillStyle = "#0c0d10";
g.fillRect(0, 0, cw, ch);
// halves tint (subtle blue / orange) — knocked back to 70% saturation (−30%) via
// meep's color_desaturate. It works in OKHSV/linear space, so round-trip the
// sRGB swatch through linear around it; the 0.05 overlay alpha is unchanged.
const desat30 = (r8, g8, b8) => {
const c = new Color(); c.setRGBUint8(r8, g8, b8);
const d = color_desaturate(Color.from_sRGB_to_linear(c), 0.6); // saturation × (1 − 0.3)
const o = Color.from_linear_to_sRGB(d);
return `rgba(${Math.round(o.r * 255)},${Math.round(o.g * 255)},${Math.round(o.b * 255)},0.05)`;
};
g.fillStyle = desat30(78, 168, 240); g.fillRect(0, 0, cw, ch / 2);
g.fillStyle = desat30(240, 162, 60); g.fillRect(0, ch / 2, cw, ch / 2);
// (tyre scuffs are now scattered projected decals — see fieldDecals.js — so
// they're crisper and can vary per-scuff, instead of being baked in here.)
// panel grid
g.strokeStyle = "rgba(255,255,255,0.05)";
g.lineWidth = 1;
for (let x = -W / 2; x <= W / 2; x += 8) { g.beginPath(); g.moveTo(X(x), 0); g.lineTo(X(x), ch); g.stroke(); }
for (let z = -L / 2; z <= L / 2; z += 8) { g.beginPath(); g.moveTo(0, Y(z)); g.lineTo(cw, Y(z)); g.stroke(); }
const line = (col, w) => { g.strokeStyle = col; g.lineWidth = w; };
// halfway line (the two kickoff circles are now a high-res projected decal —
// see fieldDecals.js — so the big circle stays crisp at any zoom).
line("rgba(235,240,245,0.7)", R(0.5));
g.beginPath(); g.moveTo(0, Y(0)); g.lineTo(cw, Y(0)); g.stroke();
// goal lines + goal areas at each end
const gw = def.goalWidth;
for (const sz of [-1, 1]) {
const zEnd = sz * (L / 2 - 1.5);
line("rgba(235,240,245,0.7)", R(0.6));
g.beginPath(); g.moveTo(X(-gw / 2 - 6), Y(zEnd)); g.lineTo(X(gw / 2 + 6), Y(zEnd)); g.stroke();
line("rgba(235,240,245,0.4)", R(0.4));
g.strokeRect(X(-gw / 2 - 4), Y(sz * (L / 2 - 14)), R(gw + 8), R(12) * sz < 0 ? -R(12) : R(12));
}
// (boost-pad markings are now per-pad ground decals built in buildPads, not
// baked here, so they can sit exactly under each pad and stay on cooldown.)
return textureFromCanvas(cv);
}
// HDR environment loading.
//
// Under Shade the scene's environment map is BOTH the image-based lighting source
// and the visible background — there is no skybox object and no `scene.background`
// to set separately, so this one texture gives the cars their reflections and puts
// a sky above the open arena. It must be OCTAHEDRAL: Shade samples the environment
// with an octahedral projection and asserts as much.
//
// The engine ships `load_environment_map(url)`, which does exactly the conversion
// below — but as a side effect it also triggers a browser download of the
// converted `.hdr` (it is written as an authoring convenience). A demo page must
// not push a file at the visitor, so the chain is spelled out here instead:
//
// fetch → parse_rgbe → equirectangular Sampler2D
// → resample_equirectangular_to_octahedral → sampler2d_to_f16
// → ShadeImage → ShadeTexture
//
// It runs once, on the main thread, at load.
import { BinaryDataType } from "@woosh/meep-engine/src/core/binary/type/BinaryDataType.js";
import { Sampler2D } from "@woosh/meep-engine/src/engine/graphics/texture/sampler/Sampler2D.js";
import { sampler2d_to_f16 } from "@woosh/meep-engine/src/engine/graphics/texture/sampler/sampler2d_to_f16.js";
import { parse_rgbe } from "@woosh/meep-engine/src/shade/renderer/light/environment/rgbe/parse_rgbe.js";
import {
resample_equirectangular_to_octahedral
} from "@woosh/meep-engine/src/shade/renderer/light/environment/resample_equirectangular_to_octahedral.js";
import { ColorSpace } from "@woosh/meep-engine/src/shade/renderer/texture/ColorSpace.js";
import { ShadeImage } from "@woosh/meep-engine/src/shade/renderer/texture/source/ShadeImage.js";
import { ShadeTexture } from "@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js";
/** Largest finite value a half float can hold. */
const F16_MAX = 65504;
/**
* Load an equirectangular Radiance (`.hdr`) file as an octahedral environment
* texture, ready for `graphics.set_environment_map`.
*
* @param {string} url
* @param {number} [resolution] side of the octahedral map. It is convolved into
* irradiance and a few roughness mips before anything lights from it, but it is
* also what the sky is drawn from, so this is a sharpness/latency trade.
* @returns {Promise<ShadeTexture>}
*/
export async function loadEquirectangularEnvironment(url, resolution = 1024) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch environment '${url}': ${response.status} ${response.statusText}`);
}
const rgbe = parse_rgbe(await response.arrayBuffer(), BinaryDataType.Float32);
const equirectangular = new Sampler2D(rgbe.data, 4, rgbe.width, rgbe.height);
const resampled = resample_equirectangular_to_octahedral(equirectangular, resolution);
// Clamp to the half-float range before the conversion. A noon sky HDR carries
// sun radiance well past 65504, and `sampler2d_to_f16` turns anything above it
// into `Infinity`; the environment convolution then hands the deferred resolve
// a NaN, and every lit surface in the frame comes out black while the sky - read
// straight from the texture - still looks right. Nothing downstream tells you
// that is what happened, so the clamp lives here, at the conversion.
{
const d = resampled.data;
for (let i = 0; i < d.length; i++) {
if (d[i] > F16_MAX) {
d[i] = F16_MAX;
}
}
}
const octahedral = sampler2d_to_f16(resampled);
const image = ShadeImage.fromSampler2D(octahedral);
// radiance, not colour — an .hdr is already linear light
image.color_space = ColorSpace.LinearSRGB;
return ShadeTexture.from(image);
}
import { Interpolated } from "@woosh/meep-engine/src/engine/interpolation/Interpolated.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
/**
* Meep 3.21.0 blends poses without announcing the Transform64 writes. Publish
* them before rendering so attachments and mesh placement see the same pose
* as the chase camera, including frames with no physics step.
*
* Remove this workaround once InterpolationSystem publishes its own writes.
* The separate pose-deserializer matrix issue is documented in
* feature-requests/interpolated-pose-render-sync.md and is left unchanged here.
*
* @param {EntityComponentDataset} ecd
*/
export function publishInterpolatedPoses(ecd) {
ecd.traverseEntities([Interpolated, Transform64], (interpolated, transform, entity) => {
if (interpolated.interpolands.some(ip => ip.component_class === Transform64)) {
t64_announce_change(ecd, entity);
}
});
}
// Shade helpers shared by the level / pad / ball builders.
//
// meep 3 renders through Shade, its own WebGPU renderer, so the three.js idioms
// this example used to lean on have direct — but different — counterparts:
//
// • geometry is authored as a `Geometry` and clustered ONCE into a GPU-resident
// `MeshletGeometry`; every geometry a `ShadedGeometry` draws must have been
// through `meshlet_geometry_build_from_geometry`. It is a per-shape
// precompute, never per frame, so the builders below cache by size.
// • the meshlet builder runs meshoptimizer in WebAssembly, so nothing here may
// be called before `EngineHarness.bootstrap()` has resolved.
// • materials are `StandardShadeMaterial` (PBR) and their colours are LINEAR.
// A hex written by a designer is sRGB-encoded, so it is decoded exactly once,
// here.
// • there is no `CanvasTexture` and no `TextureLoader`: an image becomes a
// `ShadeImage` (from a canvas' pixels, or from an `ImageBitmap` the asset
// manager decoded off-thread) and then a `ShadeTexture`.
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import { Sampler2D } from "@woosh/meep-engine/src/engine/graphics/texture/sampler/Sampler2D.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { make_octahedron_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { ColorSpace } from "@woosh/meep-engine/src/shade/renderer/texture/ColorSpace.js";
import { ShadeImage } from "@woosh/meep-engine/src/shade/renderer/texture/source/ShadeImage.js";
import { ShadeTexture } from "@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js";
/**
* An sRGB hex integer (what a designer types) as the linear-light `Color` the
* renderer wants. Skipping this is the single most common way to get a washed-out
* or over-saturated material under Shade.
*
* @param {number} hex 0xrrggbb
* @returns {Color}
*/
export function linearFromHex(hex) {
const encoded = new Color();
encoded.fromUint(hex);
return Color.from_sRGB_to_linear(encoded);
}
// ─── geometry caches ─────────────────────────────────────────────────────────
// Meshlet building is the expensive half; the arena alone asks for the same wall
// slab dozens of times, so key the result on its dimensions and share it exactly
// as the v2 code shared a BufferGeometry.
const _boxes = new Map();
/**
* @param {number} width
* @param {number} height
* @param {number} depth
* @returns {MeshletGeometry}
*/
export function boxGeometry(width, height, depth) {
const key = `${width}|${height}|${depth}`;
let geometry = _boxes.get(key);
if (geometry === undefined) {
geometry = meshlet_geometry_build_from_geometry(make_box_geometry(width, height, depth));
_boxes.set(key, geometry);
}
return geometry;
}
const _spheres = new Map();
/**
* Shade ships seven primitive generators and a UV sphere is not one of them — a
* subdivided octahedron is the sphere here, and detail 3–4 is smooth.
*
* @param {number} radius
* @param {number} [detail]
* @returns {MeshletGeometry}
*/
export function sphereGeometry(radius, detail = 3) {
const key = `${radius}|${detail}`;
let geometry = _spheres.get(key);
if (geometry === undefined) {
geometry = meshlet_geometry_build_from_geometry(make_octahedron_geometry(radius, detail));
_spheres.set(key, geometry);
}
return geometry;
}
/**
* Scale a geometry's main UV set in place, before it is clustered.
*
* `ShadeTexture` carries a wrap mode but no repeat/offset — three's
* `texture.repeat.set(n, 1)` has no counterpart — so tiling is a property of the
* mesh, written into its texture coordinates.
*
* @param {Geometry} geometry
* @param {number} u
* @param {number} v
* @returns {Geometry} the same geometry
*/
export function scaleUV(geometry, u, v) {
const attribute = geometry.getAttribute(StandardAttributes.TextureCoordinates0);
if (attribute === undefined) {
return geometry;
}
const data = attribute.data;
for (let i = 0; i < data.length; i += 2) {
data[i] *= u;
data[i + 1] *= v;
}
return geometry;
}
// ─── materials ───────────────────────────────────────────────────────────────
/**
* The PBR material, in the shape the three.js code used to spell as
* `new MeshStandardMaterial({...})`.
*
* @param {object} [options]
* @param {number} [options.color] sRGB hex, decoded to linear
* @param {number} [options.roughness]
* @param {number} [options.metalness]
* @param {number} [options.emissive] sRGB hex
* @param {number} [options.emissiveIntensity]
* @param {ShadeTexture} [options.map] albedo texture
* @returns {StandardShadeMaterial}
*/
export function standardMaterial({
color = 0xffffff,
roughness = 1,
metalness = 0,
emissive,
emissiveIntensity = 1,
map,
} = {}) {
const material = new StandardShadeMaterial();
material.diffuse_color.copy(linearFromHex(color));
material.roughness_factor = roughness;
material.metallic_factor = metalness;
if (map !== undefined) {
material.texture_albedo = map;
}
if (emissive !== undefined) {
material.emissive_factor.copy(linearFromHex(emissive));
material.emissive_factor.multiplyScalar(emissiveIntensity);
}
return material;
}
// ─── textures ────────────────────────────────────────────────────────────────
/**
* A 2D canvas as a `ShadeTexture`, the replacement for `THREE.CanvasTexture`.
*
* Synchronous on purpose: `getImageData` hands back the pixels directly, so the
* caller doesn't have to become async just to paint a texture. The bytes are
* sRGB-encoded (that is what a canvas holds), which the image records so the GPU
* decodes them on sampling.
*
* @param {HTMLCanvasElement} canvas
* @returns {ShadeTexture}
*/
export function textureFromCanvas(canvas) {
const context = canvas.getContext("2d");
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
// Sampler2D wants a plain typed array; ImageData hands back a Uint8ClampedArray
const sampler = new Sampler2D(
new Uint8Array(pixels.data.buffer, pixels.data.byteOffset, pixels.data.length),
4, canvas.width, canvas.height,
);
const image = ShadeImage.fromSampler2D(sampler);
image.color_space = ColorSpace.SRGB;
return ShadeTexture.from(image);
}
/**
* A PNG/JPEG by url as a `ShadeTexture`, through the asset manager so the decode
* runs off the main thread and a file two callers want is fetched once.
*
* @param {AssetManager} assetManager
* @param {string} url
* @param {boolean} [srgb] true for colour, false for data (masks, roughness…)
* @returns {Promise<ShadeTexture>}
*/
export async function loadTexture(assetManager, url, srgb = true) {
const asset = await assetManager.promise(url, GameAssetType.ImageBitmap);
const image = ShadeImage.fromImageBitmap(asset.create());
image.color_space = srgb ? ColorSpace.SRGB : ColorSpace.LinearSRGB;
return ShadeTexture.from(image);
}
// BoostPad — an ECS component for a Rocket-League-style boost pickup.
//
// Pure state (the *logic* is BoostPadSystem, the *visual* is buildPads): position
// comes from the entity's Transform64; this just carries how much boost the pad
// grants, how long it takes to come back, its pickup radius, and a cooldown timer
// (>0 = depleted/dark, counting down to re-activation). Runtime-only.
export class BoostPad {
constructor() {
this.amount = 12; // % boost granted on pickup
this.respawnTime = 4; // s to re-activate after being taken
this.radius = 2.0; // horizontal pickup radius (u)
this.big = false; // large pad? (visual + a point light)
this.cooldown = 0; // s until re-active; 0 = active
}
get active() {
return this.cooldown <= 0;
}
}
BoostPad.typeName = "BoostPad";
BoostPad.serializable = false;
// BoostPadSystem — grants boost when a car drives into an active pad's sensor,
// then puts the pad on its respawn cooldown.
//
// Pickup is EVENT-DRIVEN: each pad is a static IsSensor cylinder (built in
// buildPads), so the physics broadphase reports car↔pad overlaps and we react in
// `handleContact` — no per-tick N×M distance scan. The only per-frame work here is
// ticking depleted pads' cooldowns back toward active (O(pads)). Pure sim (no
// rendering/sound/input); the pad visuals are toggled separately off `cooldown`.
import { System } from "@woosh/meep-engine/src/engine/ecs/System.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { BoostPad } from "./BoostPad.js";
import { BOOST } from "../tuning.js";
export class BoostPadSystem extends System {
dependencies = [BoostPad, Transform64];
constructor() {
super();
/** @type {import("../vehicles/CarController.js").CarController[]} cars that can pick up boost */
this.cars = [];
this.entityManager = null;
/** optional hook: (car, pad, transform) => void — for a pickup flash / sound */
this.onPickup = null;
}
async startup(entityManager) {
this.entityManager = entityManager;
}
/** Tick depleted pads' cooldowns back toward active (no per-car work here). */
fixedUpdate(dt) {
const em = this.entityManager;
if (em === null || dt <= 0) return;
const ds = em.dataset;
if (ds === null || ds === undefined) return;
ds.traverseComponents(BoostPad, (pad) => {
if (pad.cooldown > 0) pad.cooldown = Math.max(0, pad.cooldown - dt);
});
}
/**
* Physics contact handler (wire to each car's `PhysicsEvents.ContactBegin`). If a contact is a
* car body entering an ACTIVE pad's sensor, grant boost (clamped to max — taken
* even at a full tank, which denies it to opponents) and start the respawn.
* @param {{entityA:number, entityB:number}} contact
*/
handleContact(contact) {
const ds = this.entityManager && this.entityManager.dataset;
if (!ds) return;
// which side is the pad? (physics reports the contact pair in either order;
// getComponent returns null OR undefined for an entity without a BoostPad)
let padEnt = contact.entityA, carEnt = contact.entityB;
let pad = ds.getComponent(padEnt, BoostPad);
if (pad == null) { padEnt = contact.entityB; carEnt = contact.entityA; pad = ds.getComponent(padEnt, BoostPad); }
if (pad == null || pad.cooldown > 0) return; // not a pad, or depleted
const car = this.cars.find((c) => c.entity === carEnt);
if (car === undefined) return; // pad touched by the ball / something else
car.boost = Math.min(BOOST.max, car.boost + pad.amount);
pad.cooldown = pad.respawnTime;
if (this.onPickup !== null) this.onPickup(car, pad, ds.getComponent(padEnt, Transform64));
}
}
// buildPads — lays out the boost pads for a level and builds their entities +
// visuals. The pickup *logic* lives in BoostPadSystem; here we build, per pad:
// • a sensor cylinder (static IsSensor collider, NO mesh) — the pickup trigger,
// • a ground DECAL (a flat circular motif, always shown — even on cooldown),
// • a rising "GLOW" — an uncapped emissive cylinder with a tiling, top-transparent texture,
// • an ORB — a small emissive sphere hanging above, with a dim point light.
// On cooldown the glow / orb / light vanish and only the decal remains. The visual
// toggle (`updateVisuals`) is driven each render frame off the pad's `cooldown`,
// kept out of the system so the sim stays headless-testable.
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.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 { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { ColliderFlags } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderFlags.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { CylinderShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/CylinderShape3D.js";
import { RigidBodyFlags } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBodyFlags.js";
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { make_cylinder_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_cylinder_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { ShadeDrawSide } from "@woosh/meep-engine/src/shade/renderer/material/ShadeDrawSide.js";
import { TransparencyMode } from "@woosh/meep-engine/src/shade/renderer/material/TransparencyMode.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { linearFromHex, loadTexture, scaleUV, sphereGeometry, standardMaterial } from "../lib/shade.js";
import { BoostPad } from "./BoostPad.js";
import { PADS, BOOST } from "../tuning.js";
// Every pad — small AND large — uses the shared boost colour pair (the same one
// the HUD boost gauge uses), so the pads read as one consistent "boost" family
// instead of the old confusing blue-vs-orange split. Mapped vertically like the
// gauge: the amber base lights the glow / ground decal / surroundings, the red
// accent is the orb hovering above. Size, not hue, distinguishes small from large.
const BODY_COLOR = BOOST.barColors[0]; // amber — glow, decal, point-light wash
const ORB_COLOR = BOOST.barColors[1]; // red — the hovering orb accent
const GLOW_URL = "./textures/noise/tile_transparent-x-256.png";
const DECAL_URL = "./textures/decals/circle-07.png"; // white circular motif, tinted per pad
// The two ground-decal tints: full body colour while active, dimmed to 30% on
// cooldown (rgb only — kept opaque so it still reads as a marking, just darker).
// (a decal tint is a LINEAR albedo written into the G-buffer, so the sRGB hex is
// decoded once here rather than handed over encoded.)
const DECAL_ACTIVE = linearFromHex(BODY_COLOR);
const DECAL_COOLDOWN = DECAL_ACTIVE.clone().multiplyScalar(0.3);
DECAL_COOLDOWN.a = 1; // multiplyScalar dims alpha too; the tint stays fully opaque
// Quaternion that rotates a decal's local +Z (the projection axis) to world −Y,
// so the boost-pad motif projects straight DOWN onto the floor (+90° about X).
const PROJECT_DOWN = new Quaternion();
PROJECT_DOWN.lookRotation(
Vector3.down,
Vector3.forward
);
/** Shared glow texture: transparent at the top → opaque at the bottom, so the
* cylinder reads as a glow rising from the floor and fading out. `ShadeTexture`
* has a wrap mode but no repeat/offset (three's `texture.repeat`), so the tiling
* around the circumference is baked into the cylinder's UVs instead — see
* `scaleUV` below. */
function glowTexture(assetManager) {
return loadTexture(assetManager, GLOW_URL);
}
/** Symmetric pad layout derived from the arena half-extents. */
function padLayout(dims) {
const { hw, hl } = dims;
// 4 large pads, one per quadrant, out toward the corners
const large = [];
for (const sx of [-1, 1]) for (const sz of [-1, 1]) large.push([sx * hw * 0.66, sz * hl * 0.46]);
// a plentiful symmetric scatter of small pads (skip dead centre = kickoff,
// and any that sit on top of a large pad)
const small = [];
const xs = [-0.66, -0.33, 0, 0.33, 0.66];
const zs = [-0.74, -0.5, -0.25, 0, 0.25, 0.5, 0.74];
for (const fx of xs) for (const fz of zs) {
if (fx === 0 && fz === 0) continue;
const x = fx * hw, z = fz * hl;
const onLarge = large.some(([lx, lz]) => Math.hypot(lx - x, lz - z) < 4);
if (onLarge) continue;
small.push([x, z]);
}
return { large, small };
}
/**
* @param {{ecd}} ctx
* @param {{hw:number, hl:number}} dims arena half-extents
* @returns {Promise<{ records: Array, list: Array, updateVisuals: () => void }>}
*/
export async function buildPads(ctx, { dims }) {
const { ecd } = ctx;
const { large, small } = padLayout(dims);
const glowTex = await glowTexture(ctx.engine.assetManager);
// Two glow shapes for forty pads: meshlet clustering is a per-shape precompute,
// so build each size once and share it across every pad that wears it.
const glowGeometry = new Map();
const glowGeometryOf = (cfg) => {
let geometry = glowGeometry.get(cfg);
if (geometry === undefined) {
geometry = meshlet_geometry_build_from_geometry(
scaleUV(make_cylinder_geometry(cfg.radius, cfg.radius, cfg.height, 28, 1, true), PADS.glow.tile, 1),
);
glowGeometry.set(cfg, geometry);
}
return geometry;
};
const records = []; // { pad, glow, orb, light, baseIntensity } — for the visual toggle
const list = []; // { pad, x, z, big } — lightweight view for AI awareness
const make = (x, z, big) => {
const radius = big ? PADS.largeRadius : PADS.smallRadius;
const height = PADS.height;
const glowCfg = big ? PADS.glow.large : PADS.glow.small;
const orbCfg = big ? PADS.orb.large : PADS.orb.small;
// ── logic entity: the pickup sensor (a static IsSensor cylinder, no mesh) ──
const t = new Transform64();
t.setTranslation(x, height / 2, z);
const pad = new BoostPad();
pad.big = big;
pad.amount = big ? PADS.largeAmount : PADS.smallAmount;
pad.respawnTime = big ? PADS.largeRespawn : PADS.smallRespawn;
pad.radius = radius;
const body = new RigidBody();
body.kind = BodyKind.Static;
body.setFlag(RigidBodyFlags.IsSensor);
const collider = new Collider();
collider.shape = CylinderShape3D.from(radius, height);
collider.setFlag(ColliderFlags.IsSensor);
new Entity()
.add(t)
.add(pad)
.add(body)
.add(collider)
.build(ecd);
// ── ground decal: a meep Decal projected straight down onto the floor ──
// (always shown — a depleted pad still marks its spot). One white motif
// texture, tinted per pad via Decal.color. Transform64 scale is the projection
// box: x/y footprint on the floor, z = a thin projection depth.
const footprint = radius * 2 * PADS.decalScale;
const decal = new Decal();
decal.uri = DECAL_URL;
decal.color.copy(DECAL_ACTIVE);
// ...and it EMITS. A decal writes albedo, and an amber albedo on a pale
// floor reads darker than the floor around it - the ring the old additive
// sprite drew has to come from the emissive channel instead. The motif
// doubles as the emission mask, so the ring glows and the rest does not.
decal.uri_emissive = DECAL_URL;
decal.emissive_color.copy(DECAL_ACTIVE);
decal.emissive_intensity = PADS.decalEmissive;
// DecalSystem reads the transform's matrix, and a Transform64 leaves the
// rotation and scale out of it until updateMatrix composes them.
const dt = new Transform64();
dt.setTranslation(x, 0, z); // box centred on the ground → only ±decalDepth/2 around the floor
dt.setRotation(PROJECT_DOWN.x, PROJECT_DOWN.y, PROJECT_DOWN.z, PROJECT_DOWN.w);
dt.setScale(footprint, footprint, PADS.decalDepth);
dt.updateMatrix();
new Entity().add(dt).add(decal).build(ecd);
// ── glow: uncapped cylinder rising from the floor, fading up ─────────────
// Shade has no additive blending for surfaces and no unlit material, so the
// old `MeshBasicMaterial` + `AdditiveBlending` becomes an alpha-blended
// material that carries the glow in its EMISSIVE term: emission is added to
// the lit result, which is what "additive" was buying, and it needs no light
// to be visible. Double-sided so the inside of the tube draws too.
const glowGeo = glowGeometryOf(glowCfg);
const glowMat = new StandardShadeMaterial();
glowMat.texture_albedo = glowTex;
glowMat.diffuse_color.copy(linearFromHex(BODY_COLOR));
glowMat.diffuse_color.a = glowCfg.opacity;
glowMat.emissive_factor.copy(linearFromHex(BODY_COLOR));
glowMat.emissive_factor.multiplyScalar(glowCfg.opacity);
glowMat.roughness_factor = 1;
glowMat.transparency_mode = TransparencyMode.Transparent;
glowMat.draw_side = ShadeDrawSide.Double;
const glow = ShadedGeometry.from(glowGeo, glowMat);
const gt = new Transform64();
gt.setTranslation(x, glowCfg.height * 0.5, z);
const glowEntity = new Entity().add(gt).add(glow).build(ecd);
// ── orb: a small emissive sphere + a dim point light, hanging above ──
const orbGeo = sphereGeometry(orbCfg.radius, 3);
const orbMat = standardMaterial({
color: ORB_COLOR, emissive: ORB_COLOR, emissiveIntensity: 1.7, roughness: 0.35, metalness: 0,
});
const orb = ShadedGeometry.from(orbGeo, orbMat);
const light = new Light();
light.type.set(LightType.POINT);
light.color.copy(linearFromHex(BODY_COLOR));
light.intensity.set(orbCfg.intensity);
light.distance.set(orbCfg.distance);
const ot = new Transform64();
ot.setTranslation(x, orbCfg.height, z);
const orbEntity = new Entity().add(ot).add(orb).add(light).build(ecd);
records.push({
pad, glow, orb, light, decal,
glowEntity, orbEntity,
baseIntensity: orbCfg.intensity,
shown: true,
});
list.push({ pad, x, z, big });
};
for (const [x, z] of large) make(x, z, true);
for (const [x, z] of small) make(x, z, false);
// Toggle each pad's glow + orb + light off its active state (called per frame).
// The decal is left on always - a depleted pad still shows its ground marking,
// dimmed and no longer emitting.
//
// Hiding is done by taking the `ShadedGeometry` component off the entity and
// putting it back, NOT by clearing `ShadedGeometryFlags.Visible`: that flag is
// inert in meep 3 (nothing in the render path reads `ShadedGeometryFlags`), so
// a flag-driven hide leaves the glow and the orb on screen. Removing the
// component unlinks the row from the scene, which is what actually hides it.
//
// The state is remembered so this only touches the dataset on a transition -
// it runs every render frame, and forty pads spend most of the match unchanged.
function updateVisuals() {
for (let i = 0; i < records.length; i++) {
const r = records[i];
const active = r.pad.cooldown <= 0;
if (active !== r.shown) {
if (active) {
ecd.addComponentToEntity(r.glowEntity, r.glow);
ecd.addComponentToEntity(r.orbEntity, r.orb);
} else {
ecd.removeComponentFromEntity(r.glowEntity, ShadedGeometry);
ecd.removeComponentFromEntity(r.orbEntity, ShadedGeometry);
}
r.shown = active;
}
r.light.intensity.set(active ? r.baseIntensity : 0);
r.decal.color.copy(active ? DECAL_ACTIVE : DECAL_COOLDOWN); // full vs dimmed marking
r.decal.emissive_intensity = active ? PADS.decalEmissive : 0;
}
}
return { records, list, updateVisuals };
}
{
"id": "octane",
"name": "Octane",
"modelUrl": "./models/octane_car/scene.gltf",
"model": {
"scale": 0.0258,
"yaw": -1.5707963267948966,
"offset": [
0,
-0.62,
0.05
]
},
"body": {
"half": [
0.95,
0.5,
1.72
],
"mass": 180
},
"wheels": [
[
-0.86,
-0.18,
1.18
],
[
0.86,
-0.18,
1.18
],
[
-0.86,
-0.18,
-1.28
],
[
0.86,
-0.18,
-1.28
]
],
"wheelNodes": [
[
{
"name": "Dieci - FL (Octane)",
"spin": [
-0.70711,
0.70711,
0,
0
]
}
],
[
{
"name": "Dieci - FR (Octane)",
"spin": [
0.70711,
0.70711,
0,
0
]
}
],
[
{
"name": "Dieci - BL (Octane)",
"spin": [
-0.70711,
0.70711,
0,
0
]
}
],
[
{
"name": "Dieci - BR (Octane)",
"spin": [
0.70711,
0.70711,
0,
0
]
}
]
],
"wheelSpin": 1,
"exhausts": [
{
"position": [
-0.37617,
0.0893,
-1.50129
],
"rotation": [
1,
0,
0,
0
],
"scale": [
0.12665,
0.19472,
0.16086
]
},
{
"position": [
0.37617,
0.0893,
-1.50129
],
"rotation": [
1,
0,
0,
0
],
"scale": [
0.12665,
0.19472,
0.16086
]
}
],
"trails": [
{
"position": [
-0.62695,
0.35083,
-1.49157
],
"rotation": [
1,
0,
0,
0
],
"scale": [
0.17527,
0.17527,
0.17527
]
},
{
"position": [
0.62695,
0.35083,
-1.49157
],
"rotation": [
1,
0,
0,
0
],
"scale": [
0.17527,
0.17527,
0.17527
]
}
],
"headlights": [
{
"position": [
-0.6,
0.05,
-0.35651
],
"rotation": [
0,
0,
0,
1
],
"scale": [
1,
1,
1
]
},
{
"position": [
0.6,
0.05,
-0.31017
],
"rotation": [
0,
0,
0,
1
],
"scale": [
1,
1,
1
]
}
],
"boost": {
"position": [
0,
-0.05,
-1.53243
],
"rotation": [
1,
0,
0,
0
],
"scale": [
1,
1,
1
]
},
"accent": "0x4ea8f0"
}
{
"id": "perrier",
"name": "Perrier",
"modelUrl": "./models/perrier_buggy/scene.gltf",
"model": {
"scale": 1.06,
"yaw": 0,
"offset": [
0,
-0.66,
-0.24
]
},
"body": {
"half": [
1.02,
0.58,
1.7
],
"mass": 190
},
"wheels": [
[
-0.92,
-0.2,
1.2
],
[
0.92,
-0.2,
1.2
],
[
-0.92,
-0.2,
-1.24
],
[
0.92,
-0.2,
-1.24
]
],
"wheelNodes": [
[
{
"name": "dFLWheel",
"spin": [
0,
0,
-1,
0
]
},
{
"name": "sFLWheel",
"spin": [
0,
0,
-1,
0
]
}
],
[
{
"name": "dFRWheel",
"spin": [
0,
0,
-1,
0
]
},
{
"name": "sFRWheel",
"spin": [
0,
0,
-1,
0
]
}
],
[
{
"name": "dRLWheel",
"spin": [
0,
0,
-1,
0
]
},
{
"name": "sRLWheel",
"spin": [
0,
0,
-1,
0
]
}
],
[
{
"name": "dRRWheel",
"spin": [
0,
0,
-1,
0
]
},
{
"name": "sRRWheel",
"spin": [
0,
0,
-1,
0
]
}
]
],
"wheelSpin": 1,
"exhausts": [
{
"position": [
0.32947,
0.17326,
-1.15401
],
"rotation": [
-0.98101,
0,
0,
0.19394
],
"scale": [
0.12919,
0.12919,
0.12919
]
},
{
"position": [
-0.33876,
0.17447,
-1.15046
],
"rotation": [
-0.98175,
0,
0,
0.1902
],
"scale": [
0.13822,
0.13822,
0.13822
]
}
],
"trails": [
{
"position": [
-0.64375,
0.54009,
-1.6236
],
"rotation": [
-1,
0,
0,
0.0005
],
"scale": [
0.28965,
0.28965,
0.28965
]
},
{
"position": [
0.63521,
0.52217,
-1.61126
],
"rotation": [
-0.99999,
0,
0,
-0.00471
],
"scale": [
0.31892,
0.31892,
0.31892
]
}
],
"headlights": [
{
"position": [
-0.07738,
0.55287,
-0.37321
],
"rotation": [
0,
0,
0,
1
],
"scale": [
0.21704,
0.21704,
0.21704
]
},
{
"position": [
0.08836,
0.55287,
-0.37321
],
"rotation": [
0,
0,
0,
1
],
"scale": [
0.21704,
0.21704,
0.21704
]
}
],
"boost": {
"position": [
0,
0.07864,
-1.75
],
"rotation": [
-0.99997,
0,
0,
0.00814
],
"scale": [
0.63112,
0.63112,
0.63112
]
}
}
// AiBehaviors — the AI opponent's *behaviour*, as a meep behaviour tree.
//
// Pairs with the AiControl *state* component: the engine's BehaviorSystem ticks a
// BehaviorComponent each frame, the tree decides, and the leaves write the car's
// control intent (the same `intent` the player's input fills). This is the ECS
// split the old monolithic AiDriver lacked — state in a component, behaviour in a
// system-driven tree.
//
// Tree shape (priority fallback):
//
// ReactiveRepeat ← keeps the AI alive forever + re-evaluates from
// └─ Selector the top every tick (see note below)
// ├─ Perceive ← sensor: physics raycast + overlap → awareness
// ├─ Recover ← wedged/pinned → reverse-wiggle out
// ├─ Aerial ← high ball overhead → jump
// ├─ SeekBoost ← low tank + ball far → detour to a boost pad
// └─ ChaseBall ← default: strike the ball toward the enemy goal
//
// Why ReactiveRepeat: meep's Selector/Sequence are *stateful* — a Selector sticks
// to its running child and never re-checks higher-priority options, and the root
// resolving (Succeeded/Failed) stops the BehaviorComponent for good. A reactive
// controller wants the opposite: re-evaluate priorities from scratch every tick
// and run forever. ReactiveRepeat gives exactly that by re-initialising its child
// each tick and always reporting Running, so each tick is one clean top-down pass
// over self-guarding leaves (a leaf returns Failed to yield, Running while acting).
import { Behavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/Behavior.js";
import { BehaviorStatus } from "@woosh/meep-engine/src/engine/intelligence/behavior/BehaviorStatus.js";
import { SelectorBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/SelectorBehavior.js";
import { AbstractDecoratorBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/decorator/AbstractDecoratorBehavior.js";
import { BehaviorComponent } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorComponent.js";
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { Ray3 } from "@woosh/meep-engine/src/core/geom/3d/ray/Ray3.js";
import { PhysicsSurfacePoint } from "@woosh/meep-engine/src/engine/physics/queries/PhysicsSurfacePoint.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { clamp } from "@woosh/meep-engine/src/core/math/clamp.js";
import { AiControl } from "./AiControl.js";
import { AI, BALL } from "../tuning.js";
// ─── shared scratch (one AI ticks at a time on one thread) ───────────────────
const _q = new Quaternion();
const _fwd = new Vector3();
const _car = new Vector3();
const _ball = new Vector3();
const _toGoal = new Vector3();
const _target = new Vector3();
const _desired = new Vector3();
const _ray = new Ray3();
const _hit = new PhysicsSurfacePoint();
const _sphere = new SphereShape3D();
const _ovOut = new Uint32Array(16);
// The physics queries read a pose as `[x, y, z]` / `[x, y, z, w]`, so the
// scratch that carries the ball's centre into `overlap` is a plain array.
const _ident = new Float64Array([0, 0, 0, 1]);
const _ballCentre = new Float64Array(3);
/** car forward, flattened onto the ground plane and normalised, into `_fwd`. */
function readGroundForward(transform, fallbackZSign) {
_q.set(
transform.rotation_x, transform.rotation_y,
transform.rotation_z, transform.rotation_w
);
_fwd.set(0, 0, 1).applyQuaternion(_q);
_fwd.y = 0;
if (_fwd.lengthSqr() < 1e-5) _fwd.set(0, 0, fallbackZSign);
_fwd.normalize();
return _fwd;
}
// ─── leaf base: reach the AiControl blackboard via the tree context ──────────
class AiLeaf extends Behavior {
/** @returns {AiControl|null} */
get ai() {
const c = this.context;
if (c === null || c === undefined) return null;
return c.ecd.getComponent(c.entity, AiControl);
}
}
// ─── Perceive: a sensor leaf. Runs two physics queries to fill awareness, ages
// the jump cooldown, then returns Failed so the Selector falls through to the
// action leaves. (A "condition" node that always yields, evaluated every tick.)
class PerceiveBehavior extends AiLeaf {
tick(td) {
const ai = this.ai;
if (ai === null) return BehaviorStatus.Failed;
const ctrl = ai.controller;
const physics = ai.physics;
if (ctrl === null || physics === null) return BehaviorStatus.Failed;
ai.jumpCd = Math.max(0, ai.jumpCd - td);
const p = ctrl.transform;
const selfId = ctrl.body._bodyId;
const ballId = ai.ballBody !== null ? ai.ballBody._bodyId : -1;
// (1) forward raycast — what solid thing (wall or the other car) is ahead?
const fwd = readGroundForward(ctrl.transform, ai.attackZSign);
_ray.setOrigin(p.translation_x, p.translation_y + 0.4, p.translation_z);
_ray.setDirection(fwd.x, fwd.y, fwd.z);
_ray.tMax = AI.wallProbe;
const rayFilter = (entity, collider) => collider._bodyId !== selfId && collider._bodyId !== ballId;
ai.wallAhead = physics.raycast(_ray, _hit, rayFilter) ? _hit.t : 0;
// (2) overlap at the ball — is a contesting (dynamic) car already on it?
const b = ai.getBall();
_sphere.radius = AI.contestRadius;
const ecd = this.context.ecd;
const overlapFilter = (entity, collider) => {
if (collider._bodyId === selfId || collider._bodyId === ballId) return false;
const rb = ecd.getComponent(entity, RigidBody);
return rb !== null && rb.kind === BodyKind.Dynamic;
};
_ballCentre[0] = b.translation_x;
_ballCentre[1] = b.translation_y;
_ballCentre[2] = b.translation_z;
const n = physics.overlap(_sphere, _ballCentre, _ident, _ovOut, 0, overlapFilter);
ai.ballContested = n > 0;
return BehaviorStatus.Failed; // sensor: never "selected", just observes
}
}
// ─── Recover: wedged (slow + far from the ball) or pinned against an obstacle
// ahead → LATCH into a backoff and reverse (with a gentle committed steer to peel
// off the wall) until the car has real CLEARANCE — it has reversed far enough AND
// the forward ray is clear. Latching is the fix for the "nudge back, ram the wall,
// repeat" loop: once committed we don't hand control back to ChaseBall until the
// car is genuinely free (or a safety timeout fires).
class RecoverBehavior extends AiLeaf {
tick(td) {
const ai = this.ai;
if (ai === null) return BehaviorStatus.Failed;
const ctrl = ai.controller;
if (ctrl === null) return BehaviorStatus.Failed;
if (ctrl.frozen) { ai.backoff = false; ai.stuck = 0; return BehaviorStatus.Failed; }
const st = ctrl.state;
const b = ai.getBall();
const tr = ctrl.transform;
const px = tr.translation_x, pz = tr.translation_z;
const bx = b.translation_x, bz = b.translation_z;
const intent = ctrl.intent;
if (!ai.backoff) {
// not yet backing off — accumulate "wedged" time, then commit
const distToBall = Math.hypot(bx - px, bz - pz);
const pinned = ai.wallAhead > 0 && ai.wallAhead < AI.wallEaseDist;
const slow = st.grounded && st.speed < AI.stuckSpeed;
const wedged = slow && (distToBall > AI.stuckMinBallDist || pinned);
if (!wedged) { ai.stuck = 0; return BehaviorStatus.Failed; }
ai.stuck += td;
if (ai.stuck < AI.stuckGrace) return BehaviorStatus.Failed; // give chasing a moment first
// commit: remember where we started + which way to peel off (toward the ball side)
ai.backoff = true;
ai.backoffTime = 0;
ai.backoffX = px;
ai.backoffZ = pz;
const fwd = readGroundForward(ctrl.transform, ai.attackZSign);
const dx = bx - px, dz = bz - pz, len = Math.hypot(dx, dz) || 1;
const cross = fwd.x * (dz / len) - fwd.z * (dx / len);
ai.backoffSign = cross >= 0 ? -1 : 1; // reverse-steer that swings the nose toward the ball
}
// backing off: reverse until we've made distance AND the way ahead is clear
ai.backoffTime += td;
const moved = Math.hypot(px - ai.backoffX, pz - ai.backoffZ);
const clearAhead = ai.wallAhead === 0 || ai.wallAhead > AI.backoffWallClear;
if ((moved > AI.backoffClearDist && clearAhead) || ai.backoffTime > AI.backoffMaxTime) {
ai.backoff = false; ai.stuck = 0;
return BehaviorStatus.Failed; // clear → hand back to ChaseBall
}
intent.forward = -1;
intent.turn = ai.backoffSign * AI.backoffTurn;
intent.boost = ai.backoffTime > AI.stuckBoostAfter; // boost out if it drags
intent.powerslide = false;
intent.jump = false;
return BehaviorStatus.Running;
}
}
// ─── Aerial: high ball directly overhead while grounded → pop a jump (the
// CarController turns a held jump near the ball into a hit). Cooldown-gated.
class AerialBehavior extends AiLeaf {
tick(td) {
const ai = this.ai;
if (ai === null) return BehaviorStatus.Failed;
const ctrl = ai.controller;
if (ctrl === null || ctrl.frozen) return BehaviorStatus.Failed;
if (ai.jumpCd > 0 || !ctrl.state.grounded) return BehaviorStatus.Failed;
const b = ai.getBall();
const p = ctrl.transform;
const horiz = Math.hypot(
b.translation_x - p.translation_x,
b.translation_z - p.translation_z
);
if (b.translation_y > AI.jumpBallHeight && horiz < AI.jumpHorizDist) {
ctrl.intent.jump = true;
ai.jumpCd = AI.jumpCooldown;
return BehaviorStatus.Running;
}
return BehaviorStatus.Failed;
}
}
// ─── ChaseBall: the default driver. Aim for a stand-off point behind the ball
// on the ball→enemy-goal line so that ploughing through it sends the ball at the
// goal; commit straight at the ball when close/behind (or when contested); boost
// when lined up, far, fuelled — and not about to boost into something.
class ChaseBallBehavior extends AiLeaf {
tick(td) {
const ai = this.ai;
if (ai === null) return BehaviorStatus.Running;
const ctrl = ai.controller;
if (ctrl === null) return BehaviorStatus.Running;
const intent = ctrl.intent;
const tr = ctrl.transform;
_car.set(tr.translation_x, tr.translation_y, tr.translation_z);
// ball position, led a little by its velocity
const b = ai.getBall();
_ball.set(b.translation_x, b.translation_y, b.translation_z);
if (ai.getBallVel !== null) {
const v = ai.getBallVel();
_ball.x += v[0] * AI.leadTime;
_ball.z += v[2] * AI.leadTime;
}
const fwd = readGroundForward(tr, ai.attackZSign);
// stand-off point behind the ball, on the ball→enemy-goal line
_toGoal.set(0 - _ball.x, 0, ai.attackZSign * ai.goalZ - _ball.z);
if (_toGoal.lengthSqr() < 1e-4) _toGoal.set(0, 0, ai.attackZSign);
_toGoal.normalize();
const distToBall = Math.hypot(_ball.x - _car.x, _ball.z - _car.z);
const standoff = BALL.radius + AI.standoff;
_target.set(_ball.x - _toGoal.x * standoff, 0, _ball.z - _toGoal.z * standoff);
// close + behind the ball (or a contesting car is on it) and the ball is
// in the field → commit straight through it
const ballInField = Math.abs(_ball.z) < ai.goalZ - 1;
const behind = (_car.z - _ball.z) * ai.attackZSign < -0.5;
if (((distToBall < AI.commitDist && behind) || ai.ballContested) && ballInField) {
_target.set(_ball.x, 0, _ball.z);
}
// never aim into the net recess
_target.z = clamp(_target.z, -(ai.goalZ - AI.goalLineKeepout), ai.goalZ - AI.goalLineKeepout);
_desired.set(_target.x - _car.x, 0, _target.z - _car.z);
if (_desired.lengthSqr() < 1e-4) _desired.copy(fwd);
_desired.normalize();
// signed heading error → steer toward the target. `angle` is the rotation
// (about +Y) from the nose to the desired direction; with this car's steer
// convention (turn>0 yaws −Y) the matching steer input is +angle.
const cross = fwd.x * _desired.z - fwd.z * _desired.x;
const dot = fwd.x * _desired.x + fwd.z * _desired.z;
const angle = Math.atan2(cross, dot);
intent.turn = clamp(angle * AI.steerGain, -1, 1);
// Drive forward and arc toward the target; ease the throttle hard when the
// target is off the nose so the car pivots in a tight low-speed arc instead
// of understeering wide, then floors it once lined up. A genuine wedge is
// the Recover leaf's job — no reverse needed here with the steer sign right.
intent.forward = clamp(AI.throttleBase + AI.throttleGain * dot, AI.throttleMin, 1);
// boost when well-aimed, far, fuelled — and nothing close ahead to ram
const clearAhead = ai.wallAhead === 0 || ai.wallAhead > AI.wallEaseDist;
intent.boost = dot > AI.boostAlign && distToBall > AI.boostMinDist && ctrl.boost > AI.boostMinTank && clearAhead;
intent.powerslide = false;
return BehaviorStatus.Running; // the default action — always "selected"
}
}
// ─── SeekBoost: when low on boost and the ball is far enough away to spare a
// detour, drive to the nearest active pad (large pads favoured). Yields once the
// tank is healthy, the ball is close, or no pad is lit.
class SeekBoostBehavior extends AiLeaf {
tick(td) {
const ai = this.ai;
if (ai === null) return BehaviorStatus.Failed;
const ctrl = ai.controller;
if (ctrl === null || ctrl.frozen) return BehaviorStatus.Failed;
if (ctrl.boost > AI.lowBoost || ai.getPads === null) return BehaviorStatus.Failed;
const tr = ctrl.transform;
const px = tr.translation_x, pz = tr.translation_z;
const b = ai.getBall();
if (Math.hypot(b.translation_x - px, b.translation_z - pz) < AI.boostSeekMinBallDist) {
return BehaviorStatus.Failed;
}
// nearest active pad, with large pads given a "closeness" bonus
const pads = ai.getPads();
let best = null, bestScore = Infinity;
for (let i = 0; i < pads.length; i++) {
const pd = pads[i];
if (pd.pad.cooldown > 0) continue;
const d = Math.hypot(pd.x - px, pd.z - pz) - (pd.big ? AI.bigPadBias : 0);
if (d < bestScore) { bestScore = d; best = pd; }
}
if (best === null) return BehaviorStatus.Failed;
const fwd = readGroundForward(ctrl.transform, ai.attackZSign);
const dx = best.x - px, dz = best.z - pz;
const len = Math.hypot(dx, dz) || 1;
const desX = dx / len, desZ = dz / len;
const angle = Math.atan2(fwd.x * desZ - fwd.z * desX, fwd.x * desX + fwd.z * desZ);
const dot = fwd.x * desX + fwd.z * desZ;
const intent = ctrl.intent;
intent.turn = clamp(angle * AI.steerGain, -1, 1);
intent.forward = clamp(AI.throttleBase + AI.throttleGain * dot, AI.throttleMin, 1);
intent.boost = false; // we're here BECAUSE we're low — don't burn it
intent.powerslide = false;
return BehaviorStatus.Running;
}
}
// ─── reactive perpetual root: re-evaluate the child from the top every tick and
// never resolve, so the AI runs forever and switches priorities reactively.
class ReactiveRepeatBehavior extends AbstractDecoratorBehavior {
constructor(source) {
super();
this.setSource(source);
}
tick(td) {
const src = this.__source;
try {
src.finalize(); // clean up last tick's running child
src.initialize(this.context); // reset the Selector to the top
src.tick(td); // one fresh top-down pass
} catch (e) {
// a perception/decision error must never kill the AI
console.warn("AI behaviour tick failed:", e);
}
return BehaviorStatus.Running;
}
}
/**
* Build the AI's BehaviorComponent (the tree). Attach it — together with a filled
* {@link AiControl} — to the AI car entity; meep's BehaviorSystem ticks it.
* @returns {BehaviorComponent}
*/
export function buildAiBehavior() {
const selector = SelectorBehavior.from([
new PerceiveBehavior(),
new RecoverBehavior(),
new AerialBehavior(),
new SeekBoostBehavior(),
new ChaseBallBehavior(),
]);
return BehaviorComponent.from(new ReactiveRepeatBehavior(selector));
}
// AiControl — the AI opponent's ECS *state* component (its blackboard).
//
// The old AiDriver bundled state and behaviour in one object. The ECS-idiomatic
// split is: this component holds the data (references the leaves need + the few
// timers that span ticks), and the *behaviour* lives in a meep behavior tree
// (see AiBehaviors.js) ticked by the engine's BehaviorSystem. Behaviour leaves
// reach this via `ecd.getComponent(entity, AiControl)`.
//
// Runtime-only: it carries live references (the CarController, physics, ball
// body), so it isn't serialized.
export class AiControl {
constructor() {
/** @type {import("./CarController.js").CarController|null} the car this AI drives (writes `.intent`) */
this.controller = null;
/** @type {(() => import("@woosh/meep-engine/src/engine/ecs/transform/Transform64.js").Transform64)|null} ball world pose */
this.getBall = null;
/** @type {(() => ArrayLike<number>)|null} ball velocity (for lead), indexed x/y/z */
this.getBallVel = null;
/** which goal to attack (+1 / −1 on Z) */
this.attackZSign = 1;
/** |z| of the enemy goal line */
this.goalZ = 0;
/** @type {(() => Array<{pad:import("../pads/BoostPad.js").BoostPad, x:number, z:number, big:boolean}>)|null} boost pads, for the SeekBoost leaf */
this.getPads = null;
/** @type {import("@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js").PhysicsSystem|null} for raycast/overlap awareness */
this.physics = null;
/** @type {import("@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js").RigidBody|null} ball body (excluded from the forward ray) */
this.ballBody = null;
// ── cross-tick timers / backoff latch (mutated by the behaviour leaves) ──
this.jumpCd = 0; // s until the AI may jump again
this.stuck = 0; // s spent wedged/slow-while-trying (pre-backoff)
this.backoff = false; // latched: reversing away from a wall until it has clearance
this.backoffTime = 0; // s spent in the current backoff
this.backoffX = 0; // car position when the backoff began (for the distance check)
this.backoffZ = 0;
this.backoffSign = 1; // committed reverse-steer direction for this backoff
// ── awareness, refreshed every tick by PerceiveBehavior's physics queries ──
this.wallAhead = 0; // distance to the nearest obstacle straight ahead, 0 = clear
this.ballContested = false; // a contesting (dynamic) car sits within contestRadius of the ball
}
}
AiControl.typeName = "AiControl";
AiControl.serializable = false;
// CarController — one drivable car.
//
// A Rocket-League-style controller built on Meep's *public* physics API, the
// same primitives the engine's own RaycastVehicle uses (raycast + applyForceAt +
// applyImpulseAt) plus direct velocity edits for crisp arcade control. Per the
// GDC talk "It IS Rocket Science!", drive/steer act on the chassis at its centre
// of mass; only the suspension springs are applied at the wheel contact points.
//
// ground : 4 suspension rays hold the car at ride height and level it; throttle
// is a force at the COM, steering drives the yaw rate directly, tyre
// grip kills lateral slide (reduced while powersliding), a sticky force
// keeps it planted.
// air : pitch / yaw / roll are angular-velocity control about the car's own
// axes; boost pushes along the nose.
// jumps : first jump = impulse along the surface normal; a second press within
// a window is a straight double-jump (no stick) or a directional dodge
// (planar impulse + flip rotation). Pitching back during a dodge cancels
// the flip but keeps the linear impulse.
//
// buildCar() assembles the physics body + the GLTF art (mounted as an EntityNode
// child with the car's local scale/yaw/offset) and returns a CarController. The
// VehicleSystem steps every controller once per fixed tick, before PhysicsSystem.
import { v3_set } from "@woosh/meep-engine/src/core/geom/vec3/v3_set.js";
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { EntityNode } from "@woosh/meep-engine/src/engine/ecs/parent/EntityNode.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { RigidBodyFlags } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBodyFlags.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { Ray3 } from "@woosh/meep-engine/src/core/geom/3d/ray/Ray3.js";
import { PhysicsSurfacePoint } from "@woosh/meep-engine/src/engine/physics/queries/PhysicsSurfacePoint.js";
import { RaycastVehicle } from "@woosh/meep-engine/src/engine/physics/vehicle/RaycastVehicle.js";
import { Interpolated } from "@woosh/meep-engine/src/engine/interpolation/Interpolated.js";
import { POSE_INTERPOLAND } from "@woosh/meep-engine/src/engine/interpolation/pose_interpoland.js";
import { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { SGMeshFlags } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { clamp } from "@woosh/meep-engine/src/core/math/clamp.js";
import { lerp } from "@woosh/meep-engine/src/core/math/lerp.js";
import { CAR, CAR_AGILITY, BOOST, AIR, FLIP, RECOVERY } from "../tuning.js";
import { computeFlipAssist, makeFlipResult } from "./flipAssist.js";
// ─── shared scratch (controllers step sequentially on one thread) ────────────
const _q = new Quaternion();
const _fwd = new Vector3();
const _up = new Vector3();
const _right = new Vector3();
const _n = new Vector3();
const _nSum = new Vector3();
const _mount = new Vector3();
const _cp = new Vector3();
const _r = new Vector3();
const _vpt = new Vector3();
const _lv = new Vector3();
const _av = new Vector3();
const _tmp = new Vector3();
const _tmp2 = new Vector3();
const _driveDir = new Vector3();
const _sideDir = new Vector3();
const _force = new Float64Array(3);
const _point = { x: 0, y: 0, z: 0 };
const _impulse = new Float64Array(3);
const _flipN = new Vector3();
const _flipState = { up: null, forward: null, surfaceNormal: null, wheelContacts: 0, onGround: false, angularVelocity: null, righting: false };
const _flipOut = makeFlipResult();
const WORLD_UP = new Vector3(0, 1, 0);
function boxInverseInertia(mass, hx, hy, hz) {
const k = mass / 3;
return [
1 / (k * (hy * hy + hz * hz)),
1 / (k * (hx * hx + hz * hz)),
1 / (k * (hx * hx + hy * hy)),
];
}
export class CarController {
/**
* @param {object} opts
* @param {object} opts.def a CAR_DEFS entry
* @param {object} opts.team a TEAM entry
* @param {Entity} opts.entity the built physics entity
* @param {RigidBody} opts.body
* @param {Transform64} opts.transform
* @param {EntityNode} opts.node scene-graph node (for attaching VFX mounts)
*/
constructor({ def, team, entity, body, transform, node }) {
this.def = def;
this.team = team;
this.entity = entity;
this.body = body;
this.transform = transform;
this.node = node;
this.vehicle = null; // RaycastVehicle (suspension + ground driving); set by buildCar
this.artSG = null; // SGMesh of the car art; set by buildCar (WheelRig reads its __node)
this.artNode = null; // EntityNode of the art (local scale/yaw/offset); set by buildCar
this.wheelbase = 2.4; // front↔rear wheel span (for the steer cap); set by buildCar
// Control intent, written each frame by PlayerInput (player) or the AI
// behaviour tree (AiBehaviors, via meep's BehaviorSystem); read by step().
this.intent = {
forward: 0, // -1..1 (throttle on ground, pitch in air)
turn: 0, // -1..1 (steer on ground, yaw in air)
roll: 0, // -1..1 (air roll)
boost: false,
powerslide: false,
jump: false, // edge: a fresh press; cleared by the controller
jumpHeld: false,
};
this.boost = BOOST.max * 0.4;
this.frozen = false; // VehicleSystem freezes control forces (countdown/celebration)
this.bounds = null; // { hw, hl, ceil } play-area bounds for out-of-bounds respawn
// jump / dodge state machine
this._jumpTimer = 0; // counts up since the first jump (for the window)
this._hasJumped = false;
this._usedSecond = false;
this._holdTimer = 0; // remaining variable-jump-height window
this._dodgeTimer = 0; // remaining dodge-lock time
this._dodgePitchSign = 0; // pitch input sign at dodge start (for flip-cancel)
this._airTime = 0;
this._noProgress = 0; // time spent trying to drive but going nowhere
this._respawnTo = null; // [x,y,z,yaw] requested by _recover, applied by flushRecovery
this._righting = false; // flip-assist hysteresis: currently self-righting?
this._rightingTime = 0; // how long the flip-assist has been righting (deadlock guard)
// exposed runtime state for camera / VFX / audio / HUD
this.state = {
grounded: false,
surfaceNormal: new Vector3(0, 1, 0),
speed: 0,
forwardSpeed: 0,
boosting: false,
sliding: false,
braking: false,
throttling: false,
airborne: false,
dodging: false,
wheelContacts: 0,
flipPhase: "none", // "none" | "ground" | "right" — set by _flipAssist
};
this.home = { x: transform.translation_x, y: transform.translation_y, z: transform.translation_z, yaw: 0 };
// lightweight debug counters (handy while tuning)
this.dbg = { jumps: 0, doubles: 0, dodges: 0, flipCancels: 0 };
// Trail3Ds attached to this car (registered by CarVfx). resetTo() clears them
// on every teleport (kickoff / respawn) so the trail doesn't draw a streak
// from the car's old position to the new one.
this.trails = [];
// suspension raycast reusables
this._ray = new Ray3();
this._hit = new PhysicsSurfacePoint();
this._filter = (entity, collider) => collider._bodyId !== this.body._bodyId;
}
/** Build the basis vectors (_fwd/_up/_right) from the current pose. */
_readBasis() {
const tr = this.transform;
_q.set(tr.rotation_x, tr.rotation_y, tr.rotation_z, tr.rotation_w);
_fwd.set(0, 0, 1).applyQuaternion(_q);
_up.set(0, 1, 0).applyQuaternion(_q);
_right.set(1, 0, 0).applyQuaternion(_q);
}
/** Place the car upright at a pose and stop it (used for kickoff / respawn). */
resetTo(x, y, z, yaw, physics) {
_q.fromAxisAngle(WORLD_UP, yaw);
physics.setPose(this.body, _tmp.set(x, y, z), _q);
v3_set(this.body.linearVelocity, 0, 0, 0, 0);
v3_set(this.body.angularVelocity, 0, 0, 0, 0);
// clear ALL transient controller state so a kickoff/respawn is a true reset
// to the original state — not just a teleport. In particular drop any
// pending recovery teleport (`_recover` may have queued one from the goal
// blast knocking the car out of bounds) so it can't override this next frame.
this._respawnTo = null;
this._noProgress = 0;
this._rightingTime = 0;
this._jumpTimer = 0;
this._hasJumped = false;
this._usedSecond = false;
this._dodgeTimer = 0;
this._airTime = 0;
this._righting = false;
// this is a teleport, not motion — drop any attached trail history so it
// doesn't streak from the old pose to the new one.
for (let i = 0; i < this.trails.length; i++) this.trails[i].clear();
}
/** One fixed-step update. Called by VehicleSystem before PhysicsSystem. */
step(dt, physics) {
const body = this.body;
if (body.kind !== BodyKind.Dynamic) return;
const tr = this.transform;
const st = this.state;
this._readBasis();
const lv = body.linearVelocity, av = body.angularVelocity;
_lv.set(lv[0], lv[1], lv[2]);
_av.set(av[0], av[1], av[2]);
const speed = _lv.length();
const fwdSpeed = _lv.dot(_fwd);
// ── ground vehicle: RaycastVehicle suspension + tyre drive/steer/grip ──
// Forces act at the wheel contacts, so the chassis rolls/pitches/bobs on
// its suspension (that's the suspension look). update() runs the four
// suspension rays and applies the forces, integrated by PhysicsSystem next.
const v = this.vehicle;
const active = !this.frozen;
const speedCap = this.intent.boost ? BOOST.maxBoostSpeed : CAR.maxSpeed;
let drive = 0, brake = 0, steer = 0;
if (active) {
if (this.intent.forward > 0) {
if (fwdSpeed < -0.5) brake = CAR.brakeForce; // braking out of reverse
else if (fwdSpeed < speedCap) drive = CAR.driveForce * this.intent.forward;
} else if (this.intent.forward < 0) {
if (fwdSpeed > 0.5) brake = CAR.brakeForce; // braking out of forward
else if (fwdSpeed > -speedCap) drive = CAR.reverseForce * this.intent.forward;
} else if (Math.abs(fwdSpeed) > 0.2) {
brake = CAR.idleDrag; // engine braking while coasting
}
// speed-sensitive steer cap: keep cornering accel under the rollover point
const maxSteer = speed > 1
? Math.min(CAR.steerLock, Math.atan2(CAR.steerMaxLateral * this.wheelbase, speed * speed))
: CAR.steerLock;
steer = -this.intent.turn * maxSteer;
}
v.setDriveForce(drive);
v.setBrake(brake);
v.setSteering(steer);
// handbrake: drop the rear tyres' grip so the back steps out (drift)
const rearMu = this.intent.powerslide ? CAR.powerslideFriction : CAR.wheelFriction;
v.wheels[2].friction = rearMu;
v.wheels[3].friction = rearMu;
v.update(dt);
// read contact state back off the wheels for the air/jump/flip logic
_nSum.set(0, 0, 0);
let contacts = 0, sliding = false;
for (const w of v.wheels) {
if (!w.inContact) continue;
contacts++;
_nSum.x += w.contactNormal[0]; _nSum.y += w.contactNormal[1]; _nSum.z += w.contactNormal[2];
}
const grounded = contacts > 0;
st.wheelContacts = contacts;
if (grounded) st.surfaceNormal.copy(_nSum).normalize();
else st.surfaceNormal.copy(WORLD_UP);
const N = st.surfaceNormal;
st.speed = speed;
st.forwardSpeed = fwdSpeed;
st.grounded = grounded;
st.airborne = !grounded;
st.throttling = this.intent.forward !== 0;
st.braking = brake > 0;
st.sliding = this.intent.powerslide && grounded && speed > 4;
// Ground stabiliser: bleed off pitch + roll angular velocity while the
// wheels are down, so a collision or hard turn can't tip the car over.
// Yaw (about the surface normal) is left alone so steering still works.
if (grounded) {
const pr = _av.dot(_right), rr = _av.dot(_fwd);
const d = CAR.groundRollDamp;
v3_set(av, 0,
av[0] - (_right.x * pr + _fwd.x * rr) * d,
av[1] - (_right.y * pr + _fwd.y * rr) * d,
av[2] - (_right.z * pr + _fwd.z * rr) * d,
);
_av.set(av[0], av[1], av[2]);
}
// ── boost (works on the ground and in the air) ──────────────────────
st.boosting = false;
if (active && this.intent.boost && this.boost > BOOST.minToActivate) {
const fwdSpeed = _lv.dot(_fwd);
if (fwdSpeed < BOOST.maxBoostSpeed) {
_force[0] = _fwd.x * BOOST.force; _force[1] = _fwd.y * BOOST.force; _force[2] = _fwd.z * BOOST.force;
physics.applyForce(body, _force);
}
this.boost = Math.max(0, this.boost - BOOST.drainPerSec * dt);
st.boosting = true;
} else if (active && !this.intent.boost) {
// recharge ONLY while boost isn't held — holding at a (near-)empty tank
// would otherwise thrash between a sliver of regen and an instant drain.
this.boost = Math.min(BOOST.max, this.boost + BOOST.regenPerSec * dt);
}
// ── flip-assist (force-based self-righting; only while touching ground) ─
const flipPhase = this._flipAssist(physics);
// the ground is driven by RaycastVehicle above; in the air we add arcade
// attitude control — but not while an active righting torque is running.
if (active && !grounded && flipPhase !== "right") this._airControl(dt);
// ── jump input (edge) ───────────────────────────────────────────────
if (active && this.intent.jump) {
this.intent.jump = false;
if (grounded) this._doGroundJump(physics, N);
else if (this._hasJumped && !this._usedSecond && this._jumpTimer < AIR.jumpWindow) {
this._doSecondJump(physics);
}
}
// variable jump height while holding jump just after take-off
if (active && this._holdTimer > 0 && this.intent.jumpHeld && !grounded) {
_force[0] = N.x * AIR.jumpHoldForce; _force[1] = N.y * AIR.jumpHoldForce; _force[2] = N.z * AIR.jumpHoldForce;
physics.applyForce(body, _force);
this._holdTimer -= dt;
} else {
this._holdTimer = 0;
}
// ── timers ──────────────────────────────────────────────────────────
if (grounded) {
// Landing after a real airborne stint resets the jump chain. The
// 0.05 s guard avoids wiping the chain on the same step we jump from
// (the car is still grounded for one tick after the take-off impulse).
if (this._airTime > 0.05 && (this._hasJumped || this._usedSecond)) {
this._hasJumped = false;
this._usedSecond = false;
this._jumpTimer = 0;
this._dodgeTimer = 0;
st.dodging = false;
}
this._airTime = 0;
} else {
this._airTime += dt;
if (this._hasJumped) this._jumpTimer += dt;
}
if (this._dodgeTimer > 0) {
this._dodgeTimer -= dt;
if (this._dodgeTimer <= 0) st.dodging = false;
}
// ── clamp angular speed (dodges are exempt while their lock runs) ────
if (this._dodgeTimer <= 0) {
const a2 = (av[0] * av[0] + av[1] * av[1] + av[2] * av[2]);
const cap = AIR.maxAngularSpeed;
if (a2 > cap * cap) {
const s = cap / Math.sqrt(a2);
v3_set(av, 0, av[0] * s, av[1] * s, av[2] * s);
}
}
this._recover(dt);
}
// ── air control: pitch / yaw / roll as angular-velocity control ─────────
_airControl(dt) {
const av = this.body.angularVelocity;
const st = this.state;
// flip-cancel: pitch opposite the dodge during the lock kills rotation
if (this._dodgeTimer > 0) {
if (this._dodgePitchSign !== 0 &&
Math.sign(this.intent.forward) === -this._dodgePitchSign &&
Math.abs(this.intent.forward) > 0.5) {
v3_set(av, 0, 0, 0, 0);
this._dodgeTimer = 0;
st.dodging = false;
this.dbg.flipCancels++;
}
return; // dodge plays out; suppress normal control during the lock
}
_av.set(av[0], av[1], av[2]);
// pitch about right, yaw about up, roll about forward (car-local axes).
// Calibrated: +X (car-right) angular velocity pitches the nose DOWN, so a
// NEGATIVE coefficient makes W (forward > 0) pitch the nose UP, S nose down.
const pitch = -this.intent.forward * AIR.pitchTorque * dt;
const yaw = -this.intent.turn * AIR.yawTorque * dt; // A/D yaw (negated → A/D give the expected sides)
const roll = this.intent.roll * AIR.rollTorque * dt;
_av._add(_right.x * pitch, _right.y * pitch, _right.z * pitch);
_av._add(_up.x * yaw, _up.y * yaw, _up.z * yaw);
_av._add(_fwd.x * roll, _fwd.y * roll, _fwd.z * roll);
// gentle damping for control feel when there's no input on an axis
const anyInput = this.intent.forward || this.intent.turn || this.intent.roll;
if (!anyInput) {
const k = Math.pow(AIR.airDamping, dt);
_av.multiplyScalar(k);
}
v3_set(av, 0, _av.x, _av.y, _av.z);
}
_doGroundJump(physics, N) {
const m = this.body.mass;
_impulse[0] = N.x * m * AIR.jumpSpeed; _impulse[1] = N.y * m * AIR.jumpSpeed; _impulse[2] = N.z * m * AIR.jumpSpeed;
physics.applyImpulse(this.body, _impulse);
this._hasJumped = true;
this._usedSecond = false;
this._jumpTimer = 0;
this._holdTimer = AIR.jumpHoldExtra;
this._airTime = 0.0001;
this.dbg.jumps++;
}
_doSecondJump(physics) {
this._usedSecond = true;
const m = this.body.mass;
const inputMag = Math.hypot(this.intent.forward, this.intent.turn);
if (inputMag < AIR.dodgeDeadzone) {
// straight double jump, along car-up
_impulse[0] = _up.x * m * AIR.doubleJumpSpeed;
_impulse[1] = _up.y * m * AIR.doubleJumpSpeed;
_impulse[2] = _up.z * m * AIR.doubleJumpSpeed;
physics.applyImpulse(this.body, _impulse);
this.dbg.doubles++;
return;
}
// directional dodge: planar impulse + a flip rotation.
// Dodge direction in the car-local plane: forward from pitch (+intent.forward
// → forward, matching W=nose-up), and lateral from steer. The lateral term
// is NEGATED (-intent.turn) for the same reason the air yaw is (see _airControl):
// intent.turn is +1 for D / −1 for A, so without the negation a tap of A
// dodges right and D dodges left. Negating makes the dodge lunge to the same
// side the car yaws toward (A → left, D → right).
_tmp.set(-this.intent.turn, 0, this.intent.forward).normalize();
_driveDir.copy(_right).multiplyScalar(_tmp.x)._add(_fwd.x * _tmp.z, _fwd.y * _tmp.z, _fwd.z * _tmp.z).normalize();
// linear velocity boost in the dodge direction
const lv = this.body.linearVelocity;
v3_set(lv, 0, lv[0] + _driveDir.x * AIR.dodgeSpeed,
lv[1] + _driveDir.y * AIR.dodgeSpeed,
lv[2] + _driveDir.z * AIR.dodgeSpeed);
// flip rotation: spin about (up × dodgeDir) so the car rotates over its nose/side
_tmp2.copy(_up).cross(_driveDir).normalize();
const av = this.body.angularVelocity;
v3_set(av, 0, _tmp2.x * AIR.dodgeAngular, _tmp2.y * AIR.dodgeAngular, _tmp2.z * AIR.dodgeAngular);
this._dodgeTimer = AIR.dodgeLock;
this._dodgePitchSign = Math.sign(this.intent.forward);
this.state.dodging = true;
this.dbg.dodges++;
}
// Force-based flip-assist. Detects ground contact in ANY orientation with a
// world-down probe from the car centre (the suspension rays point along
// car-local down, useless when the car is on its side/roof), then applies the
// grounding force / righting torque chosen by computeFlipAssist(). Returns the
// phase string ("none" | "ground" | "right"). Does nothing while airborne.
_flipAssist(physics) {
const tr = this.transform;
// world-down ray from the car centre → "touching the ground"?
this._ray.setOrigin(tr.translation_x, tr.translation_y, tr.translation_z);
this._ray.setDirection(0, -1, 0);
this._ray.tMax = FLIP.groundProbe;
const onGround = physics.raycast(this._ray, this._hit, this._filter)
&& this._hit.t <= FLIP.groundProbe;
if (onGround) {
// ground normal from the probe; guard degenerate / downward-facing hits
_flipN.set(this._hit.normal[0], this._hit.normal[1], this._hit.normal[2]);
if (_flipN.lengthSqr() < 1e-8 || _flipN.y < 0) _flipN.copy(WORLD_UP);
else _flipN.normalize();
} else {
_flipN.copy(WORLD_UP);
}
_flipState.up = _up;
_flipState.forward = _fwd;
_flipState.surfaceNormal = _flipN;
_flipState.wheelContacts = this.state.wheelContacts;
_flipState.onGround = onGround;
_flipState.angularVelocity = _av.set(this.body.angularVelocity[0], this.body.angularVelocity[1], this.body.angularVelocity[2]);
_flipState.righting = this._righting;
computeFlipAssist(_flipState, _flipOut);
this._righting = _flipOut.phase === "right"; // hysteresis for next step
if (_flipOut.phase === "right") {
_force[0] = _flipOut.tx; _force[1] = _flipOut.ty; _force[2] = _flipOut.tz;
physics.applyTorque(this.body, _force);
} else if (_flipOut.phase === "ground") {
// at the COM — the wheel-contact constraint turns this downforce into
// the rotation that settles the lifted wheels back onto the floor.
_force[0] = _flipOut.fx; _force[1] = _flipOut.fy; _force[2] = _flipOut.fz;
physics.applyForce(this.body, _force);
}
this.state.flipPhase = _flipOut.phase;
return _flipOut.phase;
}
// recovery: detect a car that's truly stuck (out of bounds, or wedged and
// going nowhere) and REQUEST a respawn — but don't teleport here. setPose
// called from inside the physics step gets clobbered by the interpolation
// restore at the top of the step; flushRecovery() performs the teleport from
// the render frame instead (as the engine's RaycastVehicle example does).
// Flipping is handled by _flipAssist, not here.
_recover(dt) {
const st = this.state;
const tr = this.transform;
const px = tr.translation_x, py = tr.translation_y, pz = tr.translation_z;
const b = this.bounds;
if (b !== null) {
// the goal mouth + net is valid space (drive in / reverse out), so allow
// z past the goal line while inside the goal width; only respawn if the
// car is truly out (past the net back, or behind a solid end wall).
const inGoalMouth = b.goalHalfWidth !== undefined && Math.abs(px) < b.goalHalfWidth;
const zLimit = inGoalMouth ? b.hl + b.goalDepth + 1.5 : b.hl + 0.5;
const out = Math.abs(px) > b.hw + 2 || Math.abs(pz) > zLimit
|| py < -6 || py > b.ceil + 12;
if (out) { this._respawnTo = [this.home.x, this.home.y, this.home.z, this.home.yaw]; return; }
}
// While the flip-assist is righting the car it IS making (rotational)
// progress even at ~0 speed — don't count that as wedged. But if righting
// drags on (wedged on its side against a wall and can't rotate free), stop
// protecting it and let the wedged-respawn below kick in.
if (st.flipPhase === "right") {
this._rightingTime += dt;
if (this._rightingTime < RECOVERY.rightingGrace) { this._noProgress = 0; return; }
} else {
this._rightingTime = 0;
}
// wedged: trying to drive (throttle/steer) but going nowhere for a while
// (e.g. an AI car that ploughed into a net mouth) → respawn home.
const trying = Math.abs(this.intent.forward) > 0.1 || Math.abs(this.intent.turn) > 0.1;
if (st.speed < RECOVERY.stuckSpeed && trying) {
this._noProgress += dt;
if (this._noProgress > RECOVERY.noProgressGrace) {
this._respawnTo = [this.home.x, this.home.y, this.home.z, this.home.yaw];
this._noProgress = 0;
}
} else {
this._noProgress = 0;
}
}
/** Perform a requested respawn. Call from the render frame, not the physics step. */
flushRecovery(physics) {
if (this._respawnTo === null) return;
const [x, y, z, yaw] = this._respawnTo;
this._respawnTo = null;
this.resetTo(x, y, z, yaw, physics);
}
}
function _vecScale(out, v, s) { out.x = v.x * s; out.y = v.y * s; out.z = v.z * s; return out; }
// ─── factory ─────────────────────────────────────────────────────────────────
/**
* Build a car: physics body + box collider + interpolation + GLTF art child.
* @param {object} ctx { ecd, physics }
* @param {object} opts { def, team, position:[x,y,z], yaw, withArt }
* `withArt` (default true) loads + mounts the GLTF mesh; pass false for a
* headless car (physics only, no renderer) so the sim can run under `node --test`.
* @returns {CarController}
*/
export function buildCar(ctx, { def, team, position, yaw = 0, withArt = true }) {
const { ecd } = ctx;
const [hx, hy, hz] = def.body.half;
const transform = new Transform64();
transform.setTranslation(position[0], position[1], position[2]);
{
const q = new Quaternion().fromAxisAngle(WORLD_UP, yaw);
transform.setRotation(q.x, q.y, q.z, q.w);
// rotation lives outside the matrix until it is composed
transform.updateMatrix();
}
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = def.body.mass;
const inv = boxInverseInertia(def.body.mass, hx, hy, hz);
v3_set(body.inverseInertiaLocal, 0, inv[0] * CAR_AGILITY, inv[1] * CAR_AGILITY, inv[2] * CAR_AGILITY);
body.linearDamping = CAR.linearDamping;
body.angularDamping = 0.05;
body.flags = RigidBodyFlags.DisableSleep;
const collider = new Collider();
collider.shape = BoxShape3D.from(hx, hy, hz);
collider.friction = 0.5;
collider.restitution = 0.1;
const interpolated = new Interpolated();
interpolated.interpolands = [POSE_INTERPOLAND];
const entityObj = new Entity()
.add(transform)
.add(body)
.add(collider)
.add(interpolated);
const entityId = entityObj.build(ecd);
// scene-graph node for the art + later VFX mounts
const node = new EntityNode(entityObj);
// GLTF art (renderer-side). Skipped for a headless car so the simulation can
// run with no SGMesh/ShadedGeometry systems registered (Node tests).
let art = null;
let artNode = null;
if (withArt) {
art = SGMesh.fromURL(def.modelUrl);
art.setFlag(SGMeshFlags.CastShadow);
artNode = EntityNode.fromComponents(new Transform64(), art);
const aq = new Quaternion().fromAxisAngle(WORLD_UP, def.model.yaw);
artNode.transform.setScale(def.model.scale, def.model.scale, def.model.scale);
artNode.transform.setRotation(aq.x, aq.y, aq.z, aq.w);
artNode.transform.setTranslation(def.model.offset[0], def.model.offset[1], def.model.offset[2]);
artNode.transform.updateMatrix();
node.addChild(artNode);
}
// Ground vehicle: the engine's RaycastVehicle (suspension + tyre forces at the
// wheel contacts → the chassis physically leans/bobs on its suspension). One
// wheel per def mount; front wheels (local +Z) steer, all four drive.
const vehicle = new RaycastVehicle(ctx.physics, body, transform);
for (const w of def.wheels) {
vehicle.addWheel({
localPosition: w,
suspensionRestLength: CAR.suspensionRest,
suspensionStiffness: CAR.suspensionStiffness,
suspensionDamping: CAR.suspensionDamping,
suspensionMaxForce: CAR.suspensionMaxForce,
radius: CAR.wheelRadius,
friction: CAR.wheelFriction,
steered: w[2] > 0,
driven: true,
});
}
const controller = new CarController({ def, team, entity: entityId, body, transform, node });
controller.vehicle = vehicle;
controller.artSG = art; // the SGMesh (its __node tree holds the wheels)
controller.artNode = artNode; // local scale/yaw/offset of the art under the chassis
controller.wheelbase = Math.abs(def.wheels[0][2] - def.wheels[2][2]); // front↔rear span
controller.home = { x: position[0], y: position[1], z: position[2], yaw };
return controller;
}
// Default import + the `type: "json"` attribute: the standard form that works in
// BOTH Vite (the demo build) and Node (the `node --test` suite). A bare
// `import * as x from './x.json'` only works in Vite (it spreads the JSON's keys
// as named exports); Node rejects it without the attribute.
import octane from './concrete/octane.json' with { type: 'json' };
import perrier from './concrete/perrier.json' with { type: 'json' };
// Per-vehicle metadata.
//
// The two GLTF models are authored at wildly different scales and orientations
// (measured from their bounding boxes at load time):
//
// octane : ~139.9 × 62.1 × 72.5 — length runs along model +X, pivot near the
// floor. Needs ~0.0257× scale and a yaw to bring its length onto the
// car-local forward axis (+Z).
// perrier: ~2.10 × 1.79 × 3.16 — length already along model +Z, near unit
// scale, pivot near the floor.
//
// So each car carries its own model transform plus the gameplay mount points.
// All mount points are in CAR-LOCAL space, with the convention used everywhere
// in this example:
//
// +Z = forward (nose) +Y = up +X = right
//
// `body.half` is the gray collider box's half-extents [x(width), y(height),
// z(length)]; the GLTF art is mounted as a child and offset so its wheels meet
// the bottom of that box. RL-style hitboxes are flatter than the visible car, so
// the collider is deliberately shorter than the model's roof.
export const CAR_DEFS = {
octane: octane,
perrier: perrier,
};
export const CAR_IDS = Object.keys(CAR_DEFS);
/**
* Read an attachment's car-local position from EITHER the legacy shape (a bare
* `[x,y,z]`, or `{position, size}`) or the editor's Transform64 shape
* (`{position, rotation, scale}`). Lets the runtime consume both the hand-written
* defs and whatever an authoring tool exports, without a flag day. (The
* three.js-based car-def editor that produced the Transform64 shape was retired
* with three.js itself; `editor/carDefIO.js` — the pure def⇔model conversion it
* used, and its tests — is kept.)
* @returns {number[]} [x,y,z]
*/
export function attachmentPosition(entry, fallback = [0, 0, 0]) {
if (Array.isArray(entry)) return entry;
if (entry && Array.isArray(entry.position)) return entry.position;
return fallback;
}
/** An attachment's rotation quaternion [x,y,z,w], or identity for a legacy mount. */
export function attachmentRotation(entry) {
if (entry && Array.isArray(entry.rotation)) return entry.rotation;
return [0, 0, 0, 1];
}
/**
* An attachment's scale [x,y,z], or `null` if the (legacy) shape carries none — a
* bare `[x,y,z]` mount has no scale; `{position,size}` → uniform [size,size,size];
* `{position,rotation,scale}` → its scale. Callers supply their own default.
*/
export function attachmentScale(entry) {
if (!entry || Array.isArray(entry)) return null;
if (Array.isArray(entry.scale)) return entry.scale;
if (typeof entry.size === "number") return [entry.size, entry.size, entry.size];
return null;
}
// Flip-assist decision logic — pure, with no engine dependencies.
//
// Given the car's orientation and ground contact, decide which assist (if any) to
// apply. All assists require the car to be TOUCHING THE GROUND — a jumped or
// knocked-up car (onGround === false) is left entirely alone.
//
// "ground" : 2+ wheels lifted but at least one still touching (1–2 contacts)
// and not badly rolled → a force opposite the surface normal, pressing
// the car down so the lifted wheels settle back onto the floor.
// "right" : rolled past tiltRightRad (~85°) → a torque that rotates the car's
// up-axis back toward the surface normal, self-righting it. The
// grounding force is suppressed in this phase.
// "none" : upright (or airborne) → nothing.
//
// Vectors are plain {x,y,z}; the result is written into `out` (also plain) to
// avoid allocations on the hot path.
import { clamp } from "@woosh/meep-engine/src/core/math/clamp.js";
import { FLIP } from "../tuning.js";
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
/**
* @param {object} s car state, vectors as {x,y,z}:
* up car's up-axis (world space)
* forward car's forward-axis (world space) — fallback roll axis when inverted
* surfaceNormal ground normal (world space; +Y on a flat floor)
* wheelContacts number of wheels currently touching (0–4)
* onGround is the car touching/near the ground in ANY orientation?
* angularVelocity current angular velocity (world space), for damping
* righting was the car already in the righting phase last step? (hysteresis)
* @param {object} out mutated result: { phase, fx, fy, fz, tx, ty, tz }
* @param {object} [cfg=FLIP]
* @returns {object} out
*/
export function computeFlipAssist(s, out, cfg = FLIP) {
out.phase = "none";
out.fx = out.fy = out.fz = 0;
out.tx = out.ty = out.tz = 0;
// Airborne → no assist. This is the whole point of the ground check: forces
// must never fire while the player is jumping or has been knocked into the air.
if (!s.onGround) return out;
const up = s.up, N = s.surfaceNormal;
const tilt = Math.acos(clamp(dot(up, N), -1, 1)); // 0 = upright, π = upside-down
// Hysteresis: start righting past tiltRightRad, but once started keep righting
// until well past the tipping point (tiltSettleRad), so the car doesn't stall
// on its side just under the start threshold.
const wantRight = tilt > cfg.tiltRightRad || (s.righting && tilt > cfg.tiltSettleRad);
if (wantRight) {
// Phase "right": torque about (up × N) rotates up toward N.
let ax = up.y * N.z - up.z * N.y;
let ay = up.z * N.x - up.x * N.z;
let az = up.x * N.y - up.y * N.x;
let len = Math.sqrt(ax * ax + ay * ay + az * az);
if (len < 1e-4) {
// up ≈ ±N: cross product degenerate. If exactly inverted, roll about the
// car's forward axis to break the unstable equilibrium and start righting.
ax = s.forward.x; ay = s.forward.y; az = s.forward.z;
len = Math.sqrt(ax * ax + ay * ay + az * az) || 1;
}
const k = cfg.rightTorque / len;
const av = s.angularVelocity;
out.tx = ax * k - av.x * cfg.rightDamping;
out.ty = ay * k - av.y * cfg.rightDamping;
out.tz = az * k - av.z * cfg.rightDamping;
out.phase = "right";
} else if (s.wheelContacts >= 1 && s.wheelContacts <= 2) {
// Phase "ground": press the car onto the surface (opposite the normal).
out.fx = -N.x * cfg.groundForce;
out.fy = -N.y * cfg.groundForce;
out.fz = -N.z * cfg.groundForce;
out.phase = "ground";
}
return out;
}
/** Convenience: a fresh result object for computeFlipAssist's `out` param. */
export function makeFlipResult() {
return { phase: "none", fx: 0, fy: 0, fz: 0, tx: 0, ty: 0, tz: 0 };
}
// PlayerInput — keyboard → a CarController's intent.
//
// Ground: W/S throttle·reverse, A/D steer, X powerslide, Shift boost, Space jump.
// Air: W/S pitch, A/D yaw, Q/E roll, Shift boost, Space (2nd tap) dodge/flip.
// Extras: F toggles ball-cam, R requests a respawn.
//
// Axes only change on key events, so we recompute them in the listeners. Jump is
// an edge (set on press, cleared by the controller); jumpHeld drives variable
// jump height.
import InputController from "@woosh/meep-engine/src/engine/input/ecs/components/InputController.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
const KEYS = [
["w", "fwd"], ["up_arrow", "fwd"],
["s", "back"], ["down_arrow", "back"],
["a", "left"], ["left_arrow", "left"],
["d", "right"], ["right_arrow", "right"],
["q", "rollL"],
["e", "rollR"],
["x", "slide"],
["shift", "boost"],
];
/**
* @param {EntityComponentDataset} ecd
* @param {import("./CarController.js").CarController} controller
* @param {object} [opts] { onBallCam, onReset }
*/
export function attachPlayerInput(ecd, controller, opts = {}) {
const intent = controller.intent;
const down = Object.create(null);
function recompute() {
intent.forward = (down.fwd ? 1 : 0) - (down.back ? 1 : 0);
intent.turn = (down.right ? 1 : 0) - (down.left ? 1 : 0);
intent.roll = (down.rollR ? 1 : 0) - (down.rollL ? 1 : 0);
intent.boost = !!down.boost;
intent.powerslide = !!down.slide;
}
const bindings = [];
for (const [key, action] of KEYS) {
bindings.push({ path: `keyboard/keys/${key}/down`, listener: () => { down[action] = true; recompute(); } });
bindings.push({ path: `keyboard/keys/${key}/up`, listener: () => { down[action] = false; recompute(); } });
}
// Jump (Space): edge + held.
bindings.push({ path: "keyboard/keys/space/down", listener: () => { intent.jump = true; intent.jumpHeld = true; } });
bindings.push({ path: "keyboard/keys/space/up", listener: () => { intent.jumpHeld = false; } });
// Ball-cam toggle (F) and respawn (R).
if (opts.onBallCam) bindings.push({ path: "keyboard/keys/f/down", listener: opts.onBallCam });
if (opts.onReset) bindings.push({ path: "keyboard/keys/r/down", listener: opts.onReset });
new Entity().add(new InputController(bindings)).build(ecd);
}
// VehicleSystem — steps every CarController once per fixed tick.
//
// Registered BEFORE PhysicsSystem so its fixedUpdate runs first: each controller
// reads input, casts its suspension rays and accumulates suspension/drive/boost
// forces (plus direct velocity edits for steering, grip and air control). The
// physics step that follows integrates those forces and resolves contacts.
import { System } from "@woosh/meep-engine/src/engine/ecs/System.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
export class VehicleSystem extends System {
// The system doesn't iterate entities (it steps registered controllers in
// fixedUpdate), but the engine requires every System to declare ≥1 dependency.
dependencies = [Transform64];
constructor() {
super();
/** @type {import("./CarController.js").CarController[]} */
this.controllers = [];
this.physics = null; // assigned in main once PhysicsSystem exists
this.controlEnabled = true; // when false, cars sit (suspension runs) but take no input
}
addCar(controller) {
this.controllers.push(controller);
return controller;
}
fixedUpdate(dt) {
const physics = this.physics;
if (physics === null || dt <= 0) return;
for (let i = 0; i < this.controllers.length; i++) {
const c = this.controllers[i];
// Frozen = no control forces (kickoff countdown, goal celebration), but
// suspension still runs so the car holds its ride height. We DON'T wipe
// the intent: a held input (player W, or the AI's behaviour-tree intent)
// carries through the countdown so the car launches the instant control
// resumes on "GO". The AI fills its intent from its own ECS system
// (BehaviorSystem ticking the car's behaviour tree), not from here.
c.frozen = !this.controlEnabled;
c.step(dt, physics);
}
}
}
// WheelRig — drives the GLTF wheel meshes off the car's RaycastVehicle wheels.
//
// The car body is a rigid mesh that leans/pitches on its (RaycastVehicle)
// suspension. The four wheels must instead stay PLANTED on the ground and spin /
// steer. The wheel nodes live inside the loaded model, so we animate each wheel's
// LOCAL transform in place. After the model loads we:
// 1. find the four wheel nodes by name (carDef.wheelNodes),
// 2. capture each wheel's rest local transform and, in that wheel's PARENT frame,
// the car-up axis (suspension travel) and car-right axis (rolling axle),
// 3. match each to its RaycastVehicle wheel by nearest car-local mount,
// 4. each frame offset the wheel along car-up by its suspension travel (so it
// stays on the ground as the body rolls), spin it about the axle, and steer
// the fronts — all relative to the captured rest pose.
//
// WHERE THE NODES LIVE. meep 3 has no renderer scene graph to reach into: the
// MeshSystem expands a loaded model into ENTITIES — one per glTF node, each
// carrying a `Name`, a `TransformAttachment` (its local transform plus the entity
// it is relative to) and, where it draws, a `ShadedGeometry`. So "find the wheel
// node" is "find the descendant entity with that name", and "animate the wheel"
// is "write its TransformAttachment.transform" — the TransformAttachmentSystem
// recomposes the world Transform64 from it the moment it changes.
//
// All maths uses meep's own types: Vector3 / Quaternion (meep has no Matrix4).
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import Name from "@woosh/meep-engine/src/engine/ecs/name/Name.js";
import { ParentEntity } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { TransformAttachment } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachment.js";
import {
TRANSFORM_ATTACHMENT_EVENT_CHANGE
} from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TRANSFORM_ATTACHMENT_EVENT_CHANGE.js";
import {
transform_attachment_parent_of
} from "@woosh/meep-engine/src/engine/ecs/transform-attachment/transform_attachment_parent_of.js";
import { CAR } from "../tuning.js";
const _axleQ = new Quaternion();
const _spin = new Quaternion();
const _steer = new Quaternion();
const _rot = new Quaternion();
const _inv = new Quaternion();
const _carRotation = new Quaternion();
const _up = new Vector3();
const CAR_UP = new Vector3(0, 1, 0);
/**
* The top of an entity's transform hierarchy: walk `TransformAttachment.parent`
* until an entity has none, which is the model's own root.
*
* @param {EntityComponentDataset} dataset
* @param {number} entity
* @returns {number}
*/
function transformRootOf(dataset, entity) {
let cursor = entity;
for (; ;) {
const parent = transform_attachment_parent_of(dataset, cursor);
if (parent === -1) {
return cursor;
}
cursor = parent;
}
}
export class WheelRig {
/** @param {CarController} car needs `vehicle`, `artNode`, `def.wheelNodes` */
constructor(car) {
this.car = car;
this.bound = false;
this.wheels = [];
}
/**
* Index the model's named nodes, once it has arrived.
*
* The MeshSystem expands the model under the art entity, so a node of THIS car
* is a `Name` whose transform hierarchy roots at an entity logically parented
* (`ParentEntity`) to the art entity. Searching names first and ancestry second
* is the cheap way round — the dataset already indexes components by type.
*
* @param {EntityComponentDataset} dataset
* @param {number} artEntity
* @returns {Map<string, number>} normalised name → entity
*/
_indexModelNodes(dataset, artEntity) {
const found = new Map();
dataset.traverseComponents(Name, (name, entity) => {
const root = transformRootOf(dataset, entity);
const parent = dataset.getComponent(root, ParentEntity);
if (parent === undefined || parent === null || parent.entity !== artEntity) {
return;
}
found.set(name.getValue().replace(/\s+/g, "_"), entity);
});
return found;
}
_tryBind() {
const car = this.car;
const artNode = car.artNode;
if (artNode === undefined || artNode === null || !artNode.entity.isBuilt) return false;
const names = car.def.wheelNodes;
if (!names || names.length === 0) { this.bound = true; return true; }
const dataset = artNode.entity.dataset;
this.dataset = dataset;
const artEntity = artNode.entity.id;
const byName = this._indexModelNodes(dataset, artEntity);
if (byName.size === 0) {
// the model has not finished loading; try again next frame
return false;
}
const norm = (str) => str.replace(/\s+/g, "_");
// car pose right now, so a node's parent frame can be expressed relative to
// the chassis (which is what the suspension travel and steer axes are in).
const carTransform = car.transform;
_carRotation.set(
carTransform.rotation_x, carTransform.rotation_y,
carTransform.rotation_z, carTransform.rotation_w
);
_up.copy(CAR_UP).applyQuaternion(_carRotation); // chassis up, in world space
// wheelNodes[i] lists every PART of wheel i (tyre, rim, …), in the same
// order as def.wheels (→ RaycastVehicle wheels). Each part is { name, spin }
// where `spin` is a quaternion defining that node's rolling AXLE in its own
// LOCAL frame (axle = spin · +X̂). Pinning the axle per node is what lets a
// wheel authored in a tilted / mirrored frame still roll true. front/back
// order keeps steering on the front pair; wheelSpin flips roll direction.
const spinSign = car.def.wheelSpin || 1;
const mount = (i) => car.def.wheels[i];
names.forEach((parts, i) => {
const wheel = car.vehicle.wheels[i];
for (const part of parts) {
const name = typeof part === "string" ? part : part.name;
const spinDef = (part && part.spin) || [0, 0, 0, 1];
const entity = byName.get(norm(name));
if (entity === undefined) continue;
// the node's LOCAL transform — what the hierarchy composes from
const attachment = dataset.getComponent(entity, TransformAttachment);
if (attachment === undefined || attachment === null) continue;
const t = attachment.transform;
const restPos = new Vector3(t.translation_x, t.translation_y, t.translation_z);
const restQuat = new Quaternion(t.rotation_x, t.rotation_y, t.rotation_z, t.rotation_w);
// rolling axle in the node's LOCAL frame, straight from the def:
// rotate +X̂ by the per-node quaternion. No chain maths → robust to
// however the node's frame is oriented.
_axleQ.set(spinDef[0], spinDef[1], spinDef[2], spinDef[3]);
const axleLocal = new Vector3(1, 0, 0).applyQuaternion(_axleQ).normalize();
// Car-up expressed in the wheel's PARENT frame — the suspension
// TRAVEL direction, and the front-wheel STEER axis. The parent's
// composed world Transform64 gives its world rotation directly, so
// this is one inverse rotation rather than a walk up the chain.
const parentEntity = transform_attachment_parent_of(dataset, entity);
const upInParent = new Vector3().copy(_up);
if (parentEntity !== -1) {
const parentTransform = dataset.getComponent(parentEntity, Transform64);
if (parentTransform !== undefined && parentTransform !== null) {
_inv.set(
parentTransform.rotation_x, parentTransform.rotation_y,
parentTransform.rotation_z, parentTransform.rotation_w
).invert();
upInParent.applyQuaternion(_inv);
}
}
upInParent.normalize();
// parent-local units per metre: the wheel's rest offset is authored
// in the model's units, its mount in metres, so calibrate off the two
// (the parent chain's scale is not reliable enough to read directly).
const m = mount(i);
const localR = Math.hypot(restPos.x, restPos.z) || 1;
const carR = Math.hypot(m[0], m[2]) || 1;
const metresToParent = localR / carR;
this.wheels.push({
transform: t, entity, restPos, restQuat, axleLocal, upInParent,
metresToParent, wheel, steered: wheel.steered, spinSign,
});
}
});
if (this.wheels.length === 0) {
// The model is in the dataset but none of `wheelNodes` matched. Almost
// always a load still settling, so retry — but only for a few frames:
// a genuine name mismatch would otherwise re-scan the dataset forever.
this._attempts = (this._attempts || 0) + 1;
if (this._attempts < 30) return false;
console.warn("WheelRig: no wheel node of", this.car.def.wheelNodes, "found under the car model; wheels will not animate");
}
this.bound = true;
return true;
}
/**
* @param {import("@woosh/meep-engine/src/engine/ecs/EntityManager.js").default} em
* the entity manager — for the fixed-step clock used to interpolate.
*/
update(em) {
if (!this.bound && !this._tryBind()) return;
// The chassis is render-interpolated between fixed steps (Interpolated +
// POSE_INTERPOLAND). The wheels' inputs — suspension length, spin angle and
// steer — are raw physics scalars that only change at the 60 Hz step rate,
// so applying them straight makes the wheels snap while the body glides
// (visible as jitter above 60 fps). Blend them across the step at the SAME
// sub-step alpha the InterpolationSystem uses for the body, so wheels and
// chassis move in lockstep.
const tick = em.fixedStepTick;
const alpha = em.getFixedStepAlpha();
if (tick !== this._lastTick) {
const first = this._lastTick === undefined;
this._lastTick = tick;
for (const w of this.wheels) {
const wh = w.wheel;
const spin = wh.rotation;
w.pSusp = first ? wh.suspensionLength : w.cSusp;
w.pSteer = first ? wh.steering : w.cSteer;
// spin accumulates monotonically; don't lerp across a discontinuity
// (e.g. a respawn that resets the wheel) — snap instead.
w.pSpin = (first || Math.abs(spin - w.cSpin) > Math.PI) ? spin : w.cSpin;
w.cSusp = wh.suspensionLength;
w.cSteer = wh.steering;
w.cSpin = spin;
}
}
for (const w of this.wheels) {
const t = w.transform;
const suspensionLength = w.pSusp + (w.cSusp - w.pSusp) * alpha;
const spin = w.pSpin + (w.cSpin - w.pSpin) * alpha;
const steering = w.pSteer + (w.cSteer - w.pSteer) * alpha;
// suspension travel: wheel rises toward the chassis as it compresses,
// drops as it extends — keeping it on the ground while the body rolls.
const travel = (CAR.suspensionRest - suspensionLength) * w.metresToParent;
t.setTranslation(
w.restPos.x + w.upInParent.x * travel,
w.restPos.y + w.upInParent.y * travel,
w.restPos.z + w.upInParent.z * travel,
);
// roll about the per-node axle IN THE NODE'S LOCAL FRAME (post-multiply
// the rest pose), so it's correct whatever the node's frame; then steer
// the fronts about car-up (in the parent frame, premultiply).
_spin.fromAxisAngle(w.axleLocal, spin * w.spinSign);
_rot.copy(w.restQuat).multiply(_spin); // rest · spin
if (w.steered && steering !== 0) {
_steer.fromAxisAngle(w.upInParent, steering);
_rot.multiplyQuaternions(_steer, _rot); // premultiply: steer · (rest·spin)
}
t.setRotation(_rot.x, _rot.y, _rot.z, _rot.w);
t.updateMatrix();
// The wheel's pose lives inside a TransformAttachment, which is plain
// data: TransformAttachmentSystem recomposes the wheel's world transform
// only once the write is announced on the attachment's own channel.
this.dataset.sendEvent(w.entity, TRANSFORM_ATTACHMENT_EVENT_CHANGE);
}
}
}
// The match ball — a light, bouncy sphere the cars launch around.
import { v3_set } from "@woosh/meep-engine/src/core/geom/vec3/v3_set.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { RigidBodyFlags } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBodyFlags.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometryFlags } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
import { Interpolated } from "@woosh/meep-engine/src/engine/interpolation/Interpolated.js";
import { POSE_INTERPOLAND } from "@woosh/meep-engine/src/engine/interpolation/pose_interpoland.js";
import { sphereGeometry, standardMaterial } from "./lib/shade.js";
import { BALL } from "./tuning.js";
/**
* @param {object} ctx { ecd, physics }
* @param {object} opts { position:[x,y,z] }
* @returns {{ entity, body, transform, radius }}
*/
export function buildBall(ctx, { position }) {
const r = BALL.radius;
const transform = new Transform64();
transform.setTranslation(position[0], position[1], position[2]);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = BALL.mass;
const invI = 1 / (0.4 * BALL.mass * r * r); // solid sphere I = 2/5 m r²
v3_set(body.inverseInertiaLocal, 0, invI, invI, invI);
body.linearDamping = BALL.linearDamping;
body.angularDamping = BALL.angularDamping;
body.flags = RigidBodyFlags.DisableSleep; // always tracked (HUD arrow, trail)
const collider = new Collider();
collider.shape = SphereShape3D.from(r);
collider.friction = BALL.friction;
collider.restitution = BALL.restitution;
// Shiny metal ball, reflecting the environment map. Shade has no UV sphere —
// a subdivided octahedron is the sphere, and detail 4 is smooth at this size.
const mesh = ShadedGeometry.from(
sphereGeometry(r, 4),
standardMaterial({ color: 0xe9edf2, roughness: 0.5, metalness: 1.0, emissive: 0x223044, emissiveIntensity: 0.25 }),
);
mesh.setFlag(ShadedGeometryFlags.CastShadow);
mesh.setFlag(ShadedGeometryFlags.ReceiveShadow);
const interpolated = new Interpolated();
interpolated.interpolands = [POSE_INTERPOLAND];
const entity = new Entity()
.add(transform)
.add(body)
.add(collider)
.add(mesh)
.add(interpolated);
const entityId = entity.build(ctx.ecd);
return { entity: entityId, body, transform, radius: r };
}
// Jet Propulsion Alliance — a Rocket-League-style game on Meep.
//
// Orchestrator: boot the engine + systems, build the level, spawn the cars and
// ball, wire input / camera / VFX / audio / HUD / match flow. Each subsystem
// lives in its own module (vehicles/, level/, fx/, audio/, hud/, camera/); this
// file just assembles them. Append `?top` to the URL for an overhead camera.
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { MeshSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSystem.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 {
ShadedGeometryHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js";
import Highlight from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/Highlight.js";
import { Camera } from "@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js";
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { ColliderObserverSystem } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderObserverSystem.js";
import { PhysicsEvents } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsEvents.js";
import { ParticleEmitterSystem } from "@woosh/meep-engine/src/engine/graphics3/ParticleEmitterSystem.js";
import { Trail3DSystem } from "@woosh/meep-engine/src/engine/graphics3/Trail3DSystem.js";
import { InterpolationSystem } from "@woosh/meep-engine/src/engine/interpolation/InterpolationSystem.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.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 { DecalSystem } from "@woosh/meep-engine/src/engine/graphics3/DecalSystem.js";
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";
import InputControllerSystem from "@woosh/meep-engine/src/engine/input/ecs/systems/InputControllerSystem.js";
import GUIElement from "@woosh/meep-engine/src/engine/ecs/gui/GUIElement.js";
import GUIElementSystem from "@woosh/meep-engine/src/engine/ecs/gui/GUIElementSystem.js";
import HeadsUpDisplay from "@woosh/meep-engine/src/engine/ecs/gui/hud/HeadsUpDisplay.js";
import HeadsUpDisplaySystem from "@woosh/meep-engine/src/engine/ecs/gui/hud/HeadsUpDisplaySystem.js";
import ViewportPosition from "@woosh/meep-engine/src/engine/ecs/gui/position/ViewportPosition.js";
import ViewportPositionSystem from "@woosh/meep-engine/src/engine/ecs/gui/position/ViewportPositionSystem.js";
import { loadEquirectangularEnvironment } from "./lib/environment.js";
import { linearFromHex } from "./lib/shade.js";
import { publishInterpolatedPoses } from "./lib/publishInterpolatedPoses.js";
import { CAR_DEFS } from "./vehicles/carDefs.js";
import { VehicleSystem } from "./vehicles/VehicleSystem.js";
import { buildCar } from "./vehicles/CarController.js";
import { attachPlayerInput } from "./vehicles/PlayerInput.js";
import { AiControl } from "./vehicles/AiControl.js";
import { buildAiBehavior } from "./vehicles/AiBehaviors.js";
import { WheelRig } from "./vehicles/WheelRig.js";
import { BehaviorSystem } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorSystem.js";
import { BehaviorComponent } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorComponent.js";
import { BoostPad } from "./pads/BoostPad.js";
import { BoostPadSystem } from "./pads/BoostPadSystem.js";
import { buildPads } from "./pads/buildPads.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { TransformedShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/TransformedShape3D.js";
import { createChaseCamera } from "./camera/chaseCamera.js";
import { buildArena } from "./level/arena.js";
import { buildBall } from "./ball.js";
import { attachBallTracker } from "./hud/ballTracker.js";
import { MatchManager } from "./match.js";
import { CarVfx } from "./fx/CarVfx.js";
import { Vfx } from "./fx/vfx.js";
import { CarAudio, Sfx } from "./audio/audio.js";
import { DistanceHighlights } from "./fx/distanceHighlight.js";
import { AudioEmitterSystem } from "@woosh/meep-engine/src/engine/sound/ecs/audio/AudioEmitterSystem.js";
import { AudioEmitter } from "@woosh/meep-engine/src/engine/sound/ecs/audio/AudioEmitter.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { ParticleEmitter } from "@woosh/meep-engine/src/engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js";
import Trail3D from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3D.js";
import { BALL, TEAM, ARENA, MATCH, RENDER, BOOST, HIGHLIGHT } from "./tuning.js";
// ─── bootstrap ───────────────────────────────────────────────────────────────
const vehicleSystem = new VehicleSystem();
const boostPadSystem = new BoostPadSystem();
/** @type {MeshSystem} */
let meshSystem = null;
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// One Shade scene per engine; every system that DRAWS has to be handed the
// same one, or its content is simply absent from the frame.
const scene = EngineHarness.shadeScene(engine);
const gltf = new GLTFSceneBundleAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltf);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltf);
// glTF textures are decoded to ImageBitmaps off the main thread; without
// this loader the car models arrive as untextured geometry.
config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
// (GameAssetType.Image - the Sampler2D decode the decals want - is
// registered by DecalSystem itself if nothing else has.)
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
// The model path. MeshSystem places a loaded glTF, and expands a static one
// into ENTITIES - one per node, named and parented - which is what WheelRig
// then animates. `load` is injected so the system can be driven in a test
// with no network and no device.
meshSystem = new MeshSystem(engine.graphics, scene, (url) => load_model_scene_bundle(engine.assetManager, url));
config.addSystem(meshSystem);
// Outline highlights. The drawing is one pass - a neighbourhood test on
// the visibility buffer, which already knows which mesh every pixel came from -
// and it lives in HighlightOutlineSystem. What is highlighted comes from
// two source systems, one per kind of thing: SGMeshHighlightSystem for a
// loaded model (the opponent car), ShadedGeometryHighlightSystem for a
// primitive built in code (the ball). Both feed the one pass, so an
// outline is never drawn twice.
const highlightOutline = new HighlightOutlineSystem(engine.graphics);
config.addSystem(highlightOutline);
config.addSystem(new SGMeshHighlightSystem(highlightOutline, meshSystem));
config.addSystem(new ShadedGeometryHighlightSystem(highlightOutline));
config.addSystem(new DecalSystem(engine.graphics, engine.assetManager));
config.addSystem(new TransformAttachmentSystem());
config.addSystem(new ParticleEmitterSystem(engine.graphics, engine.assetManager));
config.addSystem(new Trail3DSystem(engine.graphics));
config.addSystem(vehicleSystem); // before physics
// AI: meep's BehaviorSystem ticks each AI car's behaviour tree (which
// writes its control intent); VehicleSystem then acts on that intent.
config.addSystem(new BehaviorSystem(engine));
const physics = new PhysicsSystem();
config.addSystem(physics);
config.addSystem(new ColliderObserverSystem(physics));
config.addSystem(boostPadSystem); // grants boost when a car drives over a pad
const interpolation = new InterpolationSystem();
config.addSystem(interpolation);
physics.interpolationLog = interpolation.log;
// HUD pipeline: HeadsUpDisplay projects a world point to screen, the
// ViewportPositionSystem clamps it to the edge when off-screen, and
// GUIElementSystem mounts the DOM view into the engine's GUI root.
config.addSystem(new HeadsUpDisplaySystem(engine.graphics));
config.addSystem(new ViewportPositionSystem(engine.gameView.size));
config.addSystem(new GUIElementSystem(engine.gui.view, engine));
// No ambient-occlusion plugin to add: GTAO is part of Shade's frame and on
// by default (`renderer.feature_ssao_enabled`).
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: false,
focus: new Vector3(0, 1, 0),
distance: 16,
cameraAutoClip: true,
cameraController: false,
showFps: false,
});
// Shade's lights carry PHOTOMETRIC intensities — a directional light's is the
// illuminance it delivers, so the three.js-era 1.25 is now a dim overcast rather
// than a noon sun and the arena reads as dusk. RENDER.sunIntensity is the value
// that puts a real sun back on the walls.
//
// (`ambientIntensity` is not passed: a LightType.AMBIENT entity is silently
// dropped in meep 3 — the environment map IS the ambient term, and it is doing
// that job here.)
await EngineHarness.buildLights({
engine,
castShadow: true,
shadowmapResolution: 2048,
sunShadowDistance: 150,
sunIntensity: RENDER.sunIntensity,
});
// ─── exposure ───────────────────────────────────────────────────────
// Tonemapping is part of Shade's frame rather than a renderer setting, and the
// exposure in front of it can adapt to scene brightness on its own. It is off
// here, as in every demo on this site: an arena that dims when you drive into a
// goal explosion and brightens again afterwards reads as a bug rather than as a
// camera. With it off the exposure is fixed at 2^compensation, so the sun above
// is what makes the picture, and two screenshots of the same moment agree.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.graphics.renderer.exposure_compensation = RENDER.exposureCompensation;
// ─── HDR environment ──────────────────────────────────────────────
// One equirectangular HDR, converted to the octahedral projection Shade samples
// (see lib/environment.js). There is no skybox object in meep 3:
// `scene.lights.environment` is BOTH the image-based lighting source - the
// reflections and fill on the cars and the gray-boxed arena - AND what the
// background pass draws, so this single texture lights the arena and puts the sky
// above its open roof.
engine.graphics.set_environment_map(await loadEquirectangularEnvironment("./noon_grass_2k.hdr"));
const ecd = engine.entityManager.dataset;
const physics = engine.entityManager.getSystem(PhysicsSystem);
vehicleSystem.physics = physics;
// Publish the final render poses before the chase camera, highlights and wheel
// rig run. The installed engine announces physics writes but not interpolation.
engine.graphics.on.preRender.add(() => publishInterpolatedPoses(ecd));
// runtime-attached component types
ecd.registerComponentType(HeadsUpDisplay);
ecd.registerComponentType(ViewportPosition);
ecd.registerComponentType(GUIElement);
ecd.registerComponentType(Light);
ecd.registerComponentType(ParticleEmitter);
ecd.registerComponentType(Trail3D);
ecd.registerComponentType(AiControl); // AI state (blackboard)
ecd.registerComponentType(BehaviorComponent); // AI behaviour tree (ticked by BehaviorSystem)
ecd.registerComponentType(BoostPad); // boost pickup state
ecd.registerComponentType(Decal); // boost-pad ground decals (attached at runtime)
if (engine.entityManager.getSystem(InputControllerSystem) === null) {
engine.entityManager.addSystem(new InputControllerSystem(engine.devices));
}
// Positional audio: the SoundListener is wired to the camera by EngineHarness;
// we add the sopra-backed emitter system (which auto-registers the .wav asset
// loader). Signature is (assetManager, soundEngine) — the system pulls the
// context, destination, and sopra renderer off the SoundEngine itself.
engine.entityManager.addSystem(new AudioEmitterSystem(
engine.assetManager, engine.sound,
));
ecd.registerComponentType(AudioEmitter);
const ctx = { ecd, physics, engine }; // engine: lets the builders reach the asset manager (texture loading)
// ─── level ──────────────────────────────────────────────────────────────────
const level = buildArena(ctx, ARENA); // the one level; buildArena returns { goalSensors, kickoffSpawns, ballSpawn, dims }
// ─── ball ─────────────────────────────────────────────────────────────────────
const ball = buildBall(ctx, { position: level.ballSpawn });
// ─── player car (Blue) ──────────────────────────────────────────────────────
const bounds = {
hw: level.dims.hw, hl: level.dims.hl, ceil: level.dims.domeHeight,
// the goal mouth + net is valid space — cars drive in and back out (RL-style),
// so the out-of-bounds check allows z past the goal line inside the net.
goalDepth: level.dims.goalDepth, goalHalfWidth: ARENA.goalWidth / 2,
};
const player = buildCar(ctx, {
def: CAR_DEFS.octane,
team: TEAM.blue,
position: level.kickoffSpawns.blue.position,
yaw: level.kickoffSpawns.blue.yaw,
});
player.bounds = bounds;
vehicleSystem.addCar(player);
let ballCamOn = false; // start in car-cam (follows the car's heading); F toggles ball-cam
// `?top` gives a fixed overhead camera (handy for seeing the whole pitch)
if (new URLSearchParams(location.search).has("top")) {
const { Camera } = await import("@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js");
const TopDownCameraController = (await import("@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js")).default;
const camEntity = ecd.getAnyComponent(Camera).entity;
ecd.removeComponentFromEntity(camEntity, TopDownCameraController);
const camT = ecd.getComponent(camEntity, Transform64);
engine.graphics.on.preRender.add(() => {
camT.setTranslation(0, 36, 0.01); // below the dome ceiling
camT.setRotation(0.70710678, 0, 0, 0.70710678); // look straight down (−Y)
});
} else {
createChaseCamera({
engine,
ecd,
getCarTransform: () => player.transform,
getCarVelocity: () => player.body.linearVelocity,
getBall: () => ball.transform,
isBallCam: () => ballCamOn,
});
}
attachPlayerInput(ecd, player, {
onBallCam: () => { ballCamOn = !ballCamOn; },
onReset: () => player.resetTo(player.home.x, player.home.y, player.home.z, player.home.yaw, physics),
});
// ─── AI car (Orange) ──────────────────────────────────────────────────────────
const ai = buildCar(ctx, {
def: CAR_DEFS.perrier,
team: TEAM.orange,
position: level.kickoffSpawns.orange.position,
yaw: level.kickoffSpawns.orange.yaw,
});
ai.bounds = bounds;
vehicleSystem.addCar(ai);
// AI brain: ECS state component (AiControl) + a behaviour tree on a
// BehaviorComponent, both attached to the AI car entity. meep's BehaviorSystem
// ticks the tree each frame; its leaves read this state, query the physics world
// for awareness, and write `ai.intent`.
const aiControl = new AiControl();
aiControl.controller = ai;
aiControl.getBall = () => ball.transform;
aiControl.getBallVel = () => ball.body.linearVelocity;
aiControl.attackZSign = TEAM.orange.attackZSign;
aiControl.goalZ = level.dims.hl;
aiControl.physics = physics;
aiControl.ballBody = ball.body;
ecd.addComponentToEntity(ai.entity, aiControl);
ecd.addComponentToEntity(ai.entity, buildAiBehavior());
// ─── distance highlight: outline the ball and the opponent, fading in with distance
// A Highlight on the ball and one on the opponent's car. The two ride different
// source systems - the ball is a bare ShadedGeometry primitive, the car is a
// loaded model - and both are drawn by the single outline pass. Their opacity is
// driven each frame by DistanceHighlights off the projected (sphere) size, so a
// distant target lights up and a close one doesn't.
//
// One thing the renderer changed: an outline is a neighbourhood test on the
// visibility buffer, so it is drawn only where the thing is actually visible. A
// ball behind the far wall is no longer outlined through it.
const makeHighlight = (hex) => {
const hl = new Highlight();
const def = hl.createElement();
// An outline colour is LINEAR, like a material's: the pass blends it into the
// frame's colour buffer before tone mapping, so an undecoded hex reads pale.
def.color.copy(linearFromHex(hex));
def.color.a = 0; // opacity is driven each frame by DistanceHighlights
return { hl, def };
};
const ballHighlight = makeHighlight(HIGHLIGHT.ballColor); // warm gold on the ball
ecd.addComponentToEntity(ball.entity, ballHighlight.hl);
const oppHighlight = makeHighlight(ai.team.color); // opponent gets its team colour
ecd.addComponentToEntity(ai.artNode.entity.id, oppHighlight.hl);
const cameraTransform = ecd.getComponent(ecd.getAnyComponent(Camera).entity, Transform64);
const oppRadius = Math.hypot(ai.def.body.half[0], ai.def.body.half[1], ai.def.body.half[2]); // body bounding sphere
const distanceHighlights = new DistanceHighlights(engine, cameraTransform)
.track(ballHighlight.def, () => ball.transform, BALL.radius)
.track(oppHighlight.def, () => ai.transform, oppRadius);
// run after the chase camera's preRender (so the camera pose is current this frame)
engine.graphics.on.preRender.add(() => distanceHighlights.update());
// ─── VFX: per-car kits + transient effects + ball trail ───────────────────────
const vfx = new Vfx(ctx);
const carVfx = [new CarVfx(player, vfx), new CarVfx(ai, vfx)];
// ─── boost pads: pickups around the arena (logic in BoostPadSystem) ───────────
const pads = await buildPads(ctx, { dims: level.dims });
boostPadSystem.cars = [player, ai];
boostPadSystem.onPickup = (c, pad, t) => {
// pickup pop in the shared boost colour (matches the pad orb); size still scales
// the flash radius + intensity so a big pad reads as a bigger grab.
vfx.flash(
t.translation_x, t.translation_y + 1, t.translation_z,
BOOST.barColors[1], pad.big ? 6 : 2.5, pad.big ? 18 : 9, 0.3
);
};
// pickup is driven by the pads' IsSensor cylinders, not a per-tick distance scan.
// Contact events are entity-scoped (the PhysicsSystem.onContactBegin signal is gone):
// listen for ContactBegin on each car's body and let the system pick out car↔pad hits.
for (const c of [player, ai]) {
ecd.addEntityEventListener(c.entity, PhysicsEvents.ContactBegin, (p) => boostPadSystem.handleContact(p));
}
aiControl.getPads = () => pads.list; // let the AI detour for boost when low
// visual wheels: detach the GLTF wheels and drive them off the suspension.
// Updated in preRender (before the draw) and interpolated at the engine's
// sub-step alpha, so the wheels track the render-interpolated chassis exactly —
// running them in postRender would leave them a frame behind the body.
const wheelRigs = [new WheelRig(player), new WheelRig(ai)];
engine.graphics.on.preRender.add(() => {
for (const r of wheelRigs) r.update(engine.entityManager);
});
// ─── audio: per-car engine/tyre loops + positional one-shots ──────────────────
const sfx = new Sfx(ctx);
const carAudio = [new CarAudio(ctx, player), new CarAudio(ctx, ai)];
// fire jump/dodge one-shots by watching the controllers' debug counters
const audioWatch = [player, ai].map((c) => ({ c, jumps: c.dbg.jumps, dodges: c.dbg.dodges, doubles: c.dbg.doubles }));
// tube-based 3D trail: a volumetric tube extruded around the ball's path.
// Knot alpha is aged out by the tube simulator (color.a is only the build-time
// seed), so visibility is gated by animating `width` instead — a zero-diameter
// tube is invisible.
const ballTrail = new Trail3D();
ballTrail.maxAge = 0.45;
ballTrail.width = 0; // grown above the speed threshold (postRender below)
ballTrail.radialSegments = 12; // rounder tube for the big ball
ballTrail.color.set(0.7, 0.85, 1.0, 1);
ecd.addComponentToEntity(ball.entity, ballTrail);
ball.trail = ballTrail; // so MatchManager can clear it on a kickoff teleport (no streak)
// ─── HUD: off-screen ball tracker ─────────────────────────────────────────────
attachBallTracker(ecd, ball.entity, engine);
// ─── match: scoreboard, clock, goals, kickoff resets ──────────────────────────
const match = new MatchManager({
physics, vehicleSystem, ball, player, ai, level, ecd,
clockSpeed: engine.ticker.clock.speed, // for goal slow-mo (x0.5 for a moment)
setBallCam: (v) => { ballCamOn = v; }, // ball-cam on a goal, car-cam for the countdown
onGoal: (team, goal) => {
const z = goal.zSign * (level.dims.hl + level.dims.goalDepth / 2);
vfx.goalCelebration(0, 3, z, TEAM[team].color);
sfx.goal(0, 4, z);
},
});
const goalByEntity = new Map();
for (const g of level.goalSensors) goalByEntity.set(g.entity, g);
// RL-style goal explosion: launch each car up + away from the ball's entry point,
// velocity falling off LINEARLY to zero at goalBlastRadius (a gentler falloff than
// squared, so cars further from the net still get a real shove). The impulse is
// applied at the NEAREST part of the car (approximated as its hitbox box, minus
// wheels) rather than the centre of mass, so an off-centre blast also spins the
// car — found via the box's support function toward the blast point.
const _support = new Float32Array(3);
function goalBlast(cx, cy, cz) {
for (const car of [player, ai]) {
const tr = car.transform;
// car ← blast (away dir)
const dx = tr.translation_x - cx, dy = tr.translation_y - cy, dz = tr.translation_z - cz;
const dist = Math.hypot(dx, dy, dz);
const fall = Math.max(0, 1 - dist / MATCH.goalBlastRadius);
const v = MATCH.goalBlastSpeed * fall; // linear falloff
if (v < 0.5) continue;
const h = Math.hypot(dx, dz) || 1; // horizontal away direction
const dirX = dx / h, dirZ = dz / h, up = MATCH.goalBlastUp;
const n = Math.hypot(dirX, up, dirZ); // normalise (away + up)
const m = car.body.mass;
const impulse = [dirX / n * v * m, up / n * v * m, dirZ / n * v * m];
// point-blank: the "nearest part" direction is ill-defined (the car is ON the
// blast), so just shove the centre of mass — no meaningful lever arm.
if (dist < 0.6) { physics.applyImpulse(car.body, impulse); continue; }
// nearest part of the car to the blast = the box's support point in the
// direction from the car toward the blast (= −away). Apply the impulse there.
tr.updateMatrix();
const hb = car.def.body.half;
const box = TransformedShape3D.from_m4(BoxShape3D.from(hb[0], hb[1], hb[2]), tr.matrix);
const tl = dist || 1;
box.support(_support, 0, -dx / tl, -dy / tl, -dz / tl);
physics.applyImpulseAt(car.body, tr, impulse, _support);
}
}
const BALL_HIT_FX_SPEED = 12; // ball speed above which an impact spawns FX
// Listening on the ball's entity means every dispatch already involves the ball, so we
// just read the OTHER side of the pair (contact events are entity-scoped — the global
// PhysicsSystem.onContactBegin signal no longer exists).
ecd.addEntityEventListener(ball.entity, PhysicsEvents.ContactBegin, (p) => {
const ballId = ball.entity;
// goal?
const otherId = p.entityA === ballId ? p.entityB : p.entityA;
if (goalByEntity.has(otherId)) {
const g = goalByEntity.get(otherId);
const bp = ball.transform; // ball position = goal location
goalBlast(bp.translation_x, bp.translation_y, bp.translation_z); // launch nearby cars, RL-style
match.handleGoal(g.scoringTeam, g);
return;
}
// otherwise an impact — spawn dust + a flash + thump scaled by the ball's speed
const bv = ball.body.linearVelocity;
const speed = Math.hypot(bv[0], bv[1], bv[2]);
if (speed > BALL_HIT_FX_SPEED) {
const strength = Math.min(1, (speed - BALL_HIT_FX_SPEED) / 30);
// Spawn the dust/flash at the impact point on the ball's SURFACE, not its
// centre. The contact event carries no usable geometry on ContactBegin in this
// engine build (point/normal/depth are all 0 — the manifold isn't filled until
// ContactStay), so derive the point from motion instead: at impact the ball is
// still travelling INTO the surface, so the contact lies on its surface in the
// velocity direction. (`speed` > 12 here, so the normalise is always safe.)
const bp = ball.transform;
const k = BALL.radius / speed; // bv * k == normalize(bv) * radius
const hx = bp.translation_x + bv[0] * k;
const hy = bp.translation_y + bv[1] * k;
const hz = bp.translation_z + bv[2] * k;
vfx.impact(hx, hy, hz, strength);
sfx.impact(hx, hy, hz, strength);
}
});
// ─── per-frame: VFX gating, ball trail, match + HUD ───────────────────────────
let lastMs = performance.now();
engine.graphics.on.postRender.add(() => {
const now = performance.now();
const dt = Math.min((now - lastMs) / 1000, 0.1);
lastMs = now;
// perform any requested car respawns here (from the render frame, so the
// teleport isn't undone by the physics-step interpolation restore)
player.flushRecovery(physics);
ai.flushRecovery(physics);
for (const k of carVfx) k.update(dt);
for (const a of carAudio) a.update(dt);
vfx.update(dt);
sfx.update(dt);
pads.updateVisuals(); // light up / darken pucks off their (sim-owned) cooldown
// jump / dodge one-shots from the controllers' debug counters
for (const w of audioWatch) {
const p = w.c.transform;
const px = p.translation_x, py = p.translation_y, pz = p.translation_z;
if (w.c.dbg.jumps !== w.jumps || w.c.dbg.doubles !== w.doubles) sfx.jump(px, py, pz);
if (w.c.dbg.dodges !== w.dodges) sfx.dodge(px, py, pz);
w.jumps = w.c.dbg.jumps; w.dodges = w.c.dbg.dodges; w.doubles = w.c.dbg.doubles;
}
// ball trail grows in above a speed threshold (Trail3D writes `width` into
// each new head knot, so animating it tapers the tube in/out)
const bv = ball.body.linearVelocity;
const fast = Math.hypot(bv[0], bv[1], bv[2]) > BALL.trailMinSpeed;
const tw = fast ? 0.6 : 0;
ballTrail.width += (tw - ballTrail.width) * Math.min(1, dt * 8);
match.update(dt);
});
window.__jpa = { engine, ecd, physics, player, ai, aiControl, ball, level, match, vfx, carVfx, wheelRigs, vehicleSystem, boostPadSystem, pads, goalBlast, distanceHighlights, ballHighlight, oppHighlight, setBallCam: (v) => { ballCamOn = v; } };
console.log("%cJet Propulsion Alliance", "color:#ffb02e;font-weight:bold;font-size:14px");
// MatchManager — score, clock, kickoff resets, and all HUD/DOM updates.
//
// A small phase machine drives the match:
// countdown → play → (goal) celebrate → countdown → … → (clock 0) ended → reset
// During countdown/celebrate the cars are control-frozen (suspension still runs,
// so they sit at ride height) and the ball is parked at centre.
import { v3_set } from "@woosh/meep-engine/src/core/geom/vec3/v3_set.js";
import LinearModifier from "@woosh/meep-engine/src/core/model/stat/LinearModifier.js";
import { Interpolated } from "@woosh/meep-engine/src/engine/interpolation/Interpolated.js";
import { MATCH, BOOST } from "./tuning.js";
const PHASE = { COUNTDOWN: "countdown", PLAY: "play", CELEBRATE: "celebrate", ENDED: "ended" };
export class MatchManager {
/**
* @param {object} o
* @param {import("@woosh/meep-engine/src/core/model/stat/Stat.js").default} [o.clockSpeed]
* the engine clock-speed Stat (`engine.ticker.clock.speed`) — for goal slow-mo.
* @param {import("@woosh/meep-engine/src/engine/ecs/EntityComponentDataset.js").EntityComponentDataset} [o.ecd]
* the dataset — used only to fetch the cars'/ball's Interpolated components so a
* kickoff teleport can be held against the render interpolation (see kickoff()).
*/
constructor({ physics, vehicleSystem, ball, player, ai, level, onGoal = null, setBallCam = null, clockSpeed = null, ecd = null }) {
this.physics = physics;
this.vehicleSystem = vehicleSystem;
this.ball = ball;
this.player = player;
this.ai = ai;
this.level = level;
this.onGoalVfx = onGoal;
this.setBallCam = setBallCam; // (bool) → switch the chase camera mode
this.clockSpeed = clockSpeed; // Stat; null → slow-mo is a no-op (headless/tests)
this.score = { blue: 0, orange: 0 };
this.clock = MATCH.durationSeconds;
this.phase = PHASE.COUNTDOWN;
this.phaseTimer = MATCH.kickoffCountdown;
this._flashTimer = 0;
this._slowMoMod = null; // the live LinearModifier on clockSpeed, if any
this._slowMoTimer = 0; // real seconds left of the goal slow-mo
// The cars + ball are render-interpolated. A teleport (setPose) writes the
// live pose + flags a one-frame `snap`, but does NOT write the interpolation
// LOG — the log is only filled by the physics producer once per fixed step.
// On a high-refresh display (render rate > physics rate) the frame after a
// kickoff can run ZERO fixed steps: it consumes the `snap` without recording
// the new pose, and the next frame then blends the body back to the STALE
// pre-kickoff pose (car left facing the wrong way, ball still in the goal).
// We defend by re-asserting `snap` every frame until the producer has
// recorded the new pose (a couple of fixed steps). `_holdSnapTick` is the
// fixed-step tick at which that hold can stop; <0 means not holding.
this._snapTargets = ecd !== null && ecd !== undefined
? [player.entity, ai.entity, ball.entity].map((e) => ecd.getComponent(e, Interpolated)).filter(Boolean)
: [];
this._holdSnapTick = -1;
// DOM is optional: the HUD elements only exist in the browser. Guarding
// here (the render helpers already null-check each element) keeps the whole
// match machine runnable headlessly for unit tests.
this.el = (typeof document !== "undefined") ? {
blue: document.getElementById("score-blue"),
orange: document.getElementById("score-orange"),
clock: document.getElementById("clock"),
banner: document.getElementById("banner"),
boost: document.getElementById("boost-fill"),
speed: document.getElementById("speed"),
} : {};
// Paint the boost gauge from the shared boost colours (tuning.js) so the
// bar and the pads stay in lockstep from one source of truth.
if (this.el.boost) {
const hx = (c) => "#" + c.toString(16).padStart(6, "0");
this.el.boost.style.background =
`linear-gradient(90deg, ${hx(BOOST.barColors[0])}, ${hx(BOOST.barColors[1])})`;
}
this.kickoff(true);
this._renderScore();
}
/** Called by the goal-sensor contact handler. */
handleGoal(scoringTeam, goalInfo) {
if (this.phase !== PHASE.PLAY) return;
this.score[scoringTeam]++;
this._renderScore();
this.phase = PHASE.CELEBRATE;
this.phaseTimer = MATCH.goalCelebration;
this.vehicleSystem.controlEnabled = false;
this._startSlowMo(); // drag time down so the blast reads
if (this.setBallCam) this.setBallCam(true); // watch the ball during the celebration
this._banner(`${scoringTeam === "blue" ? "BLUE" : "ORANGE"} SCORES!`, scoringTeam);
if (this.onGoalVfx) this.onGoalVfx(scoringTeam, goalInfo);
}
/** Slow the engine clock to MATCH.goalSlowMoSpeed for MATCH.goalSlowMoSeconds. */
_startSlowMo() {
if (this.clockSpeed === null) return;
if (this._slowMoMod === null) {
this._slowMoMod = new LinearModifier(MATCH.goalSlowMoSpeed, 0); // x·0.5
this.clockSpeed.addModifier(this._slowMoMod);
}
this._slowMoTimer = MATCH.goalSlowMoSeconds;
}
/** Restore normal time (remove the slow-mo modifier). Idempotent. */
_endSlowMo() {
if (this._slowMoMod !== null && this.clockSpeed !== null) {
this.clockSpeed.removeModifier(this._slowMoMod);
}
this._slowMoMod = null;
this._slowMoTimer = 0;
}
kickoff(initial = false) {
this._endSlowMo(); // always resume full speed for the countdown / kickoff
const ks = this.level.kickoffSpawns;
this.player.resetTo(ks.blue.position[0], ks.blue.position[1], ks.blue.position[2], ks.blue.yaw, this.physics);
this.ai.resetTo(ks.orange.position[0], ks.orange.position[1], ks.orange.position[2], ks.orange.yaw, this.physics);
this.player.boost = BOOST.max / 3; // each round starts at ~33% boost
this.ai.boost = BOOST.max / 3;
if (this.setBallCam) this.setBallCam(false); // car-cam for the countdown
const bs = this.level.ballSpawn;
this.physics.setPose(this.ball.body, [bs[0], bs[1], bs[2]], [0, 0, 0, 1]);
v3_set(this.ball.body.linearVelocity, 0, 0, 0, 0);
v3_set(this.ball.body.angularVelocity, 0, 0, 0, 0);
if (this.ball.trail) this.ball.trail.clear(); // teleport → drop trail history so it doesn't streak
// Hold the teleport against the render interpolation until the physics
// producer has logged the new pose (give it two fixed steps of margin), so
// a high-refresh display can't blend the cars/ball back to where they were
// at the goal. See the constructor note. <0 here disables the hold (no tick
// source / no interpolated targets).
const tick = this._currentTick();
this._holdSnapTick = (tick !== null && this._snapTargets.length > 0) ? tick + 2 : -1;
this._holdKickoffPose();
this.phase = PHASE.COUNTDOWN;
this.phaseTimer = MATCH.kickoffCountdown;
this.vehicleSystem.controlEnabled = false;
if (!initial) this._banner("", null, false);
}
/** Current fixed-step tick, or null if there's no entity manager (odd setups). */
_currentTick() {
const em = this.physics ? this.physics.entityManager : null;
return (em !== null && em !== undefined) ? em.fixedStepTick : null;
}
/** Re-flag the kickoff bodies' render `snap` so the interpolation leaves their
* live (teleported) pose alone this frame. */
_holdKickoffPose() {
for (const ip of this._snapTargets) ip.snap = true;
}
resetMatch() {
this.score.blue = 0;
this.score.orange = 0;
this.clock = MATCH.durationSeconds;
this._renderScore();
this.kickoff();
}
update(dt) {
// goal slow-mo runs on real (wall-clock) dt — the match loop is driven from
// postRender, not the scaled clock — so "a few seconds" means a few REAL
// seconds even while the physics is crawling at half speed.
if (this._slowMoTimer > 0) {
this._slowMoTimer -= dt;
if (this._slowMoTimer <= 0) this._endSlowMo();
}
// Keep the just-kicked-off bodies pinned against the render interpolation
// until the physics producer has logged their new pose (a couple of fixed
// steps), then release them to interpolate normally. Without this, a frame
// that runs no fixed step undoes the kickoff teleport on high-refresh
// displays (car snaps back to its post-goal pose, ball back into the net).
if (this._holdSnapTick >= 0) {
const tick = this._currentTick();
if (tick === null || tick >= this._holdSnapTick) this._holdSnapTick = -1;
else this._holdKickoffPose();
}
switch (this.phase) {
case PHASE.COUNTDOWN: {
this.phaseTimer -= dt;
const n = Math.ceil(this.phaseTimer);
this._banner(n > 0 ? String(n) : "GO!", null);
if (this.phaseTimer <= 0) {
this.phase = PHASE.PLAY;
this.vehicleSystem.controlEnabled = true;
this._flash("GO!", null, 0.6);
}
break;
}
case PHASE.PLAY: {
this.clock -= dt;
if (this.clock <= 0) {
this.clock = 0;
this.phase = PHASE.ENDED;
this.phaseTimer = 4.5;
this.vehicleSystem.controlEnabled = false;
const lead = this.score.blue === this.score.orange ? "DRAW"
: this.score.blue > this.score.orange ? "BLUE WINS" : "ORANGE WINS";
this._banner(lead, this.score.blue > this.score.orange ? "blue" : this.score.orange > this.score.blue ? "orange" : null);
}
break;
}
case PHASE.CELEBRATE: {
this.phaseTimer -= dt;
if (this.phaseTimer <= 0) this.kickoff();
break;
}
case PHASE.ENDED: {
this.phaseTimer -= dt;
if (this.phaseTimer <= 0) this.resetMatch();
break;
}
}
// transient flash banner (e.g. "GO!")
if (this._flashTimer > 0) {
this._flashTimer -= dt;
if (this._flashTimer <= 0 && this.phase === PHASE.PLAY) this._banner("", null, false);
}
this._renderClock();
this._renderTelemetry();
}
// ── DOM helpers ──────────────────────────────────────────────────────────
_renderScore() {
if (this.el.blue) this.el.blue.textContent = String(this.score.blue);
if (this.el.orange) this.el.orange.textContent = String(this.score.orange);
}
_renderClock() {
if (!this.el.clock) return;
const s = Math.max(0, Math.ceil(this.clock));
const m = Math.floor(s / 60);
this.el.clock.textContent = `${m}:${String(s % 60).padStart(2, "0")}`;
}
_renderTelemetry() {
if (this.el.boost) this.el.boost.style.width = `${Math.max(0, Math.min(100, this.player.boost))}%`;
if (this.el.speed) this.el.speed.textContent = (this.player.state.speed * 3.6).toFixed(0);
}
_banner(text, team, show = true) {
const b = this.el.banner;
if (!b) return;
b.textContent = text;
b.style.color = team === "blue" ? "#4ea8f0" : team === "orange" ? "#f0a23c" : "#e6edf3";
b.classList.toggle("show", show && text !== "");
}
_flash(text, team, seconds) {
this._banner(text, team);
this._flashTimer = seconds;
}
}
// Central tuning for Jet Propulsion Alliance.
//
// One place for every magic number so the feel can be dialed without hunting
// through the systems. Units are loose "game units" (a car is ~3.4 long); the
// physics world gravity is the engine default (−9.81 on Y).
// ─── Rendering: sun + exposure ────────────────────────────────────────────────
// Shade tonemaps every frame, so the bright things here (the sky-lit floor,
// boost flames, orbs, the goal explosion) roll off with their colour retained
// rather than clipping to flat white. Automatic exposure is off - see main.js -
// so these two numbers are the whole of the frame's brightness.
export const RENDER = {
// Sun illuminance. Shade's lights are PHOTOMETRIC — a directional light's
// intensity is the illuminance it delivers, not three.js' arbitrary 0..1-ish
// multiplier — so the old 1.25 lands as a dim overcast and the arena reads as
// dusk. This is what puts a real noon sun back on the walls and a hard shadow
// under each car.
// Every other demo on this site runs its sun at 6 lux, lit by the default
// environment. This one is under a real noon-sky HDR, and a 6-lux sun leaves
// the pitch reading as dusk under a midday sky - a mismatch you see rather
// than a look. The sky sets the level here, and the sun matches it.
sunIntensity: 20,
// Exposure, in F-STOPS, since automatic exposure is off: the frame is exposed
// at 2^this. Left at 0 so the sun above is the only brightness control, and
// so this demo sits at the same exposure as every other one on the site.
exposureCompensation: 0,
};
// ─── Vehicle: ground driving ─────────────────────────────────────────────────
export const CAR = {
// Suspension raycast (one ray per wheel mount, cast along car-local down).
suspensionRest: 0.32, // ride height target above the wheel contact
suspensionStiffness: 12000, // N/m — holds ~190 kg car up at a small compression
suspensionDamping: 800, // N·s/m — kept low enough that explicit damping (c·dt/m<1)
// stays stable and doesn't inject energy (no bounce/launch)
suspensionMaxForce: 6000, // hard clamp so a damping spike can't fling the car
wheelRadius: 0.34,
// Engine. The ground vehicle is the engine's RaycastVehicle: drive / steer /
// grip act as forces/impulses at the wheel contact points, so the chassis
// physically rolls into turns, pitches under accel/brake and bobs on landing —
// that's where the "suspension" look comes from (the GLTF mesh is rigid).
driveForce: 5200, // total N split across the driven wheels (cut off at maxSpeed)
reverseForce: 3400,
maxSpeed: 26, // u/s — drive cuts out above this (boost can exceed)
linearDamping: 0.25,
idleDrag: 600, // N per-wheel brake when coasting (engine braking)
brakeForce: 2600, // N per-wheel brake when throttle opposes motion
wheelFriction: 1.6, // tyre μ (grip + drive budget per wheel) — also lets the
// higher driveForce actually bite for a snappier launch
powerslideFriction: 0.5, // rear-wheel μ while handbraking → drift
groundRollDamp: 0.24, // per-step bleed of pitch+roll rate while grounded —
// the "stay planted" stabiliser so play (and the
// sharper turns below) can't tip the car. Yaw is
// untouched so steering still works.
// Steering: front wheels steer. Full lock at low speed; at speed the lock is
// capped so a turn never demands more lateral accel than the tyres can hold.
// Pushed up toward the grip limit (~μ·g) for a much snappier turn, with the
// ground stabiliser above keeping it from rolling over.
steerLock: 0.7, // rad of steer at low speed (full lock)
steerMaxLateral: 11.5, // m/s² — cornering-accel ceiling that sets the high-speed lock
};
// ─── Vehicle: mass / inertia ─────────────────────────────────────────────────
export const CAR_AGILITY = 1.0; // inverse-inertia multiplier (1 = solid box; higher = tippier on the ground)
// ─── Vehicle: boost ──────────────────────────────────────────────────────────
export const BOOST = {
max: 100,
force: 4200, // N along forward while boosting
drainPerSec: 33, // boost units/s consumed
regenPerSec: 1, // slow passive recharge — only while NOT boosting
minToActivate: 5, // need a few % in the tank to fire (no empty-tank thrash)
maxBoostSpeed: 60, // u/s ceiling while boosting
// The two "boost" colours — the amber→red gradient of the HUD boost gauge
// (demo.html's #boost-fill). Single source of truth: the bar gradient is set
// from this at runtime, and EVERY boost pad (small + large) uses the same pair,
// so the pads read as one consistent boost-colour family rather than the old
// mixed blue/orange. Mapped vertically on a pad like the bar: [0] = the amber
// base (glow / ground decal / light wash), [1] = the red accent (the hovering orb).
barColors: [0xffb02e, 0xff5e3a],
};
// ─── Vehicle: jumps, dodges, aerial control ──────────────────────────────────
export const AIR = {
jumpSpeed: 9.5, // m/s impulse along the surface normal (first jump)
doubleJumpSpeed: 8.5, // m/s along car-up (straight second jump)
jumpWindow: 1.25, // s after first jump in which a 2nd jump/dodge is allowed
jumpHoldExtra: 0.2, // s of extra upward force while holding jump (variable height)
jumpHoldForce: 3200,
dodgeSpeed: 13, // m/s planar velocity added by a dodge/flip
dodgeAngular: 9.0, // rad/s flip rotation imparted by a dodge
dodgeLock: 0.62, // s during which the dodge animation/torque plays out
dodgeDeadzone: 0.25, // |input| below this at 2nd jump → straight double jump
pitchTorque: 26, // angular accel (rad/s²) per unit input — pitch (about right)
yawTorque: 12, // — yaw (about up, in-air steering)
rollTorque: 30, // — roll (about forward)
maxAngularSpeed: 5.5, // rad/s cap (matches RL)
airDamping: 0.92, // angular velocity retained per second when no input (control feel)
groundAngularDamping: 0.05,
};
// ─── Flip-assist (force-based self-righting; only while touching the ground) ──
export const FLIP = {
// Beyond this roll angle (car-up vs surface normal) we START righting the car
// with a torque instead of grounding it.
tiltRightRad: 85 * Math.PI / 180,
// ...and (hysteresis) keep righting until it drops below this, so it doesn't
// stall on its side at ~85° in the dead zone between "righting" and "wheels
// touching" — a car balanced on its side edge won't fall onto its wheels under
// gravity until it's past its tipping point (well below 85°).
tiltSettleRad: 40 * Math.PI / 180,
// World-down ray length from the car centre used to decide "touching the
// ground" in ANY orientation (the suspension rays point along car-local down,
// which is useless when the car is on its side or roof). A jumped/knocked-up
// car rises past this and gets no assist.
groundProbe: 1.45,
// Phase "ground": 1–2 wheels touching (2+ lifted) → press the car onto the
// surface (force opposite the normal) so the lifted wheels settle back down.
groundForce: 8200,
// Phase "right": torque strength + angular-velocity damping. Must exceed the
// gravitational torque holding a flat car on its roof (≈ mass·g·halfWidth ≈
// 1700 N·m for the Octane) or it can't tip the car off its face. Damping keeps
// the post-tip spin controlled.
rightTorque: 3200,
rightDamping: 420,
};
// ─── Recovery (respawn only; flipping is handled by FLIP above) ───────────────
export const RECOVERY = {
stuckSpeed: 2.0, // u/s — "not really moving" (wedged → respawn)
noProgressGrace: 3.5, // s slow-while-trying before a respawn home
rightingGrace: 2.5, // s the flip-assist may run before a stuck car is respawned
};
// ─── Boost pickups (Rocket-League-style pads) ────────────────────────────────
// Drive over an active pad to refill boost; it then goes dark and re-activates
// after its respawn time. Smalls are plentiful + quick; larges are rare + fill.
export const PADS = {
smallAmount: 12, // % boost a small pad restores
smallRespawn: 4, // s before a small pad re-activates
smallRadius: 1.0, // cylinder radius (pickup volume)
largeAmount: 100, // a large pad fills the tank
largeRespawn: 10,
largeRadius: 1.4,
height: 1.0, // pads are ~1m-tall cylinders (IsSensor) so they're easy to drive into
// ── visuals (rebuilt each render frame off `cooldown`) ──────────────────────
// A ground decal (a meep Decal, always shown), a rising "glow" cylinder, and
// a little orb + point light hanging above it. On cooldown the glow/orb/light
// vanish; only the decal stays. Larger pads sit higher + brighter; smalls lower.
decalScale: 1.0, // decal footprint ÷ pickup DIAMETER (motif a touch wider than the trigger)
decalEmissive: 6, // how hard the ground ring emits while the pad is charged. A decal
// writes albedo, so without this the amber motif reads as a DARK
// patch on the pale floor; emission is what makes it a glowing ring.
// Zeroed on cooldown, where only the dimmed albedo remains.
decalDepth: 0.2, // FP-decal projection-box thickness along the floor normal. The box is centred
// on the ground (position.y = 0), so it reaches only decalDepth/2 = 0.1 above
// the floor — enough to catch the flat pitch, but it no longer projects up the
// sides of the cars driving over it.
glow: {
tile: 4, // times the noise texture repeats around the cylinder
large: { radius: 0.65, height: 0.9, opacity: 0.55 },
small: { radius: 0.55, height: 0.5, opacity: 0.55 },
},
orb: {
// radius: orb size · height: how high it hangs · intensity/distance: its point light
large: { radius: 0.42, height: 1.3, intensity: 15.2, distance: 9 },
small: { radius: 0.0, height: 0.17, intensity: 7.0, distance: 5.5 },
},
};
// ─── Field markings (projected decals, not baked into the floor texture) ──────
// The kickoff markings (the two concentric circles the ball starts inside) and the
// random tyre scuffs are projected onto the floor as decals instead of being
// painted into the floor's diffuse texture — the big circle stays crisp (its own
// 512² texture) and the scuffs scatter / rotate / vary freely. Both textures are
// generated by tools/gen-decals.mjs. (Needs the floor material to have gone through
// the material manager so it actually receives projected decals — see arena.js.)
export const FIELD = {
// centre markings — one decal of the kickoff circle + the centre spot.
centerKickoffRadius: 11, // world radius of the big circle (matches the pitch geometry)
centerOuterFrac: 0.9, // where that ring sits in center_markings.png — KEEP == gen-decals OUTER_FRAC
centerColor: 0xeaf0f6, // line tint (cool white)
centerBrightness: 0.85, // scales the tint so the lit line reads bright but not blown out
// tyre scuffs — many dark grungy streaks scattered over the pitch.
scuffCount: 70,
scuffSizeMin: 5, // world width of a scuff footprint…
scuffSizeMax: 15, // …up to this (each also gets a random rotation + opacity)
scuffColor: 0x0a0b0d, // near-black: the alpha-blended decal darkens the floor where it lands
scuffOpacityMin: 0.22,
scuffOpacityMax: 0.55,
scuffMargin: 8, // keep scuffs this far inside the touchlines (off the very edge / goals)
scuffSeed: 0x1234abcd, // deterministic scatter (xorshift)
decalDepth: 0.2, // FP-decal projection-box thickness; centred on the ground so it reaches
// only 0.1 above the floor → the markings/scuffs never catch the cars
};
// ─── AI opponent (behavior-tree driver) ──────────────────────────────────────
// Tuning for the ECS AI: an AiControl component (state) + a BehaviorComponent
// tree ticked by meep's BehaviorSystem. The leaves read these and write the car's
// control intent; two physics queries (a forward raycast + an overlap at the ball)
// give it a bit of spatial awareness.
export const AI = {
leadTime: 0.18, // s of ball velocity to lead the aim point by
standoff: 3.2, // u behind the ball (on the ball→goal line) to strike from
commitDist: 9, // within this & behind the ball → drive straight through it
goalLineKeepout: 5, // never aim within this of the enemy goal line (stay out of the net)
steerGain: 1.6, // P-gain from heading error (rad) to steer input (sign: +angle)
// Drive forward and arc toward the target; ease the throttle when the target is
// off the nose so the car pivots in a tight low-speed arc instead of
// understeering wide, then floors it once lined up. With the steer sign right a
// forward arc reaches anything; a genuine wedge is the Recover leaf's job.
throttleBase: 0.5, // forward throttle = clamp(base + gain·alignment, min, 1)
throttleGain: 0.6,
throttleMin: 0.35, // forward floor → always making ground toward the ball
boostAlign: 0.86, // nose·target above this (and far + fuel + clear ahead) → boost
boostMinDist: 14,
boostMinTank: 12,
jumpBallHeight: 4.5, // ball this high overhead (and close) → pop a jump
jumpHorizDist: 4.5,
jumpCooldown: 1.4, // s between AI jumps
// boost economy: when low and the ball is far, detour to the nearest active pad
lowBoost: 28, // grab boost below this tank %
boostSeekMinBallDist: 16,// …but only if the ball is at least this far (don't abandon a play)
bigPadBias: 8, // u of "virtual closeness" favouring large pads when choosing
// recovery (wedged): slow + far from the ball, or pinned against geometry ahead.
// Once triggered, the AI LATCHES into a backoff and reverses until it has real
// clearance (distance + a clear forward ray) — it does NOT re-evaluate every
// tick, which is what caused the "nudge back, ram wall, repeat" loop.
stuckSpeed: 1.5,
stuckMinBallDist: 5,
stuckGrace: 0.6, // s slow-while-trying before committing to a backoff
stuckBoostAfter: 1.2, // s into the backoff before boosting out
backoffClearDist: 10, // reverse until this far from where the backoff began
backoffWallClear: 7, // …and the forward ray is clear beyond this
backoffMaxTime: 3.0, // hard cap so a cornered car doesn't reverse forever
backoffTurn: 0.5, // gentle committed steer while reversing (reorient off the wall)
// physics-query awareness
wallProbe: 8, // forward raycast length (u) to spot a wall/car ahead
wallEaseDist: 5, // an obstacle closer than this ahead → don't boost into it
contestRadius: 6, // overlap-sphere radius at the ball to sense a contesting car
};
// ─── Ball ────────────────────────────────────────────────────────────────────
export const BALL = {
radius: 2.08, // ~30% larger than the original 1.6
mass: 5.5, // light enough to launch, heavy enough to control
restitution: 0.55,
friction: 0.35,
linearDamping: 0.16,
angularDamping: 0.2,
trailMinSpeed: 16, // u/s above which the ball grows a trail
};
// ─── Distance highlight ───────────────────────────────────────────────────────
// A mesh outline (SGMesh/ShadedGeometry Highlight) on the ball + opponent whose
// opacity fades IN as the thing shrinks on screen, to help the player track them
// at distance. We approximate each as a sphere and compute its projected size as a
// fraction of the viewport area: opacity is 0 at `areaFadeStart`, and lerps up to
// `maxOpacity` once it's down to `areaFadeFull` (smaller ⇒ more highlight).
// The ball and the opponent car are both outlined. Since 3.14 the outline pass
// takes entries from two source systems - one for loaded models, one for
// primitives built in code - so a bare ShadedGeometry like the ball carries a
// Highlight again.
export const HIGHLIGHT = {
ballColor: 0xfff1b0, // warm gold outline on the ball (opponent uses its team colour)
// Thresholds on meep's `sphere_project` projected-area metric (see
// distanceHighlight.js): the outline begins (opacity 0) at areaFadeStart and
// reaches full opacity at areaFadeFull — bigger projected area = closer = less
// highlight. (sphere_project's area runs ~4·aspect larger than a literal viewport
// fraction, so these aren't a 0–1 screen fraction; they're tuned to its scale.)
areaFadeStart: 0.0071,
areaFadeFull: 0.0028,
maxOpacity: 0.4,
};
// ─── Arena (domeArena level) ─────────────────────────────────────────────────
// Footprint scaled up ~1.73× linear ⇒ ~3× the floor area (150×200 vs the old
// 84×116) so there's far more room to drive. Cars/ball are unchanged; walls, dome
// and goals scale proportionally. Spawns, goal positions and the boost-pad layout
// all derive from these dims, so they follow automatically.
export const ARENA = {
width: 150, // X extent (wall to wall)
length: 200, // Z extent (goal to goal)
wallHeight: 36, // vertical wall before the dome curves in
domeHeight: 68, // apex of the dome above the floor
cornerChamfer: 28, // 45° corner cut length
goalWidth: 44,
goalHeight: 18,
goalDepth: 14,
wallThickness: 3,
floorFriction: 0.95,
wallRestitution: 0.35,
};
// ─── Camera (RL "ball-cam" chase) ────────────────────────────────────────────
export const CAMERA = {
back: 13, // distance behind the car
up: 5.2, // height above the car
lookAhead: 9, // look-at point ahead of the car (car-cam)
lookUp: 1.5,
stiffness: 7.5, // position spring rate
// Car-cam follows the car's LINEAR motion, not its orientation, so a spin or
// air tumble doesn't whip the camera around. The heading eases toward the
// (horizontal) velocity on its own spring; below minHeadingSpeed it holds.
headingStiffness: 5,
minHeadingSpeed: 2.5, // u/s — car nearly stopped → keep the current heading
// Speed widens the FOV a touch so going fast "feels" faster.
fov: 70, // base vertical FOV (deg)
fovBoost: 10, // extra FOV (deg) reached at fovSpeedRef
fovSpeedRef: 42, // u/s at which the full boost is applied (~boost top speed)
fovStiffness: 3, // FOV spring rate (smooths speed jitter)
snapDist: 30, // jumps bigger than this snap (respawn/kickoff)
ballCamHeightBias: 2.5, // raise the look target toward the ball in ball-cam
};
// ─── Match ───────────────────────────────────────────────────────────────────
export const MATCH = {
durationSeconds: 300,
kickoffCountdown: 3.0, // s of "3..2..1" hold before kickoff (and at match start)
goalCelebration: 3.0, // s the goal banner + VFX play before kickoff
// Goal slow-motion: on a goal, time is dragged down for a moment so the
// explosion reads. Applied as a multiplicative modifier on the engine clock
// speed (1 → goalSlowMoSpeed). Measured in REAL seconds (the match phase loop
// runs on wall-clock dt, not the scaled clock), and it ends well before the
// celebration does so kickoff + countdown play at full speed.
goalSlowMoSpeed: 0.5, // clock speed during the slow-mo (0.5 = half speed)
goalSlowMoSeconds: 2.0, // real seconds the slow-mo lasts (< goalCelebration)
// Goal "explosion": cars near the ball's entry point get launched up and away,
// velocity scaling with a LINEAR falloff to zero at the radius (gentler than a
// squared falloff, so cars further out still get a real shove).
goalBlastSpeed: 48, // m/s imparted to a car right at the blast centre (2× — punchier)
goalBlastRadius: 45, // u — falloff reaches zero here (+50% reach)
goalBlastUp: 0.7, // upward bias of the launch direction
};
// ─── Teams ───────────────────────────────────────────────────────────────────
// Blue defends −Z (scores in +Z goal); Orange defends +Z (scores in −Z goal).
export const TEAM = {
blue: { id: "blue", color: 0x4ea8f0, defendZSign: -1, attackZSign: 1 },
orange: { id: "orange", color: 0xf0a23c, defendZSign: 1, attackZSign: -1 },
};
// gen-decals — writes the ground decal textures (white motif on transparency; the
// Decal component tints each per use). Run once:
// node tools/gen-decals.mjs
// Output committed to public/textures/decals/. Pure Node (zlib only) — a tiny
// 8-bit-RGBA PNG encoder + distance-field motifs, so there's no canvas dependency.
//
// Emits:
// • boost_pad.png 256² — the boost-pad motif (two rings, radial ticks, dot)
// • center_markings.png 512² — the kickoff markings (the two concentric circles
// the ball starts inside), high-res so the big circle
// stays crisp where a baked-into-the-floor texel can't.
// • scuff.png 256² — one grungy tyre-scuff smudge, scattered + rotated +
// tinted dark across the pitch as many FP decals.
import { deflateSync } from "node:zlib";
import { writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "textures", "decals");
// ── boost-pad motif (256²) → RGBA (white, alpha = coverage) ──────────────────
function drawMotif() {
const n = 256, data = new Uint8Array(n * n * 4);
const c = (n - 1) / 2;
const R = (f) => f * n;
const ringAA = (d, r, hw) => Math.max(0, 1 - Math.abs(d - r) / hw); // triangular falloff
const TWO_PI = Math.PI * 2, N_TICKS = 12, segHalf = 0.05 * TWO_PI;
const tickIn = R(0.31), tickOut = R(0.40);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const dx = x - c, dy = y - c, d = Math.hypot(dx, dy);
let a = 0;
a = Math.max(a, ringAA(d, R(0.435), 3.5)); // outer ring
a = Math.max(a, ringAA(d, R(0.295), 2.5)); // inner ring
if (d >= tickIn && d <= tickOut) { // radial ticks between the rings
let ang = Math.atan2(dy, dx); if (ang < 0) ang += TWO_PI;
const seg = TWO_PI / N_TICKS;
let da = Math.abs(ang - Math.round(ang / seg) * seg);
da = Math.min(da, seg - da);
if (da < segHalf) a = Math.max(a, 1 - da / segHalf);
}
a = Math.max(a, ringAA(d, R(0.10), 2.5)); // small centre RING
const i = (y * n + x) * 4;
data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; data[i + 3] = Math.round(a * 255);
}
}
return { data, width: n, height: n };
}
// ── kickoff markings (512²): two concentric rings, white, alpha = coverage ────
// The big ring sits at OUTER_FRAC of the half-texture; buildFieldDecals sizes the
// decal so that fraction lands exactly on the world kickoff circle (see FIELD in
// tuning.js — keep OUTER_FRAC in sync there). The small ring is the centre spot,
// at the same ratio the field uses (1.4 : 11 world units).
const OUTER_FRAC = 0.9; // big ring radius ÷ (texture width / 2)
const INNER_FRAC = OUTER_FRAC * (1.4 / 11); // centre-spot ring, same ratio as the pitch
function drawCenterMarkings() {
const n = 512, data = new Uint8Array(n * n * 4);
const c = (n - 1) / 2, half = n / 2;
const ringAA = (d, r, hw) => Math.max(0, Math.min(1, 1 - Math.abs(d - r) / hw)); // triangular AA
const outerR = OUTER_FRAC * half, innerR = INNER_FRAC * half;
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const dx = x - c, dy = y - c, d = Math.hypot(dx, dy);
let a = 0;
a = Math.max(a, ringAA(d, outerR, 4.0)); // kickoff circle (~8px line at 512²)
a = Math.max(a, ringAA(d, innerR, 3.0)); // centre-spot ring
const i = (y * n + x) * 4;
data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; data[i + 3] = Math.round(a * 255);
}
}
return { data, width: n, height: n };
}
// ── tyre scuff (256²): an elongated, grungy smudge, white, alpha = coverage ───
// Scattered across the pitch with random rotation / scale / dark tint, one texture
// reads as many distinct scuffs (and shares a single FP-decal atlas slot).
function drawScuff() {
const n = 256, data = new Uint8Array(n * n * 4);
const c = (n - 1) / 2;
// cheap value noise (hash lattice + smooth bilerp) for the grunge breakup
const hash = (ix, iy) => {
let h = (ix * 374761393 + iy * 668265263) >>> 0;
h = ((h ^ (h >>> 13)) * 1274126177) >>> 0;
return ((h ^ (h >>> 16)) >>> 0) / 0xFFFFFFFF;
};
const sm = (t) => t * t * (3 - 2 * t);
const vnoise = (x, y) => {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const a = hash(xi, yi), b = hash(xi + 1, yi), cc = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
const u = sm(xf), v = sm(yf);
return (a * (1 - u) + b * u) * (1 - v) + (cc * (1 - u) + d * u) * v;
};
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const nx = (x - c) / c, ny = (y - c) / c; // [-1, 1]
// elongated soft falloff: wide along x, narrow along y → a streak
const d = Math.hypot(nx / 1.0, ny / 0.5);
let cov = Math.max(0, 1 - d);
cov *= cov; // soften the edge
// grunge: two octaves, stretched along the streak so it breaks into ribbons
let nv = vnoise((x / n) * 7, (y / n) * 3) * 0.7
+ vnoise((x / n) * 17, (y / n) * 9) * 0.3;
cov *= 0.2 + 1.0 * nv;
const i = (y * n + x) * 4;
data[i] = 255; data[i + 1] = 255; data[i + 2] = 255;
data[i + 3] = Math.round(Math.max(0, Math.min(1, cov)) * 255);
}
}
return { data, width: n, height: n };
}
// ── minimal 8-bit RGBA PNG encoder ──────────────────────────────────────────
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
return t;
})();
function crc32(buf) { let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const t = Buffer.from(type, "ascii");
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
return Buffer.concat([len, t, data, crc]);
}
function encodePNG({ data, width, height }) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; // depth 8, colour type 6 (RGBA)
const stride = width * 4;
const raw = Buffer.alloc(height * (1 + stride));
for (let y = 0; y < height; y++) {
raw[y * (1 + stride)] = 0; // filter: none
raw.set(data.subarray(y * stride, (y + 1) * stride), y * (1 + stride) + 1);
}
const idat = deflateSync(raw, { level: 9 });
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0)),
]);
}
mkdirSync(OUT, { recursive: true });
for (const [name, img] of [
["boost_pad.png", drawMotif()],
["center_markings.png", drawCenterMarkings()],
["scuff.png", drawScuff()],
]) {
const png = encodePNG(img);
const path = join(OUT, name);
writeFileSync(path, png);
console.log("wrote", path, png.length, "bytes");
}
// Procedural SFX generator for Jet Propulsion Alliance.
//
// The asset library has no car audio, so we synthesise short mono WAVs with
// nothing but Node core — no runtime deps, fully self-contained. Run once:
// node tools/gen-sounds.mjs (or: npm run gen-assets)
// Output → public/sounds/*.wav. Loops are authored to tile seamlessly (engine
// tones use integer cycles-per-loop; noisy loops cross-fade their seam).
import { writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), "../public/sounds");
mkdirSync(OUT, { recursive: true });
const SR = 22050;
function writeWav(name, samples) {
const n = samples.length;
const buf = Buffer.alloc(44 + n * 2);
buf.write("RIFF", 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write("WAVE", 8);
buf.write("fmt ", 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20);
buf.writeUInt16LE(1, 22); buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 2, 28);
buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34);
buf.write("data", 36); buf.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) {
let s = samples[i];
s = s < -1 ? -1 : s > 1 ? 1 : s;
buf.writeInt16LE((s * 32767) | 0, 44 + i * 2);
}
writeFileSync(resolve(OUT, name), buf);
console.log("wrote", name, (n / SR).toFixed(2) + "s");
}
const TAU = Math.PI * 2;
// Seamless engine tone: a buzzy stack of harmonics whose base frequency fits an
// integer number of cycles in the loop, so the loop tiles without a click.
function engineTone(baseHz, seconds, harmonics) {
const n = Math.round(seconds * SR);
const cyclesBase = Math.round(baseHz * seconds); // integer → seamless
const f0 = cyclesBase / seconds;
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
const t = i / SR;
let s = 0;
for (let h = 0; h < harmonics.length; h++) {
const { mul, amp, phase = 0 } = harmonics[h];
s += amp * Math.sin(TAU * f0 * mul * t + phase);
}
// gentle wobble (idle lope), also integer-cycle for seamlessness
s *= 0.85 + 0.15 * Math.sin(TAU * (Math.round(7 * seconds) / seconds) * t);
out[i] = s * 0.5;
}
return out;
}
// One-pole low/high-pass helpers for shaping noise.
function lowpass(buf, a) { let y = 0; for (let i = 0; i < buf.length; i++) { y += a * (buf[i] - y); buf[i] = y; } return buf; }
function highpass(buf, a) { let y = 0, px = 0; for (let i = 0; i < buf.length; i++) { y = a * (y + buf[i] - px); px = buf[i]; buf[i] = y; } return buf; }
function noise(n) { const o = new Float32Array(n); for (let i = 0; i < n; i++) o[i] = Math.random() * 2 - 1; return o; }
// Cross-fade the last `fade`s of a noisy loop into its head so it tiles cleanly.
function seamLoop(buf, fadeSeconds) {
const f = Math.min(Math.round(fadeSeconds * SR), (buf.length / 2) | 0);
const n = buf.length;
const out = buf.slice(0, n - f);
for (let i = 0; i < f; i++) {
const k = i / f;
out[i] = out[i] * k + buf[n - f + i] * (1 - k);
}
return out;
}
function envMul(buf, attack, release) {
const n = buf.length, a = Math.round(attack * SR), r = Math.round(release * SR);
for (let i = 0; i < n; i++) {
let e = 1;
if (i < a) e = i / a;
else if (i > n - r) e = (n - i) / r;
buf[i] *= e;
}
return buf;
}
// ── engine loops (gear-layered: crossfaded by speed at runtime) ──────────────
writeWav("engine_low.wav", engineTone(70, 1.0, [
{ mul: 1, amp: 1.0 }, { mul: 2, amp: 0.5 }, { mul: 3, amp: 0.35 }, { mul: 5, amp: 0.18 },
]));
writeWav("engine_mid.wav", engineTone(120, 1.0, [
{ mul: 1, amp: 0.9 }, { mul: 2, amp: 0.55 }, { mul: 3, amp: 0.4 }, { mul: 4, amp: 0.25 }, { mul: 6, amp: 0.12 },
]));
writeWav("engine_high.wav", engineTone(190, 1.0, [
{ mul: 1, amp: 0.8 }, { mul: 2, amp: 0.6 }, { mul: 3, amp: 0.45 }, { mul: 5, amp: 0.22 }, { mul: 7, amp: 0.12 },
]));
// ── boost (band-passed jet hiss loop) ────────────────────────────────────────
{
let b = noise(Math.round(1.0 * SR));
b = highpass(b, 0.92); b = lowpass(b, 0.35);
for (let i = 0; i < b.length; i++) b[i] *= 0.9;
writeWav("boost.wav", seamLoop(b, 0.08));
}
// ── tyre screech (bright, slightly tonal noise loop) ─────────────────────────
{
let b = noise(Math.round(0.7 * SR));
b = highpass(b, 0.85);
for (let i = 0; i < b.length; i++) b[i] = b[i] * 0.6 + 0.4 * Math.sin(TAU * 2200 * i / SR) * (0.5 + 0.5 * b[i]);
writeWav("screech.wav", seamLoop(b, 0.06));
}
// ── rolling on the floor (low filtered rumble loop) ──────────────────────────
{
let b = noise(Math.round(1.0 * SR));
b = lowpass(b, 0.08); b = lowpass(b, 0.08);
for (let i = 0; i < b.length; i++) b[i] *= 3.5;
writeWav("roll.wav", seamLoop(b, 0.1));
}
// ── jump / dodge whooshes (rising filtered noise) ────────────────────────────
function whoosh(seconds, f0, f1) {
const n = Math.round(seconds * SR);
let b = noise(n);
b = lowpass(b, 0.2);
for (let i = 0; i < n; i++) {
const t = i / n;
const f = f0 + (f1 - f0) * t;
b[i] = b[i] * 0.5 + 0.5 * Math.sin(TAU * f * i / SR);
}
return envMul(b, 0.01, seconds * 0.6);
}
writeWav("jump.wav", whoosh(0.28, 240, 620));
writeWav("dodge.wav", whoosh(0.22, 320, 760));
// ── collisions (low thump + noise crack) ─────────────────────────────────────
function thump(seconds, f, crack) {
const n = Math.round(seconds * SR);
const out = new Float32Array(n);
const nz = lowpass(noise(n), 0.5);
for (let i = 0; i < n; i++) {
const t = i / SR;
const decay = Math.exp(-t * 18);
const pitch = f * (1 + 2 * Math.exp(-t * 60)); // quick pitch drop
out[i] = (Math.sin(TAU * pitch * t) * decay + nz[i] * crack * Math.exp(-t * 40)) * 0.9;
}
return out;
}
writeWav("hit1.wav", thump(0.3, 90, 0.6));
writeWav("hit2.wav", thump(0.28, 130, 0.5));
// ── goal horn (major triad swell) ────────────────────────────────────────────
{
const seconds = 1.2, n = Math.round(seconds * SR);
const out = new Float32Array(n);
const freqs = [330, 415, 494, 660];
for (let i = 0; i < n; i++) {
const t = i / SR;
let s = 0;
for (const f of freqs) s += Math.sin(TAU * f * t) + 0.3 * Math.sin(TAU * f * 2 * t);
out[i] = s / freqs.length;
}
writeWav("goal.wav", envMul(out, 0.02, 0.8));
}
console.log("done →", OUT);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jet Propulsion Alliance · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #05070a;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
/* ─── Scoreboard (top centre) ─────────────────────────────────────── */
.scoreboard {
top: 1rem; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 1.1rem;
padding: 0.55rem 1.1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
}
.scoreboard .score {
font-size: 1.9rem; font-weight: 700; line-height: 1;
min-width: 2.2ch; text-align: center;
font-variant-numeric: tabular-nums;
}
.scoreboard .score.blue { color: #4ea8f0; text-shadow: 0 0 14px rgba(78,168,240,0.55); }
.scoreboard .score.orange { color: #f0a23c; text-shadow: 0 0 14px rgba(240,162,60,0.55); }
.scoreboard .clock {
font-size: 1.05rem; color: #cdd6df; letter-spacing: 0.06em;
font-variant-numeric: tabular-nums; min-width: 4.2ch; text-align: center;
}
.scoreboard .sep { width: 1px; align-self: stretch; background: #283039; }
/* ─── Banner (kickoff / goal / countdown) ─────────────────────────── */
#banner {
position: fixed;
top: 28%; left: 50%; transform: translateX(-50%);
z-index: 120;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 2.6rem; font-weight: 800; letter-spacing: 0.04em;
text-align: center; pointer-events: none;
text-shadow: 0 4px 24px rgba(0,0,0,0.7);
opacity: 0; transition: opacity 0.25s ease;
}
#banner.show { opacity: 1; }
/* ─── Boost + speed (bottom right) ────────────────────────────────── */
.telemetry {
bottom: 1rem; right: 1rem;
padding: 0.7rem 0.9rem;
display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-end;
font-family: ui-monospace, "JetBrains Mono", monospace;
}
.telemetry .speed { font-size: 0.82rem; color: #9aa5b1; }
.telemetry .speed b { color: #4ef0a8; font-size: 1.05rem; }
.boost-meter {
width: 168px; height: 12px; border-radius: 7px;
background: #11161d; border: 1px solid #2a333f; overflow: hidden;
}
.boost-meter #boost-fill {
height: 100%; width: 100%;
/* pre-JS default; the real gradient is set at runtime from BOOST.barColors
(tuning.js) — the same pair the boost pads use. Keep these two in sync. */
background: linear-gradient(90deg, #ffb02e, #ff5e3a);
transition: width 0.08s linear;
}
.telemetry .boost-label { font-size: 0.6rem; color: #6b7785; text-transform: uppercase; letter-spacing: 0.14em; }
/* ─── Controls legend (bottom left) ───────────────────────────────── */
.legend {
bottom: 1rem; left: 1rem;
padding: 0.75rem 0.95rem;
font-size: 0.78rem; line-height: 1.6;
max-width: 430px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
.legend kbd {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
background: #161c24;
border: 1px solid #2a333f;
border-radius: 4px;
padding: 0.04rem 0.32rem;
color: #cdd6df;
}
/* ─── Off-screen ball tracker (positioned by ViewportPosition) ────── */
.ball-tracker {
display: flex; align-items: center; justify-content: center;
width: 34px; height: 34px;
pointer-events: none;
opacity: 0; /* hidden while the ball is on screen */
transition: opacity 0.2s ease;
}
/* The system adds this class only when the marker is clamped to the screen
edge — i.e. the ball is off-screen. Then show a chevron rotated by the
angle JS feeds in (--ball-arrow-angle), so it points at the ball. */
.ball-tracker.hud-system-sticky-flag {
opacity: 1;
}
.ball-tracker.hud-system-sticky-flag::after {
content: "➤";
font-size: 26px;
color: #ffd27a;
filter: drop-shadow(0 0 5px rgba(0,0,0,0.85));
transform: rotate(var(--ball-arrow-angle, 0deg));
}
</style>
</head>
<body>
<div class="panel scoreboard">
<span class="score blue" id="score-blue">0</span>
<span class="sep"></span>
<span class="clock" id="clock">5:00</span>
<span class="sep"></span>
<span class="score orange" id="score-orange">0</span>
</div>
<div id="banner"></div>
<div class="panel telemetry">
<div class="speed"><b id="speed">0</b> km/h</div>
<div class="boost-meter"><div id="boost-fill"></div></div>
<div class="boost-label">boost</div>
</div>
<div class="panel legend">
<strong>Jet Propulsion Alliance</strong> — a Rocket-League-style demo on Meep's physics.<br>
<kbd>W</kbd>/<kbd>S</kbd> drive · <kbd>A</kbd>/<kbd>D</kbd> steer · <kbd>X</kbd> powerslide ·
<kbd>Shift</kbd> boost · <kbd>Space</kbd> jump (tap again to dodge/flip) ·
<kbd>Q</kbd>/<kbd>E</kbd> air-roll · <kbd>F</kbd> ball-cam · <kbd>R</kbd> reset.
In the air, <kbd>W</kbd>/<kbd>S</kbd> pitch and <kbd>A</kbd>/<kbd>D</kbd> yaw. Click the view first for keyboard focus.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Jet Propulsion Alliance",
"description": "A Rocket-League-style game built on Meep. A custom rigid-body car controller applies drive, boost, aerial pitch/yaw/roll, jumps and dodges as forces at the centre of mass with raycast ground sensing; a bouncy ball and goal sensors drive a scoreboard with kickoff resets, against a simple AI opponent. Showcases clustered effect lighting, particles, trails, positional sound and an off-screen ball tracker — all at once.",
"category": "Gameplay",
"status": "live",
"order": 1,
"tags": ["physics", "vehicle", "game", "rocket-league", "particles", "trails", "decals", "sound", "hud", "clustered-lighting", "ecs"],
"sourceHint": "examples-src/jet-propulsion-alliance/",
"demoUrl": "/examples/jet-propulsion-alliance/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/jet-propulsion-alliance",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A Rocket-League-style game: custom rigid-body car physics, boost, aerial control, dodges, a bouncy ball, goal sensors, particles, trails, decals, positional sound and an off-screen ball tracker.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"gen-sounds": "node tools/gen-sounds.mjs"
},
"dependencies": {
"@woosh/meep-engine": "3.21.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# jet-propulsion-alliance
A Rocket-League-style game built on Meep — two rocket-powered cars and a big
bouncy ball in a domed arena with a goal at each end. You drive the **Octane**
(blue); a simple AI drives the **Perrier buggy** (orange). Knock the ball into
the orange net to score; the clock, scoreboard and kickoff resets make it a
match.
The point of the example is *integration*: a custom vehicle controller, a
physics ball + sensor goals, dozens of transient effect lights, particles,
trails, projected decals, positional sound and an off-screen HUD tracker all
running together.
## Controls
Click the view first to give it keyboard focus.
| | |
|---|---|
| **W / S** | throttle / reverse (on the ground) · **pitch** (in the air) |
| **A / D** | steer (on the ground) · **yaw** (in the air) |
| **Q / E** | air-roll |
| **X** | powerslide (handbrake) |
| **Shift** | boost |
| **Space** | jump — tap again in the air to **dodge / flip** (hold a direction) or double-jump |
| **F** | toggle ball-cam |
| **R** | respawn |
Append `?top` to the URL for a fixed overhead view (handy for seeing the whole pitch).
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/jet-propulsion-alliance/demo.html`.
## Regenerating the synthesized audio
There is no car audio in the shared asset library, so the engine loops, boost,
tyre and impact sounds are synthesized into short WAVs by a dependency-free Node
script (already run; the output is committed under `public/sounds/`). The field
markings are drawn at runtime onto a canvas texture (`level/fieldTexture.js`).
```bash
npm run gen-sounds # node tools/gen-sounds.mjs
```
## What this demonstrates
- **A custom Rocket-League-style controller** (`vehicles/CarController.js`) built
on Meep's public physics API — the same primitives the engine's own
`RaycastVehicle` uses (`raycast` + `applyForceAt` + `applyImpulseAt`) plus
direct velocity edits for crisp arcade control. Per the GDC talk *"It IS Rocket
Science!"*, drive and steering act on the chassis **at its centre of mass**;
only the four suspension springs are applied at the wheel contacts. Boost,
aerial pitch/yaw/roll, surface-relative jumps, double-jumps, directional dodges
and flip-cancels are all in there.
- **Force-based flip-assist** (`vehicles/flipAssist.js`) — a car on its side or
roof self-rights with a torque (and a grounding force when only a wheel or two
is lifted), but *only while touching the ground* — it never fires in the air.
The decision logic is a dependency-free pure function.
- **Sensor goals + contact events** — each net is a `BoxShape3D` `IsSensor`
collider; a `PhysicsEvents.ContactBegin` listener on the ball entity reports it
entering, and the `MatchManager` scores it and triggers a kickoff reset.
- **Render interpolation** — cars and ball carry `Interpolated` / `POSE_INTERPOLAND`
so they render smoothly between fixed physics steps. A `preRender` publication
bridges Meep 3.21.0's missing transform notifications, and the chase camera
synchronizes its pose and lens before the same frame is drawn. The separate
pose-deserializer matrix limitation remains documented in the
[engine bug report](../../feature-requests/interpolated-pose-render-sync.md).
- **Particles** (`fx/`) — always-on exhaust smoke plus transient boost-flame and
tyre-dust puffs, gated by car state.
- **Trails** (`Trail3D`) — volumetric tube trails behind a fast car and a fast ball.
- **Transient effect lighting** — a boost point-light per car and short-lived
point-lights for impacts and goal celebrations. Shade bins lights into froxels
and applies them deferred, so a goal celebration lighting a dozen extra lamps
costs the frame very little.
- **Projected decals** (`level/fieldDecals.js`, `pads/buildPads.js`) — the kickoff
circles, seventy scattered tyre scuffs and a motif under every boost pad. A
decal is written into the G-buffer before anything is lit, so it lands on any
opaque surface with no per-material opt-in — and its tint is a *linear* albedo,
which is why every hex here is decoded with `Color.from_sRGB_to_linear` first.
- **HDR environment** — one equirectangular `.hdr`, resampled to the octahedral
projection Shade wants (`lib/environment.js`). It is both the image-based
lighting source (reflections and fill on the cars and the gray-boxed arena) and
the visible sky above the open roof: meep 3 has no separate skybox.
- **Positional sound** (`audio/`) — per-car engine loops cross-faded by speed to
fake gear changes (Meep has no playback-rate control), plus rolling / screech /
boost loops and positional one-shots for jumps, dodges, impacts and the goal horn.
- **Wheels off the ECS hierarchy** (`vehicles/WheelRig.js`) — a loaded model is
expanded into entities, one per glTF node, each with a `Name` and a
`TransformAttachment`. The rig finds the four wheels by name and writes their
local transforms so they stay planted, spin and steer while the body leans on
its suspension. There is no renderer scene graph to reach into.
- **HUD** — a DOM scoreboard / clock / boost meter, and an off-screen ball tracker
using `HeadsUpDisplay` + `ViewportPosition.stickToScreenEdge`.
## Structure
```
src/
main.js orchestrator: bootstrap, build level, spawn, wire it together
tuning.js every tuning constant in one place
level/ arena builder, field texture, level registry
vehicles/ CarController + VehicleSystem + player input + AI driver + car metadata
fx/ particle specs, per-car VFX kit, transient effects
audio/ per-car loops + positional one-shots
hud/ off-screen ball tracker
camera/ ball-cam chase camera
lib/ Shade helpers: geometry/material/texture builders, HDR env
ball.js the match ball
match.js score / clock / kickoff state machine
tools/ dependency-free WAV + PNG generators
```
Only `@woosh/meep-engine` is used — nothing else.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/jet-propulsion-alliance");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/jet-propulsion-alliance"),
emptyOutDir: false,
rollupOptions: {
input: {
demo: resolve(__dirname, "demo.html"),
},
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});
Octane_Chassis_baseColor.png · 1589.2 KB
Octane_Chassis_metallicRoughness.png · 231.3 KB
Material.005_baseColor.png · 942.3 KB
Material.006_baseColor.png · 1280.8 KB
Material.007_baseColor.png · 1891.4 KB
boost_pad.png · 26.2 KB
center_markings.png · 24.8 KB
circle-01.png · 22.6 KB
circle-07.png · 19.2 KB
scuff.png · 9.9 KB
tile_transparent-x-256.png · 30.1 KB
Circle_02.png · 2.7 KB
Light_Beam_04.png · 3.0 KB
Smoke_08.png · 3.4 KB
Smoke_14.png · 3.1 KB
Circle_04.png · 1.3 KB
edge4_fill20_mono64.png · 0.6 KB
thumbnail.png · 216.3 KB
license.txt
Unsupported file type.
license.txt
Unsupported file type.
engine_high.wav
Binary file.
engine_low.wav
Binary file.
engine_mid.wav
Binary file.
noon_grass_2k.hdr
Binary file.