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 { 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));
}
}
