Spaces:
Running on Zero
Running on Zero
Fix track preview start marker alignment
Browse filesThe track preview placed the green start marker at the center of StartTileCoords and ignored StartTileConnection. On A01, this shifted the marker half a tile left of the saved start.
Use the saved connection for the marker and share the native connection offsets with the orange route endpoints. Validate that the start matches its atlas tile and clarify that the route is approximate.
This PR includes the matching RELEASE_MANIFEST.json entry so the renderer and integrity manifest reach main together.
Validation: six focused renderer regressions pass, covering A01, every side/index, adjacent endpoints, invalid start data, and input preservation. Independent review also accepted all 828 connections in the current atlas.
- track-map.js +32 -16
track-map.js
CHANGED
|
@@ -14,6 +14,25 @@
|
|
| 14 |
throw Error('Track coordinates exceed preview bounds');
|
| 15 |
return [x,y];
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
async function decode(file){
|
| 18 |
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
|
| 19 |
if(typeof DecompressionStream!=='function')throw Error('This browser cannot preview gzip tracks');
|
|
@@ -39,14 +58,20 @@
|
|
| 39 |
const id=item?.Block?.TileSetId;
|
| 40 |
const block=atlas?.blocks?.[`${id?.AtlasId}:${id?.AlternativeId}`];
|
| 41 |
if(!block||![0,1,2].includes(block.surface))throw Error('Track uses a piece not in the simulator atlas');
|
| 42 |
-
|
|
|
|
| 43 |
});
|
| 44 |
if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile');
|
| 45 |
const start=vector(saved.StartTileCoords);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const minX=Math.min(...tiles.map(t=>t.x)),maxX=Math.max(...tiles.map(t=>t.x));
|
| 47 |
const minY=Math.min(...tiles.map(t=>t.y)),maxY=Math.max(...tiles.map(t=>t.y));
|
| 48 |
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
|
| 49 |
-
return {tiles,start,bounds:{minX,maxX,minY,maxY}};
|
| 50 |
}
|
| 51 |
function render(canvas,track){
|
| 52 |
const context=canvas.getContext('2d');
|
|
@@ -63,25 +88,16 @@
|
|
| 63 |
context.beginPath();context.roundRect(x+2,y+2,size-4,size-4,Math.max(4,size*.13));context.fill();
|
| 64 |
context.strokeStyle='#d7e8f2';context.lineWidth=Math.max(1,size*.018);
|
| 65 |
context.stroke();
|
| 66 |
-
const edge=connection=>{
|
| 67 |
-
const offset=(connection.index-1)*size*.16;
|
| 68 |
-
switch(connection.side){
|
| 69 |
-
case 0:return [x+size/2+offset,y+2];
|
| 70 |
-
case 1:return [x+size/2+offset,y+size-2];
|
| 71 |
-
case 2:return [x+2,y+size/2+offset];
|
| 72 |
-
case 3:return [x+size-2,y+size/2+offset];
|
| 73 |
-
default:return [x+size/2,y+size/2];
|
| 74 |
-
}
|
| 75 |
-
};
|
| 76 |
if(Array.isArray(tile.connections)&&tile.connections.length>=2){
|
| 77 |
-
const a=
|
| 78 |
context.beginPath();context.moveTo(...a);
|
| 79 |
context.quadraticCurveTo(x+size/2,y+size/2,...z);
|
| 80 |
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
|
| 81 |
context.lineCap='round';context.stroke();
|
| 82 |
}
|
| 83 |
}
|
| 84 |
-
const
|
|
|
|
| 85 |
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
|
| 86 |
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
|
| 87 |
}
|
|
@@ -106,10 +122,10 @@
|
|
| 106 |
if(current!==version)return;
|
| 107 |
render(canvas,track);
|
| 108 |
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
|
| 109 |
-
status.textContent=source+' 路 '+track.tiles.length+' pieces 路 green dot marks the
|
| 110 |
legend.textContent=NAMES.filter((_,i)=>counts[i]).map((name,index)=>{
|
| 111 |
const i=NAMES.indexOf(name);return name+' '+counts[i];
|
| 112 |
-
}).join(' 路 ')+' 路
|
| 113 |
}catch(error){
|
| 114 |
if(current!==version)return;
|
| 115 |
renderEmpty();status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
|
|
|
|
| 14 |
throw Error('Track coordinates exceed preview bounds');
|
| 15 |
return [x,y];
|
| 16 |
}
|
| 17 |
+
function connection(value,message='Track has an invalid piece connection'){
|
| 18 |
+
if(!value||typeof value!=='object'||Array.isArray(value)||
|
| 19 |
+
!Number.isInteger(value.side)||value.side<0||value.side>3||
|
| 20 |
+
!Number.isInteger(value.index)||value.index<0||value.index>2||
|
| 21 |
+
!Number.isInteger(value.type)||value.type<0)throw Error(message);
|
| 22 |
+
return {side:value.side,index:value.index,type:value.type};
|
| 23 |
+
}
|
| 24 |
+
function connectionPoint(value,x,y,size){
|
| 25 |
+
const {side,index}=connection(value);
|
| 26 |
+
// Native Connection.position uses offsets 128,384,640 in a 768-unit tile.
|
| 27 |
+
// Shared boundaries keep adjacent path endpoints and the saved start aligned.
|
| 28 |
+
const offset=[128,384,640][index]/768*size;
|
| 29 |
+
switch(side){
|
| 30 |
+
case 0:return [x+offset,y];
|
| 31 |
+
case 1:return [x+offset,y+size];
|
| 32 |
+
case 2:return [x,y+offset];
|
| 33 |
+
case 3:return [x+size,y+offset];
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
async function decode(file){
|
| 37 |
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
|
| 38 |
if(typeof DecompressionStream!=='function')throw Error('This browser cannot preview gzip tracks');
|
|
|
|
| 58 |
const id=item?.Block?.TileSetId;
|
| 59 |
const block=atlas?.blocks?.[`${id?.AtlasId}:${id?.AlternativeId}`];
|
| 60 |
if(!block||![0,1,2].includes(block.surface))throw Error('Track uses a piece not in the simulator atlas');
|
| 61 |
+
if(!Array.isArray(block.connections)||block.connections.length!==2)throw Error('Track piece connections are unavailable');
|
| 62 |
+
return {x,y,surface:block.surface,connections:block.connections.map(value=>connection(value))};
|
| 63 |
});
|
| 64 |
if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile');
|
| 65 |
const start=vector(saved.StartTileCoords);
|
| 66 |
+
const startTile=tiles.find(tile=>tile.x===start[0]&&tile.y===start[1]);
|
| 67 |
+
if(!startTile)throw Error('Start tile is not present in this track');
|
| 68 |
+
const startConnection=connection({side:saved.StartTileConnection?.Side,index:saved.StartTileConnection?.Index,type:saved.StartTileConnection?.Type},'Track has an invalid or missing start connection');
|
| 69 |
+
if(!startTile.connections.some(item=>item.side===startConnection.side&&item.index===startConnection.index&&item.type===startConnection.type))
|
| 70 |
+
throw Error('Saved start connection does not match the start tile');
|
| 71 |
const minX=Math.min(...tiles.map(t=>t.x)),maxX=Math.max(...tiles.map(t=>t.x));
|
| 72 |
const minY=Math.min(...tiles.map(t=>t.y)),maxY=Math.max(...tiles.map(t=>t.y));
|
| 73 |
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
|
| 74 |
+
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
|
| 75 |
}
|
| 76 |
function render(canvas,track){
|
| 77 |
const context=canvas.getContext('2d');
|
|
|
|
| 88 |
context.beginPath();context.roundRect(x+2,y+2,size-4,size-4,Math.max(4,size*.13));context.fill();
|
| 89 |
context.strokeStyle='#d7e8f2';context.lineWidth=Math.max(1,size*.018);
|
| 90 |
context.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
if(Array.isArray(tile.connections)&&tile.connections.length>=2){
|
| 92 |
+
const a=connectionPoint(tile.connections[0],x,y,size),z=connectionPoint(tile.connections[1],x,y,size);
|
| 93 |
context.beginPath();context.moveTo(...a);
|
| 94 |
context.quadraticCurveTo(x+size/2,y+size/2,...z);
|
| 95 |
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
|
| 96 |
context.lineCap='round';context.stroke();
|
| 97 |
}
|
| 98 |
}
|
| 99 |
+
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
|
| 100 |
+
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
|
| 101 |
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
|
| 102 |
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
|
| 103 |
}
|
|
|
|
| 122 |
if(current!==version)return;
|
| 123 |
render(canvas,track);
|
| 124 |
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
|
| 125 |
+
status.textContent=source+' 路 '+track.tiles.length+' pieces 路 green dot marks the saved starting point';
|
| 126 |
legend.textContent=NAMES.filter((_,i)=>counts[i]).map((name,index)=>{
|
| 127 |
const i=NAMES.indexOf(name);return name+' '+counts[i];
|
| 128 |
+
}).join(' 路 ')+' 路 Orange line shows the approximate route; collision shapes and car positions are not shown.';
|
| 129 |
}catch(error){
|
| 130 |
if(current!==version)return;
|
| 131 |
renderEmpty();status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
|