ald-tao-gpu / track-map.js
TiGa-RCE's picture
Draw track preview with native cubic curves
1f4513d verified
Raw History Blame
8.94 kB
/* Browser-only preview of the selected saved track. No upload for preview. */
(function(root,factory){
if(typeof module==='object'&&module.exports)module.exports=factory();
else root.TrackMap=factory();
})(typeof window==='undefined'?this:window,function(){
const COLOURS=['#607e96','#b89561','#b9d9ed'];
const NAMES=['Asphalt','Dirt','Ice'];
const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
function vector(value){
const match=typeof value==='string'&&VECTOR.exec(value);
if(!match)throw Error('Track has invalid tile coordinates');
const x=Number(match[1]),y=Number(match[2]);
if(!Number.isSafeInteger(x)||!Number.isSafeInteger(y)||Math.abs(x)>1000||Math.abs(y)>1000)
throw Error('Track coordinates exceed preview bounds');
return [x,y];
}
function connection(value,message='Track has an invalid piece connection'){
if(!value||typeof value!=='object'||Array.isArray(value)||
!Number.isInteger(value.side)||value.side<0||value.side>3||
!Number.isInteger(value.index)||value.index<0||value.index>2||
!Number.isInteger(value.type)||value.type<0)throw Error(message);
return {side:value.side,index:value.index,type:value.type};
}
function connectionPoint(value,x,y,size){
const {side,index}=connection(value);
// Native Connection.position uses offsets 128,384,640 in a 768-unit tile.
// Shared boundaries keep adjacent path endpoints and the saved start aligned.
const offset=[128,384,640][index]/768*size;
switch(side){
case 0:return [x+offset,y];
case 1:return [x+offset,y+size];
case 2:return [x,y+offset];
case 3:return [x+size,y+offset];
}
}
function connectionControl(own,other,point,target){
// Native TrackUtils.GetPathCurve (random_track.rs, b90b8af): each
// connection's inward normal sets its cubic handle, adjusted by port type.
// At the exit this is the next tile's negated entry normal, so the same
// per-tile construction also preserves the curve when endpoints reverse.
const [nx,ny]=[[0,1],[0,-1],[1,0],[-1,0]][own.side];
const dx=target[0]-point[0],dy=target[1]-point[1];
const angle=own.type===1?Math.PI/4:own.type===3?Math.atan2(128,384):own.type===4?Math.atan2(384,128):0;
const rotation=Math.sign(nx*dy-ny*dx)*angle;
const c=Math.cos(rotation),s=Math.sin(rotation);
const smoothness=[0,2,100].includes(own.type)&&[1,3,4].includes(other.type)?0.2:0.5;
const reach=Math.hypot(dx,dy)*smoothness;
return [point[0]+(nx*c-ny*s)*reach,point[1]+(nx*s+ny*c)*reach];
}
async function decode(file){
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
if(typeof DecompressionStream!=='function')throw Error('This browser cannot preview gzip tracks');
const reader=file.stream().pipeThrough(new DecompressionStream('gzip')).getReader();
const chunks=[];let total=0;
try{
while(true){
const {done,value}=await reader.read();if(done)break;
total+=value.byteLength;
if(total>2*1024*1024)throw Error('Track preview exceeds the 2 MB expanded limit');
chunks.push(value);
}
}finally{reader.releaseLock();}
const bytes=new Uint8Array(total);let at=0;
for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}
return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));
}
function model(saved,atlas){
const entries=Object.entries(saved?.Tiles??{});
if(entries.length<1||entries.length>256)throw Error('Track has no previewable tiles');
const tiles=entries.map(([position,item])=>{
const [x,y]=vector(position);
const id=item?.Block?.TileSetId;
const block=atlas?.blocks?.[`${id?.AtlasId}:${id?.AlternativeId}`];
if(!block||![0,1,2].includes(block.surface))throw Error('Track uses a piece not in the simulator atlas');
if(!Array.isArray(block.connections)||block.connections.length!==2)throw Error('Track piece connections are unavailable');
return {x,y,surface:block.surface,connections:block.connections.map(value=>connection(value))};
});
if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile');
const start=vector(saved.StartTileCoords);
const startTile=tiles.find(tile=>tile.x===start[0]&&tile.y===start[1]);
if(!startTile)throw Error('Start tile is not present in this track');
const startConnection=connection({side:saved.StartTileConnection?.Side,index:saved.StartTileConnection?.Index,type:saved.StartTileConnection?.Type},'Track has an invalid or missing start connection');
if(!startTile.connections.some(item=>item.side===startConnection.side&&item.index===startConnection.index&&item.type===startConnection.type))
throw Error('Saved start connection does not match the start tile');
const minX=Math.min(...tiles.map(t=>t.x)),maxX=Math.max(...tiles.map(t=>t.x));
const minY=Math.min(...tiles.map(t=>t.y)),maxY=Math.max(...tiles.map(t=>t.y));
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
}
function render(canvas,track){
const context=canvas.getContext('2d');
if(!context)throw Error('Canvas preview unavailable');
const w=canvas.width,h=canvas.height;
context.clearRect(0,0,w,h);
context.fillStyle='#0c1926';context.fillRect(0,0,w,h);
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
const left=(w-columns*size)/2,top=(h-rows*size)/2;
for(const tile of track.tiles){
const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
context.fillStyle=COLOURS[tile.surface];
context.beginPath();context.roundRect(x+2,y+2,size-4,size-4,Math.max(4,size*.13));context.fill();
context.strokeStyle='#d7e8f2';context.lineWidth=Math.max(1,size*.018);
context.stroke();
if(Array.isArray(tile.connections)&&tile.connections.length>=2){
const [entry,exit]=tile.connections;
const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
context.beginPath();context.moveTo(...a);
context.bezierCurveTo(...p1,...p2,...z);
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
context.lineCap='round';context.stroke();
}
}
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
}
function mount({document,fetch,picker}){
const canvas=document.getElementById('trackMap');
const status=document.getElementById('mapStatus');
const legend=document.getElementById('mapLegend');
if(!canvas||!status||!picker)return;
let version=0,atlasPromise=null;
async function refresh(){
const current=++version;
const info=picker.info();
const source=info.source==='builtin'?info.display_name:document.getElementById('track')?.files?.[0]?.name;
if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
status.textContent='Loading '+source+'…';
try{
atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
if(!r.ok)throw Error('Track atlas unavailable');return r.json();
});
const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
const track=model(await decode(file),atlas);
if(current!==version)return;
render(canvas,track);
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
legend.textContent=NAMES.filter((_,i)=>counts[i]).map((name,index)=>{
const i=NAMES.indexOf(name);return name+' '+counts[i];
}).join(' · ')+' · Orange line follows the track centerline; collision shapes and car positions are not shown.';
}catch(error){
if(current!==version)return;
renderEmpty();status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
}
}
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);}
document.addEventListener('track-library-change',refresh);
refresh();
return {refresh};
}
return {mount,model,decode,render};
});