Back to Limb Race
SOURCE / PINNED RELEASE

Made of little things.

Limb Race

Release
6a8ae4fc626e…
Author-recorded commit
008c01ee6454…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-c2869f12ad8

Archive hash verified: 7be8492d1f89b891…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/render.ts
import { MAX_RADIUS, groundAt, type Design, type Race, type Track } from './physics.js';
export function fitCanvas(canvas:HTMLCanvasElement): {ctx:CanvasRenderingContext2D;w:number;h:number} {
  const {width:w,height:h}=canvas.getBoundingClientRect(), dpr=Math.min(2,window.devicePixelRatio||1);
  if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)) {canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);}
  const ctx=canvas.getContext('2d')!; ctx.setTransform(dpr,0,0,dpr,0,0);return{ctx,w,h};
}
export function drawLimbs(ctx:CanvasRenderingContext2D, design:Design, x:number,y:number,r:number,angle=0,ink='#263c39'):void {
  ctx.save();ctx.translate(x,y);ctx.rotate(angle);ctx.strokeStyle=ink;ctx.lineWidth=r*.13;ctx.lineCap='round';ctx.lineJoin='round';
  for(const stroke of design) {ctx.beginPath();ctx.moveTo(0,0);for(const p of stroke)ctx.lineTo(p.x*r,p.y*r);ctx.stroke();}
  ctx.beginPath();ctx.arc(0,0,r*.165,0,Math.PI*2);ctx.fillStyle='#f35748';ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#fff5e9';ctx.stroke();
  ctx.restore();
}
export function drawPad(canvas:HTMLCanvasElement,design:Design,locked:boolean):void {
  const {ctx,w,h}=fitCanvas(canvas),r=Math.min(w,h)*.43;
  ctx.clearRect(0,0,w,h);ctx.fillStyle='#fffefa';ctx.fillRect(0,0,w,h);
  ctx.fillStyle='#d9ded6';
  for(let x=w/2%20;x<w;x+=20)for(let y=h/2%20;y<h;y+=20){ctx.beginPath();ctx.arc(x,y,.75,0,7);ctx.fill();}
  ctx.strokeStyle='#d7ded5';ctx.lineWidth=1;ctx.setLineDash([4,6]);ctx.strokeRect(w/2-r,h/2-r,r*2,r*2);ctx.setLineDash([]);
  drawLimbs(ctx,design,w/2,h/2,r,0,locked?'#86938a':'#263c39');
  if(!design.length) {
    ctx.fillStyle='#6d7a72';ctx.textAlign='center';ctx.font='12px "Avenir Next", sans-serif';
    if(w>200)ctx.fillText('Start at the red dot.',w/2,h-5);
  }
}
export function drawTrack(canvas:HTMLCanvasElement,track:Track,race:Race,started:boolean,dark:boolean):void {
  const {ctx,w,h}=fitCanvas(canvas);if(!w||!h)return;
  const scale=Math.max(.45,Math.min(1.35,h/355)), visible=w/scale;
  const camera=Math.max(0,Math.min(track.length-visible*.65,race.x-visible*.27));
  const restBase=h*.78-300*scale,hubScreenY=race.y*scale+restBase;
  // Follow tall climbs and deep trenches before limbs leave the viewport.
  const base=restBase+Math.max(0,h*.30-hubScreenY)-Math.max(0,hubScreenY-h*.65);
  ctx.fillStyle=dark?'#253d41':'#e9f1e8';ctx.fillRect(0,0,w,h);
  // Distant, slow-moving hills and little clouds give the course depth.
  ctx.save();ctx.translate(-camera*scale*.18,0);
  ctx.fillStyle=dark?'#304e4d':'#dce8d1';
  for(let i=-1;i<12;i++){ctx.beginPath();ctx.ellipse(i*290+150,h*.68,230,h*.36,0,Math.PI,0);ctx.fill();}
  ctx.fillStyle=dark?'#3f5855':'#f9fbef';
  for(let i=0;i<9;i++){const x=i*350+160,y=h*.18+(i%3)*19;ctx.beginPath();ctx.roundRect(x,y,77,13,7);ctx.fill();ctx.beginPath();ctx.ellipse(x+37,y,22,10,0,Math.PI,0);ctx.fill();}
  ctx.restore();ctx.save();ctx.translate(-camera*scale,base);ctx.scale(scale,scale);
  ctx.beginPath();ctx.moveTo(track.ground[0].x,700);for(const p of track.ground)ctx.lineTo(p.x,p.y);ctx.lineTo(track.ground.at(-1)!.x,700);ctx.closePath();
  ctx.fillStyle=dark?'#637e60':track.color;ctx.fill();
  ctx.save();ctx.clip();ctx.strokeStyle=dark?'#73906b':'#91b78a';ctx.globalAlpha=.35;ctx.lineWidth=1;
  for(let x=Math.floor(camera/28)*28;x<camera+visible+100;x+=28){ctx.beginPath();ctx.moveTo(x,150);ctx.lineTo(x-180,700);ctx.stroke();}
  ctx.restore();ctx.beginPath();for(const [i,p]of track.ground.entries()){if(i===0)ctx.moveTo(p.x,p.y);else ctx.lineTo(p.x,p.y);}ctx.strokeStyle=dark?'#b9d0a2':'#3e6852';ctx.lineWidth=4;ctx.stroke();
  // Distance posts and plants are scenery, never invisible collision geometry.
  for(let x=350;x<track.length;x+=400){const y=groundAt(track,x).y;ctx.fillStyle=dark?'#b4c7b5':'#628268';ctx.font='10px monospace';ctx.textAlign='center';ctx.fillText(`${Math.round((track.length-x)/10)} m`,x,y+27);}
  for(let x=270;x<track.length;x+=277){const y=groundAt(track,x).y;ctx.strokeStyle=dark?'#a3bd8c':'#557b50';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-4,y-9);ctx.moveTo(x,y);ctx.lineTo(x+5,y-13);ctx.stroke();}
  const sy=groundAt(track,130).y;ctx.fillStyle=dark?'#b9d0ba':'#50775d';ctx.font='bold 10px monospace';ctx.fillText('START',116,sy+35);
  ctx.strokeStyle='#50775d';ctx.setLineDash([4,4]);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(130,sy);ctx.lineTo(130,sy+18);ctx.stroke();ctx.setLineDash([]);
  const fy=groundAt(track,track.length).y;ctx.fillStyle='#35493e';ctx.fillRect(track.length,fy-110,4,110);
  for(let y=0;y<4;y++)for(let x=0;x<5;x++){ctx.fillStyle=(x+y)%2?'#fffdf0':'#35493e';ctx.fillRect(track.length+4+x*9,fy-110+y*9,9,9);}
  ctx.fillStyle=dark?'#d7dfcb':'#3b5a48';ctx.font='bold 11px monospace';ctx.fillText('FINISH',track.length+24,fy+26);
  if(started&&Math.abs(race.vx)>60){ctx.strokeStyle=dark?'#89a69b':'#b0c2a5';ctx.lineWidth=2;for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(race.x-65-i*9,race.y-10+i*13);ctx.lineTo(race.x-85-i*9,race.y-10+i*13);ctx.stroke();}}
  const ground=groundAt(track,race.x).y;ctx.fillStyle='#23362918';ctx.beginPath();ctx.ellipse(race.x,ground+3,33,5,0,0,7);ctx.fill();
  drawLimbs(ctx,race.visualDesign(),race.x,race.y,MAX_RADIUS,race.angle,dark?'#f2f3d9':'#263c39');
  if(!started){ctx.fillStyle=dark?'#d9e6d0':'#506b59';ctx.font='11px "Avenir Next", sans-serif';ctx.textAlign='center';ctx.fillText('YOUR INVENTION',race.x,race.y-77);}
  ctx.restore();
}
export function thumbnail(design:Design):HTMLCanvasElement {
  const canvas=document.createElement('canvas');canvas.width=84;canvas.height=72;canvas.className='limb-thumb';canvas.setAttribute('aria-label','Recorded limb drawing');
  drawLimbs(canvas.getContext('2d')!,design,42,36,27);return canvas;
}