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};
});