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.
//
// Each car carries one SoundEmitter with several looping tracks; we modulate
// their volumes per frame (Meep's SoundTrack has no playback-rate control, so
// "gear shifting" is faked by cross-fading three engine loops by speed). A small
// pool plays positional one-shots (jumps, dodges, impacts, the goal horn). 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 { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { SoundEmitter } from "@woosh/meep-engine/src/engine/sound/ecs/emitter/SoundEmitter.js";
import { SoundEmitterFlags } from "@woosh/meep-engine/src/engine/sound/ecs/emitter/SoundEmitterFlags.js";
import { SoundTrack } from "@woosh/meep-engine/src/engine/sound/ecs/emitter/SoundTrack.js";
import { SoundTrackFlags } from "@woosh/meep-engine/src/engine/sound/ecs/emitter/SoundTrackFlags.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);
function loopTrack(url, volume = 0) {
const t = new SoundTrack();
t.url = url;
t.setFlag(SoundTrackFlags.Loop);
t.setFlag(SoundTrackFlags.StartWhenReady);
t.volume = volume;
return t;
}
function spatialEmitter(channel = "effects", min = 6, max = 130) {
const e = new SoundEmitter();
e.setFlag(SoundEmitterFlags.Spatialization);
e.setFlag(SoundEmitterFlags.Attenuation);
e.distanceMin = min;
e.distanceMax = max;
e.channel = channel;
return e;
}
// ── per-car engine / tyre loops ──────────────────────────────────────────────
export class CarAudio {
constructor(ctx, controller) {
this.c = controller;
const e = spatialEmitter();
this.low = loopTrack(`${S}/engine_low.wav`, 0.25);
this.mid = loopTrack(`${S}/engine_mid.wav`, 0);
this.high = loopTrack(`${S}/engine_high.wav`, 0);
this.boost = loopTrack(`${S}/boost.wav`, 0);
this.roll = loopTrack(`${S}/roll.wav`, 0);
this.screech = loopTrack(`${S}/screech.wav`, 0);
for (const t of [this.low, this.mid, this.high, this.boost, this.roll, this.screech]) e.tracks.add(t);
ctx.ecd.addComponentToEntity(controller.entity, e);
this.emitter = e;
}
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
this.low.volume = approach(this.low.volume, tri(r, 0) * eng, dt, 8);
this.mid.volume = approach(this.mid.volume, tri(r, 1) * eng, dt, 8);
this.high.volume = approach(this.high.volume, tri(r, 2) * eng, dt, 8);
this.boost.volume = approach(this.boost.volume, st.boosting ? 0.5 : 0, dt, 12);
this.roll.volume = approach(this.roll.volume, st.grounded ? clamp(st.speed / 16, 0, 0.32) : 0, dt, 6);
this.screech.volume = approach(this.screech.volume, st.sliding ? 0.45 : 0, dt, 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 = spatialEmitter("effects", 6, 110);
e.volume.set(volume);
const t = new SoundTrack();
t.url = url;
t.setFlag(SoundTrackFlags.StartWhenReady);
t.volume = 1;
e.tracks.add(t);
const tr = new Transform();
tr.position.set(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 Transform each
// preRender, reading the car's (interpolated) Transform so the motion is smooth
// at the full display refresh rate.
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 TopDownCameraController from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 {() => Transform} opts.getCarTransform
* @param {() => ({x:number,y:number,z:number})} [opts.getCarVelocity] car linear velocity
* @param {() => ({x:number,y:number,z:number}|null)} [opts.getBall] ball world position, or null
* @param {() => boolean} [opts.isBallCam]
*/
export function createChaseCamera({
engine, ecd, getCarTransform,
getCarVelocity = () => ({ x: 0, y: 0, z: 0 }),
getBall = () => null,
isBallCam = () => true,
}) {
const cameraEntity = ecd.getAnyComponent(Camera).entity;
ecd.removeComponentFromEntity(cameraEntity, TopDownCameraController);
const camT = ecd.getComponent(cameraEntity, Transform);
const camComponent = ecd.getComponent(cameraEntity, Camera);
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) {
const r = tr.rotation;
carRot.set(r.x, r.y, r.z, r.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.position.x, tr.position.y, tr.position.z);
const vel = getCarVelocity();
const ball = getBall();
const ballCam = isBallCam() && ball !== null;
// ── heading ─────────────────────────────────────────────────────────
if (!started) {
headingFromFacing(tr);
} else if (ballCam) {
ballPos.set(ball.x, ball.y, ball.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.x, 0, vel.z);
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,
);
}
// Orient via the Transform, not the THREE camera. meep's Transform.lookAt
// is the intuitive convention — the camera ends up actually looking AT the
// focus (the CameraSystem flips it for THREE's −Z-forward camera). Set the
// position first; lookAt reads it.
camT.position.set(camPos.x, camPos.y, camPos.z);
camT.lookAt(focus, WORLD_UP);
// ── speed FOV: widen a touch when fast so it "feels" faster ──────────
// Set the FOV on the THREE camera directly (orientation goes through the
// Transform; FOV does not). Going via Camera.fov.set rebuilds the whole
// camera object on every change — a fresh PerspectiveCamera reassigned to
// graphics.camera — which hitches the post-process pipeline once a second or
// so as the rounded FOV ticks. A direct fov + updateProjectionMatrix is
// continuous and rebuild-free.
const speed = Math.hypot(vel.x, vel.y, vel.z);
const targetFov = CAMERA.fov + CAMERA.fovBoost * Math.min(speed / CAMERA.fovSpeedRef, 1);
currentFov += (targetFov - currentFov) * (1 - Math.exp(-CAMERA.fovStiffness * dt));
const cam = engine.graphics.camera;
if (cam !== undefined && cam.isPerspectiveCamera && Math.abs(cam.fov - currentFov) > 1e-3) {
cam.fov = currentFov;
cam.updateProjectionMatrix();
}
}
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 three.js / DOM.
//
// Every attachment is edited as a Transform: { 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-Transform
* 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 Transform 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;
}
// JPA Car-Def Editor — a standalone three.js tool to place a car's attachments
// (exhaust / trail / boost / headlights) and dial in each wheel's rolling axle on
// the real GLTF, then export a carDefs-ready JSON.
//
// Run with `npm run dev` and open /editor.html. Drop the model's files (scene.gltf
// + scene.bin + textures, or a single .glb) onto the Model slot and a car-def JSON
// onto the def slot. Attachments are Transforms (a unit box scaled by the
// Transform); wheels spin at 0.5 rev/s about a long axle arrow you can rotate.
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
import { defToModel, modelToDef } from "./carDefIO.js";
const $ = (id) => document.getElementById(id);
const WHEEL_RPS = 0.5; // wheel spin: revolutions per second
const KIND_COLOR = { exhaust: 0xff7043, trail: 0x4ea8f0, boost: 0xffd24a, headlight: 0xf2f2f2 };
// ── three.js scene ───────────────────────────────────────────────────────────
const viewport = $("viewport");
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x14171c);
const camera = new THREE.PerspectiveCamera(55, 1, 0.01, 1000);
camera.position.set(4, 3, 6);
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x202830, 1.1));
const sun = new THREE.DirectionalLight(0xffffff, 1.6);
sun.position.set(5, 10, 7);
scene.add(sun);
scene.add(new THREE.GridHelper(20, 20, 0x3a4350, 0x232a32));
scene.add(new THREE.AxesHelper(1.5)); // +X red, +Y green, +Z blue (= car forward)
const transform = new TransformControls(camera, renderer.domElement);
transform.setSpace("local");
transform.addEventListener("dragging-changed", (e) => { orbit.enabled = !e.value; });
transform.addEventListener("objectChange", () => { if (selected) syncOne(selected); });
scene.add(transform);
function resize() {
const w = viewport.clientWidth, h = viewport.clientHeight;
renderer.setSize(w, h);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
addEventListener("resize", resize);
// ── state ────────────────────────────────────────────────────────────────────
let model = null; // carDefIO model
let gltfRoot = null; // loaded Object3D (model transform applied)
let carSize = 4; // longest model dimension (for axle-arrow length)
let markers = []; // { att, kind, object, members? } one per attachment
let selected = null;
let mode = "rotate";
let spinning = true;
let theta = 0;
const restLocal = new Map(); // nodeName → { node, restQuat: THREE.Quaternion }
let pendingJson = null; // def dropped before the model
const _q = new THREE.Quaternion(), _q2 = new THREE.Quaternion(), _spin = new THREE.Quaternion();
const _v = new THREE.Vector3(), _axis = new THREE.Vector3();
const Y = new THREE.Vector3(0, 1, 0);
// ── model loading ──────────────────────────────────────────────────────────────
function applyModelTransform() {
if (!gltfRoot || !model) return;
const m = model.model;
gltfRoot.scale.setScalar(m.scale ?? 1);
gltfRoot.quaternion.setFromAxisAngle(Y, m.yaw ?? 0);
const o = m.offset || [0, 0, 0];
gltfRoot.position.set(o[0], o[1], o[2]);
scene.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(gltfRoot);
const size = box.getSize(new THREE.Vector3());
carSize = Math.max(size.x, size.y, size.z) || 4;
}
function loadGltfFromFiles(files) {
const byName = new Map();
let mainName = null;
for (const f of files) {
byName.set(f.name, URL.createObjectURL(f));
if (/\.(gltf|glb)$/i.test(f.name)) mainName = f.name;
}
if (!mainName) { setStatus("No .gltf/.glb among the dropped files."); return; }
const manager = new THREE.LoadingManager();
manager.setURLModifier((url) => {
const base = decodeURIComponent(url.split("/").pop().split("?")[0]);
return byName.get(base) || url;
});
const loader = new GLTFLoader(manager);
loader.load(byName.get(mainName), (gltf) => {
if (gltfRoot) scene.remove(gltfRoot);
gltfRoot = gltf.scene;
scene.add(gltfRoot);
captureRestPose();
buildNodeList();
if (pendingJson) { applyDef(pendingJson); pendingJson = null; }
else applyModelTransform();
frameCamera();
setStatus();
}, undefined, (err) => setStatus("GLTF load failed: " + err));
}
function captureRestPose() {
restLocal.clear();
gltfRoot.traverse((o) => {
if (o.name) restLocal.set(o.name, { node: o, restQuat: o.quaternion.clone() });
});
}
function applyDef(json) {
try { model = defToModel(typeof json === "string" ? JSON.parse(json) : json); }
catch (e) { setStatus("Bad JSON: " + e.message); return; }
applyModelTransform();
rebuildMarkers();
setStatus();
}
function frameCamera() {
const box = new THREE.Box3().setFromObject(gltfRoot);
const c = box.getCenter(new THREE.Vector3());
orbit.target.copy(c);
camera.position.copy(c).add(new THREE.Vector3(carSize, carSize * 0.7, carSize * 1.2));
}
// ── markers ────────────────────────────────────────────────────────────────────
function clearMarkers() {
transform.detach();
for (const m of markers) scene.remove(m.object);
markers = [];
selected = null;
}
function rebuildMarkers() {
clearMarkers();
if (!model) return;
// reference: the body collider box (not editable)
addBodyBox();
for (const att of model.attachments) {
markers.push(att.kind === "wheel" ? makeWheelMarker(att) : makeMountMarker(att));
}
buildAttachList();
}
let bodyBox = null;
function addBodyBox() {
if (bodyBox) scene.remove(bodyBox);
const h = model.body.half || [1, 0.5, 2];
bodyBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(h[0] * 2, h[1] * 2, h[2] * 2)),
new THREE.LineBasicMaterial({ color: 0x55606e }),
);
scene.add(bodyBox);
}
// a unit box + forward(+Z) arrow + tangent(+X) line — scaled by the Transform
function makeMountMarker(att) {
const g = new THREE.Group();
const color = KIND_COLOR[att.kind] || 0xffffff;
const box = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.25, depthWrite: false }),
);
g.add(box);
g.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)), new THREE.LineBasicMaterial({ color })));
g.add(new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), 0.9, 0x4ea8f0, 0.28, 0.16)); // forward
const tangent = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-0.7, 0, 0), new THREE.Vector3(0.7, 0, 0)]),
new THREE.LineBasicMaterial({ color: 0xffa23c }),
);
g.add(tangent);
g.position.fromArray(att.position);
g.quaternion.fromArray(att.rotation);
g.scale.fromArray(att.scale);
scene.add(g);
return { att, kind: att.kind, object: g };
}
// wheel: a proxy (axle frame) + a long double arrow along its local +X. The member
// GLTF nodes spin about that world axle. Editing rotates the proxy (the axle).
function makeWheelMarker(att) {
const members = [];
let anchor = null;
for (const name of att.nodes) {
const rec = restLocal.get(name) || restLocal.get(name.replace(/\s+/g, "_"));
if (!rec) continue;
rec.node.quaternion.copy(rec.restQuat); // rest pose for an accurate capture
rec.node.updateWorldMatrix(true, false);
const restWorld = rec.node.getWorldQuaternion(new THREE.Quaternion());
const parentInv = rec.node.parent.getWorldQuaternion(new THREE.Quaternion()).invert();
members.push({ node: rec.node, restWorld, parentInv });
if (!anchor) anchor = rec.node.getWorldPosition(new THREE.Vector3());
}
const proxy = new THREE.Object3D();
proxy.position.copy(anchor || new THREE.Vector3());
// proxy world rotation = restWorld0 · spin → its +X is the world axle
if (members.length) proxy.quaternion.copy(members[0].restWorld).multiply(_q.fromArray(att.rotation));
const len = carSize * 0.55;
proxy.add(new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), new THREE.Vector3(), len, 0xffd24a, len * 0.12, len * 0.07));
proxy.add(new THREE.ArrowHelper(new THREE.Vector3(-1, 0, 0), new THREE.Vector3(), len, 0xffd24a, len * 0.12, len * 0.07));
scene.add(proxy);
return { att, kind: "wheel", object: proxy, members };
}
// ── selection + transform ────────────────────────────────────────────────────
function select(marker) {
selected = marker;
if (!marker) { transform.detach(); }
else {
transform.attach(marker.object);
transform.setMode(marker.kind === "wheel" ? "rotate" : mode);
}
refreshAttachListSel();
$("nodeSection").style.display = marker && marker.kind === "wheel" ? "" : "none";
if (marker && marker.kind === "wheel") refreshNodeMembership();
}
// write a marker's live three.js transform back into its model attachment
function syncOne(marker) {
const att = marker.att;
if (marker.kind === "wheel") {
if (marker.members.length) {
// spin quat = restWorld0⁻¹ · proxyWorld (what WheelRig wants: axle = spin·+X̂)
_q.copy(marker.members[0].restWorld).invert().multiply(marker.object.quaternion);
att.rotation = [_q.x, _q.y, _q.z, _q.w];
}
} else {
marker.object.position.toArray(att.position);
marker.object.quaternion.toArray(att.rotation);
marker.object.scale.toArray(att.scale);
}
}
function syncAll() { for (const m of markers) syncOne(m); }
// ── wheel spin animation ───────────────────────────────────────────────────────
function spinWheels(dt) {
if (spinning) theta = (theta + WHEEL_RPS * 2 * Math.PI * dt) % (Math.PI * 2);
for (const m of markers) {
if (m.kind !== "wheel" || !m.members.length) continue;
_axis.set(1, 0, 0).applyQuaternion(m.object.quaternion).normalize(); // world axle
_spin.setFromAxisAngle(_axis, theta);
for (const mem of m.members) {
// node.local = parentWorldInv · (spin · restWorld)
_q2.copy(_spin).multiply(mem.restWorld);
_q.copy(mem.parentInv).multiply(_q2);
mem.node.quaternion.copy(_q);
}
}
}
// ── UI: attachment list ────────────────────────────────────────────────────────
function buildAttachList() {
const el = $("attachList"); el.innerHTML = "";
markers.forEach((m) => {
const b = document.createElement("button");
b.textContent = m.att.label;
if (m.kind === "wheel") b.classList.add("kind-wheel");
b.onclick = () => select(m);
el.appendChild(b);
});
refreshAttachListSel();
}
function refreshAttachListSel() {
[...$("attachList").children].forEach((b, i) => b.classList.toggle("sel", markers[i] === selected));
}
// ── UI: GLTF node list (assign nodes to the selected wheel) ─────────────────────
function buildNodeList() {
const el = $("nodeList"); el.innerHTML = "";
const names = [...restLocal.keys()].sort();
for (const name of names) {
const b = document.createElement("button");
b.textContent = name;
b.dataset.node = name;
b.onclick = () => toggleNodeOnWheel(name);
el.appendChild(b);
}
}
function refreshNodeMembership() {
const set = new Set(selected && selected.kind === "wheel" ? selected.att.nodes : []);
[...$("nodeList").children].forEach((b) => b.classList.toggle("member", set.has(b.dataset.node)));
}
function toggleNodeOnWheel(name) {
if (!selected || selected.kind !== "wheel") { setStatus("Select a wheel first, then click nodes to add/remove them."); return; }
const nodes = selected.att.nodes;
const i = nodes.indexOf(name);
if (i >= 0) {
nodes.splice(i, 1);
// restore the removed node to its rest pose
const rec = restLocal.get(name); if (rec) rec.node.quaternion.copy(rec.restQuat);
} else nodes.push(name);
// rebuild just this wheel's marker (members changed)
const idx = markers.indexOf(selected);
scene.remove(selected.object);
const nm = makeWheelMarker(selected.att);
markers[idx] = nm;
select(nm);
}
// ── export ────────────────────────────────────────────────────────────────────
function exportJSON() {
if (!model) return "";
syncAll();
const json = JSON.stringify(modelToDef(model), null, 2);
$("jsonOut").value = json;
return json;
}
// ── add / delete mount attachments ──────────────────────────────────────────────
function addAttachment(kind) {
if (!model) { setStatus("Load a def first."); return; }
const y = (model.body.half ? model.body.half[1] : 0.5);
const att = { kind, label: `${kind} ${model.attachments.filter((a) => a.kind === kind).length}`, position: [0, y, 0], rotation: [0, 0, 0, 1], scale: [0.4, 0.4, 0.4] };
model.attachments.push(att);
const m = makeMountMarker(att);
markers.push(m);
buildAttachList();
select(m);
}
function deleteSelected() {
if (!selected || selected.kind === "wheel") { setStatus("Pick a (non-wheel) attachment to delete."); return; }
const i = markers.indexOf(selected);
scene.remove(selected.object);
markers.splice(i, 1);
model.attachments.splice(model.attachments.indexOf(selected.att), 1);
select(null);
buildAttachList();
}
// ── status ─────────────────────────────────────────────────────────────────────
function setStatus(msg) {
const parts = [];
if (gltfRoot) parts.push(`model: ${restLocal.size} nodes, size ≈ ${carSize.toFixed(2)}`);
if (model) parts.push(`def: ${model.name} (${model.attachments.length} attachments)`);
if (msg) parts.unshift(msg);
$("status").textContent = parts.join(" · ");
}
// ── drop zones ───────────────────────────────────────────────────────────────
function wireDrop(el, handler) {
el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("over"); });
el.addEventListener("dragleave", () => el.classList.remove("over"));
el.addEventListener("drop", (e) => {
e.preventDefault(); el.classList.remove("over");
handler([...e.dataTransfer.files]);
});
}
wireDrop($("dropGltf"), (files) => { if (files.length) loadGltfFromFiles(files); });
wireDrop($("dropJson"), (files) => {
const f = files.find((x) => /\.json$/i.test(x.name)) || files[0];
if (!f) return;
f.text().then((txt) => { if (gltfRoot) applyDef(txt); else { pendingJson = txt; setStatus("Def loaded — drop the model next."); } });
});
// ── buttons / keys ───────────────────────────────────────────────────────────
$("attachList"); // ensure exists
document.querySelectorAll("[data-add]").forEach((b) => b.onclick = () => addAttachment(b.dataset.add));
$("delAttach").onclick = deleteSelected;
document.querySelectorAll("[data-mode]").forEach((b) => b.onclick = () => setMode(b.dataset.mode));
$("toggleSpin").onclick = () => { spinning = !spinning; $("toggleSpin").classList.toggle("active", spinning); };
$("copyJson").onclick = () => {
const json = exportJSON();
if (json) navigator.clipboard?.writeText(json).then(() => setStatus("Copied JSON to clipboard."), () => setStatus("Clipboard blocked — copy from the box below."));
};
function setMode(m) {
mode = m;
$("modePill").textContent = m;
document.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("active", b.dataset.mode === m));
if (selected && selected.kind !== "wheel") transform.setMode(m);
}
setMode("rotate");
$("toggleSpin").classList.add("active");
addEventListener("keydown", (e) => {
if (e.key === "w") setMode("translate");
else if (e.key === "e") setMode("rotate");
else if (e.key === "r") setMode("scale");
else if (e.key === "Escape") select(null);
});
// ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now();
function tick() {
requestAnimationFrame(tick);
const now = performance.now();
const dt = Math.min((now - last) / 1000, 0.05); last = now;
spinWheels(dt);
orbit.update();
renderer.render(scene, camera);
}
resize();
tick();
setStatus("Drop a model + a def to begin.");
// 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 (clustered
// lighting) 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 { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 Trail2D from "@woosh/meep-engine/src/engine/graphics/ecs/trail2d/Trail2D.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} Transform 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 Transform(), emitter);
child.transform.position.set(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` (Transform) is the
// editor's key; `boostMount` ([x,y,z]) is the legacy one. The Transform'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 (clustered lighting): lit only while boosting
this.boostLight = new Light();
this.boostLight.type.set(LightType.POINT);
this.boostLight.color.fromUint(controller.team.color);
this.boostLight.intensity.set(0);
this.boostLight.distance.set(11);
const lightNode = EntityNode.fromComponents(new Transform(), this.boostLight);
lightNode.transform.position.set(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;
});
this.trails = mounts.map((m) => {
const trail = new Trail2D();
trail.maxAge = 0.5;
trail.width = 0;
trail.textureURL = "./textures/trail/Circle_04.png";
trail.color.fromUint(controller.team.color);
const child = EntityNode.fromComponents(new Transform(), trail);
child.transform.position.set(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;
const r = tr.rotation;
_q.set(r.x, r.y, r.z, r.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.position.x, tr.position.y, tr.position.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) + 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: appear at speed / while boosting, fade otherwise
const fast = st.boosting || st.speed > 17;
const targetA = fast ? 0.5 : 0;
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);
trail.color.a += (targetA - trail.color.a) * 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 Transform — set fresh this frame
// by the chase camera — rather than the THREE camera's matrix, which lags a frame in
// preRender. The projection uses z² and |centre|², both sign-symmetric, so meep's vs
// THREE'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";
const DEG2RAD = Math.PI / 180;
export class DistanceHighlights {
/**
* @param {Engine} engine
* @param {import("@woosh/meep-engine/src/engine/ecs/transform/Transform.js").Transform} cameraTransform
* the camera entity's Transform (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 {() => ({x:number,y:number,z:number})} getCenter target world 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() {
const cam = this.engine.graphics.camera;
if (cam === null || cam === undefined || this.targets.length === 0) {
return;
}
// current world→camera matrix from the (just-updated) camera Transform
this.cameraTransform.updateMatrix();
m4_invert(this._view, this.cameraTransform.matrix);
const fl = 1 / Math.tan((cam.fov * DEG2RAD) / 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.x; this._sph[1] = c.y; this._sph[2] = c.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 Transform (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 Transform 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. Showcases clustered lighting by spawning many
// transient POINT lights that are removed once they fade.
import * as THREE from "three";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { 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.fromUint(colorHex);
light.intensity.set(peak);
light.distance.set(distance);
const t = new Transform();
t.position.set(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 Transform();
t.position.set(x, y, z);
if (rot) t.rotation.set(rot.x, rot.y, rot.z, rot.w);
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
// Transform (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 * as THREE from "three"; // geometry + materials only
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 { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { 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) => new THREE.MeshStandardMaterial({ 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 Transform();
t.position.set(pos[0], pos[1], pos[2]);
if (rot) t.rotation.set(rot.x, rot.y, rot.z, rot.w);
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) {
const mesh = ShadedGeometry.from(
new THREE.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 diffuse texture; the boost-pad
// motifs are projected FP Decals.) The plane uses a STANDARD material on purpose:
// Forward+ only applies clustered lights + projected decals to "lit" materials
// (MeshStandardMaterial / a lit ShaderMaterial — see isLitMaterial), so a Lambert
// floor would receive neither the pad decals nor the pad point lights.
addBox(ctx, {
pos: [0, -1, 0], half: [hw + gd, 1, hl + gd],
friction: def.floorFriction, visible: false,
});
{
const geo = new THREE.PlaneGeometry(W + gd * 2, L + gd * 2).rotateX(-Math.PI / 2);
// Run the material through the material manager so the Forward+ plugin's
// shader transform is applied — THAT is what injects the clustered-light +
// projected-decal code. The GLTF loader does this for model materials
// (GLTFAssetLoader: `materialManager.obtain(mat).getValue()`); a hand-made
// ShadedGeometry material skips it (build_three_object uses the raw material),
// so without this the floor receives NEITHER the pad point lights NOR the
// boost-pad decals projected onto it.
const floorMat = ctx.engine.graphics.getMaterialManager()
.obtain(new THREE.MeshStandardMaterial({ map: makeFieldTexture(def), roughness: 0.95, metalness: 0.0 }))
.getValue();
const mesh = ShadedGeometry.from(geo, floorMat);
mesh.setFlag(ShadedGeometryFlags.ReceiveShadow);
const t = new Transform();
t.position.set(0, 0.01, 0);
new Entity().add(t).add(mesh).build(ctx.ecd);
}
// ── pitch markings projected onto the floor as FP 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);
tint.emissive = new THREE.Color(team.color).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 FP 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 alpha-blended FP Decals — the same projection path the boost pads use,
// so they rely on the floor material having gone through the material manager (see
// arena.js) to actually receive projected decals.
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { 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.fromUint(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
const t = new Transform();
t.position.set(0, 0, 0); // centred on the ground → thin slab, won't catch the cars
t.rotation.copy(PROJECT_DOWN);
t.scale.set(footprint, footprint, FIELD.decalDepth);
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();
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.fromUint(FIELD.scuffColor);
decal.color.a = op; // tint.a scales the FP-decal blend → per-scuff strength
const t = new Transform();
t.position.set(x, 0, z); // centred on the ground (thin slab → won't catch the cars)
_dir.set(Math.sin(ang), 0, Math.cos(ang));
t.rotation.lookRotation(Vector3.down, _dir); // project down, rolled by `ang`
t.scale.set(size, size, FIELD.decalDepth);
new Entity().add(t).add(decal).build(ecd);
}
}
// Field-floor texture.
//
// The arena floor is one large flat box, which Meep's projected FPDecalSystem
// doesn't apply to (that path is for meep-material / GLTF geometry) and onto
// which transparent overlay quads don't reliably composite. So the floor's
// markings — panel grid, kick-off circle, goal lines, boost-pad rings — plus
// grungy tyre scuffs are baked straight into the floor's diffuse texture with a
// 2D canvas. Reliable, and it reads like a painted pitch.
import * as THREE from "three";
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 {THREE.CanvasTexture}
*/
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. (ACES tone mapping + RENDER.exposure
// now tame the overall brightness — see 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 FP 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 FP 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.)
const tex = new THREE.CanvasTexture(cv);
tex.anisotropy = 16;
tex.needsUpdate = true;
return tex;
}
// 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 Transform; 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 { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { BoostPad } from "./BoostPad.js";
import { BOOST } from "../tuning.js";
export class BoostPadSystem extends System {
dependencies = [BoostPad, Transform];
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, Transform));
}
}
// 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 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 * as THREE from "three"; // geometry + materials + texture loading only
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { 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 { Color } from "@woosh/meep-engine/src/core/color/Color.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).
const DECAL_ACTIVE = new Color();
DECAL_ACTIVE.fromUint(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 FP 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: tiles around X, transparent at the top → opaque at the
* bottom, so the cylinder reads as a glow rising from the floor and fading out. */
function glowTexture() {
const tex = new THREE.TextureLoader().load(GLOW_URL);
tex.wrapS = THREE.RepeatWrapping; // tile around the circumference
tex.wrapT = THREE.ClampToEdgeWrapping; // one span up the height
tex.repeat.set(PADS.glow.tile, 1);
return tex;
}
/** 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 {{ records: Array, list: Array, updateVisuals: () => void }}
*/
export function buildPads(ctx, { dims }) {
const { ecd } = ctx;
const { large, small } = padLayout(dims);
const glowTex = glowTexture();
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 Transform();
t.position.set(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 FP 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. Transform 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);
const dt = new Transform();
dt.position.set(x, 0, z); // box centred on the ground → only ±decalDepth/2 around the floor
dt.rotation.copy(PROJECT_DOWN);
dt.scale.set(footprint, footprint, PADS.decalDepth);
new Entity().add(dt).add(decal).build(ecd);
// ── glow: uncapped cylinder rising from the floor, additive + fading up ──
const glowGeo = new THREE.CylinderGeometry(glowCfg.radius, glowCfg.radius, glowCfg.height, 28, 1, true);
const glowMat = new THREE.MeshBasicMaterial({
map: glowTex, color: BODY_COLOR, transparent: true, opacity: glowCfg.opacity,
blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
});
const glow = ShadedGeometry.from(glowGeo, glowMat);
glow.clearFlag(ShadedGeometryFlags.CastShadow); // a translucent glow casts/receives no shadow
glow.clearFlag(ShadedGeometryFlags.ReceiveShadow);
const gt = new Transform();
gt.position.set(x, glowCfg.height * 0.5, z);
new Entity().add(gt).add(glow).build(ecd);
// ── orb: a small emissive sphere + a dim point light, hanging above ──
const orbGeo = new THREE.SphereGeometry(orbCfg.radius, 12, 12);
const orbMat = new THREE.MeshStandardMaterial({
color: ORB_COLOR, emissive: ORB_COLOR, emissiveIntensity: 1.7, roughness: 0.35, metalness: 0,
});
const orb = ShadedGeometry.from(orbGeo, orbMat);
orb.clearFlag(ShadedGeometryFlags.CastShadow); // a glowing light-orb shouldn't cast/receive shadow
orb.clearFlag(ShadedGeometryFlags.ReceiveShadow);
const light = new Light();
light.type.set(LightType.POINT);
light.color.fromUint(BODY_COLOR);
light.intensity.set(orbCfg.intensity);
light.distance.set(orbCfg.distance);
const ot = new Transform();
ot.position.set(x, orbCfg.height, z);
new Entity().add(ot).add(orb).add(light).build(ecd);
records.push({ pad, glow, orb, light, decal, baseIntensity: orbCfg.intensity });
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.
function updateVisuals() {
for (let i = 0; i < records.length; i++) {
const r = records[i];
const active = r.pad.cooldown <= 0;
r.glow.writeFlag(ShadedGeometryFlags.Visible, active);
r.orb.writeFlag(ShadedGeometryFlags.Visible, active);
r.light.intensity.set(active ? r.baseIntensity : 0);
r.decal.color.copy(active ? DECAL_ACTIVE : DECAL_COOLDOWN); // full vs dimmed marking
}
}
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);
const _ident = { x: 0, y: 0, z: 0, w: 1 };
/** car forward, flattened onto the ground plane and normalised, into `_fwd`. */
function readGroundForward(transform, fallbackZSign) {
const r = transform.rotation;
_q.set(r.x, r.y, r.z, r.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.position;
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.x, p.y + 0.4, p.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;
};
const n = physics.overlap(_sphere, b, _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 p = ctrl.transform.position;
const intent = ctrl.intent;
if (!ai.backoff) {
// not yet backing off — accumulate "wedged" time, then commit
const distToBall = Math.hypot(b.x - p.x, b.z - p.z);
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 = p.x;
ai.backoffZ = p.z;
const fwd = readGroundForward(ctrl.transform, ai.attackZSign);
const dx = b.x - p.x, dz = b.z - p.z, 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(p.x - ai.backoffX, p.z - 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.position;
const horiz = Math.hypot(b.x - p.x, b.z - p.z);
if (b.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.position.x, tr.position.y, tr.position.z);
// ball position, led a little by its velocity
const b = ai.getBall();
_ball.set(b.x, b.y, b.z);
if (ai.getBallVel !== null) {
const v = ai.getBallVel();
_ball.x += v.x * AI.leadTime;
_ball.z += v.z * 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 p = ctrl.transform.position;
const b = ai.getBall();
if (Math.hypot(b.x - p.x, b.z - p.z) < 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 - p.x, pd.z - p.z) - (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 - p.x, dz = best.z - p.z;
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 {(() => {x:number,y:number,z:number})|null} ball world position */
this.getBall = null;
/** @type {(() => {x:number,y:number,z:number})|null} ball velocity (for lead) */
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 { 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 { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 = { x: 0, y: 0, z: 0 };
const _point = { x: 0, y: 0, z: 0 };
const _impulse = { x: 0, y: 0, z: 0 };
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 {Transform} 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.position.x, y: transform.position.y, z: transform.position.z, yaw: 0 };
// lightweight debug counters (handy while tuning)
this.dbg = { jumps: 0, doubles: 0, dodges: 0, flipCancels: 0 };
// Trail2Ds 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 r = this.transform.rotation;
_q.set(r.x, r.y, r.z, r.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, { x, y, z }, { x: _q.x, y: _q.y, z: _q.z, w: _q.w });
this.body.linearVelocity.set(0, 0, 0);
this.body.angularVelocity.set(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.x, lv.y, lv.z);
_av.set(av.x, av.y, av.z);
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;
av.set(
av.x - (_right.x * pr + _fwd.x * rr) * d,
av.y - (_right.y * pr + _fwd.y * rr) * d,
av.z - (_right.z * pr + _fwd.z * rr) * d,
);
_av.set(av.x, av.y, av.z);
}
// ── 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.x = _fwd.x * BOOST.force; _force.y = _fwd.y * BOOST.force; _force.z = _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.x = N.x * AIR.jumpHoldForce; _force.y = N.y * AIR.jumpHoldForce; _force.z = 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.x * av.x + av.y * av.y + av.z * av.z);
const cap = AIR.maxAngularSpeed;
if (a2 > cap * cap) {
const s = cap / Math.sqrt(a2);
av.set(av.x * s, av.y * s, av.z * 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) {
av.set(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.x, av.y, av.z);
// 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);
}
av.set(_av.x, _av.y, _av.z);
}
_doGroundJump(physics, N) {
const m = this.body.mass;
_impulse.x = N.x * m * AIR.jumpSpeed; _impulse.y = N.y * m * AIR.jumpSpeed; _impulse.z = 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.x = _up.x * m * AIR.doubleJumpSpeed;
_impulse.y = _up.y * m * AIR.doubleJumpSpeed;
_impulse.z = _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;
lv.set(lv.x + _driveDir.x * AIR.dodgeSpeed,
lv.y + _driveDir.y * AIR.dodgeSpeed,
lv.z + _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;
av.set(_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;
const p = tr.position;
// world-down ray from the car centre → "touching the ground"?
this._ray.setOrigin(p.x, p.y, p.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.x, this._hit.normal.y, this._hit.normal.z);
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 = this.body.angularVelocity;
_flipState.righting = this._righting;
computeFlipAssist(_flipState, _flipOut);
this._righting = _flipOut.phase === "right"; // hysteresis for next step
if (_flipOut.phase === "right") {
_force.x = _flipOut.tx; _force.y = _flipOut.ty; _force.z = _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.x = _flipOut.fx; _force.y = _flipOut.fy; _force.z = _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 p = this.transform.position;
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(p.x) < b.goalHalfWidth;
const zLimit = inGoalMouth ? b.hl + b.goalDepth + 1.5 : b.hl + 0.5;
const out = Math.abs(p.x) > b.hw + 2 || Math.abs(p.z) > zLimit
|| p.y < -6 || p.y > 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 Transform();
transform.position.set(position[0], position[1], position[2]);
{
const q = new Quaternion().fromAxisAngle(WORLD_UP, yaw);
transform.rotation.set(q.x, q.y, q.z, q.w);
}
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = def.body.mass;
const inv = boxInverseInertia(def.body.mass, hx, hy, hz);
body.inverseInertiaLocal.set(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 Transform(), art);
const aq = new Quaternion().fromAxisAngle(WORLD_UP, def.model.yaw);
artNode.transform.scale.set(def.model.scale, def.model.scale, def.model.scale);
artNode.transform.rotation.set(aq.x, aq.y, aq.z, aq.w);
artNode.transform.position.set(def.model.offset[0], def.model.offset[1], def.model.offset[2]);
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 Transform shape
* (`{position, rotation, scale}`). Lets the runtime consume both the hand-written
* defs and whatever the editor (editor.html) exports, without a flag day.
* @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/THREE dependencies so it can
// be unit-tested in isolation (see test/flipAssist.test.js).
//
// 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 { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 = [Transform];
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 (they aren't standalone ECS
// entities, so they can't be re-parented), 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.
//
// All maths uses meep's own types: Vector3 / Quaternion plus the array-based
// mat4 helpers (meep has no Matrix4 class) to compose the wheel-parent chain and
// pull its rotation back out.
import { Vector3 } from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Quaternion } from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { compose_matrix4_array } from "@woosh/meep-engine/src/core/geom/3d/mat4/compose_matrix4_array.js";
import { decompose_matrix_4_array } from "@woosh/meep-engine/src/core/geom/3d/mat4/decompose_matrix_4_array.js";
import { m4_multiply } from "@woosh/meep-engine/src/core/geom/3d/mat4/m4_multiply.js";
import Name from "@woosh/meep-engine/src/engine/ecs/name/Name.js";
import { CAR } from "../tuning.js";
// scratch 4x4 matrices (column-major float arrays) + vectors/quaternions
const _local = new Float64Array(16);
const _acc = new Float64Array(16);
const _art = new Float64Array(16);
const _parentMat = new Float64Array(16);
const _p = new Vector3();
const _s = new Vector3();
const _parentQuat = new Quaternion();
const _axleQ = new Quaternion();
const _spin = new Quaternion();
const _steer = new Quaternion();
const _rot = new Quaternion();
const CAR_UP = new Vector3(0, 1, 0);
// local TRS matrix of a node, into `out` (meep mat4 array).
function localMatrix(node, out) {
const t = node.transform;
compose_matrix4_array(out, t.position, t.rotation, t.scale);
return out;
}
// matrix of `node` in the frame of the loaded-model root (walk .parent to the top,
// including the root's own transform): out = M_root · … · M_node — i.e. the frame
// the art node lives in. Premultiply chain, matching THREE.Matrix4.premultiply.
function matrixInModel(node, out) {
let started = false;
for (let n = node; n !== null; n = n.parent) {
localMatrix(n, _local);
if (!started) { out.set(_local); started = true; }
else { m4_multiply(out, _local, out); } // out = M_n · out
}
return out;
}
export class WheelRig {
/** @param {CarController} car needs `vehicle`, `artNode`, `artSG`, `def.wheelNodes` */
constructor(car) {
this.car = car;
this.bound = false;
this.wheels = [];
}
_tryBind() {
const car = this.car;
const sg = car.artSG;
if (sg === undefined || sg === null || sg.__node === null || sg.__node === undefined) return false;
const names = car.def.wheelNodes;
if (!names || names.length === 0) { this.bound = true; return true; }
const norm = (str) => str.replace(/\s+/g, "_");
const byName = new Map();
sg.__node.traverse((n) => {
const nm = n.entity.getComponent(Name);
if (nm !== null) byName.set(norm(nm.getValue()), n);
});
localMatrix(car.artNode, _art); // art (scale/yaw/offset) relative to chassis
// 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 node = byName.get(norm(name));
if (node === undefined) continue;
const t = node.transform;
const restPos = new Vector3(t.position.x, t.position.y, t.position.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();
// wheel parent rotation in the chassis frame — used for the suspension
// TRAVEL direction (car-up) and the front-wheel STEER axis. Its scale
// is unreliable, so metres→parent-units is calibrated from the mount.
if (node.parent !== null) {
matrixInModel(node.parent, _acc);
m4_multiply(_parentMat, _art, _acc); // _parentMat = M_art · parentChain
} else {
_parentMat.set(_art);
}
decompose_matrix_4_array(_parentMat, _p, _parentQuat, _s);
const invParentQuat = _parentQuat.clone().invert();
const upInParent = CAR_UP.clone().applyQuaternion(invParentQuat).normalize();
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; // parent-local units per metre
this.wheels.push({ node, restPos, restQuat, axleLocal, upInParent, metresToParent, wheel, steered: wheel.steered, spinSign });
}
});
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.node.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.position.set(
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.rotation.set(_rot.x, _rot.y, _rot.z, _rot.w);
}
}
}
// The match ball — a light, bouncy sphere the cars launch around.
import * as THREE from "three";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { 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 Transform();
transform.position.set(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²
body.inverseInertiaLocal.set(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 (reflects the environment map)
const mesh = ShadedGeometry.from(
new THREE.SphereGeometry(r, 28, 20),
new THREE.MeshStandardMaterial({ 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 { EquirectangularReflectionMapping, PMREMGenerator, ACESFilmicToneMapping } from "three";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import { SGMeshSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.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/graphics/particles/ecs/ParticleEmitterSystem.js";
import Trail2DSystem from "@woosh/meep-engine/src/engine/graphics/ecs/trail2d/Trail2DSystem.js";
import { InterpolationSystem } from "@woosh/meep-engine/src/engine/interpolation/InterpolationSystem.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { GLTFAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFAssetLoader.js";
import { TextureAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/texture/TextureAssetLoader.js";
import { ImageRGBADataLoader } from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageRGBADataLoader.js";
import { FPDecalSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/FPDecalSystem.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 { AmbientOcclusionPostProcessEffect } from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.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 { 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 { SoundEmitterSystem } from "@woosh/meep-engine/src/engine/sound/ecs/emitter/SoundEmitterSystem.js";
import { SoundEmitter } from "@woosh/meep-engine/src/engine/sound/ecs/emitter/SoundEmitter.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 Trail2D from "@woosh/meep-engine/src/engine/graphics/ecs/trail2d/Trail2D.js";
import { BALL, TEAM, ARENA, MATCH, HIGHLIGHT, RENDER, BOOST } from "./tuning.js";
// ─── bootstrap ───────────────────────────────────────────────────────────────
const vehicleSystem = new VehicleSystem();
const boostPadSystem = new BoostPadSystem();
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
const gltf = new GLTFAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltf);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltf);
config.addLoader(GameAssetType.Texture, new TextureAssetLoader());
config.addLoader(GameAssetType.Image, new ImageRGBADataLoader()); // FPDecalSystem loads each decal.uri as an Image
config.addSystem(new ShadedGeometrySystem(engine));
config.addSystem(new SGMeshSystem(engine));
// mesh outline highlights: SGMeshHighlightSystem propagates a Highlight on a
// GLTF (SGMesh) entity down to its leaf ShadedGeometry nodes; the
// ShadedGeometryHighlightSystem renders the outline (and the ball's directly).
config.addSystem(new SGMeshHighlightSystem());
config.addSystem(new ShadedGeometryHighlightSystem(engine));
config.addSystem(new FPDecalSystem(engine));
config.addSystem(new TransformAttachmentSystem());
config.addSystem(new ParticleEmitterSystem(engine));
config.addSystem(new Trail2DSystem(engine));
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));
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: false,
focus: new Vector3(0, 1, 0),
distance: 16,
cameraAutoClip: true,
cameraController: false,
showFps: false,
});
await EngineHarness.buildLights({
engine,
castShadow: true,
shadowmapResolution: 2048,
sunShadowDistance: 150,
sunIntensity: 1.25,
ambientIntensity: 0.05,
});
// ─── tone mapping ─────────────────────────────────────────────────────────────
// GraphicsEngine hard-sets NoToneMapping at init; override it to ACES filmic so
// the HDR-sky-lit arena and the bright VFX roll off instead of clipping to white.
// The FP materials are MeshStandardMaterial (+ the Forward+ onBeforeCompile), so
// they carry THREE's <tonemapping_fragment> and honour this setting.
engine.graphics.renderer.toneMapping = ACESFilmicToneMapping;
engine.graphics.renderer.toneMappingExposure = RENDER.toneMappingExposure;
// ─── HDR environment ─────────────────────────────────────────────────────────
// One equirectangular HDR used as the PBR env map (PMREM-filtered → reflections
// and image-based fill on the cars and the gray-boxed arena) and as the sky
// behind the dome. Same pattern as the chess example.
new RGBELoader().load("./noon_grass_2k.hdr", (hdr) => {
hdr.mapping = EquirectangularReflectionMapping;
engine.graphics.scene.background = hdr;
const pmrem = new PMREMGenerator(engine.graphics.renderer);
engine.graphics.scene.environment = pmrem.fromEquirectangular(hdr).texture;
pmrem.dispose();
});
const ecd = engine.entityManager.dataset;
const physics = engine.entityManager.getSystem(PhysicsSystem);
vehicleSystem.physics = physics;
// runtime-attached component types
ecd.registerComponentType(HeadsUpDisplay);
ecd.registerComponentType(ViewportPosition);
ecd.registerComponentType(GUIElement);
ecd.registerComponentType(Light);
ecd.registerComponentType(ParticleEmitter);
ecd.registerComponentType(Trail2D);
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 emitter system (which auto-registers the .wav asset loader).
engine.entityManager.addSystem(new SoundEmitterSystem(
engine.assetManager, engine.sound.context.destination, engine.sound.context,
));
ecd.registerComponentType(SoundEmitter);
const ctx = { ecd, physics, engine }; // engine: lets builders run materials through the material manager (FP decal/light injection)
// ─── 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, Transform);
engine.graphics.on.preRender.add(() => {
camT.position.set(0, 36, 0.01); // below the dome ceiling
camT.rotation.set(0.70710678, 0, 0, 0.70710678); // look straight down (−Y)
});
} else {
createChaseCamera({
engine,
ecd,
getCarTransform: () => player.transform,
getCarVelocity: () => player.body.linearVelocity,
getBall: () => ball.transform.position,
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.position;
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 + opponent, fading in with distance ──
// A Highlight on the ball (ShadedGeometry) and the opponent's GLTF (SGMesh) — the
// SGMeshHighlightSystem pushes the latter down onto the car's leaf meshes. The
// outline opacity is driven each frame by DistanceHighlights off the projected
// (sphere) size, so distant targets light up and close ones don't.
const makeHighlight = (hex) => {
const hl = new Highlight();
const def = hl.createElement();
def.color.fromUint(hex); // outline colour from the team/ball hex
def.color.a = 0; // opacity is driven each frame by DistanceHighlights
return { hl, def };
};
const ballHighlight = makeHighlight(HIGHLIGHT.ballColor);
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, Transform);
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.position, BALL.radius)
.track(oppHighlight.def, () => ai.transform.position, 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 = buildPads(ctx, { dims: level.dims });
boostPadSystem.cars = [player, ai];
boostPadSystem.onPickup = (c, pad, t) => {
const p = t.position;
// 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(p.x, p.y + 1, p.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 }));
const ballTrail = new Trail2D();
ballTrail.maxAge = 0.45;
ballTrail.width = 0.6;
ballTrail.textureURL = "./textures/trail/Circle_04.png";
ballTrail.color.set(0.7, 0.85, 1.0, 0);
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;
const cp = tr.position;
const dx = cp.x - cx, dy = cp.y - cy, dz = cp.z - cz; // car ← blast (away dir)
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 = { x: dirX / n * v * m, y: up / n * v * m, z: 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, { x: _support[0], y: _support[1], z: _support[2] });
}
}
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.position; // ball position = goal location
goalBlast(bp.x, bp.y, bp.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.x, bv.y, bv.z);
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.position;
const k = BALL.radius / speed; // bv * k == normalize(bv) * radius
const hx = bp.x + bv.x * k, hy = bp.y + bv.y * k, hz = bp.z + bv.z * 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.position;
if (w.c.dbg.jumps !== w.jumps || w.c.dbg.doubles !== w.doubles) sfx.jump(p.x, p.y, p.z);
if (w.c.dbg.dodges !== w.dodges) sfx.dodge(p.x, p.y, p.z);
w.jumps = w.c.dbg.jumps; w.dodges = w.c.dbg.dodges; w.doubles = w.c.dbg.doubles;
}
// ball trail fades in above a speed threshold
const bv = ball.body.linearVelocity;
const fast = Math.hypot(bv.x, bv.y, bv.z) > BALL.trailMinSpeed;
const ta = fast ? 0.6 : 0;
ballTrail.color.a += (ta - ballTrail.color.a) * 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 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, { x: bs[0], y: bs[1], z: bs[2] }, { x: 0, y: 0, z: 0, w: 1 });
this.ball.body.linearVelocity.set(0, 0, 0);
this.ball.body.angularVelocity.set(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 / tone mapping ─────────────────────────────────────────────────
// Meep's GraphicsEngine ships with NO tone mapping (renderer.toneMapping =
// NoToneMapping, outputEncoding = Linear) — so the HDR-sky-lit arena renders
// linearly and any bright surface hard-clips to flat white (which is why setting
// `toneMappingExposure` alone did nothing). We switch the renderer to ACES filmic
// tone mapping: bright things (the sky-lit floor, boost flames, orbs, the goal
// explosion, the pad decals) now roll off smoothly with their colour retained
// instead of clipping, and exposure finally bites for overall brightness control.
export const RENDER = {
// ACES exposure — raise to brighten, lower to darken the whole image. The arena
// floor faces straight up into a very bright noon-sky HDR, so at 1.0 it (and the
// sky) read as a washed pale blue; 0.7 pulls everything back to a saturated,
// "sensible" range while the filmic curve keeps the bright VFX from clipping.
toneMappingExposure: 0.7,
};
// ─── 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 FP 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)
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 FP 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 FP 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).
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 },
};
// Headless integration test for the AI opponent.
//
// Boots the REAL simulation in Node — EntityManager + PhysicsSystem +
// VehicleSystem + meep's BehaviorSystem — with NO renderer and NO GLTF (the car is
// built with `withArt:false`). A flat static floor, one AI car, and a fixed ball
// target. Then it just runs `em.simulate` and checks the behaviour tree actually
// drives the car toward the ball. This is the "you can test the sim yourself"
// proof: physics + steering + the behaviour tree all run without graphics.
import { test } from "node:test";
import assert from "node:assert/strict";
import { EntityManager } from "@woosh/meep-engine/src/engine/ecs/EntityManager.js";
import { EntityComponentDataset } from "@woosh/meep-engine/src/engine/ecs/EntityComponentDataset.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { Interpolated } from "@woosh/meep-engine/src/engine/interpolation/Interpolated.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 { VehicleSystem } from "../src/vehicles/VehicleSystem.js";
import { buildCar } from "../src/vehicles/CarController.js";
import { AiControl } from "../src/vehicles/AiControl.js";
import { buildAiBehavior } from "../src/vehicles/AiBehaviors.js";
import { CAR_DEFS } from "../src/vehicles/carDefs.js";
import { TEAM } from "../src/tuning.js";
const STEP = 1 / 60;
async function bootWorld() {
const em = new EntityManager();
em.attachDataset(new EntityComponentDataset());
const vehicleSystem = new VehicleSystem();
em.addSystem(vehicleSystem); // before physics
const physics = new PhysicsSystem();
em.addSystem(physics);
em.addSystem(new BehaviorSystem(null)); // our AI leaves don't use ctx.engine
await new Promise((res, rej) => em.startup(res, rej));
vehicleSystem.physics = physics;
const ecd = em.dataset;
// components we attach by hand / that no registered system references
ecd.registerComponentType(Collider);
ecd.registerComponentType(Interpolated);
ecd.registerComponentType(AiControl);
ecd.registerComponentType(BehaviorComponent);
// flat static floor (top at y = 0)
const ft = new Transform(); ft.position.set(0, -0.5, 0);
const fb = new RigidBody(); fb.kind = BodyKind.Static;
const fc = new Collider(); fc.shape = BoxShape3D.from(90, 0.5, 90); fc.friction = 0.95;
new Entity().add(ft).add(fb).add(fc).build(ecd);
return { em, ecd, physics, vehicleSystem };
}
test("the AI behaviour tree drives the car toward the ball (headless: physics + steering)", async () => {
const { em, ecd, physics, vehicleSystem } = await bootWorld();
// headless AI car (no GLTF art)
const car = buildCar({ ecd, physics }, {
def: CAR_DEFS.perrier, team: TEAM.orange, position: [-30, 1, 0], yaw: 0, withArt: false,
});
car.bounds = null;
vehicleSystem.addCar(car);
// fixed ball target at the centre, on the ground
const ballPos = { x: 0, y: 1, z: 0 };
const ai = new AiControl();
ai.controller = car;
ai.getBall = () => ballPos;
ai.getBallVel = () => ({ x: 0, y: 0, z: 0 });
ai.attackZSign = TEAM.orange.attackZSign;
ai.goalZ = 58;
ai.physics = physics;
ai.ballBody = null;
ecd.addComponentToEntity(car.entity, ai);
ecd.addComponentToEntity(car.entity, buildAiBehavior());
const distToBall = () => {
const p = car.transform.position;
return Math.hypot(p.x - ballPos.x, p.z - ballPos.z);
};
const startDist = distToBall();
let topSpeed = 0;
for (let i = 0; i < 300; i++) { // 5 s
em.simulate(STEP);
if (car.state.speed > topSpeed) topSpeed = car.state.speed;
}
const endDist = distToBall();
assert.ok(Number.isFinite(endDist), "car position stays finite (no NaN blow-up)");
assert.ok(topSpeed > 5, `AI actually accelerates the car (top speed ${topSpeed.toFixed(1)} > 5)`);
assert.ok(endDist < startDist - 12, `AI closes on the ball (${startDist.toFixed(1)} → ${endDist.toFixed(1)})`);
});
// Headless test for the air-dodge direction.
//
// A directional double-jump (dodge) must lunge to the SAME side the car yaws
// toward in the air — pressing A (left) should dodge left, D (right) should dodge
// right. Both derive from `intent.turn` (+1 = D, −1 = A); the air yaw negates it,
// so the dodge must negate it too or the two disagree (the reported "dodges are
// reversed" bug). This test pins that consistency by calling the dodge directly
// and checking its lateral push shares a side with the yaw.
import { test } from "node:test";
import assert from "node:assert/strict";
import { EntityManager } from "@woosh/meep-engine/src/engine/ecs/EntityManager.js";
import { EntityComponentDataset } from "@woosh/meep-engine/src/engine/ecs/EntityComponentDataset.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { Interpolated } from "@woosh/meep-engine/src/engine/interpolation/Interpolated.js";
import { VehicleSystem } from "../src/vehicles/VehicleSystem.js";
import { buildCar } from "../src/vehicles/CarController.js";
import { CAR_DEFS } from "../src/vehicles/carDefs.js";
import { TEAM } from "../src/tuning.js";
async function bootWorld() {
const em = new EntityManager();
em.attachDataset(new EntityComponentDataset());
const vehicleSystem = new VehicleSystem();
em.addSystem(vehicleSystem);
const physics = new PhysicsSystem();
em.addSystem(physics);
await new Promise((res, rej) => em.startup(res, rej));
vehicleSystem.physics = physics;
const ecd = em.dataset;
ecd.registerComponentType(Collider);
ecd.registerComponentType(Interpolated);
const ft = new Transform(); ft.position.set(0, -0.5, 0);
const fb = new RigidBody(); fb.kind = BodyKind.Static;
const fc = new Collider(); fc.shape = BoxShape3D.from(90, 0.5, 90);
new Entity().add(ft).add(fb).add(fc).build(ecd);
return { em, ecd, physics, vehicleSystem };
}
test("air dodge lunges to the same side the air-yaw turns (A=left, D=right, not reversed)", async () => {
const { ecd, physics, vehicleSystem } = await bootWorld();
const car = buildCar({ ecd, physics }, {
def: CAR_DEFS.octane, team: TEAM.blue, position: [0, 12, 0], yaw: 0, withArt: false,
});
car.bounds = null;
vehicleSystem.addCar(car);
// D = right (+1), A = left (−1)
for (const { turn, label } of [{ turn: 1, label: "D" }, { turn: -1, label: "A" }]) {
// identity rotation (nose +Z, up +Y, right +X), airborne, dodge available
car.transform.rotation.set(0, 0, 0, 1);
car.body.linearVelocity.set(0, 0, 0);
car.body.angularVelocity.set(0, 0, 0);
car._hasJumped = true; car._usedSecond = false; car._airTime = 1; car._dodgeTimer = 0;
car.intent.forward = 0; car.intent.turn = turn; car.intent.roll = 0;
car._readBasis(); // populate the car's basis from the pose
car._doSecondJump(physics); // → directional dodge (forward=0, |turn|=1)
const lv = car.body.linearVelocity; // dodge sets velocity directly
// The air yaw applies a rotation of sign(-turn) about car-up; that turns the
// nose toward (up × fwd). At identity up×fwd = +X, so the "yaw side" along +X
// is sign(-turn). The lateral dodge (along +X here) must share that sign.
const yawSideX = -turn; // (up × fwd)·right = +1 at identity
assert.ok(Math.sign(lv.x) === Math.sign(yawSideX) && Math.abs(lv.x) > 1,
`${label}: dodge must lunge to the yaw side (lv.x=${lv.x.toFixed(2)}, expected sign ${Math.sign(yawSideX)})`);
// pressing a pure left/right should not throw the car forward/back
assert.ok(Math.abs(lv.z) < 1e-6, `${label}: a pure lateral dodge has no forward/back component (lv.z=${lv.z.toFixed(2)})`);
}
});
// Headless tests for the boost-pad pickup system (src/pads/BoostPadSystem.js).
//
// Pickup is now EVENT-DRIVEN: in-game each pad is a static IsSensor cylinder and
// the physics broadphase reports car↔pad overlaps via each car entity's `PhysicsEvents.ContactBegin`,
// which calls `system.handleContact`. These tests drive that handler directly with
// synthetic contacts (no renderer / physics world needed) and exercise the cooldown
// respawn via `em.simulate` (the system's only per-frame work). Cars are real
// placeholder entities carrying a `boost` field (contacts always reference real
// entities, and the handler does getComponent(carEnt, BoostPad)).
import { test } from "node:test";
import assert from "node:assert/strict";
import { EntityManager } from "@woosh/meep-engine/src/engine/ecs/EntityManager.js";
import { EntityComponentDataset } from "@woosh/meep-engine/src/engine/ecs/EntityComponentDataset.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { BoostPad } from "../src/pads/BoostPad.js";
import { BoostPadSystem } from "../src/pads/BoostPadSystem.js";
import { BOOST, PADS } from "../src/tuning.js";
const STEP = 1 / 60;
async function boot() {
const em = new EntityManager();
em.attachDataset(new EntityComponentDataset());
const system = new BoostPadSystem();
em.addSystem(system); // registers BoostPad + Transform
await new Promise((res, rej) => em.startup(res, rej));
return { em, ecd: em.dataset, system };
}
function addPad(ecd, { amount, respawn, radius, big = false }) {
const t = new Transform();
t.position.set(0, PADS.height / 2, 0);
const pad = new BoostPad();
pad.amount = amount; pad.respawnTime = respawn; pad.radius = radius; pad.big = big;
const entity = new Entity().add(t).add(pad).build(ecd);
return { pad, entity };
}
// a real entity to stand in for a car body, carrying a mutable boost level
function addCar(ecd, boost) {
const entity = new Entity().add(new Transform()).build(ecd);
return { entity, boost };
}
const smallPad = (ecd) => addPad(ecd, { amount: PADS.smallAmount, respawn: PADS.smallRespawn, radius: PADS.smallRadius });
const sim = (em, seconds) => { for (let i = 0; i < Math.round(seconds / STEP); i++) em.simulate(STEP); };
test("car entering an active pad's sensor grants its boost and depletes the pad", async () => {
const { ecd, system } = await boot();
const { pad, entity } = smallPad(ecd);
const c = addCar(ecd, 5);
system.cars = [c];
system.handleContact({ entityA: entity, entityB: c.entity });
assert.equal(c.boost, 5 + PADS.smallAmount, "boost increased by the small amount");
assert.equal(pad.active, false, "pad depleted");
assert.ok(pad.cooldown > 0, "pad on respawn cooldown");
});
test("contact order doesn't matter (pad can be entityA or entityB)", async () => {
const { ecd, system } = await boot();
const { pad, entity } = smallPad(ecd);
const c = addCar(ecd, 5);
system.cars = [c];
system.handleContact({ entityA: c.entity, entityB: entity }); // reversed
assert.equal(c.boost, 5 + PADS.smallAmount);
assert.equal(pad.active, false);
});
test("a depleted pad ignores contacts (no double-collect)", async () => {
const { ecd, system } = await boot();
const { pad, entity } = smallPad(ecd);
pad.cooldown = pad.respawnTime; // already taken
const c = addCar(ecd, 5);
system.cars = [c];
system.handleContact({ entityA: entity, entityB: c.entity });
assert.equal(c.boost, 5, "no boost from a depleted pad");
});
test("a full tank still consumes the pad (deny mechanic)", async () => {
const { ecd, system } = await boot();
const { pad, entity } = smallPad(ecd);
const c = addCar(ecd, BOOST.max);
system.cars = [c];
system.handleContact({ entityA: entity, entityB: c.entity });
assert.equal(c.boost, BOOST.max, "boost can't exceed max");
assert.equal(pad.active, false, "pad consumed anyway — denying it to opponents");
});
test("a large pad fills the tank and clamps to max", async () => {
const { ecd, system } = await boot();
const { entity } = addPad(ecd, { amount: PADS.largeAmount, respawn: PADS.largeRespawn, radius: PADS.largeRadius, big: true });
const c = addCar(ecd, 30);
system.cars = [c];
system.handleContact({ entityA: entity, entityB: c.entity });
assert.equal(c.boost, BOOST.max, "filled to max, not over");
});
test("a contact with a non-car (e.g. the ball) is ignored", async () => {
const { ecd, system } = await boot();
const { pad, entity } = smallPad(ecd);
const other = new Entity().add(new Transform()).build(ecd); // a real entity that isn't a registered car
system.cars = [addCar(ecd, 5)];
system.handleContact({ entityA: entity, entityB: other });
assert.equal(pad.active, true, "pad not consumed by a non-car overlap");
});
test("a depleted pad re-activates after its respawn time (cooldown tick)", async () => {
const { em, ecd, system } = await boot();
const { pad, entity } = smallPad(ecd);
const c = addCar(ecd, 5);
system.cars = [c];
system.handleContact({ entityA: entity, entityB: c.entity });
assert.equal(pad.active, false);
sim(em, PADS.smallRespawn + 0.2);
assert.equal(pad.active, true, "pad re-activated after respawn time");
});
// Tests for the editor's pure car-def I/O + axle maths (src/editor/carDefIO.js).
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyQuat, axleFromQuat, defToModel, modelToDef } from "../src/editor/carDefIO.js";
const close = (a, b, eps = 1e-4) => Math.abs(a - b) <= eps;
const vclose = (a, b, eps = 1e-4) => a.length === b.length && a.every((v, i) => close(v, b[i], eps));
test("applyQuat: identity leaves the vector", () => {
assert.ok(vclose(applyQuat([0, 0, 0, 1], [1, 2, 3]), [1, 2, 3]));
});
test("axleFromQuat: identity → +X", () => {
assert.ok(vclose(axleFromQuat([0, 0, 0, 1]), [1, 0, 0]));
});
test("axleFromQuat: 90° about +Z maps +X → +Y", () => {
const s = Math.SQRT1_2;
assert.ok(vclose(axleFromQuat([0, 0, s, s]), [0, 1, 0]));
});
test("axleFromQuat: the octane left-wheel quaternion → its measured axle", () => {
// [0,0,0.4362,0.8999] should rotate +X to ~[0.619,0.785,0]
assert.ok(vclose(axleFromQuat([0, 0, 0.4362, 0.8999]), [0.619, 0.785, 0], 2e-3));
});
test("defToModel: old-shape def → editable Transform attachments", () => {
const def = {
id: "octane",
model: { scale: 0.0258, yaw: -Math.PI / 2, 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]],
wheelNodes: [
[{ name: "FL", spin: [0, 0, 0.4362, 0.8999] }],
[{ name: "FR", spin: [0, 0, -0.8999, 0.4362] }],
],
exhausts: [{ position: [-0.34, -0.12, -1.72], size: 1.0 }],
trailMounts: [[-0.72, 0.05, -1.7], [0.72, 0.05, -1.7]],
boostMount: [0, -0.05, -1.78],
headlights: [[-0.6, 0.05, 1.72]],
};
const m = defToModel(def);
const wheels = m.attachments.filter((a) => a.kind === "wheel");
assert.equal(wheels.length, 2);
assert.ok(vclose(wheels[0].position, [-0.86, -0.18, 1.18]));
assert.ok(vclose(wheels[0].rotation, [0, 0, 0.4362, 0.8999]));
assert.deepEqual(wheels[0].nodes, ["FL"]);
const ex = m.attachments.filter((a) => a.kind === "exhaust");
assert.equal(ex.length, 1);
assert.ok(vclose(ex[0].scale, [1, 1, 1]), "size → uniform scale");
assert.equal(m.attachments.filter((a) => a.kind === "trail").length, 2);
assert.equal(m.attachments.filter((a) => a.kind === "headlight").length, 1);
assert.equal(m.attachments.filter((a) => a.kind === "boost").length, 1);
});
test("modelToDef round-trips a Transform shape and is re-loadable", () => {
const def = {
id: "perrier",
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]],
wheelNodes: [[{ name: "dFLWheel", spin: [0, 0, -0.8999, 0.4362] }, { name: "dFLHub", spin: [0, 0, -0.8999, 0.4362] }]],
exhausts: [{ position: [0, 0.05, -1.7], size: 1.1 }],
};
const exported = modelToDef(defToModel(def));
// wheels → runtime shape: positions array + wheelNodes [[{name,spin}]]
assert.equal(exported.wheels.length, 1);
assert.ok(vclose(exported.wheels[0], [-0.92, -0.2, 1.2]));
assert.deepEqual(exported.wheelNodes[0].map((p) => p.name), ["dFLWheel", "dFLHub"]);
assert.ok(exported.wheelNodes[0].every((p) => vclose(p.spin, [0, 0, -0.8999, 0.4362])), "every part gets the wheel's spin");
assert.equal(exported.wheelSpin, 1);
assert.ok(Array.isArray(exported.exhausts[0].rotation) && exported.exhausts[0].rotation.length === 4);
// re-load the exported def — attachments survive
const m2 = defToModel(exported);
assert.equal(m2.attachments.filter((a) => a.kind === "wheel").length, 1);
assert.equal(m2.attachments.filter((a) => a.kind === "exhaust").length, 1);
const w = m2.attachments.find((a) => a.kind === "wheel");
assert.ok(vclose(w.rotation, [0, 0, -0.8999, 0.4362]));
assert.deepEqual(w.nodes, ["dFLWheel", "dFLHub"]);
});
// Tests for the car flip-assist (src/vehicles/flipAssist.js).
//
// Run with: npm test (node --test)
//
// Two layers:
// 1. DECISION tests — call computeFlipAssist() directly and assert the chosen
// phase and the exact force/torque it returns for hand-built car states,
// including every "in the air → do nothing" case.
// 2. INTEGRATION tests — a small, faithful angular-only rigid-body sim (using
// the Octane's real inverse inertia and the global angular-speed clamp) that
// applies the assist each step and checks the car actually rights itself when
// grounded, and does NOT when airborne.
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeFlipAssist, makeFlipResult } from "../src/vehicles/flipAssist.js";
import { FLIP, CAR_AGILITY } from "../src/tuning.js";
// ── tiny vector / quaternion helpers (plain {x,y,z} / {x,y,z,w}) ──────────────
const v = (x, y, z) => ({ x, y, z });
const vlen = (a) => Math.hypot(a.x, a.y, a.z);
function qFromAxisAngle(ax, ay, az, ang) {
const l = Math.hypot(ax, ay, az) || 1;
const s = Math.sin(ang / 2) / l;
return { x: ax * s, y: ay * s, z: az * s, w: Math.cos(ang / 2) };
}
function qNormalize(q) {
const l = Math.hypot(q.x, q.y, q.z, q.w) || 1;
return { x: q.x / l, y: q.y / l, z: q.z / l, w: q.w / l };
}
const qConj = (q) => ({ x: -q.x, y: -q.y, z: -q.z, w: q.w });
function qRot(q, a) { // rotate vector a by quaternion q
const ix = q.w * a.x + q.y * a.z - q.z * a.y;
const iy = q.w * a.y + q.z * a.x - q.x * a.z;
const iz = q.w * a.z + q.x * a.y - q.y * a.x;
const iw = -q.x * a.x - q.y * a.y - q.z * a.z;
return {
x: ix * q.w + iw * -q.x + iy * -q.z - iz * -q.y,
y: iy * q.w + iw * -q.y + iz * -q.x - ix * -q.z,
z: iz * q.w + iw * -q.z + ix * -q.y - iy * -q.x,
};
}
function qIntegrate(q, w, dt) { // advance orientation q by world angular velocity w
const dx = 0.5 * (w.x * q.w + w.y * q.z - w.z * q.y) * dt;
const dy = 0.5 * (w.y * q.w + w.z * q.x - w.x * q.z) * dt;
const dz = 0.5 * (w.z * q.w + w.x * q.y - w.y * q.x) * dt;
const dw = 0.5 * (-w.x * q.x - w.y * q.y - w.z * q.z) * dt;
return qNormalize({ x: q.x + dx, y: q.y + dy, z: q.z + dz, w: q.w + dw });
}
const approx = (a, b, eps = 1e-4) => Math.abs(a - b) <= eps;
function assertVecApprox(got, ex, eps = 1e-3) {
assert.ok(approx(got.x, ex.x, eps) && approx(got.y, ex.y, eps) && approx(got.z, ex.z, eps),
`expected ≈ (${ex.x}, ${ex.y}, ${ex.z}), got (${got.x}, ${got.y}, ${got.z})`);
}
const N_UP = v(0, 1, 0);
// ─────────────────────────────────────────────────────────────────────────────
// 1. DECISION TESTS
// ─────────────────────────────────────────────────────────────────────────────
function decide(partial) {
const out = makeFlipResult();
computeFlipAssist({
up: v(0, 1, 0), forward: v(0, 0, 1), surfaceNormal: N_UP,
wheelContacts: 0, onGround: true, angularVelocity: v(0, 0, 0), righting: false,
...partial,
}, out);
return out;
}
// roll the car by `deg` about an axis, from upright (used by several tests)
function upAfterRoll(deg, ax, ay, az) {
return qRot(qFromAxisAngle(ax, ay, az, deg * Math.PI / 180), v(0, 1, 0));
}
test("airborne: no assist even when fully inverted", () => {
const out = decide({ up: v(0, -1, 0), onGround: false, wheelContacts: 0 });
assert.equal(out.phase, "none");
assert.deepEqual([out.fx, out.fy, out.fz], [0, 0, 0]);
assert.deepEqual([out.tx, out.ty, out.tz], [0, 0, 0]);
});
test("airborne: no assist when tipped on 2 wheels", () => {
// even with wheel contacts reported, onGround=false wins → nothing
const up = qRot(qFromAxisAngle(0, 0, 1, 0.5), v(0, 1, 0));
const out = decide({ up, onGround: false, wheelContacts: 2 });
assert.equal(out.phase, "none");
});
test("grounded + upright (4 wheels): no assist", () => {
const out = decide({ up: v(0, 1, 0), wheelContacts: 4 });
assert.equal(out.phase, "none");
});
test("grounded + 3 wheels: no assist (only 1–2 lifted triggers grounding)", () => {
const up = qRot(qFromAxisAngle(0, 0, 1, 0.15), v(0, 1, 0));
const out = decide({ up, wheelContacts: 3 });
assert.equal(out.phase, "none");
});
test("grounded + 2 wheels, mild roll: grounding force opposite the normal", () => {
const up = qRot(qFromAxisAngle(0, 0, 1, 30 * Math.PI / 180), v(0, 1, 0));
const out = decide({ up, wheelContacts: 2 });
assert.equal(out.phase, "ground");
assertVecApprox(v(out.fx, out.fy, out.fz), v(0, -FLIP.groundForce, 0));
assert.deepEqual([out.tx, out.ty, out.tz], [0, 0, 0]); // pure force, no torque
});
test("grounded + 1 wheel: still grounding", () => {
const up = qRot(qFromAxisAngle(1, 0, 0, 50 * Math.PI / 180), v(0, 1, 0));
const out = decide({ up, wheelContacts: 1 });
assert.equal(out.phase, "ground");
});
test("grounding force is opposite an inclined surface normal too", () => {
const N = v(0, Math.SQRT1_2, Math.SQRT1_2); // 45° ramp
const out = decide({ up: N, surfaceNormal: N, wheelContacts: 2 });
// up==N → tilt 0 → grounding (2 wheels); force = -N * groundForce
assert.equal(out.phase, "ground");
assertVecApprox(v(out.fx, out.fy, out.fz),
v(-N.x * FLIP.groundForce, -N.y * FLIP.groundForce, -N.z * FLIP.groundForce));
});
test("grounded + on the side (90°): righting torque about up×N", () => {
const up = v(1, 0, 0); // rolled onto the +X side
const out = decide({ up, wheelContacts: 0, angularVelocity: v(0, 0, 0) });
assert.equal(out.phase, "right");
// up×N = (1,0,0)×(0,1,0) = (0,0,1); normalized → torque (0,0,rightTorque)
assertVecApprox(v(out.tx, out.ty, out.tz), v(0, 0, FLIP.rightTorque));
assert.deepEqual([out.fx, out.fy, out.fz], [0, 0, 0]);
});
test("a torque about up×N actually reduces the tilt (correct sign)", () => {
const up = v(1, 0, 0);
const out = decide({ up, wheelContacts: 0 });
// apply the torque as a small angular velocity and check up moves toward N
const w = v(out.tx, out.ty, out.tz);
const l = vlen(w), e = 1e-3 / l;
const dUp = { x: w.y * up.z - w.z * up.y, y: w.z * up.x - w.x * up.z, z: w.x * up.y - w.y * up.x };
const upNext = v(up.x + dUp.x * e, up.y + dUp.y * e, up.z + dUp.z * e);
assert.ok(upNext.y > up.y, "up.y should increase toward the surface normal");
});
test("grounded + fully inverted (180°): falls back to forward axis, non-zero torque", () => {
const out = decide({ up: v(0, -1, 0), forward: v(0, 0, 1), wheelContacts: 0 });
assert.equal(out.phase, "right");
// up×N is degenerate here → axis = forward (0,0,1) → torque (0,0,rightTorque)
assertVecApprox(v(out.tx, out.ty, out.tz), v(0, 0, FLIP.rightTorque));
});
test("righting torque includes angular-velocity damping", () => {
const up = v(1, 0, 0);
const out = decide({ up, wheelContacts: 0, angularVelocity: v(0, 0, 2) });
// torque.z = rightTorque - av.z*rightDamping
assert.ok(approx(out.tz, FLIP.rightTorque - 2 * FLIP.rightDamping, 1e-3));
assert.ok(approx(out.tx, 0) && approx(out.ty, 0));
});
test("past 85°, righting takes precedence over grounding even with wheel contacts", () => {
const up = qRot(qFromAxisAngle(0, 0, 1, 95 * Math.PI / 180), v(0, 1, 0));
const out = decide({ up, wheelContacts: 2 }); // 2 wheels but rolled past 85°
assert.equal(out.phase, "right");
});
// ── hysteresis: keep righting below the 85° start threshold once it's begun ────
test("hysteresis: at 60° tilt, does NOT start righting from rest", () => {
const out = decide({ up: upAfterRoll(60, 0, 0, 1), wheelContacts: 0, righting: false });
assert.notEqual(out.phase, "right"); // below the 85° start threshold
});
test("hysteresis: at 60° tilt, KEEPS righting if already righting", () => {
const out = decide({ up: upAfterRoll(60, 0, 0, 1), wheelContacts: 0, righting: true });
assert.equal(out.phase, "right"); // 60° is between settle (40°) and start (85°)
});
test("hysteresis: releases once below the settle angle", () => {
const out = decide({ up: upAfterRoll(30, 0, 0, 1), wheelContacts: 2, righting: true });
assert.notEqual(out.phase, "right"); // 30° < 40° settle → stop righting
assert.equal(out.phase, "ground"); // now 2 wheels → grounding takes over
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. INTEGRATION TESTS — does the car actually right itself?
// ─────────────────────────────────────────────────────────────────────────────
// Octane inverse inertia (body frame), mirroring CarController.buildCar.
const MASS = 180, HX = 0.95, HY = 0.5, HZ = 1.72, K = MASS / 3;
const INV_I = v(
CAR_AGILITY / (K * (HY * HY + HZ * HZ)),
CAR_AGILITY / (K * (HX * HX + HZ * HZ)),
CAR_AGILITY / (K * (HX * HX + HY * HY)),
);
const MAX_W = 5.5; // AIR.maxAngularSpeed clamp applied by the controller
/**
* Faithful angular-only rigid-body sim. Applies the flip-assist torque each step
* (the grounding phase is a COM force → no torque in free flight, so it is a
* no-op here, as it would be without the wheel-contact constraint).
* @returns {{finalUpY:number, maxUpY:number, minUpY:number}}
*/
function simulate({ q0, onGround, wheelContacts = 0, seconds, dt = 1 / 120 }) {
let q = q0, w = v(0, 0, 0), righting = false;
const out = makeFlipResult();
let maxUpY = -Infinity, minUpY = Infinity;
const steps = Math.round(seconds / dt);
for (let i = 0; i < steps; i++) {
const up = qRot(q, v(0, 1, 0));
if (up.y > maxUpY) maxUpY = up.y;
if (up.y < minUpY) minUpY = up.y;
const fwd = qRot(q, v(0, 0, 1));
computeFlipAssist({ up, forward: fwd, surfaceNormal: N_UP, wheelContacts, onGround, angularVelocity: w, righting }, out);
righting = out.phase === "right";
// α_world = R · ( I_local⁻¹ · ( Rᵀ · τ_world ) )
const tl = qRot(qConj(q), v(out.tx, out.ty, out.tz));
const aw = qRot(q, v(tl.x * INV_I.x, tl.y * INV_I.y, tl.z * INV_I.z));
w = v(w.x + aw.x * dt, w.y + aw.y * dt, w.z + aw.z * dt);
const wl = vlen(w);
if (wl > MAX_W) { const s = MAX_W / wl; w = v(w.x * s, w.y * s, w.z * s); }
q = qIntegrate(q, w, dt);
}
const up = qRot(q, v(0, 1, 0));
return { finalUpY: up.y, maxUpY, minUpY };
}
// roll the car by `deg` about `axis`, starting from upright
const flipped = (deg, ax, ay, az) => qFromAxisAngle(ax, ay, az, deg * Math.PI / 180);
test("grounded + on its side → rights to (near) upright", () => {
const r = simulate({ q0: flipped(90, 0, 0, 1), onGround: true, seconds: 4 });
assert.ok(r.maxUpY > 0.9, `should reach near-upright, got maxUpY=${r.maxUpY.toFixed(3)}`);
});
test("grounded + on its other side → rights to (near) upright", () => {
const r = simulate({ q0: flipped(90, 0, 0, -1), onGround: true, seconds: 4 });
assert.ok(r.maxUpY > 0.9, `maxUpY=${r.maxUpY.toFixed(3)}`);
});
test("grounded + nearly inverted → rights to (near) upright", () => {
const r = simulate({ q0: flipped(179, 1, 0, 0), onGround: true, seconds: 5 });
assert.ok(r.maxUpY > 0.9, `maxUpY=${r.maxUpY.toFixed(3)}`);
});
test("grounded + EXACTLY inverted → fallback axis still rights it", () => {
const r = simulate({ q0: flipped(180, 1, 0, 0), onGround: true, seconds: 5 });
assert.ok(r.maxUpY > 0.9, `maxUpY=${r.maxUpY.toFixed(3)}`);
});
test("grounded + various roll angles all right themselves", () => {
for (const deg of [100, 120, 140, 160]) {
for (const [ax, ay, az] of [[0, 0, 1], [1, 0, 0]]) {
const r = simulate({ q0: flipped(deg, ax, ay, az), onGround: true, seconds: 5 });
assert.ok(r.maxUpY > 0.9, `roll ${deg}° about (${ax},${ay},${az}): maxUpY=${r.maxUpY.toFixed(3)}`);
}
}
});
test("AIRBORNE + inverted → does NOT right (stays flipped)", () => {
const r = simulate({ q0: flipped(180, 1, 0, 0), onGround: false, seconds: 5 });
// started fully inverted (up.y=-1); with no assist it must never approach upright
assert.ok(r.maxUpY < 0.05, `should stay flipped in the air, got maxUpY=${r.maxUpY.toFixed(3)}`);
assert.ok(r.finalUpY < -0.9, `should still be inverted, got finalUpY=${r.finalUpY.toFixed(3)}`);
});
test("airborne + on its side → does NOT right", () => {
const r = simulate({ q0: flipped(90, 0, 0, 1), onGround: false, seconds: 5 });
assert.ok(Math.abs(r.finalUpY) < 0.05, `should stay on its side, got finalUpY=${r.finalUpY.toFixed(3)}`);
});
test("grounded + already upright → stays upright (no spurious torque)", () => {
const r = simulate({ q0: qFromAxisAngle(0, 1, 0, 0), onGround: true, wheelContacts: 4, seconds: 2 });
assert.ok(r.minUpY > 0.999, `should never tip, got minUpY=${r.minUpY.toFixed(4)}`);
});
test("righting torque is strong enough to beat gravity holding a car on its roof", () => {
// The free-body sim above can't see gravity. A box resting on its roof tips
// about its side edge; the gravitational hold torque peaks at mass·g·halfWidth.
// If the righting torque doesn't beat that, the car can't lift off its face —
// which is exactly the bug this guards against.
const holdTorque = MASS * 9.81 * HX; // ≈ 1676 N·m for the Octane
assert.ok(FLIP.rightTorque > holdTorque,
`rightTorque (${FLIP.rightTorque}) must exceed the gravity hold (${holdTorque.toFixed(0)} N·m)`);
});
// Headless tests for the post-goal reset + goal slow-motion.
//
// Boots the REAL sim in Node (EntityManager + PhysicsSystem + VehicleSystem, no
// renderer / no GLTF) plus a DOM-less MatchManager, then drives a full
// goal → celebration → kickoff and asserts BOTH cars and the ball are restored to
// their kickoff state (position / velocity / boost / jump-dodge FSM), and that a
// goal slows the engine clock for a moment and then restores full speed.
import { test } from "node:test";
import assert from "node:assert/strict";
import { EntityManager } from "@woosh/meep-engine/src/engine/ecs/EntityManager.js";
import { EntityComponentDataset } from "@woosh/meep-engine/src/engine/ecs/EntityComponentDataset.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.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 { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.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 { InterpolationSystem } from "@woosh/meep-engine/src/engine/interpolation/InterpolationSystem.js";
import Stat from "@woosh/meep-engine/src/core/model/stat/Stat.js";
import { VehicleSystem } from "../src/vehicles/VehicleSystem.js";
import { buildCar } from "../src/vehicles/CarController.js";
import { MatchManager } from "../src/match.js";
import { CAR_DEFS } from "../src/vehicles/carDefs.js";
import { TEAM, BOOST, MATCH } from "../src/tuning.js";
const STEP = 1 / 60;
async function bootWorld() {
const em = new EntityManager();
em.attachDataset(new EntityComponentDataset());
const vehicleSystem = new VehicleSystem();
em.addSystem(vehicleSystem); // before physics
const physics = new PhysicsSystem();
em.addSystem(physics);
// Render interpolation — REQUIRED to reproduce the high-refresh kickoff bug:
// setPose's `snap` only protects one frame, and a frame with no fixed step can
// undo the teleport. The producer (physics) writes into this system's log.
const interpolation = new InterpolationSystem();
em.addSystem(interpolation);
physics.interpolationLog = interpolation.log;
await new Promise((res, rej) => em.startup(res, rej));
vehicleSystem.physics = physics;
const ecd = em.dataset;
ecd.registerComponentType(Collider);
ecd.registerComponentType(Interpolated);
// flat static floor (top at y = 0)
const ft = new Transform(); ft.position.set(0, -0.5, 0);
const fb = new RigidBody(); fb.kind = BodyKind.Static;
const fc = new Collider(); fc.shape = BoxShape3D.from(120, 0.5, 120); fc.friction = 0.95;
new Entity().add(ft).add(fb).add(fc).build(ecd);
return { em, ecd, physics, vehicleSystem };
}
// A fake "level" with the two pieces the MatchManager reads for a kickoff.
function makeLevel() {
return {
kickoffSpawns: {
blue: { position: [20, 1, -30], yaw: 0.5 },
orange: { position: [-20, 1, 30], yaw: -0.5 },
},
ballSpawn: [0, 3, 0],
dims: { hw: 75, hl: 100, goalDepth: 14, goalWidth: 44 },
goalSensors: [],
};
}
// Minimal physics-only ball (no THREE mesh) — all the MatchManager touches is
// `body` and `transform`.
function buildBall(ecd, position) {
const transform = new Transform();
transform.position.set(position[0], position[1], position[2]);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = 2;
body.flags = RigidBodyFlags.DisableSleep;
const collider = new Collider();
collider.shape = SphereShape3D.from(1.0);
const interpolated = new Interpolated();
interpolated.interpolands = [POSE_INTERPOLAND];
const entity = new Entity().add(transform).add(body).add(collider).add(interpolated).build(ecd);
return { entity, body, transform };
}
function makeCars(ecd, physics, vehicleSystem, level) {
const ks = level.kickoffSpawns;
const player = buildCar({ ecd, physics }, {
def: CAR_DEFS.octane, team: TEAM.blue,
position: ks.blue.position, yaw: ks.blue.yaw, withArt: false,
});
const ai = buildCar({ ecd, physics }, {
def: CAR_DEFS.perrier, team: TEAM.orange,
position: ks.orange.position, yaw: ks.orange.yaw, withArt: false,
});
player.bounds = null; ai.bounds = null; // no out-of-bounds recovery in the test
vehicleSystem.addCar(player); vehicleSystem.addCar(ai);
return { player, ai };
}
const speed = (b) => Math.hypot(b.linearVelocity.x, b.linearVelocity.y, b.linearVelocity.z);
test("a goal → celebration → kickoff resets BOTH cars and the ball to the kickoff state", async () => {
const { em, ecd, physics, vehicleSystem } = await bootWorld();
const level = makeLevel();
const { player, ai } = makeCars(ecd, physics, vehicleSystem, level);
const ball = buildBall(ecd, level.ballSpawn);
const clockSpeed = new Stat(1);
const match = new MatchManager({ physics, vehicleSystem, ball, player, ai, level, clockSpeed, ecd });
// ── displace everything mid-play + dirty transient controller state ──
physics.setPose(player.body, { x: 8, y: 1, z: 12 }, { x: 0, y: 0, z: 0, w: 1 });
player.body.linearVelocity.set(7, 0, 5); player.boost = 95;
player._hasJumped = true; player._usedSecond = true; player._airTime = 2;
physics.setPose(ai.body, { x: -9, y: 1, z: -7 }, { x: 0, y: 0, z: 0, w: 1 });
ai.body.linearVelocity.set(-4, 0, -3); ai.boost = 88;
physics.setPose(ball.body, { x: 25, y: 4, z: 33 }, { x: 0, y: 0, z: 0, w: 1 });
ball.body.linearVelocity.set(9, 2, 8); ball.body.angularVelocity.set(3, 3, 3);
match.phase = "play"; vehicleSystem.controlEnabled = true;
match.handleGoal("blue", {});
assert.equal(match.phase, "celebrate", "goal moves to the celebration phase");
// run the REAL render-frame order until kickoff fires (phase → countdown)
let steps = 0;
while (match.phase === "celebrate" && steps < 3000) {
em.simulate(STEP);
player.flushRecovery(physics);
ai.flushRecovery(physics);
match.update(STEP);
steps++;
}
assert.equal(match.phase, "countdown", "celebration ends in a kickoff");
const ks = level.kickoffSpawns;
const near = (a, b, t = 0.4) => Math.abs(a - b) <= t;
// cars are back at their spawns
assert.ok(near(player.transform.position.x, ks.blue.position[0]) && near(player.transform.position.z, ks.blue.position[2]),
`player at blue spawn (got ${player.transform.position.x.toFixed(2)}, ${player.transform.position.z.toFixed(2)})`);
assert.ok(near(ai.transform.position.x, ks.orange.position[0]) && near(ai.transform.position.z, ks.orange.position[2]),
`ai at orange spawn (got ${ai.transform.position.x.toFixed(2)}, ${ai.transform.position.z.toFixed(2)})`);
// velocities cleared by the reset
assert.ok(speed(player.body) < 1.5 && speed(ai.body) < 1.5, "car velocities cleared");
// boost back to the kickoff fraction, FSM cleared
assert.ok(Math.abs(player.boost - BOOST.max / 3) < 0.01 && Math.abs(ai.boost - BOOST.max / 3) < 0.01,
"boost reset to the kickoff fraction");
assert.equal(player._hasJumped, false, "jump FSM cleared");
assert.equal(player._usedSecond, false, "double-jump FSM cleared");
assert.equal(player._respawnTo, null, "no pending recovery left over");
// ball back at centre, motion stopped
assert.ok(near(ball.transform.position.x, level.ballSpawn[0], 0.5) && near(ball.transform.position.z, level.ballSpawn[2], 0.5),
`ball at centre (got ${ball.transform.position.x.toFixed(2)}, ${ball.transform.position.z.toFixed(2)})`);
assert.ok(speed(ball.body) < 3, "ball velocity cleared");
// and the slow-mo is gone by the time we reach the countdown
assert.ok(Math.abs(clockSpeed.getValue() - 1) < 1e-6, "full speed restored for the countdown");
// it STAYS put through the countdown (control is frozen) — not yanked away
const px0 = player.transform.position.x, pz0 = player.transform.position.z;
const bx0 = ball.transform.position.x, bz0 = ball.transform.position.z;
for (let i = 0; i < 40; i++) { em.simulate(STEP); player.flushRecovery(physics); ai.flushRecovery(physics); match.update(STEP); }
assert.ok(near(player.transform.position.x, px0, 0.6) && near(player.transform.position.z, pz0, 0.6), "player stays at spawn during the countdown");
assert.ok(near(ball.transform.position.x, bx0, 0.6) && near(ball.transform.position.z, bz0, 0.6), "ball stays at centre during the countdown");
});
test("resetTo restores the car to a clean original state (clears velocity, FSM, and a pending recovery)", async () => {
const { ecd, physics, vehicleSystem } = await bootWorld();
const level = makeLevel();
const { player: car } = makeCars(ecd, physics, vehicleSystem, level);
// dirty every bit of transient state, including a queued respawn
car._respawnTo = [99, 1, 99, 1.2];
car._hasJumped = true; car._usedSecond = true; car._airTime = 3; car._righting = true;
car._noProgress = 5; car._dodgeTimer = 0.4; car._jumpTimer = 0.3;
car.body.linearVelocity.set(10, 5, -3); car.body.angularVelocity.set(2, 2, 2);
car.resetTo(-20, 1, 30, -0.5, physics);
assert.equal(car._respawnTo, null, "pending recovery teleport is dropped (can't override the reset next frame)");
assert.equal(car._hasJumped, false);
assert.equal(car._usedSecond, false);
assert.equal(car._airTime, 0);
assert.equal(car._righting, false);
assert.equal(car._noProgress, 0);
assert.ok(speed(car.body) < 1e-6, "velocity zeroed");
assert.ok(Math.abs(car.transform.position.x - (-20)) < 1e-6 && Math.abs(car.transform.position.z - 30) < 1e-6, "teleported to the target");
});
test("a goal slows the engine clock to ~50% and restores full speed within the window", async () => {
const { ecd, physics, vehicleSystem } = await bootWorld();
const level = makeLevel();
const { player, ai } = makeCars(ecd, physics, vehicleSystem, level);
const ball = buildBall(ecd, level.ballSpawn);
const clockSpeed = new Stat(1);
const match = new MatchManager({ physics, vehicleSystem, ball, player, ai, level, clockSpeed, ecd });
assert.equal(clockSpeed.getValue(), 1, "clock starts at full speed");
match.phase = "play";
match.handleGoal("blue", {});
assert.ok(Math.abs(clockSpeed.getValue() - MATCH.goalSlowMoSpeed) < 1e-6, "clock slows on the goal");
// advance just past the slow-mo window (real dt) WITHOUT reaching kickoff
let t = 0;
while (t < MATCH.goalSlowMoSeconds + 0.2 && match.phase === "celebrate") { match.update(STEP); t += STEP; }
assert.ok(Math.abs(clockSpeed.getValue() - 1) < 1e-6, "full speed restored after the slow-mo window");
assert.equal(match.phase, "celebrate", "slow-mo ends before the celebration does (kickoff is at full speed)");
assert.equal(clockSpeed.getModifiers().length, 0, "no leftover modifier on the clock");
});
// ── REGRESSION: the kickoff teleport must survive a high-refresh display ──
// Repro of the reported bug: after a goal, the car stays where it was at the score
// (wrong position + facing) and the ball stays in the goal. Root cause: setPose
// writes the live pose + a one-frame `snap`, but not the interpolation LOG; on a
// display whose refresh exceeds the 60 Hz physics rate some render frames run ZERO
// fixed steps, so a frame consumes `snap` without recording the new pose and the
// next frame blends the body back to its stale pre-kickoff pose. We force exactly
// that by stepping the *post-kickoff* frames at a sub-step dt (no fixed step runs).
test("kickoff reset survives a high-refresh display (zero-physics-step render frames don't undo it)", async () => {
const { em, ecd, physics, vehicleSystem } = await bootWorld();
const level = makeLevel();
const { player, ai } = makeCars(ecd, physics, vehicleSystem, level);
const ball = buildBall(ecd, level.ballSpawn);
const match = new MatchManager({ physics, vehicleSystem, ball, player, ai, level, ecd });
// park everyone at clearly-non-spawn poses (the "scored a goal" state): the car
// mid-field facing +x, the ball deep in the +z goal. Let the interpolation log
// fill with these so there's a stale pose to be (wrongly) blended back to.
physics.setPose(player.body, { x: 6, y: 1, z: 40 }, { x: 0, y: 0.7071, z: 0, w: 0.7071 });
physics.setPose(ai.body, { x: -6, y: 1, z: -40 }, { x: 0, y: 0, z: 0, w: 1 });
physics.setPose(ball.body, { x: 3, y: 2.5, z: 34 }, { x: 0, y: 0, z: 0, w: 1 });
player.body.linearVelocity.set(0, 0, 0); ai.body.linearVelocity.set(0, 0, 0); ball.body.linearVelocity.set(0, 0, 0);
match.phase = "play"; vehicleSystem.controlEnabled = true;
for (let i = 0; i < 20; i++) em.simulate(STEP); // log the displaced poses
match.handleGoal("blue", {});
// celebrate at the physics rate (1 step/frame → accumulator ≈ 0 at kickoff)
let guard = 0;
while (match.phase === "celebrate" && guard++ < 5000) {
em.simulate(STEP); player.flushRecovery(physics); ai.flushRecovery(physics); match.update(STEP);
}
assert.equal(match.phase, "countdown", "kickoff fired");
// Now a stretch of high-refresh frames: dt < the 1/60 fixed step, so most run
// NO physics step. This is the exact timing that used to undo the teleport.
const SUB = 1 / 240;
for (let i = 0; i < 24; i++) {
em.simulate(SUB); player.flushRecovery(physics); ai.flushRecovery(physics); match.update(SUB);
}
const ks = level.kickoffSpawns;
const near = (a, b, t = 0.6) => Math.abs(a - b) <= t;
const yaw = (b) => { const r = b.transform.rotation; return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)); };
assert.ok(near(player.transform.position.x, ks.blue.position[0]) && near(player.transform.position.z, ks.blue.position[2]),
`player stays at the blue spawn (got ${player.transform.position.x.toFixed(1)}, ${player.transform.position.z.toFixed(1)}, expected ${ks.blue.position[0]}, ${ks.blue.position[2]})`);
assert.ok(Math.abs(yaw(player) - ks.blue.yaw) < 0.2,
`player faces the spawn direction (got ${yaw(player).toFixed(2)}, expected ${ks.blue.yaw})`);
assert.ok(near(ai.transform.position.x, ks.orange.position[0]) && near(ai.transform.position.z, ks.orange.position[2]),
`ai stays at the orange spawn (got ${ai.transform.position.x.toFixed(1)}, ${ai.transform.position.z.toFixed(1)})`);
assert.ok(near(ball.transform.position.x, level.ballSpawn[0]) && near(ball.transform.position.z, level.ballSpawn[2]),
`ball stays at centre, not in the goal (got ${ball.transform.position.x.toFixed(1)}, ${ball.transform.position.z.toFixed(1)})`);
});
// Canary for meep's `sphere_projected_sphere_radius_sqr` (the helper that estimates
// how big a sphere appears on screen). Its `z2` line is a typo — it reads `z * x`
// where the math wants `z * z`. The JPA `DistanceHighlights` effect therefore uses
// meep's CORRECT sibling `sphere_project` (which squares z properly) instead of this
// buggy helper — see ../src/fx/distanceHighlight.js.
//
// These tests are marked `todo`: they FAIL today (documenting the bug) without
// failing the suite, and will flip to passing — flagged by the runner — if meep
// ever fixes the typo, at which point this helper becomes usable too.
//
// HOW WE KNOW WHAT "CORRECT" IS
// -----------------------------
// The sibling `sphere_project` (a faithful port of Iñigo Quílez's sphere
// projection, https://iquilezles.org/articles/sphereproj/) is the trusted
// reference. It returns the projected screen AREA and correctly squares z
// (`z2 = v4_z * v4_z`). `sphere_projected_sphere_radius_sqr` is the same formula
// repackaged as a squared RADIUS, so algebraically:
//
// radius_sqr == sphere_project / π · (0.5 / |w|) // with z*z
//
// i.e. for an affine world→camera matrix (w == 1) the two must stay in lockstep:
// radius_sqr · 2π == sphere_project.
//
// Each test asserts that relationship plus a physical sanity property. They pass
// only if the helper squares z; the shipped `z * x` build fails them — the answer
// to "is it correct?": no.
import { test } from "node:test";
import assert from "node:assert/strict";
import { sphere_projected_sphere_radius_sqr } from "@woosh/meep-engine/src/core/geom/3d/sphere/sphere_projected_sphere_radius_sqr.js";
import { sphere_project } from "@woosh/meep-engine/src/core/geom/3d/sphere/sphere_project.js";
// Marked on every test below: they document a known upstream bug, so they fail
// today but must not fail the suite. Drop this once meep ships `z * z`.
const CANARY = { todo: "meep sphere_projected_sphere_radius_sqr: z*x should be z*z" };
// Column-major (THREE/glmatrix layout, translation in 12,13,14) identity matrix.
// With this, a world-space sphere center passes through to camera space unchanged
// and w stays 1, so we can dial camera-space coordinates directly.
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
// Pure translation: moves the sphere center by (tx,ty,tz) into camera space, w==1.
const translation = (tx, ty, tz) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1];
const FL = 2.0; // focal length; any positive value works (cancels in every check below)
/** assert a ≈ b with a relative (and small absolute) tolerance */
function approx(actual, expected, msg, rel = 1e-9) {
const tol = Math.max(Math.abs(expected) * rel, 1e-12);
assert.ok(
Math.abs(actual - expected) <= tol,
`${msg}\n actual: ${actual}\n expected: ${expected}\n |diff|: ${Math.abs(actual - expected)} > tol ${tol}`,
);
}
// A sphere on the view axis (x = y = 0) at distance D in front of the camera,
// radius r. Projected size has the closed form pr² = fl²·r²/(D²−r²) — strictly
// shrinking as D grows. The buggy `z*x` makes z2 = 0 on-axis, turning this into
// ∝ √(D²−r²), which GROWS with distance.
const onAxis = (D, r) => [0, 0, -D, r];
test("on-axis: projected size shrinks as the sphere recedes (buggy z*x makes it grow)", CANARY, () => {
const r = 1;
const near = sphere_projected_sphere_radius_sqr(FL, IDENTITY, onAxis(5, r));
const mid = sphere_projected_sphere_radius_sqr(FL, IDENTITY, onAxis(10, r));
const far = sphere_projected_sphere_radius_sqr(FL, IDENTITY, onAxis(20, r));
assert.ok(near > mid, `near (D=5 → ${near}) must exceed mid (D=10 → ${mid})`);
assert.ok(mid > far, `mid (D=10 → ${mid}) must exceed far (D=20 → ${far})`);
});
test("on-axis: distance falloff matches the analytic 1/(D²−r²) law", CANARY, () => {
const r = 1;
const D1 = 4, D2 = 9;
const f1 = sphere_projected_sphere_radius_sqr(FL, IDENTITY, onAxis(D1, r));
const f2 = sphere_projected_sphere_radius_sqr(FL, IDENTITY, onAxis(D2, r));
// pr² ∝ 1/(D²−r²) ⇒ f1/f2 = (D2²−r²)/(D1²−r²). Scale-free: independent of fl
// and of the helper's own constant factor. The z*x build instead yields the
// (wrong) ratio √((D1²−r²)/(D2²−r²)).
const expectedRatio = (D2 * D2 - r * r) / (D1 * D1 - r * r);
approx(f1 / f2, expectedRatio, "on-axis falloff ratio");
});
test("lateral mirror symmetry: a sphere at +x and −x must project to the same size", CANARY, () => {
// Mirror images across the view axis are physically identical in projected
// size. Correct (z*z) ignores the sign of x; the buggy z*x does not, because
// z2 = z·x flips sign with x.
const r = 1;
const left = sphere_projected_sphere_radius_sqr(FL, IDENTITY, [3, 0, -10, r]);
const right = sphere_projected_sphere_radius_sqr(FL, IDENTITY, [-3, 0, -10, r]);
approx(left, right, "f(+x) vs f(−x)");
});
// Cross-check against the trusted `sphere_project` oracle. Both consume the same
// (fl, matrix, sphere); the only structural difference that should matter is the
// area-vs-radius packaging, so their ratio across configs must be the constant
// 1/(2π). The z*x typo breaks this for every off-axis sphere.
const SPHERES = [
[0, 0, -8, 1], // on axis
[3, 0, -10, 1], // offset in x ← where z*x diverges hardest
[-2, 1.5, -12, 2], // general offset, bigger radius
[5, -4, -25, 0.5], // far + lateral
];
for (const sph of SPHERES) {
test(`oracle exact: radius_sqr·2π == sphere_project for sphere [${sph}]`, CANARY, () => {
const radiusSqr = sphere_projected_sphere_radius_sqr(FL, IDENTITY, sph);
const area = sphere_project(sph, IDENTITY, FL);
approx(radiusSqr * 2 * Math.PI, area, "radius_sqr·2π vs sphere_project area");
});
}
test("oracle ratio: helper tracks sphere_project across configs (scale-free)", CANARY, () => {
// Even without trusting the 2π constant: for a shared matrix the helper and
// the oracle must agree on the RATIO between any two spheres. This isolates
// the z handling alone — every other factor (fl, the 0.5/|w| term, π) cancels.
const a = SPHERES[1], b = SPHERES[2];
const helperRatio = sphere_projected_sphere_radius_sqr(FL, IDENTITY, a)
/ sphere_projected_sphere_radius_sqr(FL, IDENTITY, b);
const oracleRatio = sphere_project(a, IDENTITY, FL) / sphere_project(b, IDENTITY, FL);
approx(helperRatio, oracleRatio, "helper/oracle ratio");
});
test("non-identity view matrix: agreement survives a world→camera transform", CANARY, () => {
// Exercise the matrix path (v4_multiply_mat4) with a real translation rather
// than the identity, to be sure the bug isn't masked by the trivial matrix.
const M = translation(2, -1, -15); // camera-space center → (2,-1,-15)
const sph = [0, 0, 0, 1.5]; // world origin, r=1.5
const radiusSqr = sphere_projected_sphere_radius_sqr(FL, M, sph);
const area = sphere_project(sph, M, FL);
approx(radiusSqr * 2 * Math.PI, area, "radius_sqr·2π vs sphere_project area (translated)");
});
// 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>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JPA — Car-Def Editor</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; background: #14171c; color: #d7dde5; }
#app { display: flex; height: 100%; }
#viewport { flex: 1; position: relative; }
#viewport canvas { display: block; }
#panel { width: 320px; background: #1b1f26; border-left: 1px solid #2a3038; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #8b96a5; margin: 4px 0; }
.drop { border: 1.5px dashed #3a424d; border-radius: 8px; padding: 14px 10px; text-align: center; color: #93a0b0; transition: .15s; cursor: default; }
.drop.over { border-color: #4ea8f0; background: #1f2935; color: #cfe3f7; }
.drop b { color: #d7dde5; }
.row { display: flex; gap: 6px; flex-wrap: wrap; }
button { background: #283039; color: #d7dde5; border: 1px solid #3a424d; border-radius: 6px; padding: 5px 9px; cursor: pointer; font: inherit; }
button:hover { background: #313b46; }
button.active { background: #2f6db0; border-color: #4ea8f0; color: #fff; }
button.kind-wheel { border-left: 3px solid #ffd24a; }
.list { display: flex; flex-direction: column; gap: 3px; max-height: 220px; overflow-y: auto; }
.list button { text-align: left; }
.list button.sel { background: #2f6db0; border-color: #4ea8f0; color: #fff; }
.nodes button { font-size: 11px; padding: 3px 7px; }
.nodes button.member { background: #2b5a36; border-color: #57b86a; color: #e7ffe9; }
textarea { width: 100%; height: 140px; background: #0f1318; color: #b9e6c4; border: 1px solid #2a3038; border-radius: 6px; font: 11px/1.35 ui-monospace, monospace; padding: 8px; resize: vertical; }
.hint { color: #6f7c8c; font-size: 11px; }
.pill { display:inline-block; padding:1px 6px; border-radius:99px; background:#283039; color:#9fb0c2; font-size:11px; }
#overlay { position: absolute; left: 12px; top: 12px; right: 12px; pointer-events: none; color: #9fb0c2; font-size: 12px; text-shadow: 0 1px 2px #000; }
kbd { background:#283039; border:1px solid #3a424d; border-bottom-width:2px; border-radius:4px; padding:0 5px; font-size:11px; }
</style>
</head>
<body>
<div id="app">
<div id="viewport">
<div id="overlay">
<div><b>Car-Def Editor</b> — drop a model + a def to begin.</div>
<div class="hint">Orbit: drag · zoom: wheel · transform: <kbd>W</kbd> move <kbd>E</kbd> rotate <kbd>R</kbd> scale · <kbd>Esc</kbd> deselect</div>
<div id="status" class="hint" style="margin-top:6px"></div>
</div>
</div>
<div id="panel">
<div>
<h2>1 · Model</h2>
<div id="dropGltf" class="drop">Drop <b>GLTF</b> here<br><span class="hint">(drop scene.gltf + .bin + textures together, or a .glb)</span></div>
</div>
<div>
<h2>2 · Car def (JSON)</h2>
<div id="dropJson" class="drop">Drop <b>car-def JSON</b> here</div>
</div>
<div>
<h2>Attachments</h2>
<div id="attachList" class="list"></div>
<div class="row" style="margin-top:6px">
<button data-add="exhaust">+ exhaust</button>
<button data-add="trail">+ trail</button>
<button data-add="boost">+ boost</button>
<button data-add="headlight">+ light</button>
<button id="delAttach">🗑 delete</button>
</div>
</div>
<div>
<h2>Transform <span id="modePill" class="pill">rotate</span></h2>
<div class="row">
<button data-mode="translate">move</button>
<button data-mode="rotate">rotate</button>
<button data-mode="scale">scale</button>
<button id="toggleSpin" class="active">spin wheels</button>
</div>
</div>
<div id="nodeSection" style="display:none">
<h2>GLTF nodes <span class="hint">(click to add/remove from selected wheel)</span></h2>
<div id="nodeList" class="list nodes"></div>
</div>
<div>
<h2>Export</h2>
<div class="row"><button id="copyJson">Copy JSON to clipboard</button></div>
<textarea id="jsonOut" spellcheck="false" placeholder="Exported def appears here…"></textarea>
</div>
</div>
</div>
<script type="module" src="./src/editor/editor.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",
"test": "node --test",
"gen-sounds": "node tools/gen-sounds.mjs"
},
"dependencies": {
"@woosh/meep-engine": "2.155.0",
"three": "0.136.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, clustered effect lighting, particles, trails,
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`.
## Test
```bash
npm test # node --test
```
`test/flipAssist.test.js` covers the flip-assist: decision-logic unit tests (which
phase fires, the exact force/torque, and that nothing fires in the air) plus a
small angular rigid-body sim that checks the car actually rights itself from any
roll on the ground and stays put when airborne.
## 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 with a unit + integration
test suite (`npm test`).
- **Sensor goals + contact events** — each net is a `BoxShape3D` `IsSensor`
collider; `PhysicsSystem.onContactBegin` reports the ball 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.
- **Particles** (`fx/`) — always-on exhaust smoke plus transient boost-flame and
tyre-dust puffs, gated by car state.
- **Trails** (`Trail2D`) behind a fast car and a fast ball.
- **Clustered effect lighting** — a boost point-light per car and short-lived
point-lights for impacts and goal celebrations, on top of the Forward+ pipeline.
- **HDR environment** — one equirectangular `.hdr` (`RGBELoader` + `PMREMGenerator`)
drives the PBR reflections on the cars and the sky behind the dome.
- **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.
- **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
ball.js the match ball
match.js score / clock / kickoff state machine
tools/ dependency-free WAV + PNG generators
```
Only `@woosh/meep-engine` and `three` are used — nothing else.
import { defineConfig } from "vite";
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({
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/jet-propulsion-alliance"),
emptyOutDir: false,
rollupOptions: {
input: {
demo: resolve(__dirname, "demo.html"),
editor: resolve(__dirname, "editor.html"), // car-def editor tool
},
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
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.