Back to GRAV / RIFT
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.

src/game/rigging.ts
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;
}