SOURCE / PINNED RELEASE
Made of little things.
GRAV / RIFT
- Release
- 288aa261ce07…
- Author-recorded commit
- c549b5a4e17e…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-8a7361981e8
Archive hash verified: e52a01aff70810b3…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { clamp, length, rotate, sub, type Body, type Vec } from './physics.js';
export type PartRole = 'armor' | 'leg' | 'shield' | 'weapon';
export interface PartSpec {
key: string; role: PartRole; body: Body; parent?: string;
offset: Vec; angle?: number; strength: number; limb?: string;
}
export interface RigPart extends PartSpec { angle: number }
/** Parent-ordered mounted bodies. This module owns connectivity, break stress,
* inherited momentum and capabilities; enemy controllers only supply poses. */
export class Rig {
readonly parts: RigPart[] = [];
constructor(readonly root: Body,readonly name: string) { root.rigId=root.id; }
add(spec: PartSpec): RigPart {
if(this.parts.some(p=>p.key===spec.key)||spec.parent&&!this.parts.some(p=>p.key===spec.parent))throw new Error('Rig parts need unique keys and an existing parent');
const part={...spec,angle:spec.angle??0};part.body.attached=true;part.body.rigId=this.root.id;
this.parts.push(part);return part;
}
part(body: Body): RigPart | undefined { return this.parts.find(p=>p.body===body); }
connected(role?: PartRole): RigPart[] { return this.parts.filter(p=>p.body.attached&&!p.body.dead&&(!role||p.role===role)); }
get shielded(): boolean { return this.connected('shield').length>0; }
get legs(): number { return this.connected('leg').filter(p=>p.parent!==undefined).length; }
get totalLegs(): number { return this.parts.filter(p=>p.role==='leg'&&p.parent!==undefined).length; }
get mobility(): number { return this.totalLegs?this.legs/this.totalLegs:1; }
get grabbed(): boolean { return this.root.held||this.root.thrown>0; }
pose(key: string,world: Vec,angle: number): void {
const part=this.parts.find(p=>p.key===key);if(!part||!part.body.attached)return;
const parent=part.parent?this.parts.find(p=>p.key===part.parent)!.body:this.root;
part.offset=rotate(sub(world,parent),-parent.angle);part.angle=angle-parent.angle;
// Pose immediately so a child's world-to-parent conversion sees this pose.
part.body.x=world.x;part.body.y=world.y;part.body.angle=angle;
}
sync(): void {
for(const p of this.connected()) {
const parent=p.parent?this.parts.find(n=>n.key===p.parent)!.body:this.root;
const offset=rotate(p.offset,parent.angle),b=p.body;
b.x=parent.x+offset.x;b.y=parent.y+offset.y;b.angle=parent.angle+p.angle;
b.vx=parent.vx-offset.y*parent.omega;b.vy=parent.vy+offset.x*parent.omega;b.omega=parent.omega;
}
}
stress(body: Body,amount: number): Body[] {
const part=this.part(body);if(!part||!body.attached||body.dead)return [];
body.stress+=amount;return body.stress>=part.strength?this.detach(body):[];
}
detach(body: Body): Body[] {
const start=this.part(body);if(!start||!body.attached)return [];
const severed=new Set([start.key]);
for(const p of this.parts)if(p.parent&&severed.has(p.parent))severed.add(p.key);
const result: Body[]=[];
for(const p of this.parts)if(severed.has(p.key)&&p.body.attached) {
const b=p.body;b.attached=false;b.rigId=undefined;b.stress=0;
b.hp=Math.max(30,b.hp);b.cooldown=.15;
if(p.role==='shield'){b.kind='cell';b.armed=false;b.age=0;}
result.push(b);
}
return result;
}
releaseAll(): Body[] { return this.parts.flatMap(p=>this.detach(p.body)); }
}
/** Two fixed-length segments, used by both blueprints and by their hit geometry. */
export function legKnee(hip: Vec,foot: Vec,upper: number,lower: number,bend: number): Vec {
const delta=sub(foot,hip),d=clamp(length(delta),.01,upper+lower-.01);
const along=(upper*upper-lower*lower+d*d)/(2*d),height=Math.sqrt(Math.max(0,upper*upper-along*along));
const axis={x:delta.x/(length(delta)||1),y:delta.y/(length(delta)||1)};
return {x:hip.x+axis.x*along+axis.y*height*bend,y:hip.y+axis.y*along-axis.x*height*bend};
}
/** Distance from pointer to actual convex geometry, with a small selection margin. */
export function pointerDistance(body: Body,point: Vec): number {
const p=rotate(sub(point,body),-body.angle),hull=body.hull;
let inside=true,sign=0,distance=Infinity;
for(let i=0;i<hull.length;i++) {
const a=hull[i],b=hull[(i+1)%hull.length],ab=sub(b,a),ap=sub(p,a);
const cross=ab.x*ap.y-ab.y*ap.x;if(Math.abs(cross)>.001){if(sign&&Math.sign(cross)!==sign)inside=false;sign=Math.sign(cross);}
const t=clamp((ap.x*ab.x+ap.y*ab.y)/(ab.x*ab.x+ab.y*ab.y||1),0,1);
distance=Math.min(distance,Math.hypot(p.x-a.x-ab.x*t,p.y-a.y-ab.y*t));
}
return inside?0:distance;
}
