File size: 6,525 Bytes
623f6f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24cc077
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
/* 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];
  }
  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');
      return {x,y,surface:block.surface,connections:block.connections};
    });
    if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile');
    const start=vector(saved.StartTileCoords);
    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,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();
      const edge=connection=>{
        const offset=(connection.index-1)*size*.16;
        switch(connection.side){
          case 0:return [x+size/2+offset,y+2];
          case 1:return [x+size/2+offset,y+size-2];
          case 2:return [x+2,y+size/2+offset];
          case 3:return [x+size-2,y+size/2+offset];
          default:return [x+size/2,y+size/2];
        }
      };
      if(Array.isArray(tile.connections)&&tile.connections.length>=2){
        const a=edge(tile.connections[0]),z=edge(tile.connections[1]);
        context.beginPath();context.moveTo(...a);
        context.quadraticCurveTo(x+size/2,y+size/2,...z);
        context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
        context.lineCap='round';context.stroke();
      }
    }
    const sx=left+(track.start[0]-b.minX+.5)*size,sy=top+(track.start[1]-b.minY+.5)*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 start';
        legend.textContent=NAMES.filter((_,i)=>counts[i]).map((name,index)=>{
          const i=NAMES.indexOf(name);return name+' '+counts[i];
        }).join(' · ')+' · Piece and terrain preview; collision shapes 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};
});