Spaces:
Running on Zero
Running on Zero
Render native track curves and recorded poses only
Browse files- track-map.js +180 -952
track-map.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
/* Browser-only preview
|
| 2 |
(function(root,factory){
|
| 3 |
if(typeof module==='object'&&module.exports)module.exports=factory(root);
|
| 4 |
else root.TrackMap=factory(root);
|
| 5 |
})(typeof window==='undefined'?this:window,function(root){
|
| 6 |
const COLOURS=['#607e96','#b89561','#b9d9ed'];
|
| 7 |
const NAMES=['Asphalt','Dirt','Ice'];
|
| 8 |
-
const VEHICLE_NAMES=['Formula','Rally','Snowmobile','Truck'];
|
| 9 |
const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
|
| 10 |
function vector(value){
|
| 11 |
const match=typeof value==='string'&&VECTOR.exec(value);
|
|
@@ -88,139 +87,6 @@
|
|
| 88 |
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
|
| 89 |
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
|
| 90 |
}
|
| 91 |
-
|
| 92 |
-
function cubicPoint(a,p1,p2,z,t){
|
| 93 |
-
const u=1-t,uu=u*u,tt=t*t;
|
| 94 |
-
return [
|
| 95 |
-
uu*u*a[0]+3*uu*t*p1[0]+3*u*tt*p2[0]+tt*t*z[0],
|
| 96 |
-
uu*u*a[1]+3*uu*t*p1[1]+3*u*tt*p2[1]+tt*t*z[1]
|
| 97 |
-
];
|
| 98 |
-
}
|
| 99 |
-
function cubicTangent(a,p1,p2,z,t){
|
| 100 |
-
const u=1-t;
|
| 101 |
-
const dx=3*u*u*(p1[0]-a[0])+6*u*t*(p2[0]-p1[0])+3*t*t*(z[0]-p2[0]);
|
| 102 |
-
const dy=3*u*u*(p1[1]-a[1])+6*u*t*(p2[1]-p1[1])+3*t*t*(z[1]-p2[1]);
|
| 103 |
-
const len=Math.hypot(dx,dy)||1;
|
| 104 |
-
return [dx/len,dy/len];
|
| 105 |
-
}
|
| 106 |
-
function oppositeSide(side){return [1,0,3,2][side];}
|
| 107 |
-
function neighborDelta(side){return [[0,-1],[0,1],[-1,0],[1,0]][side];}
|
| 108 |
-
|
| 109 |
-
function buildTrackGeometry(track,w,h){
|
| 110 |
-
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 111 |
-
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
|
| 112 |
-
const left=(w-columns*size)/2,top=(h-rows*size)/2;
|
| 113 |
-
const roadHalfWidth=Math.max(7,size*0.215);
|
| 114 |
-
const tileMap=new Map(track.tiles.map(t=>[`${t.x},${t.y}`,t]));
|
| 115 |
-
const orderedSegments=[];
|
| 116 |
-
const visited=new Set();
|
| 117 |
-
let curTile=tileMap.get(`${track.start[0]},${track.start[1]}`);
|
| 118 |
-
let entryConn=track.startConnection;
|
| 119 |
-
while(curTile&&orderedSegments.length<track.tiles.length){
|
| 120 |
-
const key=`${curTile.x},${curTile.y}`;
|
| 121 |
-
if(visited.has(key))break;
|
| 122 |
-
visited.add(key);
|
| 123 |
-
const [c0,c1]=curTile.connections;
|
| 124 |
-
const isFirst=c0.side===entryConn.side&&c0.index===entryConn.index;
|
| 125 |
-
const enter=isFirst?c0:c1;
|
| 126 |
-
const exit=isFirst?c1:c0;
|
| 127 |
-
orderedSegments.push({tile:curTile,enter,exit});
|
| 128 |
-
const [dx,dy]=neighborDelta(exit.side);
|
| 129 |
-
const nextTile=tileMap.get(`${curTile.x+dx},${curTile.y+dy}`);
|
| 130 |
-
if(!nextTile)break;
|
| 131 |
-
const nextSide=oppositeSide(exit.side);
|
| 132 |
-
const match=nextTile.connections.find(c=>c.side===nextSide&&c.index===exit.index);
|
| 133 |
-
if(!match)break;
|
| 134 |
-
curTile=nextTile;
|
| 135 |
-
entryConn=match;
|
| 136 |
-
}
|
| 137 |
-
for(const tile of track.tiles){
|
| 138 |
-
const key=`${tile.x},${tile.y}`;
|
| 139 |
-
if(!visited.has(key)&&tile.connections.length>=2){
|
| 140 |
-
orderedSegments.push({tile,enter:tile.connections[0],exit:tile.connections[1]});
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
const rawSamples=[];
|
| 144 |
-
const stepsPerTile=26;
|
| 145 |
-
for(let sIdx=0;sIdx<orderedSegments.length;sIdx++){
|
| 146 |
-
const {tile,enter,exit}=orderedSegments[sIdx];
|
| 147 |
-
const tx=left+(tile.x-b.minX)*size,ty=top+(tile.y-b.minY)*size;
|
| 148 |
-
const a=connectionPoint(enter,tx,ty,size),z=connectionPoint(exit,tx,ty,size);
|
| 149 |
-
const p1=connectionControl(enter,exit,a,z),p2=connectionControl(exit,entryConnForControl(exit,enter),z,a);
|
| 150 |
-
for(let i=0;i<(sIdx===orderedSegments.length-1?stepsPerTile+1:stepsPerTile);i++){
|
| 151 |
-
const t=i/stepsPerTile;
|
| 152 |
-
const [px,py]=cubicPoint(a,p1,p2,z,t);
|
| 153 |
-
const [ux,uy]=cubicTangent(a,p1,p2,z,t);
|
| 154 |
-
const nx=-uy,ny=ux;
|
| 155 |
-
rawSamples.push({x:px,y:py,tx:ux,ty:uy,nx,ny,surface:tile.surface,tileIndex:sIdx});
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
function entryConnForControl(_,enter){return enter;}
|
| 159 |
-
if(rawSamples.length<2){
|
| 160 |
-
const sx=left+(track.start[0]-b.minX)*size+size*0.5,sy=top+(track.start[1]-b.minY)*size+size*0.5;
|
| 161 |
-
rawSamples.push({x:sx,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0},{x:sx+10,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0});
|
| 162 |
-
}
|
| 163 |
-
let totalLength=0;
|
| 164 |
-
rawSamples[0].s=0;
|
| 165 |
-
for(let i=1;i<rawSamples.length;i++){
|
| 166 |
-
const ds=Math.hypot(rawSamples[i].x-rawSamples[i-1].x,rawSamples[i].y-rawSamples[i-1].y);
|
| 167 |
-
totalLength+=ds;
|
| 168 |
-
rawSamples[i].s=totalLength;
|
| 169 |
-
}
|
| 170 |
-
totalLength=Math.max(1,totalLength);
|
| 171 |
-
const n=rawSamples.length;
|
| 172 |
-
const closed=Math.hypot(rawSamples[n-1].x-rawSamples[0].x,rawSamples[n-1].y-rawSamples[0].y)<size*0.45;
|
| 173 |
-
for(let i=0;i<n;i++){
|
| 174 |
-
const prev=rawSamples[closed?(i-2+n)%n:Math.max(0,i-2)];
|
| 175 |
-
const next=rawSamples[closed?(i+2)%n:Math.min(n-1,i+2)];
|
| 176 |
-
const a1=Math.atan2(prev.ty,prev.tx),a2=Math.atan2(next.ty,next.tx);
|
| 177 |
-
let da=a2-a1;
|
| 178 |
-
while(da>Math.PI)da-=Math.PI*2;
|
| 179 |
-
while(da<-Math.PI)da+=Math.PI*2;
|
| 180 |
-
const ds=Math.max(2,Math.hypot(next.x-prev.x,next.y-prev.y));
|
| 181 |
-
const p=rawSamples[i];
|
| 182 |
-
p.curvature=da/ds;
|
| 183 |
-
p.heading=Math.atan2(p.ty,p.tx);
|
| 184 |
-
p.u=p.s/totalLength;
|
| 185 |
-
p.lx=p.x+p.nx*roadHalfWidth;
|
| 186 |
-
p.ly=p.y+p.ny*roadHalfWidth;
|
| 187 |
-
p.rx=p.x-p.nx*roadHalfWidth;
|
| 188 |
-
p.ry=p.y-p.ny*roadHalfWidth;
|
| 189 |
-
}
|
| 190 |
-
return {size,left,top,columns,rows,roadHalfWidth,samples:rawSamples,totalLength,closed};
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
function sampleAtProgress(geom,u){
|
| 194 |
-
const samples=geom.samples,n=samples.length;
|
| 195 |
-
if(!n)return {x:0,y:0,tx:1,ty:0,nx:0,ny:1,heading:0,curvature:0,surface:0,u:0};
|
| 196 |
-
let norm=geom.closed?((u%1)+1)%1:Math.max(0,Math.min(1,u));
|
| 197 |
-
const targetS=norm*geom.totalLength;
|
| 198 |
-
let lo=0,hi=n-1;
|
| 199 |
-
while(lo<hi){
|
| 200 |
-
const mid=(lo+hi)>>1;
|
| 201 |
-
if(samples[mid].s<targetS)lo=mid+1;
|
| 202 |
-
else hi=mid;
|
| 203 |
-
}
|
| 204 |
-
const i1=Math.min(n-1,lo),i0=Math.max(0,i1-1);
|
| 205 |
-
const s0=samples[i0],s1=samples[i1];
|
| 206 |
-
const span=Math.max(1e-5,s1.s-s0.s);
|
| 207 |
-
const t=Math.max(0,Math.min(1,(targetS-s0.s)/span));
|
| 208 |
-
const tx=s0.tx+(s1.tx-s0.tx)*t,ty=s0.ty+(s1.ty-s0.ty)*t;
|
| 209 |
-
const tLen=Math.hypot(tx,ty)||1;
|
| 210 |
-
const ux=tx/tLen,uy=ty/tLen;
|
| 211 |
-
return {
|
| 212 |
-
x:s0.x+(s1.x-s0.x)*t,
|
| 213 |
-
y:s0.y+(s1.y-s0.y)*t,
|
| 214 |
-
tx:ux,ty:uy,
|
| 215 |
-
nx:-uy,ny:ux,
|
| 216 |
-
heading:Math.atan2(uy,ux),
|
| 217 |
-
curvature:s0.curvature+(s1.curvature-s0.curvature)*t,
|
| 218 |
-
surface:t<0.5?s0.surface:s1.surface,
|
| 219 |
-
u:norm,
|
| 220 |
-
idx:i0
|
| 221 |
-
};
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
function render(canvas,track){
|
| 225 |
const context=canvas.getContext('2d');
|
| 226 |
if(!context)throw Error('Canvas preview unavailable');
|
|
@@ -230,18 +96,6 @@
|
|
| 230 |
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 231 |
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
|
| 232 |
const left=(w-columns*size)/2,top=(h-rows*size)/2;
|
| 233 |
-
|
| 234 |
-
// Subtle CAD coordinate grid in background
|
| 235 |
-
if(typeof context.save==='function'){
|
| 236 |
-
context.save();
|
| 237 |
-
context.strokeStyle='rgba(42,61,82,0.25)';
|
| 238 |
-
context.lineWidth=1;
|
| 239 |
-
const step=Math.max(24,Math.round(size*0.5));
|
| 240 |
-
for(let gx=left%step;gx<w;gx+=step){context.beginPath();context.moveTo(gx,0);context.lineTo(gx,h);context.stroke();}
|
| 241 |
-
for(let gy=top%step;gy<h;gy+=step){context.beginPath();context.moveTo(0,gy);context.lineTo(w,gy);context.stroke();}
|
| 242 |
-
context.restore();
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
for(const tile of track.tiles){
|
| 246 |
const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
|
| 247 |
context.fillStyle=COLOURS[tile.surface];
|
|
@@ -252,142 +106,26 @@
|
|
| 252 |
const [entry,exit]=tile.connections;
|
| 253 |
const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
|
| 254 |
const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
|
| 255 |
-
// Road bed & wall corridor
|
| 256 |
-
context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
|
| 257 |
-
context.strokeStyle='#cbd7d9';context.lineWidth=Math.max(8,size*.46);context.lineCap='round';context.stroke();
|
| 258 |
-
context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
|
| 259 |
-
context.strokeStyle=tile.surface===2?'#223c52':tile.surface===1?'#2e2720':'#162434';
|
| 260 |
-
context.lineWidth=Math.max(6,size*.40);context.lineCap='round';context.stroke();
|
| 261 |
-
// Original centerline
|
| 262 |
context.beginPath();context.moveTo(...a);
|
| 263 |
context.bezierCurveTo(...p1,...p2,...z);
|
| 264 |
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
|
| 265 |
context.lineCap='round';context.stroke();
|
| 266 |
}
|
| 267 |
}
|
| 268 |
-
|
| 269 |
-
// High-detail FIA curbs, sector gates, and start gantry when full 2D context is available
|
| 270 |
-
const geom=buildTrackGeometry(track,w,h);
|
| 271 |
-
track._geometry=geom;
|
| 272 |
-
if(typeof context.save==='function'&&geom.samples.length>8){
|
| 273 |
-
context.save();
|
| 274 |
-
const hw=geom.roadHalfWidth;
|
| 275 |
-
// FIA red/white apex curbs on sharp corners
|
| 276 |
-
for(let i=1;i<geom.samples.length-1;i+=2){
|
| 277 |
-
const s0=geom.samples[i-1],s1=geom.samples[i+1];
|
| 278 |
-
if(Math.abs(s0.curvature)*size>0.65){
|
| 279 |
-
const side=s0.curvature>0?-1:1;
|
| 280 |
-
const ox0=s0.x+s0.nx*side*(hw*0.92),oy0=s0.y+s0.ny*side*(hw*0.92);
|
| 281 |
-
const ox1=s1.x+s1.nx*side*(hw*0.92),oy1=s1.y+s1.ny*side*(hw*0.92);
|
| 282 |
-
context.beginPath();context.moveTo(ox0,oy0);context.lineTo(ox1,oy1);
|
| 283 |
-
context.strokeStyle=((i>>1)&1)?'#e05252':'#f0f6fc';
|
| 284 |
-
context.lineWidth=Math.max(2,size*0.045);context.lineCap='butt';context.stroke();
|
| 285 |
-
}
|
| 286 |
-
}
|
| 287 |
-
// Sector split markers (S1 / S2 / S3)
|
| 288 |
-
for(const [frac,label] of [[0.333,'S2'],[0.667,'S3']]){
|
| 289 |
-
const sp=sampleAtProgress(geom,frac);
|
| 290 |
-
context.beginPath();
|
| 291 |
-
context.moveTo(sp.x+sp.nx*hw*0.95,sp.y+sp.ny*hw*0.95);
|
| 292 |
-
context.lineTo(sp.x-sp.nx*hw*0.95,sp.y-sp.ny*hw*0.95);
|
| 293 |
-
context.strokeStyle='rgba(88,166,255,0.55)';context.lineWidth=1.5;context.stroke();
|
| 294 |
-
if(typeof context.fillText==='function'){
|
| 295 |
-
context.fillStyle='#8ec2ff';context.font='700 10px system-ui,sans-serif';
|
| 296 |
-
context.fillText(label,sp.x+sp.nx*(hw+8)-6,sp.y+sp.ny*(hw+8)+3);
|
| 297 |
-
}
|
| 298 |
-
}
|
| 299 |
-
// Checkered Start/Finish line at u=0
|
| 300 |
-
const st=geom.samples[0];
|
| 301 |
-
if(st){
|
| 302 |
-
const checkers=6;
|
| 303 |
-
for(let c=0;c<checkers;c++){
|
| 304 |
-
const f0=-1+2*c/checkers,f1=-1+2*(c+1)/checkers;
|
| 305 |
-
context.beginPath();
|
| 306 |
-
context.moveTo(st.x+st.nx*hw*f0,st.y+st.ny*hw*f0);
|
| 307 |
-
context.lineTo(st.x+st.nx*hw*f1,st.y+st.ny*hw*f1);
|
| 308 |
-
context.strokeStyle=(c&1)?'#0c1926':'#f0f6fc';
|
| 309 |
-
context.lineWidth=Math.max(3,size*0.055);context.lineCap='butt';context.stroke();
|
| 310 |
-
}
|
| 311 |
-
}
|
| 312 |
-
context.restore();
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
|
| 316 |
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
|
| 317 |
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
|
| 318 |
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
|
| 319 |
}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
function evaluateNeuralNetwork(nn,inputs){
|
| 323 |
-
if(!nn||!Array.isArray(nn.Weights)||!Array.isArray(nn.Biases))return null;
|
| 324 |
-
let act=inputs.slice(0,nn.Shape?.[0]||inputs.length);
|
| 325 |
-
while(act.length<(nn.Shape?.[0]||0))act.push(0);
|
| 326 |
-
const layerActivations=[act];
|
| 327 |
-
for(let l=0;l<nn.Weights.length;l++){
|
| 328 |
-
const W=nn.Weights[l],B=nn.Biases[l];
|
| 329 |
-
const next=new Array(B.length);
|
| 330 |
-
for(let j=0;j<B.length;j++){
|
| 331 |
-
let sum=B[j]||0;
|
| 332 |
-
for(let i=0;i<act.length;i++)sum+=(act[i]||0)*(W[i]?.[j]||0);
|
| 333 |
-
next[j]=Math.tanh(sum);
|
| 334 |
-
}
|
| 335 |
-
act=next;
|
| 336 |
-
layerActivations.push(act);
|
| 337 |
-
}
|
| 338 |
-
return {outputs:act,layerActivations};
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
// Analytic raycast against track corridor walls near car position
|
| 342 |
-
function castRayAgainstCorridor(geom,carX,carY,rayAngle,maxDistPx,centerIdx){
|
| 343 |
-
const samples=geom.samples,n=samples.length;
|
| 344 |
-
if(n<4)return {dist:maxDistPx,frac:1,hx:carX+Math.cos(rayAngle)*maxDistPx,hy:carY+Math.sin(rayAngle)*maxDistPx,hit:false};
|
| 345 |
-
const dx=Math.cos(rayAngle),dy=Math.sin(rayAngle);
|
| 346 |
-
let bestT=maxDistPx,hit=false;
|
| 347 |
-
const windowSize=Math.min(n-1,56);
|
| 348 |
-
const startIdx=geom.closed?(centerIdx-12+n)%n:Math.max(0,centerIdx-12);
|
| 349 |
-
for(let k=0;k<windowSize;k++){
|
| 350 |
-
const i0=geom.closed?(startIdx+k)%n:Math.min(n-2,startIdx+k);
|
| 351 |
-
const i1=geom.closed?(i0+1)%n:i0+1;
|
| 352 |
-
const s0=samples[i0],s1=samples[i1];
|
| 353 |
-
for(const [ax,ay,bx,by] of [[s0.lx,s0.ly,s1.lx,s1.ly],[s0.rx,s0.ry,s1.rx,s1.ry]]){
|
| 354 |
-
const sx=bx-ax,sy=by-ay;
|
| 355 |
-
const det=dx*sy-dy*sx;
|
| 356 |
-
if(Math.abs(det)<1e-6)continue;
|
| 357 |
-
const qx=ax-carX,qy=ay-carY;
|
| 358 |
-
const t=(qx*sy-qy*sx)/det;
|
| 359 |
-
const u=(qx*dy-qy*dx)/det;
|
| 360 |
-
if(t>2&&t<bestT&&u>=0&&u<=1){
|
| 361 |
-
bestT=t;hit=true;
|
| 362 |
-
}
|
| 363 |
-
}
|
| 364 |
-
}
|
| 365 |
-
return {
|
| 366 |
-
dist:bestT,
|
| 367 |
-
frac:Math.max(0,Math.min(1,bestT/maxDistPx)),
|
| 368 |
-
hx:carX+dx*bestT,
|
| 369 |
-
hy:carY+dy*bestT,
|
| 370 |
-
hit
|
| 371 |
-
};
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
// Draw detailed 2D vehicle chassis with steerable front wheels, white shader outline, brake lights & boost flame
|
| 375 |
-
function drawVehicle2D(ctx,x,y,heading,slipAngle,steerAngle,vehicleType,color,scale,showOutline,braking,boosting,active){
|
| 376 |
ctx.save();
|
| 377 |
ctx.translate(x,y);
|
| 378 |
-
ctx.rotate(heading
|
| 379 |
ctx.scale(scale,scale);
|
| 380 |
if(!active)ctx.globalAlpha=0.55;
|
| 381 |
|
| 382 |
-
const wheelSteer=
|
| 383 |
-
// Boost exhaust flame
|
| 384 |
-
if(boosting&&active){
|
| 385 |
-
ctx.fillStyle='#ff9a3c';
|
| 386 |
-
ctx.beginPath();ctx.moveTo(-14,-3);ctx.lineTo(-24,0);ctx.lineTo(-14,3);ctx.closePath();ctx.fill();
|
| 387 |
-
ctx.fillStyle='#ffd073';
|
| 388 |
-
ctx.beginPath();ctx.moveTo(-14,-1.6);ctx.lineTo(-19.5,0);ctx.lineTo(-14,1.6);ctx.closePath();ctx.fill();
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
// Draw wheels / skis
|
| 392 |
ctx.fillStyle='#101820';
|
| 393 |
ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
|
|
@@ -461,16 +199,7 @@
|
|
| 461 |
ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
|
| 462 |
}
|
| 463 |
|
| 464 |
-
//
|
| 465 |
-
if(braking&&active){
|
| 466 |
-
ctx.fillStyle='#ff4d4d';
|
| 467 |
-
ctx.shadowColor='#ff4d4d';ctx.shadowBlur=6;
|
| 468 |
-
ctx.fillRect(-13.8,-5.2,1.8,3.2);
|
| 469 |
-
ctx.fillRect(-13.8,2.0,1.8,3.2);
|
| 470 |
-
ctx.shadowBlur=0;
|
| 471 |
-
}
|
| 472 |
-
|
| 473 |
-
// Eliminated / crashed X badge over car
|
| 474 |
if(!active){
|
| 475 |
ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
|
| 476 |
ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
|
|
@@ -478,721 +207,220 @@
|
|
| 478 |
ctx.restore();
|
| 479 |
}
|
| 480 |
|
| 481 |
-
function buildCandidateProfile(spec,geom,maxTicks){
|
| 482 |
-
const ticks=Math.max(60,Math.min(18000,spec.ticksElapsed||maxTicks||900));
|
| 483 |
-
const lapSec=spec.bestLapSeconds&&spec.bestLapSeconds>0?spec.bestLapSeconds:null;
|
| 484 |
-
const laps=spec.forwardLaps||0;
|
| 485 |
-
const raw=spec.rawMetrics||{};
|
| 486 |
-
const driftFactor=raw.total_drifting_amount!=null?Math.min(1.4,Math.max(0.15,raw.total_drifting_amount/Math.max(1,ticks*0.25))):0.35;
|
| 487 |
-
const apexEff=raw.total_racing_line_efficiency!=null?Math.min(1,Math.max(0.2,raw.total_racing_line_efficiency/Math.max(1,ticks*0.5))):0.68;
|
| 488 |
-
const contacts=spec.physicalContacts??0;
|
| 489 |
-
const active=spec.active!==false;
|
| 490 |
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
const lapTicks=Math.max(90,lapSec*60);
|
| 495 |
-
totalProgress=Math.max(1.05,ticks/lapTicks);
|
| 496 |
-
}else if(spec.totalDistance&&spec.totalDistance>0){
|
| 497 |
-
totalProgress=Math.max(0.18,Math.min(3.5,spec.totalDistance/(geom.totalLength*3.1)));
|
| 498 |
-
}else if(laps>0){
|
| 499 |
-
totalProgress=Math.max(laps+0.15,1.1);
|
| 500 |
-
}
|
| 501 |
-
const phaseSeed=(spec.carIndex||0)*0.6180339887;
|
| 502 |
-
return {
|
| 503 |
-
id:spec.id||'primary',
|
| 504 |
-
label:spec.label||'Active Driver',
|
| 505 |
-
role:spec.role||'primary',
|
| 506 |
-
color:spec.color||'#62dfb1',
|
| 507 |
-
vehicleType:Number.isInteger(spec.vehicleType)?spec.vehicleType:0,
|
| 508 |
-
network:spec.network||null,
|
| 509 |
-
ticksElapsed:ticks,
|
| 510 |
-
bestLapSeconds:lapSec,
|
| 511 |
-
forwardLaps:laps,
|
| 512 |
-
physicalContacts:contacts,
|
| 513 |
-
active,
|
| 514 |
-
driftFactor,
|
| 515 |
-
apexEff,
|
| 516 |
-
totalProgress,
|
| 517 |
-
phaseSeed
|
| 518 |
-
};
|
| 519 |
}
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
const
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
const
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
heading:sCenter.heading,
|
| 556 |
-
curvature:sCenter.curvature,
|
| 557 |
-
surface:sCenter.surface,
|
| 558 |
-
u:sCenter.u,
|
| 559 |
-
lap:Math.floor(baseU)+1,
|
| 560 |
-
idx:sCenter.idx||0,
|
| 561 |
-
alive,
|
| 562 |
-
speedNorm,
|
| 563 |
-
speedKmh,
|
| 564 |
-
slipAngle,
|
| 565 |
-
braking,
|
| 566 |
-
boosting:boostCmd>0.5,
|
| 567 |
-
controls:{steer:steerCmd,throttle:throttleCmd,brake:brakeCmd,hbrake:hbrakeCmd,boost:boostCmd}
|
| 568 |
-
};
|
| 569 |
}
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
function mount({document,fetch,picker}){
|
| 572 |
-
const canvas=document.getElementById('trackMap');
|
| 573 |
-
|
| 574 |
-
const
|
| 575 |
-
|
| 576 |
-
let
|
| 577 |
-
let
|
| 578 |
-
let
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
const
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
render(off,currentTrack);
|
| 597 |
-
staticLayer=off;
|
| 598 |
}else{
|
| 599 |
-
|
| 600 |
-
|
| 601 |
}
|
| 602 |
}
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
const geom=currentTrack._geometry;
|
| 607 |
-
const reqTicks=analysisState?.summary?.conditions?.requested_ticks||receiptState?.report?.requested_ticks||Number(document.getElementById('training_ticks')?.value)||900;
|
| 608 |
-
maxTicks=Math.max(60,Math.min(18000,reqTicks));
|
| 609 |
-
const scrub=document.getElementById('replay_scrub');
|
| 610 |
-
if(scrub)scrub.max=String(maxTicks);
|
| 611 |
-
if(currentTick>maxTicks)currentTick=0;
|
| 612 |
-
|
| 613 |
-
const vType=analysisState?.summary?.conditions?.vehicle_type??receiptState?.report?.vehicle_type??stagedNet?.Type??Number(document.getElementById('starter_vehicle')?.value||0);
|
| 614 |
-
profiles=[];
|
| 615 |
-
swarmProfiles=[];
|
| 616 |
-
|
| 617 |
-
if(analysisState?.summary?.mode==='train'&&analysisState.summary.winners){
|
| 618 |
-
const roleMeta=[
|
| 619 |
-
['fitness','Fitness Leader','#62dfb1'],
|
| 620 |
-
['fastest_lap','Fastest Lap','#ffd073'],
|
| 621 |
-
['clean_laps','Clean Laps','#58a6ff']
|
| 622 |
-
];
|
| 623 |
-
for(const [role,label,color] of roleMeta){
|
| 624 |
-
const w=analysisState.summary.winners[role];
|
| 625 |
-
if(w&&w.status==='available'){
|
| 626 |
-
const row=analysisState.descriptorRows?.find(r=>r.candidate_id===w.candidate_id);
|
| 627 |
-
profiles.push(buildCandidateProfile({
|
| 628 |
-
id:w.candidate_id,
|
| 629 |
-
role,
|
| 630 |
-
label:`${label} (${w.candidate_id})`,
|
| 631 |
-
color,
|
| 632 |
-
vehicleType:vType,
|
| 633 |
-
network:analysisState.winnerNetworks?.[role]?.decoded||stagedNet,
|
| 634 |
-
ticksElapsed:row?.ticks_elapsed||maxTicks,
|
| 635 |
-
bestLapSeconds:w.best_lap_seconds,
|
| 636 |
-
forwardLaps:w.forward_laps,
|
| 637 |
-
physicalContacts:w.physical_contacts,
|
| 638 |
-
totalDistance:w.total_actual_distance,
|
| 639 |
-
active:row?.objectives?.active??true,
|
| 640 |
-
rawMetrics:row?.raw_metrics,
|
| 641 |
-
carIndex:w.car
|
| 642 |
-
},geom,maxTicks));
|
| 643 |
-
}
|
| 644 |
-
}
|
| 645 |
-
// Build population swarm for the selected generation in descriptor_generation (up to 20 cars)
|
| 646 |
-
if(Array.isArray(analysisState.descriptorRows)&&analysisState.descriptorRows.length){
|
| 647 |
-
const targetGen=Number(document.getElementById('descriptor_generation')?.value)||analysisState.summary.observations?.generations||1;
|
| 648 |
-
const genRows=analysisState.descriptorRows.filter(r=>r.generation===targetGen).slice(0,20);
|
| 649 |
-
const palette=['#62dfb1','#ffd073','#58a6ff','#c792ea','#ff8b7b','#7ce4c3'];
|
| 650 |
-
genRows.forEach((row,idx)=>{
|
| 651 |
-
swarmProfiles.push(buildCandidateProfile({
|
| 652 |
-
id:row.candidate_id,
|
| 653 |
-
role:'swarm',
|
| 654 |
-
label:`Gen ${row.generation} · Car #${row.car}`,
|
| 655 |
-
color:palette[idx%palette.length],
|
| 656 |
-
vehicleType:vType,
|
| 657 |
-
network:stagedNet,
|
| 658 |
-
ticksElapsed:row.ticks_elapsed||maxTicks,
|
| 659 |
-
bestLapSeconds:row.objectives?.best_lap_seconds,
|
| 660 |
-
forwardLaps:row.objectives?.forward_laps,
|
| 661 |
-
physicalContacts:row.objectives?.physical_contacts,
|
| 662 |
-
totalDistance:row.objectives?.total_actual_distance,
|
| 663 |
-
active:row.objectives?.active,
|
| 664 |
-
rawMetrics:row.raw_metrics,
|
| 665 |
-
carIndex:row.car
|
| 666 |
-
},geom,maxTicks));
|
| 667 |
-
});
|
| 668 |
-
}
|
| 669 |
-
}else if(analysisState?.summary?.mode==='evaluate'&&analysisState.evaluation){
|
| 670 |
-
const m=analysisState.evaluation.measurements;
|
| 671 |
-
profiles.push(buildCandidateProfile({
|
| 672 |
-
id:m.candidate_id||'eval',
|
| 673 |
-
role:'evaluate',
|
| 674 |
-
label:'Frozen Evaluation Policy',
|
| 675 |
-
color:'#62dfb1',
|
| 676 |
-
vehicleType:vType,
|
| 677 |
-
network:stagedNet,
|
| 678 |
-
ticksElapsed:m.ticks_elapsed||maxTicks,
|
| 679 |
-
bestLapSeconds:m.objectives?.best_lap_seconds,
|
| 680 |
-
forwardLaps:m.objectives?.forward_laps,
|
| 681 |
-
physicalContacts:m.objectives?.physical_contacts,
|
| 682 |
-
totalDistance:m.objectives?.total_actual_distance,
|
| 683 |
-
active:m.objectives?.active,
|
| 684 |
-
rawMetrics:m.raw_metrics,
|
| 685 |
-
carIndex:0
|
| 686 |
-
},geom,maxTicks));
|
| 687 |
-
}else if(receiptState?.report){
|
| 688 |
-
const r=receiptState.report;
|
| 689 |
-
profiles.push(buildCandidateProfile({
|
| 690 |
-
id:'receipt',
|
| 691 |
-
role:'fitness',
|
| 692 |
-
label:`Run Candidate (${VEHICLE_NAMES[vType]||'Formula'})`,
|
| 693 |
-
color:'#62dfb1',
|
| 694 |
-
vehicleType:vType,
|
| 695 |
-
network:stagedNet,
|
| 696 |
-
ticksElapsed:r.simulated_ticks||maxTicks,
|
| 697 |
-
bestLapSeconds:r.best_lap_seconds,
|
| 698 |
-
forwardLaps:r.best_lap_seconds?1:0,
|
| 699 |
-
physicalContacts:0,
|
| 700 |
-
totalDistance:null,
|
| 701 |
-
active:true,
|
| 702 |
-
carIndex:0
|
| 703 |
-
},geom,maxTicks));
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
if(!profiles.length){
|
| 707 |
-
profiles.push(buildCandidateProfile({
|
| 708 |
-
id:'staged',
|
| 709 |
-
role:'staged',
|
| 710 |
-
label:stagedNet?.Name?`Staged · ${stagedNet.Name}`:`Preview · ${VEHICLE_NAMES[vType]||'Formula'} Starter`,
|
| 711 |
-
color:'#62dfb1',
|
| 712 |
-
vehicleType:vType,
|
| 713 |
-
network:stagedNet,
|
| 714 |
-
ticksElapsed:maxTicks,
|
| 715 |
-
bestLapSeconds:11.8,
|
| 716 |
-
forwardLaps:1,
|
| 717 |
-
physicalContacts:0,
|
| 718 |
-
active:true,
|
| 719 |
-
carIndex:0
|
| 720 |
-
},geom,maxTicks));
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
// Precompute speed-heatmap ribbon, skid segments, and wall contact markers for the lead profile (zero per-frame path math)
|
| 724 |
-
const lead=getActiveProfiles()[0]||profiles[0];
|
| 725 |
-
speedRibbon=[];
|
| 726 |
-
skidSegments=[];
|
| 727 |
-
contactMarkers=[];
|
| 728 |
-
const ribbonSteps=140;
|
| 729 |
-
for(let i=0;i<=ribbonSteps;i++){
|
| 730 |
-
const st=evaluateCarAtTick(lead,geom,(i/ribbonSteps)*lead.ticksElapsed,maxTicks);
|
| 731 |
-
const col=st.braking?'#ff6b6b':st.speedNorm>0.76?'#62dfb1':'#ffd073';
|
| 732 |
-
speedRibbon.push({x:st.x,y:st.y,color:col,slip:st.slipAngle,heading:st.heading});
|
| 733 |
-
if(Math.abs(st.slipAngle)>0.11&&i>0){
|
| 734 |
-
const prev=speedRibbon[i-1];
|
| 735 |
-
skidSegments.push({x0:prev.x,y0:prev.y,x1:st.x,y1:st.y,nx:-Math.sin(st.heading),ny:Math.cos(st.heading)});
|
| 736 |
-
}
|
| 737 |
-
}
|
| 738 |
-
const totalContacts=lead.physicalContacts||(lead.active?0:1);
|
| 739 |
-
if(totalContacts>0){
|
| 740 |
-
// Place contact markers at sharpest corners or terminal point
|
| 741 |
-
const sortedCorners=geom.samples.map((s,idx)=>({s,idx,k:Math.abs(s.curvature)})).sort((a,b)=>b.k-a.k);
|
| 742 |
-
for(let c=0;c<Math.min(6,totalContacts);c++){
|
| 743 |
-
const pick=sortedCorners[c*4]||sortedCorners[0];
|
| 744 |
-
if(pick){
|
| 745 |
-
const side=pick.s.curvature>0?1:-1;
|
| 746 |
-
contactMarkers.push({
|
| 747 |
-
x:pick.s.x+pick.s.nx*side*geom.roadHalfWidth*0.92,
|
| 748 |
-
y:pick.s.y+pick.s.ny*side*geom.roadHalfWidth*0.92,
|
| 749 |
-
label:`Contact #${c+1}`
|
| 750 |
-
});
|
| 751 |
-
}
|
| 752 |
-
}
|
| 753 |
-
}
|
| 754 |
-
if(!lead.active){
|
| 755 |
-
const term=evaluateCarAtTick(lead,geom,lead.ticksElapsed,maxTicks);
|
| 756 |
-
contactMarkers.push({x:term.x,y:term.y,label:`Eliminated @ ${lead.ticksElapsed}t`,terminal:true});
|
| 757 |
-
}
|
| 758 |
-
}
|
| 759 |
-
|
| 760 |
-
function getActiveProfiles(){
|
| 761 |
-
if(focusMode==='swarm'&&swarmProfiles.length)return swarmProfiles;
|
| 762 |
-
if(focusMode==='fitness'||focusMode==='fastest_lap'||focusMode==='clean_laps'){
|
| 763 |
-
const found=profiles.find(p=>p.role===focusMode);
|
| 764 |
-
if(found)return [found];
|
| 765 |
-
}
|
| 766 |
-
return profiles;
|
| 767 |
}
|
| 768 |
-
|
| 769 |
-
function
|
| 770 |
-
if(!currentTrack
|
| 771 |
-
const ctx=canvas.getContext('2d');
|
| 772 |
-
|
| 773 |
-
const
|
| 774 |
-
|
| 775 |
-
const activeList=getActiveProfiles();
|
| 776 |
-
const lead=activeList[0]||profiles[0];
|
| 777 |
-
if(!lead)return;
|
| 778 |
-
const leadState=evaluateCarAtTick(lead,geom,currentTick,maxTicks);
|
| 779 |
-
|
| 780 |
-
ctx.clearRect(0,0,w,h);
|
| 781 |
-
ctx.fillStyle='#0c1926';
|
| 782 |
-
ctx.fillRect(0,0,w,h);
|
| 783 |
-
|
| 784 |
ctx.save();
|
| 785 |
-
if(cameraMode==='follow'){
|
| 786 |
-
|
| 787 |
-
const lookX=leadState.x+Math.cos(leadState.heading)*28;
|
| 788 |
-
const lookY=leadState.y+Math.sin(leadState.heading)*28;
|
| 789 |
-
ctx.translate(w*0.5,h*0.5);
|
| 790 |
-
ctx.scale(zoom,zoom);
|
| 791 |
-
ctx.translate(-lookX,-lookY);
|
| 792 |
-
}
|
| 793 |
-
|
| 794 |
-
if(staticLayer)ctx.drawImage(staticLayer,0,0);
|
| 795 |
-
else render(canvas,currentTrack);
|
| 796 |
-
|
| 797 |
-
// 1. Tire skid marks in high-slip drift zones
|
| 798 |
-
if(layers.skids&&skidSegments.length){
|
| 799 |
-
ctx.save();
|
| 800 |
-
ctx.strokeStyle='rgba(12,18,26,0.58)';
|
| 801 |
-
ctx.lineWidth=Math.max(1.4,geom.size*0.022);
|
| 802 |
-
const trackHalf=Math.max(3,geom.size*0.055);
|
| 803 |
-
for(const seg of skidSegments){
|
| 804 |
-
for(const s of [-1,1]){
|
| 805 |
-
ctx.beginPath();
|
| 806 |
-
ctx.moveTo(seg.x0+seg.nx*s*trackHalf,seg.y0+seg.ny*s*trackHalf);
|
| 807 |
-
ctx.lineTo(seg.x1+seg.nx*s*trackHalf,seg.y1+seg.ny*s*trackHalf);
|
| 808 |
-
ctx.stroke();
|
| 809 |
-
}
|
| 810 |
-
}
|
| 811 |
-
ctx.restore();
|
| 812 |
-
}
|
| 813 |
-
|
| 814 |
-
// 2. Speed-colored racing line heatmap
|
| 815 |
-
if(layers.line&&speedRibbon.length>1){
|
| 816 |
-
ctx.save();
|
| 817 |
-
ctx.lineWidth=Math.max(2.6,geom.size*0.048);
|
| 818 |
-
ctx.lineCap='round';
|
| 819 |
-
ctx.globalAlpha=0.82;
|
| 820 |
-
for(let i=1;i<speedRibbon.length;i++){
|
| 821 |
-
const p0=speedRibbon[i-1],p1=speedRibbon[i];
|
| 822 |
-
ctx.beginPath();ctx.moveTo(p0.x,p0.y);ctx.lineTo(p1.x,p1.y);
|
| 823 |
-
ctx.strokeStyle=p1.color;ctx.stroke();
|
| 824 |
-
}
|
| 825 |
-
ctx.restore();
|
| 826 |
}
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
ctx.save();
|
| 831 |
-
for(const m of contactMarkers){
|
| 832 |
-
ctx.beginPath();ctx.arc(m.x,m.y,m.terminal?9:6.5,0,Math.PI*2);
|
| 833 |
-
ctx.fillStyle=m.terminal?'rgba(255,107,107,0.28)':'rgba(255,208,115,0.24)';
|
| 834 |
-
ctx.fill();
|
| 835 |
-
ctx.strokeStyle=m.terminal?'#ff6b6b':'#ffd073';
|
| 836 |
-
ctx.lineWidth=2;
|
| 837 |
ctx.beginPath();
|
| 838 |
-
|
| 839 |
-
ctx.
|
| 840 |
-
ctx.stroke();
|
| 841 |
-
}
|
| 842 |
-
ctx.restore();
|
| 843 |
-
}
|
| 844 |
-
|
| 845 |
-
// 4. Raycast Sensor Fan on the lead vehicle (using actual .evolution Sensors or default 7-ray fan)
|
| 846 |
-
const netObj=lead.network||stagedNet;
|
| 847 |
-
const sensorDefs=Array.isArray(netObj?.Sensors)&&netObj.Sensors.some(s=>s.$type==='raycast')
|
| 848 |
-
?netObj.Sensors
|
| 849 |
-
:[
|
| 850 |
-
{$type:'speed'},
|
| 851 |
-
{$type:'raycast',Degrees:-75,Length:750},
|
| 852 |
-
{$type:'raycast',Degrees:-42,Length:850},
|
| 853 |
-
{$type:'raycast',Degrees:-18,Length:950},
|
| 854 |
-
{$type:'raycast',Degrees:0,Length:1100},
|
| 855 |
-
{$type:'raycast',Degrees:18,Length:950},
|
| 856 |
-
{$type:'raycast',Degrees:42,Length:850},
|
| 857 |
-
{$type:'raycast',Degrees:75,Length:750}
|
| 858 |
-
];
|
| 859 |
-
|
| 860 |
-
const sensorInputs=[];
|
| 861 |
-
const pxPerSimUnit=Math.max(0.08,geom.size/768);
|
| 862 |
-
for(const s of sensorDefs){
|
| 863 |
-
if(s.$type==='raycast'){
|
| 864 |
-
const rad=leadState.heading+(s.Degrees||0)*Math.PI/180;
|
| 865 |
-
const maxPx=Math.max(28,Math.min(260,(s.Length||800)*pxPerSimUnit*1.6));
|
| 866 |
-
const hit=castRayAgainstCorridor(geom,leadState.x,leadState.y,rad,maxPx,leadState.idx);
|
| 867 |
-
sensorInputs.push(hit.frac*2-1);
|
| 868 |
-
if(layers.rays&&leadState.alive){
|
| 869 |
-
ctx.save();
|
| 870 |
-
const beamCol=hit.frac<0.32?'#ff6b6b':hit.frac<0.62?'#ffd073':'#62dfb1';
|
| 871 |
-
ctx.strokeStyle=beamCol;
|
| 872 |
-
ctx.globalAlpha=0.52;
|
| 873 |
-
ctx.lineWidth=1.35;
|
| 874 |
-
ctx.beginPath();ctx.moveTo(leadState.x,leadState.y);ctx.lineTo(hit.hx,hit.hy);ctx.stroke();
|
| 875 |
-
ctx.globalAlpha=0.95;
|
| 876 |
-
ctx.fillStyle=beamCol;
|
| 877 |
-
ctx.beginPath();ctx.arc(hit.hx,hit.hy,2.6,0,Math.PI*2);ctx.fill();
|
| 878 |
-
ctx.restore();
|
| 879 |
-
}
|
| 880 |
-
}else if(s.$type==='speed'){
|
| 881 |
-
sensorInputs.push(leadState.speedNorm);
|
| 882 |
-
}else if(s.$type==='velocitySide'||s.$type==='angularVelocity'){
|
| 883 |
-
sensorInputs.push(leadState.slipAngle*2);
|
| 884 |
-
}else if(s.$type==='trackCurvature'){
|
| 885 |
-
sensorInputs.push(Math.max(-1,Math.min(1,leadState.curvature*geom.size)));
|
| 886 |
-
}else{
|
| 887 |
-
sensorInputs.push(leadState.speedNorm*0.7);
|
| 888 |
-
}
|
| 889 |
-
}
|
| 890 |
-
|
| 891 |
-
// Evaluate actual neural network forward pass if available (<0.01ms)
|
| 892 |
-
const nnEval=netObj?.NeuralNetwork?evaluateNeuralNetwork(netObj.NeuralNetwork,sensorInputs):null;
|
| 893 |
-
if(nnEval&&Array.isArray(netObj?.Outputs)&&leadState.alive){
|
| 894 |
-
const outIds=netObj.Outputs;
|
| 895 |
-
const idxSteer=outIds.indexOf(0),idxAccel=outIds.indexOf(1),idxHbk=outIds.indexOf(2),idxBrk=outIds.indexOf(3),idxBst=outIds.indexOf(4);
|
| 896 |
-
if(idxSteer>=0)leadState.controls.steer=leadState.controls.steer*0.55+nnEval.outputs[idxSteer]*0.45;
|
| 897 |
-
if(idxAccel>=0)leadState.controls.throttle=Math.max(0,(nnEval.outputs[idxAccel]+1)*0.5);
|
| 898 |
-
if(idxBrk>=0)leadState.controls.brake=Math.max(0,nnEval.outputs[idxBrk]);
|
| 899 |
-
if(idxHbk>=0)leadState.controls.hbrake=Math.max(0,nnEval.outputs[idxHbk]);
|
| 900 |
-
if(idxBst>=0)leadState.controls.boost=Math.max(0,nnEval.outputs[idxBst]);
|
| 901 |
-
}
|
| 902 |
-
|
| 903 |
-
// 5. Draw all active ghost/swarm cars back-to-front so lead car is on top
|
| 904 |
-
const carScale=Math.max(0.68,Math.min(1.38,geom.size/64));
|
| 905 |
-
for(let i=activeList.length-1;i>=0;i--){
|
| 906 |
-
const prof=activeList[i];
|
| 907 |
-
const st=i===0?leadState:evaluateCarAtTick(prof,geom,currentTick,maxTicks);
|
| 908 |
-
drawVehicle2D(
|
| 909 |
-
ctx,st.x,st.y,st.heading,st.slipAngle,st.controls.steer,
|
| 910 |
-
prof.vehicleType,prof.color,carScale*(i===0?1.06:0.92),
|
| 911 |
-
layers.outline,st.braking,st.boosting,st.alive
|
| 912 |
-
);
|
| 913 |
-
// Floating badge above ghost cars in multi-car race
|
| 914 |
-
if(activeList.length>1&&activeList.length<=4&&typeof ctx.fillText==='function'){
|
| 915 |
-
ctx.save();
|
| 916 |
-
ctx.font='700 10px system-ui,sans-serif';
|
| 917 |
-
ctx.fillStyle=prof.color;
|
| 918 |
-
ctx.strokeStyle='#08111c';ctx.lineWidth=2.5;
|
| 919 |
-
const tag=prof.role==='fitness'?'FIT':prof.role==='fastest_lap'?'LAP':prof.role==='clean_laps'?'CLN':`#${i+1}`;
|
| 920 |
-
ctx.strokeText(tag,st.x-10,st.y-14*carScale);
|
| 921 |
-
ctx.fillText(tag,st.x-10,st.y-14*carScale);
|
| 922 |
-
ctx.restore();
|
| 923 |
}
|
|
|
|
|
|
|
| 924 |
}
|
| 925 |
ctx.restore();
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
const maxSec=(maxTicks/60).toFixed(1);
|
| 937 |
-
timeLabel.textContent=`${sec}s / ${maxSec}s · Tick ${Math.round(currentTick)} / ${maxTicks}`;
|
| 938 |
-
}
|
| 939 |
-
const scrub=document.getElementById('replay_scrub');
|
| 940 |
-
if(scrub&&document.activeElement!==scrub)scrub.value=String(Math.round(currentTick));
|
| 941 |
-
}
|
| 942 |
-
|
| 943 |
-
function drawHudOverlay(ctx,w,h,lead,leadState,activeList,nnEval){
|
| 944 |
-
ctx.save();
|
| 945 |
-
// Top-left status pill
|
| 946 |
-
ctx.fillStyle='rgba(9,17,27,0.84)';
|
| 947 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 948 |
-
ctx.lineWidth=1;
|
| 949 |
-
ctx.beginPath();ctx.roundRect(12,12,285,54,8);ctx.fill();ctx.stroke();
|
| 950 |
-
|
| 951 |
-
ctx.fillStyle='#62dfb1';ctx.font='700 11px system-ui,sans-serif';
|
| 952 |
-
const sector=leadState.u<0.333?'S1':leadState.u<0.667?'S2':'S3';
|
| 953 |
-
const surfName=NAMES[leadState.surface]||'Asphalt';
|
| 954 |
-
ctx.fillText(`${lead.label.slice(0,34).toUpperCase()}`,22,29);
|
| 955 |
-
ctx.fillStyle='#c6d9e9';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
|
| 956 |
-
const bestStr=lead.bestLapSeconds?`Best ${lead.bestLapSeconds.toFixed(3)}s`:`Contacts ${lead.physicalContacts}`;
|
| 957 |
-
ctx.fillText(`LAP ${leadState.lap} · ${sector} · ${surfName.toUpperCase()} · ${bestStr}`,22,46);
|
| 958 |
-
ctx.fillStyle='#82e6bb';ctx.font='600 9.5px system-ui,sans-serif';
|
| 959 |
-
ctx.fillText('CLIENT TELEMETRY REPLAY · 0 SERVER COMPUTE',22,60);
|
| 960 |
-
|
| 961 |
-
// Bottom-left Live Control Telemetry Bars + Speedometer
|
| 962 |
-
const bx=12,by=h-94,bw=260,bh=82;
|
| 963 |
-
ctx.fillStyle='rgba(9,17,27,0.86)';
|
| 964 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 965 |
-
ctx.beginPath();ctx.roundRect(bx,by,bw,bh,8);ctx.fill();ctx.stroke();
|
| 966 |
-
|
| 967 |
-
// Speed & Slip readout
|
| 968 |
-
ctx.fillStyle='#e5edf5';ctx.font='800 18px ui-monospace,SFMono-Regular,monospace';
|
| 969 |
-
ctx.fillText(`${String(leadState.speedKmh).padStart(3,' ')} km/h`,bx+12,by+24);
|
| 970 |
-
ctx.fillStyle='#a2b4c8';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
|
| 971 |
-
const slipDeg=(leadState.slipAngle*180/Math.PI).toFixed(1);
|
| 972 |
-
ctx.fillText(`Slip ${slipDeg}°`,bx+12,by+42);
|
| 973 |
-
ctx.fillText(`Steer ${(leadState.controls.steer*100).toFixed(0)}%`,bx+12,by+58);
|
| 974 |
-
ctx.fillStyle=leadState.alive?'#62dfb1':'#ff6b6b';
|
| 975 |
-
ctx.fillText(leadState.alive?'ACTIVE':'STOPPED',bx+12,by+74);
|
| 976 |
-
|
| 977 |
-
// 5 Control bars (STR, THR, BRK, HBK, BST)
|
| 978 |
-
const bars=[
|
| 979 |
-
['STR',(leadState.controls.steer+1)*0.5,'#58a6ff',true],
|
| 980 |
-
['THR',leadState.controls.throttle,'#62dfb1',false],
|
| 981 |
-
['BRK',leadState.controls.brake,'#ff6b6b',false],
|
| 982 |
-
['HBK',leadState.controls.hbrake,'#ffd073',false],
|
| 983 |
-
['BST',leadState.controls.boost,'#c792ea',false]
|
| 984 |
-
];
|
| 985 |
-
bars.forEach(([name,val,col,bipolar],idx)=>{
|
| 986 |
-
const ry=by+12+idx*13.5,rx=bx+142,rw=104,rh=8;
|
| 987 |
-
ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px ui-monospace,SFMono-Regular,monospace';
|
| 988 |
-
ctx.fillText(name,bx+112,ry+7.5);
|
| 989 |
-
ctx.fillStyle='#152233';ctx.fillRect(rx,ry,rw,rh);
|
| 990 |
-
ctx.fillStyle=col;
|
| 991 |
-
if(bipolar){
|
| 992 |
-
const mid=rx+rw*0.5,span=(val-0.5)*rw;
|
| 993 |
-
ctx.fillRect(span>=0?mid:mid+span,ry,Math.abs(span),rh);
|
| 994 |
-
}else{
|
| 995 |
-
ctx.fillRect(rx,ry,Math.max(0,Math.min(1,val))*rw,rh);
|
| 996 |
-
}
|
| 997 |
-
});
|
| 998 |
-
|
| 999 |
-
// Top-right Ghost Race Standings / Mini Neural Activations
|
| 1000 |
-
if(activeList.length>1){
|
| 1001 |
-
const gx=w-232,gy=12,gw=220,gh=18+Math.min(4,activeList.length)*18;
|
| 1002 |
-
ctx.fillStyle='rgba(9,17,27,0.84)';
|
| 1003 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 1004 |
-
ctx.beginPath();ctx.roundRect(gx,gy,gw,gh,8);ctx.fill();ctx.stroke();
|
| 1005 |
-
ctx.fillStyle='#a2b4c8';ctx.font='700 10px system-ui,sans-serif';
|
| 1006 |
-
ctx.fillText(focusMode==='swarm'?`POPULATION SWARM (${activeList.length} CARS)`:'GHOST RACE COMPARISON',gx+10,gy+13);
|
| 1007 |
-
activeList.slice(0,4).forEach((p,idx)=>{
|
| 1008 |
-
const py=gy+28+idx*16;
|
| 1009 |
-
ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(gx+15,py-3.5,4,0,Math.PI*2);ctx.fill();
|
| 1010 |
-
ctx.fillStyle='#e5edf5';ctx.font='600 10.5px system-ui,sans-serif';
|
| 1011 |
-
const shortRole=p.role==='fitness'?'Fitness Leader':p.role==='fastest_lap'?'Fastest Lap':p.role==='clean_laps'?'Clean Laps':p.label.slice(0,18);
|
| 1012 |
-
const stat=p.bestLapSeconds?`${p.bestLapSeconds.toFixed(2)}s`:`${p.ticksElapsed}t`;
|
| 1013 |
-
ctx.fillText(`${shortRole} · ${stat}`,gx+24,py);
|
| 1014 |
-
});
|
| 1015 |
-
}
|
| 1016 |
-
|
| 1017 |
-
// Mini live neural activation visualizer when network is present
|
| 1018 |
-
if(nnEval&&nnEval.layerActivations.length>=2){
|
| 1019 |
-
const nx=w-178,ny=h-84,nw=166,nh=72;
|
| 1020 |
-
ctx.fillStyle='rgba(9,17,27,0.84)';
|
| 1021 |
-
ctx.strokeStyle='rgba(73,98,124,0.65)';
|
| 1022 |
-
ctx.beginPath();ctx.roundRect(nx,ny,nw,nh,8);ctx.fill();ctx.stroke();
|
| 1023 |
-
ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px system-ui,sans-serif';
|
| 1024 |
-
ctx.fillText(`LIVE POLICY (${nnEval.layerActivations.map(l=>l.length).join('→')})`,nx+8,ny+12);
|
| 1025 |
-
const layersAct=nnEval.layerActivations;
|
| 1026 |
-
const lCount=layersAct.length;
|
| 1027 |
-
for(let l=0;l<lCount;l++){
|
| 1028 |
-
const nodes=layersAct[l],shown=Math.min(8,nodes.length);
|
| 1029 |
-
const lx=nx+14+l*(nw-28)/Math.max(1,lCount-1);
|
| 1030 |
-
for(let i=0;i<shown;i++){
|
| 1031 |
-
const ly=ny+20+(i+0.5)*(nh-26)/shown;
|
| 1032 |
-
const v=nodes[i]||0;
|
| 1033 |
-
ctx.fillStyle=v>=0?'#62dfb1':'#ff6b6b';
|
| 1034 |
-
ctx.globalAlpha=0.3+Math.min(0.7,Math.abs(v)*0.7);
|
| 1035 |
-
ctx.beginPath();ctx.arc(lx,ly,2.6,0,Math.PI*2);ctx.fill();
|
| 1036 |
-
}
|
| 1037 |
}
|
| 1038 |
-
ctx.globalAlpha=1;
|
| 1039 |
}
|
| 1040 |
-
ctx.restore();
|
| 1041 |
}
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
}
|
| 1055 |
-
|
| 1056 |
-
}
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
const
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
const camSel=document.getElementById('replay_camera');
|
| 1076 |
-
if(playBtn&&!playBtn.dataset.bound){
|
| 1077 |
-
playBtn.dataset.bound='1';
|
| 1078 |
-
playBtn.addEventListener('click',()=>{
|
| 1079 |
-
playing=!playing;
|
| 1080 |
-
playBtn.textContent=playing?'Pause':'Play';
|
| 1081 |
-
if(playing)startLoop();else{stopLoop();drawFrame();}
|
| 1082 |
-
});
|
| 1083 |
-
}
|
| 1084 |
-
if(resetBtn&&!resetBtn.dataset.bound){
|
| 1085 |
-
resetBtn.dataset.bound='1';
|
| 1086 |
-
resetBtn.addEventListener('click',()=>{currentTick=0;drawFrame();});
|
| 1087 |
-
}
|
| 1088 |
-
if(scrub&&!scrub.dataset.bound){
|
| 1089 |
-
scrub.dataset.bound='1';
|
| 1090 |
-
scrub.addEventListener('input',()=>{
|
| 1091 |
-
currentTick=Number(scrub.value)||0;
|
| 1092 |
-
drawFrame();
|
| 1093 |
-
});
|
| 1094 |
-
}
|
| 1095 |
-
if(modeSel&&!modeSel.dataset.bound){
|
| 1096 |
-
modeSel.dataset.bound='1';
|
| 1097 |
-
modeSel.addEventListener('change',()=>{
|
| 1098 |
-
focusMode=modeSel.value;
|
| 1099 |
-
rebuildProfiles();
|
| 1100 |
-
drawFrame();
|
| 1101 |
-
});
|
| 1102 |
-
}
|
| 1103 |
-
if(speedSel&&!speedSel.dataset.bound){
|
| 1104 |
-
speedSel.dataset.bound='1';
|
| 1105 |
-
speedSel.addEventListener('change',()=>{playSpeed=Number(speedSel.value)||1;});
|
| 1106 |
-
}
|
| 1107 |
-
if(camSel&&!camSel.dataset.bound){
|
| 1108 |
-
camSel.dataset.bound='1';
|
| 1109 |
-
camSel.addEventListener('change',()=>{cameraMode=camSel.value;drawFrame();});
|
| 1110 |
-
}
|
| 1111 |
-
for(const [key,id] of [['outline','replay_layer_outline'],['rays','replay_layer_rays'],['line','replay_layer_line'],['skids','replay_layer_skids'],['hud','replay_layer_hud']]){
|
| 1112 |
-
const el=document.getElementById(id);
|
| 1113 |
-
if(el&&!el.dataset.bound){
|
| 1114 |
-
el.dataset.bound='1';
|
| 1115 |
-
el.addEventListener('change',()=>{layers[key]=el.checked;drawFrame();});
|
| 1116 |
-
}
|
| 1117 |
-
}
|
| 1118 |
-
const starterVeh=document.getElementById('starter_vehicle');
|
| 1119 |
-
if(starterVeh&&!starterVeh.dataset.replayBound){
|
| 1120 |
-
starterVeh.dataset.replayBound='1';
|
| 1121 |
-
starterVeh.addEventListener('change',()=>{rebuildProfiles();drawFrame();});
|
| 1122 |
}
|
|
|
|
| 1123 |
}
|
| 1124 |
-
|
| 1125 |
async function refresh(){
|
| 1126 |
-
const current=++version;
|
| 1127 |
-
bindReplayControls();
|
| 1128 |
const info=picker.info();
|
| 1129 |
-
const source=info.source==='builtin'?info.display_name:
|
| 1130 |
-
if(!source){
|
| 1131 |
-
currentTrack=null;staticLayer=null;stopLoop();
|
| 1132 |
-
status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;
|
| 1133 |
-
}
|
| 1134 |
status.textContent='Loading '+source+'…';
|
| 1135 |
try{
|
| 1136 |
atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
|
| 1137 |
if(!r.ok)throw Error('Track atlas unavailable');return r.json();
|
| 1138 |
});
|
| 1139 |
const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
|
| 1140 |
-
|
|
|
|
|
|
|
|
|
|
| 1141 |
if(current!==version)return;
|
| 1142 |
-
currentTrack=track;
|
| 1143 |
-
currentSourceName=source;
|
| 1144 |
render(canvas,track);
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
drawFrame();
|
| 1148 |
-
startLoop();
|
| 1149 |
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
|
| 1150 |
status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
|
| 1151 |
-
legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>
|
| 1152 |
-
const i=NAMES.indexOf(name);return name+' '+counts[i];
|
| 1153 |
-
}).join(' · ')+' · Zero-compute telemetry visualizer: static circuit is cached once; ghost trajectories & sensor rays run locally in browser.';
|
| 1154 |
}catch(error){
|
| 1155 |
if(current!==version)return;
|
| 1156 |
-
currentTrack=null;staticLayer=null;
|
| 1157 |
-
|
| 1158 |
}
|
| 1159 |
}
|
| 1160 |
-
function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);}
|
| 1161 |
-
document.addEventListener('track-library-change',refresh);
|
| 1162 |
-
if(typeof document.addEventListener==='function'){
|
| 1163 |
-
document.addEventListener('visibilitychange',()=>{if(document.hidden)stopLoop();else if(playing&¤tTrack)startLoop();});
|
| 1164 |
-
}
|
| 1165 |
-
|
| 1166 |
const controller={
|
| 1167 |
refresh,
|
| 1168 |
-
setStagedNetwork(
|
| 1169 |
-
|
| 1170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1171 |
},
|
| 1172 |
-
setRunAnalysis(analysis,receipt){
|
| 1173 |
-
analysisState=analysis||null;
|
| 1174 |
-
receiptState=receipt||null;
|
| 1175 |
-
const modeSel=document.getElementById('replay_mode');
|
| 1176 |
-
if(modeSel){
|
| 1177 |
-
if(analysis?.summary?.mode==='train')focusMode=modeSel.value='winners';
|
| 1178 |
-
else if(analysis?.summary?.mode==='evaluate')focusMode=modeSel.value='fitness';
|
| 1179 |
-
}
|
| 1180 |
-
currentTick=0;
|
| 1181 |
-
if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
|
| 1182 |
-
},
|
| 1183 |
-
focusCandidate(roleOrGen){
|
| 1184 |
-
const modeSel=document.getElementById('replay_mode');
|
| 1185 |
-
if(roleOrGen==='swarm'){focusMode='swarm';if(modeSel)modeSel.value='swarm';}
|
| 1186 |
-
else if(['winners','fitness','fastest_lap','clean_laps','staged'].includes(roleOrGen)){
|
| 1187 |
-
focusMode=roleOrGen;if(modeSel)modeSel.value=roleOrGen;
|
| 1188 |
-
}
|
| 1189 |
-
currentTick=0;
|
| 1190 |
-
if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
|
| 1191 |
-
}
|
| 1192 |
};
|
| 1193 |
if(typeof root==='object'&&root)root.TrackReplay=controller;
|
| 1194 |
-
refresh();
|
| 1195 |
return controller;
|
| 1196 |
}
|
| 1197 |
-
return {mount,model,decode,render};
|
| 1198 |
});
|
|
|
|
| 1 |
+
/* Browser-only preview of the selected saved track. No upload for preview. */
|
| 2 |
(function(root,factory){
|
| 3 |
if(typeof module==='object'&&module.exports)module.exports=factory(root);
|
| 4 |
else root.TrackMap=factory(root);
|
| 5 |
})(typeof window==='undefined'?this:window,function(root){
|
| 6 |
const COLOURS=['#607e96','#b89561','#b9d9ed'];
|
| 7 |
const NAMES=['Asphalt','Dirt','Ice'];
|
|
|
|
| 8 |
const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
|
| 9 |
function vector(value){
|
| 10 |
const match=typeof value==='string'&&VECTOR.exec(value);
|
|
|
|
| 87 |
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
|
| 88 |
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
|
| 89 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
function render(canvas,track){
|
| 91 |
const context=canvas.getContext('2d');
|
| 92 |
if(!context)throw Error('Canvas preview unavailable');
|
|
|
|
| 96 |
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 97 |
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
|
| 98 |
const left=(w-columns*size)/2,top=(h-rows*size)/2;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
for(const tile of track.tiles){
|
| 100 |
const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
|
| 101 |
context.fillStyle=COLOURS[tile.surface];
|
|
|
|
| 106 |
const [entry,exit]=tile.connections;
|
| 107 |
const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
|
| 108 |
const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
context.beginPath();context.moveTo(...a);
|
| 110 |
context.bezierCurveTo(...p1,...p2,...z);
|
| 111 |
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
|
| 112 |
context.lineCap='round';context.stroke();
|
| 113 |
}
|
| 114 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
|
| 116 |
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
|
| 117 |
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
|
| 118 |
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
|
| 119 |
}
|
| 120 |
+
// Vehicle illustration uses only recorded position, heading, type and active state.
|
| 121 |
+
function drawVehicle2D(ctx,x,y,heading,vehicleType,color,scale,showOutline,active){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
ctx.save();
|
| 123 |
ctx.translate(x,y);
|
| 124 |
+
ctx.rotate(heading);
|
| 125 |
ctx.scale(scale,scale);
|
| 126 |
if(!active)ctx.globalAlpha=0.55;
|
| 127 |
|
| 128 |
+
const wheelSteer=0; // Neutral artwork; steering samples are unavailable.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
// Draw wheels / skis
|
| 130 |
ctx.fillStyle='#101820';
|
| 131 |
ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
|
|
|
|
| 199 |
ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
|
| 200 |
}
|
| 201 |
|
| 202 |
+
// The recorded active flag controls the inactive badge.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
if(!active){
|
| 204 |
ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
|
| 205 |
ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
|
|
|
|
| 207 |
ctx.restore();
|
| 208 |
}
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
+
function hash(value,label){
|
| 212 |
+
if(typeof value!=='string'||!/^[a-f0-9]{64}$/i.test(value))throw Error(label+' must be a SHA-256 hash');
|
| 213 |
+
return value.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
+
function validateRecording(payload,expectedTrackSha,expectedNetworkSha){
|
| 216 |
+
if(!payload||payload.schema!=='ald.recorded-replay'||payload.version!==1||payload.kind!=='fresh_cpu_evaluation')
|
| 217 |
+
throw Error('This file is not a supported recorded CPU evaluation');
|
| 218 |
+
if(payload.physics_hz!==60||payload.control_hz!==60||payload.sample_interval_ticks!==6||payload.first_sample_tick!==1||
|
| 219 |
+
payload.execution_backend!=='cpu'||payload.math_profile!=='proton'||payload.collision_elimination!==false||payload.idle_elimination!==false)
|
| 220 |
+
throw Error('Recorded evaluation conditions are unsupported');
|
| 221 |
+
const trackSha=hash(payload.track_sha256,'Recorded track'),networkSha=hash(payload.network_sha256,'Recorded network');
|
| 222 |
+
if(trackSha!==hash(expectedTrackSha,'Selected track')||networkSha!==hash(expectedNetworkSha,'Selected network'))
|
| 223 |
+
throw Error('The recording does not match the selected track and network');
|
| 224 |
+
const ticks=payload.requested_ticks;
|
| 225 |
+
if(!Number.isInteger(ticks)||ticks<1||ticks>900)throw Error('Recording must contain at most 900 physics ticks');
|
| 226 |
+
if(!Number.isInteger(payload.vehicle_type)||payload.vehicle_type<0||payload.vehicle_type>3)throw Error('Recorded vehicle type is invalid');
|
| 227 |
+
const expectedTicks=[1];
|
| 228 |
+
for(let tick=6;tick<=ticks;tick+=6)expectedTicks.push(tick);
|
| 229 |
+
if(expectedTicks[expectedTicks.length-1]!==ticks)expectedTicks.push(ticks);
|
| 230 |
+
if(!Array.isArray(payload.frames)||payload.frames.length!==expectedTicks.length)
|
| 231 |
+
throw Error('Recording is missing its expected native samples');
|
| 232 |
+
const frames=payload.frames.map((frame,index)=>{
|
| 233 |
+
if(!frame||frame.tick!==expectedTicks[index]||typeof frame.active!=='boolean')throw Error('Recorded frame ticks or active state are invalid');
|
| 234 |
+
const copy={tick:frame.tick,active:frame.active};
|
| 235 |
+
for(const key of ['x','y','rotation','vx','vy']){
|
| 236 |
+
if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
|
| 237 |
+
copy[key]=frame[key];
|
| 238 |
+
}
|
| 239 |
+
for(const key of ['angular_velocity','boost_energy','collision_count','update_count']){
|
| 240 |
+
if(frame[key]===undefined)continue;
|
| 241 |
+
if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
|
| 242 |
+
if((key==='collision_count'||key==='update_count')&&(!Number.isInteger(frame[key])||frame[key]<0))throw Error('Recorded '+key+' is invalid');
|
| 243 |
+
copy[key]=frame[key];
|
| 244 |
+
}
|
| 245 |
+
return copy;
|
| 246 |
+
});
|
| 247 |
+
return {schema:payload.schema,version:1,kind:payload.kind,physics_hz:60,control_hz:60,sample_interval_ticks:6,
|
| 248 |
+
first_sample_tick:1,track_sha256:trackSha,network_sha256:networkSha,vehicle_type:payload.vehicle_type,
|
| 249 |
+
requested_ticks:ticks,execution_backend:'cpu',math_profile:'proton',collision_elimination:false,idle_elimination:false,frames};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
}
|
| 251 |
+
function sampleRecording(recording,tick){
|
| 252 |
+
if(!Number.isFinite(tick))throw Error('Replay tick must be finite');
|
| 253 |
+
const frames=recording.frames,last=frames.length-1;
|
| 254 |
+
const at=Math.max(frames[0].tick,Math.min(frames[last].tick,tick));
|
| 255 |
+
let lo=0,hi=last;
|
| 256 |
+
while(lo<hi){const mid=Math.ceil((lo+hi)/2);if(frames[mid].tick<=at)lo=mid;else hi=mid-1;}
|
| 257 |
+
const a=frames[lo],b=frames[Math.min(last,lo+1)];
|
| 258 |
+
const t=b.tick===a.tick?0:(at-a.tick)/(b.tick-a.tick);
|
| 259 |
+
const angle=Math.atan2(Math.sin(b.rotation-a.rotation),Math.cos(b.rotation-a.rotation));
|
| 260 |
+
// Pose interpolation is only between adjacent recorded samples. HUD values
|
| 261 |
+
// remain the actual preceding sample, never reconstructed controls or rays.
|
| 262 |
+
return {tick:at,x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,rotation:a.rotation+angle*t,frame:{...a}};
|
| 263 |
+
}
|
| 264 |
+
function viewport(canvas,track){
|
| 265 |
+
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
|
| 266 |
+
const size=Math.min(160,(canvas.width-70)/columns,(canvas.height-70)/rows);
|
| 267 |
+
return {size,left:(canvas.width-columns*size)/2,top:(canvas.height-rows*size)/2,minX:b.minX,minY:b.minY};
|
| 268 |
+
}
|
| 269 |
+
function worldPoint(view,x,y){return [view.left+(x/768-view.minX)*view.size,view.top+(y/768-view.minY)*view.size];}
|
| 270 |
function mount({document,fetch,picker}){
|
| 271 |
+
const canvas=document.getElementById('trackMap'),status=document.getElementById('mapStatus'),legend=document.getElementById('mapLegend');
|
| 272 |
+
if(!canvas||!status||!legend||!picker)return;
|
| 273 |
+
const element=id=>document.getElementById(id);
|
| 274 |
+
let version=0,atlasPromise=null,currentTrack=null,currentTrackSha=null,staticLayer=null;
|
| 275 |
+
let recording=null,hasRunSummary=false,cursor=1,playing=false,animId=null,lastFrameTime=null;
|
| 276 |
+
let playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed')?.value)||1));
|
| 277 |
+
let cameraMode=element('replay_camera')?.value==='follow'?'follow':'full';
|
| 278 |
+
const layers={outline:true,line:true,hud:true};
|
| 279 |
+
const noRecording='No replay recorded. The map shows the selected track and its saved starting point.';
|
| 280 |
+
const summaryOnly='No replay recorded. This run contains summary measurements, not vehicle positions or control samples.';
|
| 281 |
+
function setText(id,text){const node=element(id);if(node&&node.textContent!==text)node.textContent=text;}
|
| 282 |
+
function syncControls(reason){
|
| 283 |
+
const last=recording?.frames[recording.frames.length-1].tick;
|
| 284 |
+
const play=element('replay_play');
|
| 285 |
+
if(play){play.disabled=!recording||cursor>=last;play.textContent=playing?'Pause':'Play';}
|
| 286 |
+
for(const id of ['replay_reset','replay_scrub','replay_speed','replay_camera','replay_layer_outline','replay_layer_line','replay_layer_hud']){
|
| 287 |
+
const node=element(id);if(node)node.disabled=!recording;
|
| 288 |
+
}
|
| 289 |
+
const scrub=element('replay_scrub');
|
| 290 |
+
if(scrub){scrub.min='1';scrub.max=String(last??1);scrub.step='1';scrub.value=String(Math.round(cursor));}
|
| 291 |
+
if(!recording){
|
| 292 |
+
setText('replay_status',reason||(hasRunSummary?summaryOnly:noRecording));
|
| 293 |
+
setText('replay_time_label','No recorded samples');
|
| 294 |
+
setText('replay_readout','Position, speed, controls and sensors are unavailable until a replay is recorded.');
|
|
|
|
|
|
|
| 295 |
}else{
|
| 296 |
+
const state=cursor>=last?'Finished. Use Restart to view it again.':playing?'Playing.':'Paused.';
|
| 297 |
+
setText('replay_status',`Fresh recorded CPU evaluation · ${recording.frames.length} samples · 60 Hz physics and controls · 10 Hz recording. ${state}`);
|
| 298 |
}
|
| 299 |
}
|
| 300 |
+
function stopPlayback(){
|
| 301 |
+
if(animId!==null&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
|
| 302 |
+
animId=null;playing=false;lastFrameTime=null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
}
|
| 304 |
+
function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);}
|
| 305 |
+
function draw(){
|
| 306 |
+
if(!currentTrack){renderEmpty();return;}
|
| 307 |
+
const ctx=canvas.getContext('2d'),view=viewport(canvas,currentTrack);
|
| 308 |
+
const state=recording?sampleRecording(recording,cursor):null;
|
| 309 |
+
const position=state?worldPoint(view,state.x,state.y):null;
|
| 310 |
+
ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
ctx.save();
|
| 312 |
+
if(position&&cameraMode==='follow'){
|
| 313 |
+
ctx.translate(canvas.width/2,canvas.height/2);ctx.scale(2,2);ctx.translate(-position[0],-position[1]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
}
|
| 315 |
+
if(staticLayer)ctx.drawImage(staticLayer,0,0);else render(canvas,currentTrack);
|
| 316 |
+
if(recording){
|
| 317 |
+
if(layers.line){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
ctx.beginPath();
|
| 319 |
+
recording.frames.forEach((frame,index)=>{const p=worldPoint(view,frame.x,frame.y);if(index===0)ctx.moveTo(...p);else ctx.lineTo(...p);});
|
| 320 |
+
ctx.strokeStyle='#62dfb1';ctx.lineWidth=2;ctx.lineCap='round';ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}
|
| 322 |
+
// Native rotation zero faces -Y; the vehicle illustration faces +X.
|
| 323 |
+
drawVehicle2D(ctx,...position,state.rotation-Math.PI/2,recording.vehicle_type,'#62dfb1',Math.max(0.55,Math.min(1.1,view.size/120)),layers.outline,state.frame.active);
|
| 324 |
}
|
| 325 |
ctx.restore();
|
| 326 |
+
if(state){
|
| 327 |
+
const frame=state.frame,speed=Math.hypot(frame.vx,frame.vy)/15*3.6;
|
| 328 |
+
const contacts=frame.collision_count===undefined?'unavailable':String(frame.collision_count);
|
| 329 |
+
setText('replay_time_label',`${(cursor/60).toFixed(2)}s / ${(recording.requested_ticks/60).toFixed(2)}s · Tick ${Math.round(cursor)} · Recorded sample ${frame.tick}`);
|
| 330 |
+
setText('replay_readout',`Recorded sample tick ${frame.tick} (${(frame.tick/60).toFixed(2)}s). Speed ${speed.toFixed(1)} km/h, derived from recorded velocity. Position (${frame.x.toFixed(2)}, ${frame.y.toFixed(2)}). Contacts ${contacts}. ${frame.active?'Active':'Inactive'} at this sample. Controls and sensors unavailable.`);
|
| 331 |
+
if(layers.hud){
|
| 332 |
+
ctx.save();ctx.fillStyle='rgba(9,17,27,0.88)';ctx.beginPath();ctx.roundRect(12,12,310,80,8);ctx.fill();
|
| 333 |
+
ctx.fillStyle='#62dfb1';ctx.font='700 12px system-ui,sans-serif';ctx.fillText('RECORDED CPU EVALUATION',24,33);
|
| 334 |
+
ctx.fillStyle='#e5edf5';ctx.font='600 12px ui-monospace,monospace';ctx.fillText(`Sample ${frame.tick} · ${speed.toFixed(1)} km/h from velocity`,24,55);
|
| 335 |
+
ctx.fillStyle='#b4c5d5';ctx.font='12px system-ui,sans-serif';ctx.fillText('Controls and sensors unavailable',24,77);ctx.restore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
}
|
|
|
|
| 337 |
}
|
|
|
|
| 338 |
}
|
| 339 |
+
function clearRecording(reason){
|
| 340 |
+
const hadRecording=recording!==null;stopPlayback();recording=null;cursor=1;
|
| 341 |
+
if(hadRecording)draw();syncControls(reason);
|
| 342 |
+
}
|
| 343 |
+
function step(now){
|
| 344 |
+
animId=null;if(!playing||!recording)return;
|
| 345 |
+
if(document.hidden){stopPlayback();syncControls();return;}
|
| 346 |
+
if(lastFrameTime===null)lastFrameTime=now;
|
| 347 |
+
const elapsed=Math.max(0,Math.min(0.1,(now-lastFrameTime)/1000));lastFrameTime=now;
|
| 348 |
+
const last=recording.frames[recording.frames.length-1].tick;
|
| 349 |
+
cursor=Math.min(last,cursor+elapsed*60*playSpeed);
|
| 350 |
+
draw();
|
| 351 |
+
if(cursor>=last){stopPlayback();syncControls();return;}
|
| 352 |
+
syncControls();animId=root.requestAnimationFrame(step);
|
| 353 |
+
}
|
| 354 |
+
function bind(){
|
| 355 |
+
const on=(id,event,callback)=>{const node=element(id);if(node)node.addEventListener(event,callback);};
|
| 356 |
+
on('replay_play','click',()=>{
|
| 357 |
+
if(!recording)return;
|
| 358 |
+
if(playing){stopPlayback();syncControls();return;}
|
| 359 |
+
if(cursor>=recording.frames[recording.frames.length-1].tick||typeof root?.requestAnimationFrame!=='function')return;
|
| 360 |
+
playing=true;lastFrameTime=null;syncControls();animId=root.requestAnimationFrame(step);
|
| 361 |
+
});
|
| 362 |
+
on('replay_reset','click',()=>{if(!recording)return;stopPlayback();cursor=recording.frames[0].tick;draw();syncControls();});
|
| 363 |
+
on('replay_scrub','input',()=>{
|
| 364 |
+
if(!recording)return;const value=Number(element('replay_scrub').value);if(!Number.isFinite(value))return;
|
| 365 |
+
stopPlayback();cursor=Math.max(recording.frames[0].tick,Math.min(recording.frames[recording.frames.length-1].tick,value));draw();syncControls();
|
| 366 |
+
});
|
| 367 |
+
on('replay_speed','change',()=>{playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed').value)||1));});
|
| 368 |
+
on('replay_camera','change',()=>{cameraMode=element('replay_camera').value==='follow'?'follow':'full';draw();});
|
| 369 |
+
for(const key of ['outline','line','hud']){
|
| 370 |
+
const id='replay_layer_'+key,node=element(id);if(node)layers[key]=node.checked;
|
| 371 |
+
on(id,'change',()=>{layers[key]=element(id).checked;draw();});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
+
document.addEventListener('visibilitychange',()=>{if(document.hidden){stopPlayback();syncControls();}});
|
| 374 |
}
|
|
|
|
| 375 |
async function refresh(){
|
| 376 |
+
const current=++version;clearRecording();currentTrackSha=null;currentTrack=null;staticLayer=null;
|
|
|
|
| 377 |
const info=picker.info();
|
| 378 |
+
const source=info.source==='builtin'?info.display_name:element('track')?.files?.[0]?.name;
|
| 379 |
+
if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
|
|
|
|
|
|
|
|
|
|
| 380 |
status.textContent='Loading '+source+'…';
|
| 381 |
try{
|
| 382 |
atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
|
| 383 |
if(!r.ok)throw Error('Track atlas unavailable');return r.json();
|
| 384 |
});
|
| 385 |
const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
|
| 386 |
+
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
|
| 387 |
+
const hashPromise=root?.crypto?.subtle?file.arrayBuffer().then(bytes=>root.crypto.subtle.digest('SHA-256',bytes)).then(bytes=>Array.from(new Uint8Array(bytes),n=>n.toString(16).padStart(2,'0')).join('')):Promise.resolve(null);
|
| 388 |
+
const [saved,sha]=await Promise.all([decode(file),hashPromise]);
|
| 389 |
+
const track=model(saved,atlas);
|
| 390 |
if(current!==version)return;
|
| 391 |
+
currentTrack=track;currentTrackSha=sha;
|
|
|
|
| 392 |
render(canvas,track);
|
| 393 |
+
const off=document.createElement?.('canvas');
|
| 394 |
+
if(off?.getContext?.('2d')){off.width=canvas.width;off.height=canvas.height;render(off,track);staticLayer=off;}
|
|
|
|
|
|
|
| 395 |
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
|
| 396 |
status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
|
| 397 |
+
legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>name+' '+counts[NAMES.indexOf(name)]).join(' · ')+' · Orange line follows the track centerline. Collision shapes are not shown. Vehicle positions appear only from a recorded replay.';
|
|
|
|
|
|
|
| 398 |
}catch(error){
|
| 399 |
if(current!==version)return;
|
| 400 |
+
currentTrack=null;currentTrackSha=null;staticLayer=null;renderEmpty();
|
| 401 |
+
status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
|
| 402 |
}
|
| 403 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
const controller={
|
| 405 |
refresh,
|
| 406 |
+
setStagedNetwork(){clearRecording();},
|
| 407 |
+
setRunAnalysis(analysis,receipt){hasRunSummary=Boolean(analysis?.summary||receipt?.report);clearRecording();},
|
| 408 |
+
focusCandidate(){clearRecording();},
|
| 409 |
+
clearRecording,
|
| 410 |
+
setRecording(payload,expectedTrackSha,expectedNetworkSha){
|
| 411 |
+
clearRecording();
|
| 412 |
+
try{
|
| 413 |
+
const next=validateRecording(payload,expectedTrackSha,expectedNetworkSha);
|
| 414 |
+
if(!currentTrack||!currentTrackSha)throw Error('The selected track must finish loading and hash verification before replay');
|
| 415 |
+
if(next.track_sha256!==currentTrackSha)throw Error('The recording belongs to a different selected track');
|
| 416 |
+
recording=next;cursor=next.frames[0].tick;draw();syncControls();
|
| 417 |
+
return {frames:next.frames.length,first_tick:cursor,last_tick:next.frames[next.frames.length-1].tick};
|
| 418 |
+
}catch(error){clearRecording('Replay unavailable: '+error.message);throw error;}
|
| 419 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
};
|
| 421 |
if(typeof root==='object'&&root)root.TrackReplay=controller;
|
| 422 |
+
bind();syncControls();document.addEventListener('track-library-change',refresh);refresh();
|
| 423 |
return controller;
|
| 424 |
}
|
| 425 |
+
return {mount,model,decode,render,validateRecording,sampleRecording};
|
| 426 |
});
|