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.
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`; }
