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/physics.ts
export type Point = { x: number; y: number };
export type Design = Point[][];
export type Mode = 'classic' | 'live';
export type Material = 'rigid' | 'bendy';
export const PHYSICS_VERSION = 2;
export type Track = { id: string; name: string; difficulty: string; description: string; color: string; length: number; ground: Point[] };
export const TRACKS: Track[] = [
  { id: 'meadow', name: 'Meadow Run', difficulty: 'Easy', description: 'Rolling hills. A little hop. Find your feet.', color: '#a9d7a1', length: 2600, ground: [[-600,300],[430,300],[620,250],[790,300],[1060,300],[1230,215],[1330,215],[1450,300],[1740,300],[1830,270],[1920,300],[2170,265],[2370,300],[3100,300]].map(([x,y])=>({x,y})) },
  { id: 'steps', name: 'Step It Up', difficulty: 'Hard', description: 'Square risers. Reach over the lip and pull yourself up.', color: '#b4b6e7', length: 1800, ground: [[-600,300],[420,300],[420,205],[760,205],[760,110],[1080,110],[1330,300],[2300,300]].map(([x,y])=>({x,y})) },
  { id: 'dunes', name: 'Dune Dash', difficulty: 'Hard', description: 'Big ramps. Soft landings. Keep the momentum.', color: '#e9c477', length: 2900, ground: [[-600,300],[400,300],[660,175],[710,175],[810,330],[1060,330],[1310,205],[1400,205],[1530,315],[1800,315],[2050,155],[2110,155],[2230,330],[2530,300],[3400,300]].map(([x,y])=>({x,y})) },
  { id: 'cliffs', name: 'High Society', difficulty: 'Expert', description: 'Tall ledges and sheer drops. Long legs earn their keep.', color: '#dbb193', length: 2200, ground: [[-600,300],[500,300],[500,195],[840,195],[840,300],[1250,300],[1250,195],[1600,195],[1600,300],[2700,300]].map(([x,y])=>({x,y})) },
  { id: "sawtooth", name: "Sawtooth", difficulty: "Expert", description: "Uneven teeth. The next step never matches the last.", color: "#d5bf8b", length: 3000, ground: [[-600,300],[450,300],[450,190],[590,190],[690,290],[750,290],[750,175],[890,175],[1000,305],[1080,305],[1080,185],[1190,185],[1310,295],[1400,295],[1400,180],[1510,180],[1640,310],[1770,310],[1770,190],[1900,190],[2030,300],[2160,300],[2160,175],[2300,175],[2460,300],[3400,300]].map(([x,y])=>({x,y})) },
  { id: "pinpoint", name: "Pinpoint", difficulty: "Expert", description: "Thin pillars. Short landings. Make every foot count.", color: "#b6cdb5", length: 2700, ground: [[-600,300],[460,300],[460,195],[540,195],[540,300],[840,300],[840,190],[910,190],[910,300],[1180,300],[1180,185],[1240,185],[1240,300],[1520,300],[1520,180],[1620,180],[1620,300],[1970,300],[1970,180],[2040,180],[2040,300],[3100,300]].map(([x,y])=>({x,y})) },
  { id: "trenches", name: "Trench Trouble", difficulty: "Expert", description: "Deep cuts with tall exits. Bridge them or climb back out.", color: "#9acbcd", length: 3200, ground: [[-600,300],[400,300],[800,185],[820,185],[820,310],[1010,310],[1010,185],[1240,185],[1240,310],[1440,310],[1440,185],[1700,185],[1700,310],[1910,310],[1910,185],[2200,185],[2200,310],[2440,310],[2440,185],[2600,185],[2850,300],[3600,300]].map(([x,y])=>({x,y})) },
  { id: "skyline", name: "Skyline", difficulty: "Extreme", description: "Towering 130-unit walls. Hooks and long reach earn the summit.", color: "#d4a8bc", length: 3800, ground: [[-600,300],[500,300],[500,175],[820,175],[820,300],[1260,300],[1260,170],[1560,170],[1560,300],[2040,300],[2040,170],[2340,170],[2340,300],[2780,300],[2780,170],[3100,170],[3100,300],[4200,300]].map(([x,y])=>({x,y})) },
  { id: "switchback", name: "Ridge Runner", difficulty: "Extreme", description: "Steep ramps into ledges. Reach without losing your footing.", color: "#b0bbdd", length: 3900, ground: [[-600,300],[360,300],[640,235],[800,235],[800,110],[920,110],[1080,300],[1170,300],[1450,230],[1640,230],[1640,105],[1790,105],[1990,300],[2080,300],[2350,230],[2550,230],[2550,100],[2720,100],[2950,300],[2980,300],[3130,260],[3310,260],[3310,130],[3410,130],[3520,300],[4300,300]].map(([x,y])=>({x,y})) },
  { id: "gauntlet", name: "The Gauntlet", difficulty: "Extreme", description: "Pillars, trenches, ramps and walls. One design. A long way home.", color: "#c8a78b", length: 6000, ground: [[-600,300],[470,300],[470,180],[570,180],[570,300],[930,300],[930,180],[1050,180],[1050,300],[1370,300],[1720,235],[1720,185],[1760,185],[1760,310],[1970,310],[1970,185],[2170,185],[2170,310],[2390,310],[2390,185],[2590,185],[2800,300],[2880,300],[3160,235],[3360,235],[3360,110],[3460,110],[3680,300],[4020,300],[4020,170],[4340,170],[4340,300],[4760,300],[4760,170],[5080,170],[5080,300],[6400,300]].map(([x,y])=>({x,y})) },
];
export const STEP = 1 / 120;
export const MAX_RADIUS = 58;
export const MAX_POINTS = 240;
export const MAX_STROKES = 8;
export function cloneDesign(design: Design): Design { return design.map(s => s.map(p => ({...p}))); }
export function validDesign(input: unknown): input is Design {
  return Array.isArray(input) && input.length <= MAX_STROKES && input.every(s => Array.isArray(s) && s.length >= 2 && s.every(p => p && Number.isFinite(p.x) && Number.isFinite(p.y) && Math.abs(p.x) <= 1 && Math.abs(p.y) <= 1)) && input.reduce((n,s)=>n+s.length,0) <= MAX_POINTS;
}
export function presetDesign(name: string): Design {
  if(name==='climber')return [[1,1],[-1,1],[-1,-1],[1,-1]].map(([x,y])=>[{x:0,y:0},{x,y}]);
  const arms = name === 'star' ? 5 : 3;
  return Array.from({length:arms},(_,i)=> {
    const a = i/arms*Math.PI*2-Math.PI/2;
    return [{x:0,y:0},{x:Math.cos(a)*.65,y:Math.sin(a)*.65},{x:Math.cos(a+.32)*.94,y:Math.sin(a+.32)*.94}];
  });
}
type Sample=Point&{limb:number;weight:number};
export function samplesFor(design: Design, deduplicate=true): Sample[] {
  const samples: Sample[] = [{x:0,y:0,limb:-1,weight:0}];
  for (const [limb,stroke] of design.entries()) {
    // Every stroke is physically connected to the central hub.
    const path = [{x:0,y:0},...stroke];
    for(let i=1;i<path.length;i++) {
      const a=path[i-1],b=path[i], distance=Math.hypot(b.x-a.x,b.y-a.y)*MAX_RADIUS;
      const steps=Math.max(1,Math.ceil(distance/5));
      for(let j=1;j<=steps;j++) {
        const x=(a.x+(b.x-a.x)*j/steps)*MAX_RADIUS,y=(a.y+(b.y-a.y)*j/steps)*MAX_RADIUS;
        samples.push({x,y,limb,weight:Math.min(1.4,(x*x+y*y)/(MAX_RADIUS*MAX_RADIUS))});
      }
    }
  }
  // Bound collision work even when the pen retraces the same region many times.
  if(!deduplicate)return samples;
  const occupied=new Set<string>();
  return samples.filter(p=>{const key=`${p.limb}:${Math.round(p.x/4)},${Math.round(p.y/4)}`;if(occupied.has(key))return false;occupied.add(key);return true;});
}
export function groundAt(track: Track, x: number): {y:number; nx:number; ny:number} {
  for(let i=1;i<track.ground.length;i++) {
    const a=track.ground[i-1],b=track.ground[i],dx=b.x-a.x;
    if(dx>0 && x>=a.x && x<b.x) {
      const dy=b.y-a.y,len=Math.hypot(dx,dy);
      return {y:a.y+dy*(x-a.x)/dx,nx:dy/len,ny:-dx/len};
    }
  }
  return {y:track.ground.at(-1)?.y??300,nx:0,ny:-1};
}
type Contact = {depth:number;nx:number;ny:number};
/** Closest boundary of the solid terrain, including vertical faces and corners. */
export function terrainContact(track:Track,x:number,y:number,radius:number):Contact|null {
  const inside=y>=groundAt(track,x).y;
  let best=Infinity,cx=0,cy=0,nx=0,ny=-1;
  for(let i=1;i<track.ground.length;i++) {
    const a=track.ground[i-1],b=track.ground[i],dx=b.x-a.x,dy=b.y-a.y,len2=dx*dx+dy*dy;
    if(!len2)continue;
    if(!inside&&(x<Math.min(a.x,b.x)-radius||x>Math.max(a.x,b.x)+radius||y<Math.min(a.y,b.y)-radius||y>Math.max(a.y,b.y)+radius))continue;
    const t=Math.max(0,Math.min(1,((x-a.x)*dx+(y-a.y)*dy)/len2));
    const px=a.x+t*dx,py=a.y+t*dy,d2=(x-px)**2+(y-py)**2;
    if(d2<best){best=d2;cx=px;cy=py;const len=Math.sqrt(len2);nx=dy/len;ny=-dx/len;}
  }
  const distance=Math.sqrt(best);
  if(!inside&&distance>=radius)return null;
  if(distance>1e-7){const sign=inside?-1:1;nx=(x-cx)/distance*sign;ny=(y-cy)/distance*sign;}
  return {depth:inside?radius+distance:radius-distance,nx,ny};
}
export class Race {
  x=130; y=210; vx=0; vy=0; angle=0; omega=0; elapsed=0; finished=false; failed=false;
  samples:Sample[]=[]; private visualSamples:Sample[]=[]; inertia=1500;
  private furthestX=130; private lastProgressAt=0;
  get stalled():boolean{return this.elapsed-this.lastProgressAt>6;}
  bends:{angle:number;velocity:number}[]=[];
  /** A rotational drive only: never adds horizontal velocity or position. */
  motorDirection=1;
  constructor(public track:Track, public design:Design, public material:Material='rigid') { this.setDesign(design); }
  setDesign(design: Design): void {
    this.bends=design.map((_,i)=>this.bends[i]??{angle:0,velocity:0});
    this.design=cloneDesign(design); this.samples=samplesFor(design);this.visualSamples=samplesFor(design,false);
    this.inertia=Math.max(400,this.samples.reduce((v,p)=>v+p.x*p.x+p.y*p.y,0)/this.samples.length*.85);
  }
  visualDesign():Design {
    if(this.material==='rigid')return this.design;
    const strokes:Design=this.design.map(()=>[]);
    for(const p of this.visualSamples){if(p.limb<0)continue;const q=(this.bends[p.limb]?.angle??0)*p.weight,c=Math.cos(q),s=Math.sin(q);strokes[p.limb].push({x:(p.x*c-p.y*s)/MAX_RADIUS,y:(p.x*s+p.y*c)/MAX_RADIUS});}
    return strokes;
  }
  step(): void {
    if(this.finished||this.failed)return;
    this.elapsed+=STEP;
    if(this.material==='bendy')for(const bend of this.bends){
      // Contact loads bend a stroke; a damped spring returns it to its drawn shape.
      const acceleration=-120*bend.angle-16*bend.velocity;
      bend.velocity+=acceleration*STEP;
      bend.velocity=Math.max(-12,Math.min(12,bend.velocity));
      bend.angle+=bend.velocity*STEP;
      if(Math.abs(bend.angle)>.65){bend.angle=Math.sign(bend.angle)*.65;if(bend.angle*bend.velocity>0)bend.velocity=0;}
    }
    const motorChange=120*STEP;
    this.omega += Math.max(-motorChange,Math.min(motorChange,6.2*this.motorDirection-this.omega));
    this.vy+=900*STEP; this.vx*=.9995;
    this.x+=this.vx*STEP; this.y+=this.vy*STEP; this.angle+=this.omega*STEP;
    for(let solve=0;solve<8;solve++) {
      let contact:(Contact&{rx:number;ry:number;limb:number;weight:number})|undefined;
      for(const p of this.samples) {
        const localAngle=this.angle+(this.material==='bendy'?(this.bends[p.limb]?.angle??0)*p.weight:0);
        const c=Math.cos(localAngle),s=Math.sin(localAngle),rx=p.x*c-p.y*s,ry=p.x*s+p.y*c;
        const hit=terrainContact(this.track,this.x+rx,this.y+ry,p.x===0&&p.y===0?9:4);
        if(hit && (!contact||hit.depth>contact.depth))contact={...hit,rx,ry,limb:p.limb,weight:p.weight};
      }
      if(!contact)break;
      const {depth,rx,ry,nx,ny,limb,weight}=contact;
      const bend=this.material==='bendy'?this.bends[limb]:undefined;
      const jointInertia=this.inertia*2, jointWeight=bend?weight:0;
      this.x+=nx*depth*.85; this.y+=ny*depth*.85;
      const pointOmega=this.omega+(bend?.velocity??0)*jointWeight;
      const vn=(this.vx-pointOmega*ry)*nx+(this.vy+pointOmega*rx)*ny;
      const cross=rx*ny-ry*nx;
      if(vn<0) {
        const jointCross=cross*jointWeight;
        const impulse=-vn/(1+cross*cross/this.inertia+jointCross*jointCross/jointInertia);
        this.vx+=nx*impulse;this.vy+=ny*impulse;this.omega+=cross*impulse/this.inertia;
        if(bend)bend.velocity+=jointCross*impulse/jointInertia;
        const tx=-ny,ty=nx,ct=rx*ty-ry*tx;
        const afterOmega=this.omega+(bend?.velocity??0)*jointWeight;
        const vt=(this.vx-afterOmega*ry)*tx+(this.vy+afterOmega*rx)*ty;
        const friction=Math.max(-impulse*.95,Math.min(impulse*.95,-vt/(1+ct*ct/this.inertia+(ct*jointWeight)**2/jointInertia)));
        this.vx+=tx*friction;this.vy+=ty*friction;this.omega+=ct*friction/this.inertia;
        if(bend)bend.velocity+=ct*jointWeight*friction/jointInertia;
      }
    }
    this.vx=Math.max(-500,Math.min(650,this.vx));this.vy=Math.max(-650,Math.min(850,this.vy));
    if(this.x>this.furthestX+10){this.furthestX=this.x;this.lastProgressAt=this.elapsed;}
    if(this.x>=this.track.length) this.finished=true;
    if(this.elapsed>=180||this.x<-450||this.y>900) this.failed=true;
  }
}
export function formatTime(ms:number):string { return `${(ms/1000).toFixed(2)}s`; }