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/game.ts
import { body, clamp, collisionPairs, contact, integrate, length, resolve, sub, unit, verticalExtent, type Body, type BodyKind, type Vec } from './physics.js';
import type { Sprite } from './sprites.js';
import { CELL, SITES, TUG, WORLD, type Site } from './world.js';
import { buildStructures, unsupportedParts, type Structure } from './structures.js';
import { createWalker, poseWalker, stepWalker, type Walker } from './walkers.js';
import { Rig, pointerDistance } from './rigging.js';
import { createEnemyRig, poseWarden } from './enemy-rigs.js';
import { Launcher, type ReleaseMode } from './launcher.js';
export type Mode = 0 | 1 | 2 | 3;
export type Phase = 'ready' | 'playing' | 'paused' | 'won' | 'lost';
export interface Input { x: number; y: number; field: boolean; fire?: boolean; aim: Vec | null }
export interface Particle { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string; size: number }
export interface Bullet { x: number; y: number; vx: number; vy: number; life: number }
export interface Pop { x: number; y: number; text: string; life: number; color: string }
export interface Blast { x: number; y: number; age: number; radius: number }
export const MODE_NAMES = ['ORBIT', 'COMPRESS', 'RIP TETHER', 'SURGICAL'];
export class Game {
  phase: Phase = 'ready'; mode: Mode = 0; practice = false;
  ship = { x: Number(WORLD.startX), y: Number(WORLD.startY), vx: Number(TUG.cruise), vy: 0, hp: 100, invulnerable: 0 };
  bodies: Body[] = []; particles: Particle[] = []; bullets: Bullet[] = []; pops: Pop[] = [];
  structures: Structure[] = []; blasts: Blast[] = []; explosions = 0; demolished = 0;
  walkers: Walker[] = []; shellsCaught = 0; shellsReturned = 0; returnKills = 0;
  rigs: Rig[] = []; launcher = new Launcher(); focus: Body | null = null; limbsTorn = 0; cellsExtracted = 0; liveThrows = 0;
  tacticalOpen=false;
  private surgicalShot = false; private fireWas = false;
  input: Input = { x: 0, y: 0, field: false, aim: null };
  facing: Vec = { x: 1, y: 0 }; vortexPhase = 0;
  camera = 0; cameraY = 0; viewWidth = 1800; viewHeight = 1000; time = 0; visualTime = 0; charge = 0; salvage = 0; score = 0;
  level = 0; kills = 0; shots = 0; torn = 0; shake = 0; fieldWas = false; debug = false;
  boss: Body; bossAwake = false; bossShot = 2; supplyTimer = 0; message = ''; messageTime = 0;
  onSound: (name: 'grab' | 'throw' | 'hit' | 'boom' | 'upgrade') => void = () => {};
  constructor(public sprites: Sprite[],public site: Site = 'foundry',private random = Math.random) { this.boss = this.spawn('core',10,WORLD.bossX,WORLD.bossY,14,2.1); this.reset(); }
  get environment() { return SITES[this.site]; }
  spawn(kind: BodyKind, sprite: number, x: number, y: number, mass = 1, scale = 1): Body {
    const hull = this.sprites[sprite].hull.map(p => ({ x: p.x * scale, y: p.y * scale }));
    const b = body(kind,sprite,hull,x,y,mass,scale); this.bodies.push(b); return b;
  }
  reset(): void {
    this.bodies = []; this.particles = []; this.bullets = []; this.pops = [];
    this.structures = []; this.blasts = []; this.explosions = 0; this.demolished = 0;
    this.rigs = []; this.focus = null; this.launcher.clear(); this.launcher.setMode(0); this.limbsTorn=0;this.cellsExtracted=0;this.liveThrows=0;this.surgicalShot=false;this.fireWas=false;
    this.walkers = []; this.shellsCaught = 0; this.shellsReturned = 0; this.returnKills = 0;
    this.ship = { x: WORLD.startX, y: this.environment.startY, vx: TUG.cruise, vy: 0, hp:100, invulnerable:0 };
    this.camera = 0; this.cameraY = 0; this.updateCamera(1); this.time = 0; this.charge = 0; this.salvage = 0; this.score = 0;
    this.level = 0; this.kills = 0; this.shots = 0; this.torn = 0; this.bossAwake = false; this.bossShot = 2;
    this.mode = 0; this.supplyTimer = 0; this.fieldWas = false; this.messageTime = 0;
    this.facing = { x:1,y:0 }; this.vortexPhase = 0;
    this.clearInput();
    for (let i = 0; i < WORLD.debrisCount; i++) {
      const column = i % 32, row = Math.floor(i / 32);
      const x = 260 + column * 134 + (row * 11 % 66);
      const y = WORLD.ceiling + 55 + (row * 97 + column * 37) % (WORLD.floor - WORLD.ceiling - 110);
      const cell = i % 13 === 12, scale = cell ? .55 : i % 17 === 0 ? .7 : .4 + i % 3 * .08;
      const b = this.spawn(cell ? 'cell' : 'scrap',cell ? 6 : i % 6,x,y,.6 + i % 3 * .2,scale);
      b.angle = i * 1.8; b.omega = (i % 3 - 1) * .5;
      if(this.site==='foundry') {
        b.x = 260+(i%56)*77+Math.floor(i/56)*11;
        b.y = WORLD.floor-verticalExtent(b).max-Math.floor(i/56)*9;
        b.omega = 0;
      }
    }
    if(this.site==='foundry') this.structures = buildStructures((sprite,x,y,mass,scale)=>this.spawn('structure',sprite,x,y,mass,scale));
    for (const [x,y,kind] of [
      [880,420,'drone'],[1200,WORLD.floor-24,'wheel'],[1470,290,'drone'],
      [1760,630,'drone'],[2060,WORLD.floor-24,'wheel'],[2320,360,'drone'],
      [2600,680,'drone'],[2890,WORLD.floor-24,'wheel'],[3130,280,'drone'],
      [3440,580,'drone'],[3710,WORLD.floor-24,'wheel'],
    ] as const) {
      const b = this.spawn(kind,kind === 'drone' ? 8 : 9,x,y,2.5,1.05);
      b.hp = b.maxHp = kind === 'wheel' ? 48 : 34; b.cooldown = 3 + x % 3;
    }
    for(const x of [1340,2460,3530]) {
      const b=this.spawn('walker',13,x,WORLD.floor-106,6,1.6);
      b.hp=b.maxHp=115;const rig=createEnemyRig(b,this.spawn.bind(this));this.rigs.push(rig);this.walkers.push(createWalker(b,rig));
    }
    this.boss = this.spawn('core',10,WORLD.bossX,WORLD.bossY,14,2.1); this.boss.hp = this.boss.maxHp = 230; this.boss.attached = true;
    const bossRig=createEnemyRig(this.boss,this.spawn.bind(this));this.rigs.push(bossRig);
    for (let i = 0; i < 6; i++) {
      const anchor = { x: i < 3 ? -53 : 44, y: (i % 3 - 1) * 45 };
      const b = this.spawn('plate',7,this.boss.x + anchor.x,this.boss.y + anchor.y,3,1.35);
      b.hp = b.maxHp = 95;bossRig.add({key:`armor-${i}`,role:'armor',body:b,offset:anchor,strength:80});
    }
    this.poseRigs();
  }
  start(practice = false,site: Site = this.site): void { this.site = site; this.practice = practice; this.reset(); this.phase = 'playing'; this.say(this.site==='foundry'?'LIFT THE SCRAP. TEAR DOWN THE YARD.':'RMB TO GATHER. LMB TO HURL.'); }
  updateCamera(dt: number): void {
    const x = clamp(this.ship.x-this.viewWidth*.28,0,Math.max(0,WORLD.width-this.viewWidth));
    const y = this.viewHeight >= WORLD.height ? (WORLD.height-this.viewHeight)/2
      : clamp(this.ship.y-this.viewHeight*.5,0,WORLD.height-this.viewHeight);
    const follow = Math.min(1,dt*3);
    this.camera += (x-this.camera)*follow; this.cameraY += (y-this.cameraY)*follow;
  }
  clearInput(): void { this.input.x = 0; this.input.y = 0; this.input.field = false; this.input.fire = false; this.input.aim = null; this.fieldWas = false; this.charge = 0;this.focus=null;this.surgicalShot=false;this.fireWas=false;this.launcher.clear(); }
  pause(): void { if (this.phase === 'playing') { this.phase = 'paused'; this.clearInput(); } }
  setMode(mode: Mode): void {
    if(this.mode===mode)return;
    if(mode===3||this.mode===3)for(const b of this.held){b.held=false;b.thrown=0;}
    this.mode=mode;this.focus=null;this.charge=0;this.fieldWas=false;this.surgicalShot=false;this.launcher.clear();
  }
  setRelease(mode: ReleaseMode): void { this.launcher.setMode(mode); }
  rigFor(b: Body): Rig | undefined { return this.rigs.find(r=>r.root.id===b.rigId); }
  private poseRigs(): void {
    for(const w of this.walkers)if(!w.body.dead)poseWalker(w,this.bodies);
    const bossRig=this.rigFor(this.boss);if(bossRig&&!this.boss.dead)poseWarden(bossRig,this.time);
  }
  get surgicalRange(): number { return 570+this.level*50; }
  get hover(): Body | null {
    const pointer=this.input.aim;if(!pointer)return null;
    return this.bodies.filter(b=>!b.dead&&length(sub(b,this.ship))<this.surgicalRange&&pointerDistance(b,pointer)<18)
      .sort((a,b)=>pointerDistance(a,pointer)-pointerDistance(b,pointer)||(a.attached?-1:0)-(b.attached?-1:0)||length(sub(a,pointer))-length(sub(b,pointer)))[0]??null;
  }
  get focusLabel(): string {
    const b=this.focus??this.hover;if(!b)return 'POINT AT ONE OBJECT';
    const rig=this.rigFor(b),part=rig?.part(b);
    if(rig?.root===b&&rig.shielded)return 'GRAV SHIELD · EXTRACT THE VIOLET CELL';
    if(b.attached)return `${part?.role==='shield'?'SHIELD CELL':part?.role==='leg'?'LEG JOINT':b.kind.toUpperCase()} · PULL ${Math.min(100,Math.round(b.stress/(part?.strength??80)*100))}%`;
    return `${b.kind==='core'?'WARDEN':b.kind==='walker'?'STRIDER':b.kind.toUpperCase()} · ${b.held?'LMB HURL / WHEEL ROTATE':'RMB GRAB'}`;
  }
  private capture(b: Body): void {
    if(b.ordnance){b.ordnance.state='captured';b.ordnance.fuse=6;this.shellsCaught++;this.pop(b.x,b.y,'SHELL CAUGHT','#b5f5d5');}
    b.held=true;b.armed=false;b.thrown=0;if(this.rigFor(b)?.root===b)b.attached=false;this.onSound('grab');
  }
  private detached(parts: Body[],selected?: Body): void {
    for(const b of parts) {
      if(b.kind==='plate'){this.torn++;this.salvage+=25;this.score+=150;this.pop(b.x,b.y,'PLATE RIPPED +25');if(this.plates.length<=2)this.say('CORE EXPOSED — THROW EVERYTHING.');}
      if(b.kind==='limb')this.limbsTorn++;
      if(b.kind==='cell'){this.cellsExtracted++;this.pop(b.x,b.y,'GRAV SHIELD DOWN','#df9ef2');}
      if(b===selected&&this.input.field)this.capture(b);
      this.burst(b.x,b.y,'#ebc581',10);this.onSound('hit');
    }
    if(parts.length)this.shake=Math.max(this.shake,3);
    for(const r of this.rigs)if(!r.shielded||r.mobility===0)r.root.attached=false;
  }
  private surgical(dt: number): void {
    if(!this.input.field){this.charge=0;this.dropCargo();this.focus=null;this.surgicalShot=false;return;}
    if(!this.fieldWas&&!this.surgicalShot)this.focus=this.hover;
    const b=this.focus;if(!b||b.dead||this.surgicalShot)return;
    if(length(sub(b,this.ship))>this.surgicalRange+100){b.held=false;this.focus=null;this.surgicalShot=true;return;}
    const rig=this.rigFor(b);
    if(rig?.root===b&&rig.shielded)return;
    if(b.attached) {
      if(rig)this.detached(rig.stress(b,dt*115*(1+this.level*.2)),b);
      else {b.stress+=dt*115;if(b.stress>=80)this.detach(b);}
      this.charge=Math.min(1,b.stress/(rig?.part(b)?.strength??80));
      if(b.attached)return;
    }
    if(!b.held)this.capture(b);
    const pointer=this.input.aim??this.fieldCenter,direction=unit(sub(pointer,this.ship)),distance=clamp(length(sub(pointer,this.ship)),65,this.surgicalRange);
    const target={x:this.ship.x+direction.x*distance,y:this.ship.y+direction.y*distance};
    const strength=200/Math.sqrt(Math.max(1,b.mass/3)),damping=2*Math.sqrt(strength);
    b.vx+=((target.x-b.x)*strength-b.vx*damping)*dt;b.vy+=((target.y-b.y)*strength-b.vy*damping)*dt;b.omega*=Math.exp(-dt*8);
    this.charge=Math.min(1,this.charge+dt);
    if(this.input.fire&&!this.fireWas){this.launch([b]);this.focus=null;this.surgicalShot=true;}
  }
  get held(): Body[] { return this.bodies.filter(b => b.held && !b.dead); }
  get plates(): Body[] { return this.bodies.filter(b => b.kind === 'plate' && b.attached && !b.dead); }
  get range(): number { return (this.mode === 2 ? 255 : 210) + this.level * 28; }
  get vortexSpeed(): number { return 14 + this.level * 1.8; }
  get fieldCenter(): Vec {
    const direction = this.aim();
    return { x:this.ship.x + direction.x*165, y:this.ship.y + direction.y*165 };
  }
  get upgradeCost(): number { return 50 + this.level * 45; }
  upgrade(): boolean {
    if (this.level >= 3 || this.salvage < this.upgradeCost || !['playing','paused'].includes(this.phase)) return false;
    this.salvage -= this.upgradeCost; this.level++; this.ship.hp = Math.min(100,this.ship.hp + 20);
    this.say(`FIELD MK.${this.level + 1} — RANGE + FIELD POWER INCREASED`); this.onSound('upgrade'); return true;
  }
  say(text: string): void { this.message = text; this.messageTime = 4; }
  aim(): Vec {
    if (this.input.aim) {
      const direction = sub(this.input.aim,this.ship);
      if (length(direction) > 1) this.facing = unit(direction);
      return this.facing;
    }
    const needScrap = this.site==='foundry'&&!this.bodies.some(b=>b.held&&!b.dead);
    const salvage = needScrap ? this.bodies.filter(b=>!b.dead&&!b.attached&&b.thrown<=0&&['scrap','cell'].includes(b.kind)&&b.x>this.ship.x+60&&length(sub(b,this.ship))<400)
      .sort((a,b)=>length(sub(a,this.ship))-length(sub(b,this.ship)))[0] : undefined;
    const incoming = this.bodies.filter(b=>!b.dead&&b.ordnance?.state==='hostile'&&length(sub(b,this.ship))<650)
      .sort((a,b)=>length(sub(a,this.ship))-length(sub(b,this.ship)))[0];
    const secured=this.bodies.find(b=>!b.dead&&b.held&&b.ordnance);
    const sender=secured?this.bodies.find(b=>!b.dead&&b.id===secured.ordnance!.owner):undefined;
    const target = sender ?? incoming ?? salvage ?? this.bodies.filter(b => !b.dead && (b.kind === 'drone' || b.kind === 'wheel' || b.kind === 'walker' || b.kind === 'core' || (b.kind==='structure'&&b.attached)) && b.x > this.ship.x - 70)
      .sort((a,b) => length(sub(a,this.ship)) - length(sub(b,this.ship)))[0];
    this.facing = target ? unit(sub(target,this.ship)) : { x:1,y:0 };
    return this.facing;
  }
  burst(x: number,y: number,color: string,count = 20,speed = 140): void {
    for (let i = 0; i < count; i++) {
      const a = Math.random() * Math.PI * 2, v = speed * (.2 + Math.random());
      const life = .25 + Math.random() * .6;
      this.particles.push({ x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v,life,max:life,color,size:Math.random() < .3 ? 4 : 2 });
    }
    if (this.particles.length > 350) this.particles.splice(0,this.particles.length - 350);
  }
  pop(x: number,y: number,text: string,color = '#c9ead7'): void { this.pops.push({ x,y,text,life:1.5,color }); }
  detach(b: Body): void {
    if (!b.attached) return;
    const rig=this.rigFor(b);if(rig&&rig.root!==b){
      const selected=this.mode===3?this.focus===b:this.input.field&&b.kind==='plate'&&length(sub(b,this.fieldCenter))<this.range;
      this.detached(rig.detach(b),selected?b:undefined);return;
    }
    if(b.kind==='structure') {
      this.unbolt(b);
      for(const rig of this.structures) for(const part of unsupportedParts(rig)) this.unbolt(part);
      this.pop(b.x,b.y,'SUPPORT BROKEN','#f7bf75');this.onSound('hit');this.shake=Math.max(this.shake,5);return;
    }
  }
  private unbolt(b: Body): void {
    b.attached=false;b.mount=undefined;b.stress=0;this.demolished++;this.salvage+=4;this.score+=20;
    b.held=this.input.field&&(this.mode===3?this.focus===b:length(sub(b,this.fieldCenter))<this.range);
    this.burst(b.x,b.y,'#eab879',10,180);
  }
  private explode(b: Body,returnOwner?: number): void {
    const sender=b.ordnance?.state==='returned'?b.ordnance.owner:returnOwner;
    this.explosions++;this.blasts.push({x:b.x,y:b.y,age:0,radius:CELL.blastRadius});
    if(this.blasts.length>24)this.blasts.shift();
    this.burst(b.x,b.y,'#fff0b1',60,680);this.burst(b.x,b.y,'#f49148',40,410);
    this.onSound('boom');this.shake=Math.max(this.shake,28*clamp(1-length(sub(this.ship,b))/1400,.1,1));
    for(const other of [...this.bodies]) {
      const distance=length(sub(other,b));
      if(other.dead||other===b||distance>CELL.blastRadius)continue;
      const strength=1-distance/CELL.blastRadius;
      if(other.kind==='cell'||other.kind==='shell') { this.destroy(other,false,sender);continue; }
      if(other.attached&&other.kind!=='core') {
        const rig=this.rigFor(other);
        if(rig)this.detached(rig.stress(other,45+strength*100));
        else {other.stress+=45+strength*100;if(other.stress>=80)this.detach(other);}
      }
      // A shield module can become an explosive cell during the stress step.
      // Keep the returning shell's credit through this newly released link.
      if((other as Body).kind==='cell') { this.destroy(other,false,sender);continue; }
      const direction=distance>1?unit(sub(other,b)):{x:0,y:-1};
      const impulse=(400+strength*850)/Math.sqrt(other.mass);
      other.vx+=direction.x*impulse;other.vy+=direction.y*impulse;other.omega+=(other.id%2?1:-1)*strength*15;
      if(!other.attached&&!other.held) { other.thrown=2.5;other.volley=-b.id-1; }
      this.damage(other,['scrap','structure','plate'].includes(other.kind)?strength*22:35+strength*110);
      if(other.dead&&other.kind==='walker'&&sender===other.id) {
        this.returnKills++;this.pop(other.x,other.y-55,'RETURN TO SENDER','#b5f5d5');
      }
    }
    if(length(sub(this.ship,b))<190)this.hurt(18);
  }
  destroy(b: Body, explosive = false,returnOwner?: number): void {
    if (b.dead) return; b.dead = true; b.held = false;
    if(b.kind==='structure'&&b.attached) { b.attached=false;for(const rig of this.structures)for(const part of unsupportedParts(rig))this.unbolt(part); }
    const rig=this.rigFor(b);if(rig?.root===b){this.detached(rig.releaseAll());}
    else if(rig&&b.attached){this.detached(rig.detach(b));}
    if (b.kind === 'core') {
      this.phase = 'won'; this.clearInput(); this.score += 1500; this.burst(b.x,b.y,'#f4c572',90,400);
      this.onSound('boom'); this.shake = 12; this.plates.forEach(p => { p.attached = false; p.vx = (Math.random()-.5)*250; }); return;
    }
    const enemy = b.kind === 'drone' || b.kind === 'wheel' || b.kind === 'walker';
    if (enemy) { const salvage=b.kind==='walker'?75:40;this.kills++; this.score += b.kind==='walker'?450:250; this.salvage += salvage; this.pop(b.x,b.y,`SALVAGE +${salvage}`); }
    this.burst(b.x,b.y,b.kind === 'cell' ? '#ffd67b' : '#a9b8a0',enemy ? 30 : 12,enemy ? 210 : 100);
    if (b.kind === 'cell'||b.kind==='shell') { this.explode(b,returnOwner);return; }
    const dropsCell = enemy&&this.random()<CELL.dropChance;
    if (enemy || b.scale > .38) {
      const count=b.kind==='walker'?6:enemy?4:2;
      for (let i = 0; i < count; i++) {
        const cell = dropsCell && i === count-1;
        const sprite=cell?6:b.kind==='walker'&&i<3?11:(b.sprite+i)%6;
        const part = this.spawn(cell ? 'cell' : 'scrap',sprite,b.x + (i-1)*12,b.y,Math.max(.2,b.mass/count),cell ? .55 : b.scale * (b.kind==='walker'?.38:.55));
        part.vx = b.vx * .3 + (Math.random()-.5)*180; part.vy = b.vy * .3 + (Math.random()-.5)*170;
        part.omega = (Math.random()-.5)*8; part.cooldown = .4;
      }
    }
    if (explosive || enemy) this.onSound('hit');
  }
  damage(b: Body, amount: number): void {
    if (b.dead) return;
    if (b.kind === 'core' && this.plates.length > 2) { this.pop(b.x,b.y-80,'ARMOR LOCKED','#f3b65b'); return; }
    if(b.attached&&this.rigFor(b)?.root!==b&&this.rigFor(b)) {
      b.hp-=amount;if(b.hp<=0)this.detach(b);else this.detached(this.rigFor(b)!.stress(b,amount*.65));return;
    }
    if (b.attached && b.kind==='structure') {
      b.stress += amount * .65; b.hp -= amount;
      if (b.hp <= 0 || b.stress >= 80) this.detach(b); return;
    }
    b.hp -= amount; if (b.hp <= 0) this.destroy(b);
  }
  hurt(amount: number): void {
    if (this.ship.invulnerable > 0 || this.phase !== 'playing') return;
    this.ship.hp = Math.max(this.practice ? 25 : 0,this.ship.hp - amount); this.ship.invulnerable = 1;
    this.shake = Math.max(this.shake,5); this.burst(this.ship.x,this.ship.y,'#ed8267',15); this.onSound('hit');
    if (this.ship.hp <= 0) { this.phase = 'lost'; this.clearInput(); this.onSound('boom'); }
  }
  private fireShell(walker: Walker): void {
    const origin=walker.body,aim=walker.aim,offset=origin.radius+18;
    const shell=this.spawn('shell',14,origin.x+aim.x*offset,origin.y+aim.y*offset,1.2,.65);
    shell.hp=shell.maxHp=30;shell.armed=true;
    shell.ordnance={owner:origin.id,state:'hostile',fuse:6};
    const flight=clamp(length(sub(this.ship,shell))/320,.7,2.1);
    shell.vx=(this.ship.x+this.ship.vx*.25-shell.x)/flight;
    shell.vy=(this.ship.y+this.ship.vy*.2-shell.y)/flight-this.environment.gravity*.25*flight*.5;
    shell.omega=-5;this.burst(shell.x,shell.y,'#ffb178',14,130);this.onSound('throw');
    if(this.shellsCaught===0)this.say('INCOMING SHELL — HOLD YOUR FIELD TO CATCH.');
  }
  release(): void { this.launch(this.held); }
  private dropCargo(): void {
    for(const b of this.held) {
      b.held=false;b.thrown=0;
      b.vx=this.ship.vx+(b.vx-this.ship.vx)*.1;b.vy=this.ship.vy+(b.vy-this.ship.vy)*.1;b.omega*=.2;
      // Unpowered shells resume their fuse; only an intentional shot is a return.
      if(b.ordnance){b.ordnance.state='hostile';b.armed=true;}
    }
  }
  private launch(held: Body[]): void {
    const direction = this.aim();
    if (!held.length) return;
    held.forEach((b,i) => {
      // Keep every part inside the aimed cone, even with hundreds in one volley.
      const spread = (held.length > 1 ? i/(held.length-1)-.5 : 0) * (this.mode === 0 ? .52 : .12);
      const a = Math.atan2(direction.y,direction.x)+spread;
      const speed = (this.mode === 1 ? 700+this.charge*600 : this.mode === 2 ? 1000 : this.mode===3?1050/Math.sqrt(Math.max(1,b.mass/4)):860) + this.level*65;
      if(this.rigFor(b)?.root===b||b.kind==='drone'||b.kind==='wheel')this.liveThrows++;
      b.vx = Math.cos(a)*speed + this.ship.vx; b.vy = Math.sin(a)*speed + this.ship.vy;
      b.held = false; b.thrown = 3; b.volley = this.shots + 1; b.omega += (i%2 ? 1 : -1)*4;
      b.launchGrace=.3;
      if(b.kind==='cell')b.armed=true;
      if(b.ordnance) { b.armed=true;b.ordnance.state='returned';b.ordnance.fuse=6;this.shellsReturned++; }
      this.burst(b.x,b.y,'#b5f5d5',3,50);
    });
    this.shots++; this.onSound('throw'); this.shake = 2+this.charge*3;
    this.ship.vx -= direction.x*30; this.ship.vy -= direction.y*30;
  }
  step(dt: number): void {
    this.visualTime += dt;
    this.blasts.forEach(b=>b.age+=dt);this.blasts=this.blasts.filter(b=>b.age<.95);
    this.shake = Math.max(0,this.shake-dt*22);
    this.particles.forEach(p => { p.x += p.vx*dt; p.y += p.vy*dt; p.vy += 80*dt; p.life -= dt; });
    this.particles = this.particles.filter(p => p.life > 0);
    this.pops.forEach(p => { p.y -= dt*24; p.life -= dt; }); this.pops = this.pops.filter(p => p.life>0);
    if (this.phase !== 'playing') return;
    this.time += dt; this.messageTime = Math.max(0,this.messageTime-dt);
    this.vortexPhase += this.vortexSpeed*dt;
    const ship = this.ship, input = this.input;
    ship.invulnerable = Math.max(0,ship.invulnerable-dt);
    if (this.practice) ship.hp = Math.min(100,ship.hp+dt*3);
    const forward = ship.x < WORLD.bossX-270 ? TUG.cruise : 0;
    ship.vx += ((input.x*TUG.speedX+forward)-ship.vx)*Math.min(1,dt*5);
    ship.vy += (input.y*TUG.speedY-ship.vy)*Math.min(1,dt*5);
    ship.x = clamp(ship.x+ship.vx*dt,this.camera+40,WORLD.width-120);
    ship.y = clamp(ship.y+ship.vy*dt,WORLD.ceiling+TUG.radius,WORLD.floor-TUG.radius);
    this.updateCamera(dt);
    if (ship.x > WORLD.bossX-570 && !this.bossAwake) { this.bossAwake = true; this.say('WARDEN / 07 — RIP ITS ARMOR. EXPOSE THE CORE.'); }
    if (this.bossAwake&&!this.boss.dead&&!this.boss.held&&this.boss.thrown<=0) {
      if(this.boss.attached){const mobility=this.rigFor(this.boss)?.mobility??1;this.boss.y = WORLD.floor-65-135*mobility+Math.sin(this.time*1.6)*11*mobility;
      this.boss.x = WORLD.bossX+Math.sin(this.time*.45)*50*mobility;}
      else {const mobility=this.rigFor(this.boss)?.mobility??0;if(mobility>0)this.boss.vy+=((WORLD.floor-65-135*mobility-this.boss.y)*40-this.boss.vy*12)*dt;}
      this.bossShot -= dt;
      if (this.bossShot <= 0) {
        this.bossShot = this.plates.length > 2 ? 2.5 : 1.7;
        const a = Math.atan2(ship.y-this.boss.y,ship.x-this.boss.x);
        for (const off of [-.15,0,.15]) this.bullets.push({ x:this.boss.x-65,y:this.boss.y,vx:Math.cos(a+off)*190,vy:Math.sin(a+off)*190,life:5 });
      }
    }
    const field = this.fieldCenter;
    if(this.mode===3)this.surgical(dt);
    else if (input.field) {
      this.charge = Math.min(1,this.charge+dt*1.1);
      const available = this.bodies.filter(b => !b.dead && b.kind !== 'core' && b.kind !== 'wheel' && b.kind !== 'walker' && !(b.attached&&(b.kind==='limb'||b.kind==='power')) && !b.held && b.thrown <= 0 && length(sub(b,field)) < this.range)
        .sort((a,b) => length(sub(a,field))-length(sub(b,field)));
      const aim = this.aim(); let tethered = false;
      for (const b of available) {
        if (b.attached) {
          if (this.mode === 2 && tethered) continue;
          const d = unit(sub(b,ship)); if (d.x*aim.x+d.y*aim.y < .35) continue;
          b.stress += dt*(this.mode === 2 ? 105 : 26)*(1+this.level*.2); tethered = true;
          if (b.stress >= 80) this.detach(b);
        } else if (b.kind === 'drone') {
          if (this.mode === 2) { b.hp -= dt*45; b.vx += (field.x-b.x)*dt*5; b.vy += (field.y-b.y)*dt*5; if (b.hp <= 0) this.destroy(b); }
        } else {
          this.capture(b);
        }
      }
    }
    if(this.mode!==3)this.held.forEach(b => {
        // Stable per-part phases prevent the entire swarm jumping when more scrap enters.
        const a = this.vortexPhase + b.id*2.3999632297;
        const radius = this.mode === 0 ? 68 + b.id%5*7 : this.mode === 1 ? 28*(1-this.charge*.55) : 40;
        const rx = Math.cos(a)*radius, ry = Math.sin(a)*radius;
        const spin = this.vortexSpeed, strength = 320*(1+this.level*.18);
        // Position + velocity tracking, with centripetal acceleration, keeps the
        // fast orbit wide instead of damping it into a sluggish central clump.
        b.vx += ((field.x+rx-b.x)*strength + (ship.vx-ry*spin-b.vx)*30 - rx*spin*spin)*dt;
        b.vy += ((field.y+ry-b.y)*strength + (ship.vy+rx*spin-b.vy)*30 - ry*spin*spin)*dt;
        const tumble = (b.id%2 ? 1 : -1)*(20+b.id%7);
        b.omega += (tumble-b.omega)*Math.min(1,dt*16);
      });
    if(this.mode!==3&&!this.tacticalOpen){
      this.launch(this.launcher.tick(dt,!!input.fire,this.held));
      if(!input.field){this.dropCargo();this.charge=0;}
    }
    this.fieldWas = input.field;this.fireWas=!!input.fire;
    for(const walker of this.walkers) if(!walker.body.dead&&stepWalker(walker,ship,this.bodies,dt))this.fireShell(walker);
    for (const b of [...this.bodies]) {
      if (b.dead) continue;
      if(b.ordnance&&!b.held&&b.ordnance.state!=='captured') {
        b.ordnance.fuse-=dt;
        if(b.ordnance.fuse<=0) { this.destroy(b);continue; }
      }
      // Rig parts are kinematic, but their impact cooldowns still advance.
      if (b.attached) { b.cooldown = Math.max(0,b.cooldown-dt); b.age += dt; }
      if (b.kind === 'core'&&b.attached) continue;
      if(b.attached&&this.rigFor(b))continue;
      if (b.attached && b.mount) {
        const tx=b.mount.x,ty=b.mount.y;
        const pulling = input.field && length(sub(b,field)) < this.range;
        const dir = pulling ? unit(sub(field,b)) : {x:0,y:0};
        b.vx += ((tx+dir.x*b.stress*.12-b.x)*110-b.vx*17)*dt;
        b.vy += ((ty+dir.y*b.stress*.12-b.y)*110-b.vy*17)*dt;
        b.x += b.vx*dt; b.y += b.vy*dt; b.angle = (b.mountAngle??0)+Math.sin(this.time*21+b.id)*b.stress*.0015;
        if (!pulling) b.stress = Math.max(0,b.stress-dt*9); continue;
      }
      if ((b.kind === 'drone' || b.kind === 'wheel')&&!b.held&&b.thrown<=0) {
        if (Math.abs(b.x-ship.x) < this.viewWidth) {
          b.vx += ((b.kind === 'drone' ? -24 : -13)-b.vx)*dt*2;
          const ty = b.kind === 'drone' ? b.homeY+Math.sin(this.time*1.5+b.id)*80 : WORLD.floor-24;
          b.vy += ((ty-b.y)*9-b.vy*4)*dt; b.angle = b.kind === 'drone' ? Math.sin(this.time*2+b.id)*.1 : 0;
          if (b.cooldown <= 0 && Math.abs(b.x-ship.x) < 650) {
            const dir = unit(sub(ship,b)); this.bullets.push({x:b.x,y:b.y,vx:dir.x*160,vy:dir.y*160,life:5}); b.cooldown = 3.4;
          }
        }
      }
      const supported=(b.kind==='drone'||b.kind==='wheel'||b.kind==='walker'&&(this.rigFor(b)?.mobility??1)>0||b.kind==='core'&&(this.rigFor(b)?.mobility??0)>0)&&b.thrown<=0;
      integrate(b,dt,b.held||supported?0:this.environment.gravity*(b.kind==='shell'?.25:1));
      // Living rigs remain in the playable corridor, even after a violent throw.
      // In particular the objective cannot drift out of reach into cleanup space.
      if(this.rigFor(b)?.root===b&&(b.x<70+b.radius||b.x>WORLD.width-70-b.radius)) {
        b.x=clamp(b.x,70+b.radius,WORLD.width-70-b.radius);b.vx*=-.5;
      }
      const extent = verticalExtent(b), low = WORLD.ceiling-extent.min, high = WORLD.floor-extent.max;
      if (b.y > high || b.y < low) {
        const speed = Math.abs(b.vy); b.y = clamp(b.y,low,high);
        if((b.kind==='cell'||b.kind==='shell')&&!b.held&&(b.armed||(b.age>.5&&speed>CELL.hardImpact))) { this.destroy(b);continue; }
        const grounded=this.environment.gravity>0&&b.y>=high&&!b.held;
        b.vy *= grounded?(speed<65?0:-.18):-.62;
        b.vx *= grounded?Math.exp(-dt*8):.91;b.omega *= grounded?Math.exp(-dt*12):.8;
        if (b.thrown > 0 && speed > 250 && b.cooldown <= 0) { this.damage(b,speed*.06); b.cooldown = .2; }
      }
    }
    this.poseRigs();
    const active = this.bodies.filter(b => !b.dead && b.x > this.camera-160 && b.x < this.camera+this.viewWidth+200);
    for (const [a,b] of collisionPairs(active)) {
      if (a.dead || b.dead || (a.attached && b.attached) || (a.held && b.held) || ((a.attached || b.attached) && (a.held || b.held))) continue;
      if((a.ordnance?.state==='hostile'&&(a.ordnance.owner===b.id||a.ordnance.owner===b.rigId))||(b.ordnance?.state==='hostile'&&(b.ordnance.owner===a.id||b.ordnance.owner===a.rigId)))continue;
      // A compressed payload shares a field until impact; it must not destroy itself at launch.
      if (a.thrown > 0 && b.thrown > 0 && a.volley === b.volley) continue;
      const hit = contact(a,b); if (!hit) continue;
      const impact=Math.max(0,-((b.vx-a.vx)*hit.normal.x+(b.vy-a.vy)*hit.normal.y));
      let detonation=false;
      for(const cell of [a,b]) if((cell.kind==='cell'||cell.kind==='shell')&&!cell.held&&(cell.armed||(cell.age>.5&&impact>CELL.hardImpact))) {
        this.destroy(cell);detonation=true;
      }
      if(detonation)continue;
      const launched = a.thrown > 0 || b.thrown > 0, speed = resolve(a,b,hit);
      const fallingStructure=(a.kind==='structure'&&!a.attached)||(b.kind==='structure'&&!b.attached);
      if (speed > 85 && (launched||fallingStructure&&speed>120) && a.cooldown <= 0 && b.cooldown <= 0) {
        const damage = clamp(speed*.16,12,90);
        this.damage(a,damage*b.mass*.5); this.damage(b,damage*a.mass*.5);
        a.cooldown = .16; b.cooldown = .16; this.burst(hit.point.x,hit.point.y,'#f3cf83',9,130); this.onSound('hit');
        this.shake = Math.max(this.shake,2);
      }
    }
    for (const b of active) {
      if(!b.dead&&!b.held&&b.kind==='shell'&&b.armed&&length(sub(b,ship))<b.radius+TUG.radius) { this.destroy(b);continue; }
      if (b.dead || b.held || b.thrown > 0 || !['drone','wheel','walker','core','plate','structure'].includes(b.kind)) continue;
      if (length(sub(b,ship)) < b.radius*.7+TUG.radius) this.hurt(16);
    }
    const shieldParts = this.held;
    for (const p of this.bullets) {
      p.x += p.vx*dt; p.y += p.vy*dt; p.life -= dt;
      const shield = shieldParts.find(b => length(sub(b,p)) < b.radius*.8+5);
      if (shield) { p.life = 0; this.burst(p.x,p.y,'#c3f9dc',5,70); }
      else if (length(sub(p,ship)) < TUG.radius+3) { p.life = 0; this.hurt(12); }
    }
    this.bullets = this.bullets.filter(p => p.life>0);
    this.supplyTimer += dt;
    if (this.supplyTimer > 5 && this.bodies.filter(b => !b.dead && ['scrap','cell','plate'].includes(b.kind) && !b.attached && length(sub(b,ship))<350).length < 12) {
      this.supplyTimer = 0;
      for (let i = 0; i < 12; i++) this.spawn('scrap',i%6,ship.x+70+(i%4)*50,
        this.site==='foundry'?WORLD.floor-16-Math.floor(i/4)*14:clamp(ship.y-110+Math.floor(i/4)*100,WORLD.ceiling+35,WORLD.floor-35),.6+i%3*.2,.4+i%3*.08);
    }
    // Reclaim only wreckage that has left the level. Never trim a carried payload
    // or a live volley to an arbitrary body count.
    this.bodies = this.bodies.filter(b => !b.dead && (b.kind==='core' || b.held || b.thrown > 0 || b.attached || (b.x > this.camera-400 && b.x < WORLD.width+520)));
    this.rigs=this.rigs.filter(r=>!r.root.dead&&this.bodies.includes(r.root));
    this.walkers=this.walkers.filter(w=>!w.body.dead&&this.bodies.includes(w.body));
  }
}