Spaces:
Running on Zero
Running on Zero
File size: 8,938 Bytes
623f6f8 f50e63e 1f4513d 623f6f8 f50e63e 623f6f8 f50e63e 623f6f8 f50e63e 623f6f8 24cc077 623f6f8 1f4513d 623f6f8 1f4513d 623f6f8 f50e63e 623f6f8 f50e63e 623f6f8 1f4513d 623f6f8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | /* 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};
});
|