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/physics.ts
export type Vec = { x: number; y: number };
export const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
export const length = (v: Vec) => Math.hypot(v.x, v.y);
export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y });
export const dot = (a: Vec, b: Vec) => a.x * b.x + a.y * b.y;
export const cross = (a: Vec, b: Vec) => a.x * b.y - a.y * b.x;
export const unit = (v: Vec): Vec => { const d = length(v) || 1; return { x: v.x / d, y: v.y / d }; };
export function rotate(v: Vec, angle: number): Vec {
  const c = Math.cos(angle), s = Math.sin(angle);
  return { x: v.x * c - v.y * s, y: v.x * s + v.y * c };
}
export function convexHull(points: Vec[]): Vec[] {
  const sorted = [...new Map(points.map(p => [`${p.x},${p.y}`, p])).values()].sort((a, b) => a.x - b.x || a.y - b.y);
  if (sorted.length < 3) return sorted;
  const half = (list: Vec[]) => {
    const result: Vec[] = [];
    for (const p of list) {
      while (result.length >= 2 && cross(sub(result.at(-1)!, result.at(-2)!), sub(p, result.at(-1)!)) <= 0) result.pop();
      result.push(p);
    }
    return result;
  };
  const lower = half(sorted), upper = half([...sorted].reverse());
  return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}
/** Encloses opaque pixel squares, not merely their centers. */
export function hullFromPixels(alpha: ArrayLike<number>, width: number, height: number, scale = 1): Vec[] {
  const points: Vec[] = [];
  for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
    if (alpha[(y * width + x) * 4 + 3] < 128) continue;
    for (const [dx, dy] of [[0, 0], [1, 0], [1, 1], [0, 1]]) points.push({ x: (x + dx - width / 2) * scale, y: (y + dy - height / 2) * scale });
  }
  return convexHull(points);
}
export type BodyKind = 'scrap' | 'cell' | 'plate' | 'drone' | 'wheel' | 'core' | 'structure' | 'walker' | 'shell' | 'limb' | 'power';
export interface Ordnance { owner: number; state: 'hostile' | 'captured' | 'returned'; fuse: number }
export interface Body {
  id: number; kind: BodyKind; sprite: number; x: number; y: number; vx: number; vy: number;
  angle: number; omega: number; hull: Vec[]; radius: number; mass: number; invMass: number;
  invInertia: number; hp: number; maxHp: number; held: boolean; thrown: number; dead: boolean;
  age: number; scale: number; attached: boolean; stress: number; cooldown: number; volley: number; homeY: number;
  mount?: Vec; mountAngle?: number; armed: boolean;
  ordnance?: Ordnance;
  rigId?: number;
  launchGrace?: number;
}
let nextId = 0;
export function body(kind: BodyKind, sprite: number, hull: Vec[], x: number, y: number, mass = 1, scale = 1): Body {
  const radius = Math.max(...hull.map(length), 1);
  return { id: nextId++, kind, sprite, hull, x, y, homeY: y, vx: 0, vy: 0, angle: 0, omega: 0,
    radius, mass, invMass: 1 / mass, invInertia: 1 / (mass * radius * radius * .5), hp: 30,
    maxHp: 30, held: false, thrown: 0, dead: false, age: 0, scale, attached: false, stress: 0, cooldown: 0, volley: 0, armed: false };
}
/** Actual hull support against horizontal terrain, including rotation. */
export function verticalExtent(b: Body): { min: number; max: number } {
  const sin = Math.sin(b.angle), cos = Math.cos(b.angle);
  const ys = b.hull.map(p => p.x*sin+p.y*cos);
  return { min: Math.min(...ys), max: Math.max(...ys) };
}
export function worldHull(b: Body): Vec[] { return b.hull.map(p => { const q = rotate(p, b.angle); return { x: q.x + b.x, y: q.y + b.y }; }); }
/** Exclude whole shared-field groups before enumerating pairs, so payload size
 * does not create a quadratic held/held or same-volley collision workload. */
export function* collisionPairs(bodies: Body[]): Generator<[Body, Body]> {
  const groups = new Map<string, Body[]>();
  for (const b of bodies) {
    const key = b.held ? 'held' : b.attached ? 'rig' : b.thrown > 0 ? `volley:${b.volley}` : 'loose';
    const group = groups.get(key);
    if (group) group.push(b); else groups.set(key, [b]);
  }
  const entries = [...groups];
  for (let i = 0; i < entries.length; i++) for (let j = i; j < entries.length; j++) {
    const [keyA, aGroup] = entries[i], [keyB, bGroup] = entries[j];
    if (i === j && keyA !== 'loose') continue;
    if ((keyA === 'held' && keyB === 'rig') || (keyA === 'rig' && keyB === 'held')) continue;
    for (let aIndex = 0; aIndex < aGroup.length; aIndex++) {
      const a = aGroup[aIndex];
      for (let bIndex = i === j ? aIndex + 1 : 0; bIndex < bGroup.length; bIndex++) {
        const b = bGroup[bIndex], radius = a.radius + b.radius;
        if (!a.dead && !b.dead && !(a.rigId!==undefined&&a.rigId===b.rigId)
          && !((a.launchGrace??0)>0&&b.held||(b.launchGrace??0)>0&&a.held)
          && (a.x-b.x)**2 + (a.y-b.y)**2 <= radius**2) yield [a, b];
      }
    }
  }
}
export interface Contact { normal: Vec; depth: number; point: Vec }
export function contact(a: Body, b: Body): Contact | null {
  if (Math.hypot(a.x - b.x, a.y - b.y) > a.radius + b.radius) return null;
  const ah = worldHull(a), bh = worldHull(b);
  let depth = Infinity, normal: Vec = { x: 1, y: 0 };
  for (const polygon of [ah, bh]) for (let i = 0; i < polygon.length; i++) {
    const edge = sub(polygon[(i + 1) % polygon.length], polygon[i]);
    const axis = unit({ x: -edge.y, y: edge.x });
    const av = ah.map(p => dot(p, axis)), bv = bh.map(p => dot(p, axis));
    const overlap = Math.min(Math.max(...av), Math.max(...bv)) - Math.max(Math.min(...av), Math.min(...bv));
    if (overlap <= 0) return null;
    if (overlap < depth) { depth = overlap; normal = axis; }
  }
  if (dot(sub(b, a), normal) < 0) normal = { x: -normal.x, y: -normal.y };
  const support = (p: Vec[], n: Vec) => {
    const best = Math.max(...p.map(v => dot(v, n)));
    const near = p.filter(v => best - dot(v, n) < .5);
    return { x: near.reduce((s, v) => s + v.x, 0) / near.length, y: near.reduce((s, v) => s + v.y, 0) / near.length };
  };
  const pa = support(ah, normal), pb = support(bh, { x: -normal.x, y: -normal.y });
  return { normal, depth, point: { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 } };
}
/** SAT manifold plus angular impulse, positional stabilization and tangential friction. */
export function resolve(a: Body, b: Body, hit: Contact): number {
  const n = hit.normal, ra = sub(hit.point, a), rb = sub(hit.point, b);
  const imA = a.attached ? .025 : a.invMass, imB = b.attached ? .025 : b.invMass;
  const iiA = a.attached ? 0 : a.invInertia, iiB = b.attached ? 0 : b.invInertia;
  const rv = { x: b.vx - b.omega * rb.y - a.vx + a.omega * ra.y, y: b.vy + b.omega * rb.x - a.vy - a.omega * ra.x };
  const speed = dot(rv, n);
  const correction = Math.max(0, hit.depth - .2) * .7 / (imA + imB);
  a.x -= n.x * correction * imA; a.y -= n.y * correction * imA;
  b.x += n.x * correction * imB; b.y += n.y * correction * imB;
  if (speed >= 0) return 0;
  const j = -1.32 * speed / (imA + imB + cross(ra, n) ** 2 * iiA + cross(rb, n) ** 2 * iiB);
  const impulse = (v: Vec, amount: number) => {
    a.vx -= v.x * amount * imA; a.vy -= v.y * amount * imA; a.omega -= cross(ra, v) * amount * iiA;
    b.vx += v.x * amount * imB; b.vy += v.y * amount * imB; b.omega += cross(rb, v) * amount * iiB;
  };
  impulse(n, j);
  const tangent = unit({ x: rv.x - speed * n.x, y: rv.y - speed * n.y });
  const jt = -dot(rv, tangent) / (imA + imB + cross(ra, tangent) ** 2 * iiA + cross(rb, tangent) ** 2 * iiB);
  impulse(tangent, clamp(jt, -j * .18, j * .18));
  return -speed;
}
export function integrate(b: Body, dt: number, gravity: number): void {
  b.age += dt; b.thrown = Math.max(0, b.thrown - dt); b.cooldown = Math.max(0, b.cooldown - dt);
  b.launchGrace=Math.max(0,(b.launchGrace??0)-dt);
  b.vy += gravity * dt;
  const damping = Math.exp(-.16 * dt);
  const maxSpeed = b.held ? 2200 : 1400, maxSpin = b.held ? 38 : 24;
  b.vx = clamp(b.vx * damping, -maxSpeed, maxSpeed); b.vy = clamp(b.vy * damping, -maxSpeed, maxSpeed);
  b.omega = clamp(b.omega * Math.exp(-.18 * dt), -maxSpin, maxSpin);
  b.x += b.vx * dt; b.y += b.vy * dt; b.angle += b.omega * dt;
}