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 { Game } from './game.js';
import { clamp, worldHull, type Vec } from './physics.js';
import { TUG, WORLD } from './world.js';
import { walkerLegs, type Walker } from './walkers.js';
export class Renderer {
c: CanvasRenderingContext2D; width = 1000; height = 600; scale = 1; reduced = false;
constructor(public canvas: HTMLCanvasElement, public game: Game) { this.c = canvas.getContext('2d')!; }
resize(): void {
const rect = this.canvas.getBoundingClientRect(), dpr = Math.min(2,window.devicePixelRatio || 1);
// Hosts may load the iframe while its placement is still hidden or zero-sized.
if (rect.width <= 0 || rect.height <= 0) return;
this.width = rect.width; this.height = rect.height;
this.canvas.width = Math.round(rect.width*dpr); this.canvas.height = Math.round(rect.height*dpr);
// Wide desktop view; short touch frames pan vertically instead of shrinking
// the pilot below a readable size. Portrait still exposes extra vertical room.
this.scale = Math.min(rect.height/(rect.height<360 ? 700 : WORLD.height),rect.width/800);
this.game.viewWidth = rect.width/this.scale; this.game.viewHeight = rect.height/this.scale;
this.game.updateCamera(1);
this.c.setTransform(dpr,0,0,dpr,0,0); this.c.imageSmoothingEnabled = false;
}
worldPoint(x: number,y: number): Vec {
const rect = this.canvas.getBoundingClientRect();
return {x:(x-rect.left)/this.scale+this.game.camera,y:(y-rect.top)/this.scale+this.game.cameraY};
}
rect(x: number,y: number,w: number,h: number,color: string): void { this.c.fillStyle=color; this.c.fillRect(Math.round(x),Math.round(y),Math.round(w),Math.round(h)); }
line(points: number[],color: string,width = 1): void {
const c = this.c; c.beginPath(); c.moveTo(points[0],points[1]);
for (let i=2;i<points.length;i+=2) c.lineTo(points[i],points[i+1]); c.strokeStyle=color; c.lineWidth=width; c.stroke();
}
text(text: string,x: number,y: number,color = '#72857d',size = 11): void {
this.c.fillStyle=color; this.c.font=`${size}px "SFMono-Regular", Consolas, monospace`; this.c.fillText(text,x,y);
}
draw(): void {
const c=this.c,g=this.game,w=g.viewWidth,t=g.visualTime,cam=g.camera;
c.clearRect(0,0,this.width,this.height); this.rect(0,0,this.width,this.height,'#0b1417');
c.save(); c.scale(this.scale,this.scale); c.translate(0,-g.cameraY);
if (!this.reduced && g.shake>0) c.translate((Math.random()-.5)*g.shake,(Math.random()-.5)*g.shake);
// Expand the industrial architecture with the stage, leaving sprites at
// their own physical size. Rails coincide with WORLD.ceiling / WORLD.floor.
c.save(); c.scale(1,WORLD.height/600);
const extraTop=Math.max(0,-g.cameraY)*600/WORLD.height;
const extraBottom=Math.max(0,g.cameraY+g.viewHeight-WORLD.height)*600/WORLD.height;
const haze=c.createRadialGradient(w*.58,270,10,w*.58,270,w*.75);
haze.addColorStop(0,g.site==='foundry'?'#3b382c':'#253a37'); haze.addColorStop(.55,'#14272a'); haze.addColorStop(1,'#091316');
c.fillStyle=haze; c.fillRect(0,-extraTop,w,600+extraTop+extraBottom);
// Distant salvage dock. Layers scroll at separate speeds.
for(let i=-1;i<w/240+2;i++){
const x=i*240-(cam*.18%240);
this.rect(x,115,140,285,'#132326'); this.rect(x+5,119,128,3,'#30433b');
this.rect(x+15,145,8,225,'#233833'); this.rect(x+120,145,5,225,'#213430');
this.line([x+25,350,x+112,185],'#263b35',6);
for(let j=0;j<5;j++) this.rect(x+39+j*13,385,5,6,j===2?'#7a7650':'#324942');
this.line([x-20,130,x+160,175,x+235,120],'#0c1d21',2);
}
for(let i=-1;i<w/420+2;i++){
const x=i*420-(cam*.4%420);
this.rect(x,0,44,600,'#122125'); this.rect(x+4,0,5,600,'#2b3e3a');
this.line([x+28,25,x+200,130,x+218,130],'#31413a',12);
this.line([x+28,25,x+200,130,x+218,130],'#101f23',7);
this.rect(x+22,186,30,80,'#263833'); this.rect(x+28,194,14,2,'#526054');
this.rect(x+28,205,14,2,'#526054'); this.rect(x+32,248,7,4,'#bc9557');
}
// Overhead machinery and ground are actual gameplay bounds.
this.rect(0,-extraTop,w,82+extraTop,'#0a1215');
this.rect(0,74,w,7,'#34433a'); this.rect(0,81,w,4,'#73806a');
this.rect(0,86,w,6,'#17282a');
this.rect(0,512,w,88+extraBottom,'#0b171b'); this.rect(0,509,w,4,'#758275');
this.rect(0,513,w,8,'#2d413d'); this.rect(0,541,w,3,'#3a4c42');
for(let i=-1;i<w/100+2;i++){
const x=i*100-(cam%100);
this.rect(x,35,70,27,'#172629'); this.rect(x+4,38,60,2,'#33433d');
this.rect(x+9,56,48,2,'#253b36'); this.rect(x+12,72,6,7,'#b59659');
this.rect(x,526,86,8,'#1b2e2d'); this.rect(x+4,528,24,3,'#344b3f');
this.rect(x+50,528,4,3,'#bf9659'); this.rect(x+59,528,4,3,'#bf9659');
this.line([x+90,548,x+90,597],'#1b3130',2);
for(let j=0;j<3;j++) this.rect(x+15+j*18,557,11,2,'#294039');
}
for(let i=-1;i<w/330+2;i++) {
const x=i*330-(cam%330);
this.rect(x+150,65,50,6,'#5c5943'); this.rect(x+159,71,31,3,'#ecc07a');
c.globalAlpha=.035; c.fillStyle='#ffd987'; c.beginPath(); c.moveTo(x+156,78); c.lineTo(x+90,350); c.lineTo(x+260,350); c.lineTo(x+197,78); c.fill(); c.globalAlpha=1;
this.text('BAY / '+String((i+Math.floor(cam/330)+12)%99).padStart(2,'0'),x+30,471,'#43574d',12);
this.rect(x+35,480,70,2,'#324b42');
}
if (!this.reduced) for(let i=0;i<35;i++){
const x=((i*83.17+t*(4+i%3)-cam*.3)%(w+40)+w+40)%(w+40), y=100+(i*41.37%370);
c.globalAlpha=.12+(i%4)*.04; this.rect(x,y,2,2,'#b7c9a2');
}
c.globalAlpha=1; c.restore();
c.save(); c.translate(-cam,0);
if(g.site==='foundry') {
this.rect(cam,WORLD.floor+7,w,5,'#a1854e');
for(let x=Math.floor(cam/100)*100;x<cam+w+100;x+=100)this.line([x,WORLD.floor+15,x+30,WORLD.floor+30],'#5c5638',8);
for(const rig of g.structures) {
if(!rig.parts.some(b=>b.attached&&!b.dead))continue;
for(const [a,b] of rig.links) if(a.attached&&b.attached&&!a.dead&&!b.dead) {
this.line([a.x,a.y,b.x,b.y],'#b49a6055',2);
this.rect((a.x+b.x)/2-3,(a.y+b.y)/2-3,6,6,'#d4ba80');
}
this.text(rig.name,rig.x-70,WORLD.floor-300,'#c4a778',12);
}
}
this.drawBoss();
for(const walker of g.walkers) if(!walker.body.dead&&walker.body.x>cam-180&&walker.body.x<cam+w+180)this.drawWalker(walker);
this.drawField();
for(const rig of g.rigs)if(rig.shielded&&!rig.root.dead){const b=rig.root;c.strokeStyle='#d39be03b';c.lineWidth=2;c.setLineDash([8,9]);c.beginPath();c.ellipse(b.x,b.y,b.radius+26,b.radius+32,0,0,Math.PI*2);c.stroke();c.setLineDash([]);}
const focus=g.fieldCenter, denseField=g.held.length>48;
for(const b of g.bodies){
if(b.dead || b.x<cam-100 || b.x>cam+w+100 || b.kind==='core' || b.kind==='walker') continue;
c.save(); c.translate(Math.round(b.x),Math.round(b.y)); c.rotate(b.angle);
if(b.kind==='power'){c.shadowColor='#df9ef2';c.shadowBlur=12;}
else if(b.kind==='cell') { c.shadowColor='#ffd477'; c.shadowBlur=12; }
else if(b.held && !denseField) { c.shadowColor='#9fedd1'; c.shadowBlur=10; }
const sprite=g.sprites[b.sprite]; const size=sprite.width*b.scale;
c.drawImage(sprite.canvas,-size/2,-size/2,size,size); c.restore();
if(b.kind==='cell'&&b.armed&&!this.reduced) {
this.line([b.x-12,b.y-12,b.x+12,b.y+12],'#fff1b4',2);
this.line([b.x+12,b.y-12,b.x-12,b.y+12],'#fff1b4',2);
}
if(b.ordnance) {
const color=b.ordnance.state==='hostile'?'#ff9471':'#b5f5d5';
c.strokeStyle=color;c.lineWidth=2;c.beginPath();c.arc(b.x,b.y,b.radius+5,0,Math.PI*2);c.stroke();
if(!this.reduced)this.line([b.x-b.vx*.075,b.y-b.vy*.075,b.x,b.y],color,3);
}
if(b.held && !this.reduced) {
c.globalAlpha=.45;
this.line([b.x-b.vx*.024,b.y-b.vy*.024,b.x,b.y],'#baf7d9',2);
c.globalAlpha=1;
}
if(b.attached && b.stress>1){
this.rect(b.x-20,b.y-32,40,3,'#3b493d'); this.rect(b.x-20,b.y-32,b.stress/80*40,3,'#a8efd2');
this.line([focus.x,focus.y,b.x,b.y],'#b6f1d1',1.5);
}
if((b.kind==='drone'||b.kind==='wheel') && b.hp<b.maxHp){
this.rect(b.x-18,b.y-34,36,3,'#583e30'); this.rect(b.x-18,b.y-34,36*b.hp/b.maxHp,3,'#ecb470');
}
if(b.thrown>0) { c.globalAlpha=.4; this.line([b.x,b.y,b.x-b.vx*.027,b.y-b.vy*.027],b.kind==='cell'?'#e8bb62':'#aedecd',2); c.globalAlpha=1; }
if(g.debug){ const points=worldHull(b); this.line([...points,points[0]].flatMap(p=>[p.x,p.y]),b.attached?'#eaa064':'#85ebcb'); }
}
for(const p of g.bullets){
c.shadowColor='#f3a566'; c.shadowBlur=9; this.rect(p.x-5,p.y-3,10,6,'#ee9960'); this.rect(p.x-2,p.y-1,4,2,'#ffe9ad'); c.shadowBlur=0;
}
this.drawShip();
this.drawSelection();
for(const p of g.particles){ c.globalAlpha=Math.max(0,p.life/p.max); this.rect(p.x,p.y,p.size,p.size,p.color); } c.globalAlpha=1;
this.drawBlasts();
for(const p of g.pops){ c.globalAlpha=Math.min(1,p.life); this.text(p.text,p.x-p.text.length*3,p.y,p.color,11); } c.globalAlpha=1;
if(g.phase==='playing' && g.input.aim){
const a=g.input.aim; const color='#e6c18a';
this.line([a.x-9,a.y-4,a.x-9,a.y-9,a.x-4,a.y-9],color);
this.line([a.x+4,a.y+9,a.x+9,a.y+9,a.x+9,a.y+4],color);
this.rect(a.x-1,a.y-1,2,2,color);
}
c.restore(); c.restore();
// Instrumentation stays in screen pixels, independent of the world zoom.
this.text('SECTOR 07 / '+g.environment.name,20,55,'#81968a',10);
this.text(g.environment.detail,20,73,g.site==='foundry'?'#a88e5f':'#466a5c',9);
if(g.bossAwake && !g.boss.dead){
const bw=Math.min(this.width-40,330), bx=(this.width-bw)/2;
this.text('WARDEN / 07',bx,101,'#e7ba78',12);
this.text(g.plates.length>2 ? `${g.plates.length} ARMOR PLATES`:'CORE EXPOSED',bx+bw-125,101,'#9a9274',10);
this.rect(bx,111,bw,4,'#493b2c'); this.rect(bx,111,bw*g.boss.hp/g.boss.maxHp,4,'#dba560');
}
const vignette=c.createLinearGradient(0,0,0,this.height); vignette.addColorStop(0,'#07101288'); vignette.addColorStop(.2,'#07101200'); vignette.addColorStop(.8,'#07101200'); vignette.addColorStop(1,'#07101288');
c.fillStyle=vignette; c.fillRect(0,0,this.width,this.height);
}
drawWalker(walker: Walker): void {
const c=this.c,b=walker.body;
for(const leg of walker.rig?[]:walkerLegs(walker,this.game.bodies)) {
const path=[leg.hip.x,leg.hip.y,leg.knee.x,leg.knee.y,leg.foot.x,leg.foot.y];
this.line(path,'#101b1c',15);this.line(path,'#936a55',9);this.line(path,'#c59e76',2);
this.rect(leg.knee.x-5,leg.knee.y-5,10,10,'#ccac7c');
this.rect(leg.knee.x-2,leg.knee.y-2,4,4,'#392f2b');
this.rect(leg.foot.x-12,leg.foot.y-4,24,7,leg.planted?'#d0ad80':'#886956');
}
c.save();c.translate(Math.round(b.x),Math.round(b.y));c.rotate(b.angle);
const sprite=this.game.sprites[b.sprite],size=sprite.width*b.scale;
c.drawImage(sprite.canvas,-size/2,-size/2,size,size);c.restore();
c.save();c.translate(b.x,b.y-6);c.rotate(Math.atan2(walker.aim.y,walker.aim.x));
this.rect(-13,-13,27,26,'#312d28');this.rect(-9,-9,19,18,'#d0a071');
this.rect(10,-9,51,18,'#192423');this.rect(13,-6,45,12,'#90694f');this.rect(18,-6,37,3,'#cfa875');
this.rect(53,-11,12,22,'#5f5140');
if(walker.windup>0) {
const charge=1-walker.windup/.7;
c.shadowColor='#ff9060';c.shadowBlur=this.reduced?0:12+charge*20;
this.rect(62,-5-charge*4,5+charge*7,10+charge*8,'#ffe2a3');c.shadowBlur=0;
}
c.restore();
this.text(walker.windup>0?'SHELL CHARGING':'STRIDER',b.x-35,b.y-104,walker.windup>0?'#ffc78c':'#b39b7b',10);
this.rect(b.x-35,b.y-92,70,3,'#49382c');this.rect(b.x-35,b.y-92,70*b.hp/b.maxHp,3,'#e4ad79');
if(this.game.debug){const points=worldHull(b);this.line([...points,points[0]].flatMap(p=>[p.x,p.y]),'#eaa064');}
}
drawSelection(): void {
const g=this.game,c=this.c;if(g.mode!==3||g.phase!=='playing')return;
const b=g.focus??g.hover;if(!b)return;
const points=worldHull(b),color=g.rigFor(b)?.root===b&&g.rigFor(b)?.shielded?'#df9ef2':'#b5f5d5';
this.line([...points,points[0]].flatMap(p=>[p.x,p.y]),color,3);
if(g.focus&&g.input.field)this.line([g.ship.x,g.ship.y,b.x,b.y],color,2);
const label=g.focusLabel,pad=(label.length*2.8+10)/this.scale;
c.save();c.translate(clamp(b.x,g.camera+pad,g.camera+g.viewWidth-pad),Math.max(b.y-b.radius-24,g.cameraY+35/this.scale));c.scale(1/this.scale,1/this.scale);
this.rect(-label.length*2.8-5,-14,label.length*5.6+10,19,'#0b171dee');this.text(label,-label.length*2.8,0,color,9);c.restore();
}
drawBlasts(): void {
const c=this.c;
for(const blast of this.game.blasts) {
const age=blast.age,fade=Math.max(0,1-age/.95);
c.save();c.translate(blast.x,blast.y);
if(this.reduced) {
c.globalAlpha=fade*.4;c.fillStyle='#d99b55';c.beginPath();c.arc(0,0,65,0,Math.PI*2);c.fill();c.restore();continue;
}
// Blocky rolling smoke stays behind the fast luminous pressure front.
for(let i=0;i<9;i++) {
const a=i*2.39996,r=25+age*110,size=24+age*48;
c.globalAlpha=fade*.28;this.rect(Math.cos(a)*r-size/2,Math.sin(a)*r-size/2-age*65,size,size,'#665549');
}
c.globalCompositeOperation='lighter';
const radius=Math.min(1,age/.45)*blast.radius,flash=Math.max(0,1-age/.36);
const glow=c.createRadialGradient(0,0,2,0,0,90+age*180);
glow.addColorStop(0,`rgba(255,221,134,${flash*.8})`);glow.addColorStop(.45,`rgba(242,112,38,${fade*.35})`);glow.addColorStop(1,'#f2772500');
c.globalAlpha=1;c.fillStyle=glow;c.fillRect(-280,-280,560,560);
if(age<.6) { c.globalAlpha=1-age/.6;c.strokeStyle='#ffd285';c.lineWidth=7*(1-age/.6)+1;c.beginPath();c.arc(0,0,radius,0,Math.PI*2);c.stroke(); }
for(let i=0;i<14;i++) {
const a=i*Math.PI*2/14+blast.x*.01,r=35+age*520;
c.globalAlpha=fade*.75;this.line([Math.cos(a)*r,Math.sin(a)*r,Math.cos(a)*(r+35*fade),Math.sin(a)*(r+35*fade)],i%2?'#ffc677':'#fff2bd',3);
}
if(flash>0) {
c.globalAlpha=flash;const size=18+Math.sin(Math.min(1,age/.25)*Math.PI)*60;
this.rect(-size,-size*.55,size*2,size*1.1,'#ffd074');this.rect(-size*.55,-size,size*1.1,size*2,'#fff2c1');
}
c.restore();
}
}
drawField(): void {
const c=this.c,g=this.game;
if(g.mode===3||!g.input.field && !g.held.length) return;
const center=g.fieldCenter,aim=g.aim(),radius=g.mode===0 ? 103 : g.mode===1 ? 52-g.charge*24 : 52;
const muzzle={x:g.ship.x+aim.x*29*TUG.scale,y:g.ship.y+aim.y*29*TUG.scale};
this.line([muzzle.x,muzzle.y,center.x,center.y],'#b8f2ce55',2);
c.save();c.translate(center.x,center.y);
c.strokeStyle='#92c9ad33';c.lineWidth=1;c.setLineDash([2,10]);
c.beginPath();c.arc(0,0,g.range,0,Math.PI*2);c.stroke();c.setLineDash([]);
const gradient=c.createRadialGradient(0,0,4,0,0,radius*1.4);
gradient.addColorStop(0,'#c6ffe123');gradient.addColorStop(.7,'#87e7bb15');gradient.addColorStop(1,'#87e7bb00');
c.fillStyle=gradient;c.fillRect(-radius*1.4,-radius*1.4,radius*2.8,radius*2.8);
// Fast spirals and inflowing streaks share the actual physics focus.
const phase=this.reduced ? 0 : g.vortexPhase;
for(let arm=0;arm<(this.reduced?2:5);arm++){
c.beginPath();
for(let j=0;j<=24;j++){
const f=j/24,r=12+f*radius,a=phase+arm*Math.PI*2/5+f*2.2;
const x=Math.cos(a)*r,y=Math.sin(a)*r;
if(j===0)c.moveTo(x,y);else c.lineTo(x,y);
}
c.strokeStyle=arm%2?'#c7ffe1b0':'#79d6b57a';c.lineWidth=arm%2?2:1;c.stroke();
}
if(!this.reduced)for(let i=0;i<12;i++){
const f=(g.time*2.4+i/12)%1,r=radius+65*(1-f),a=phase*.7+i*2.39996;
c.globalAlpha=f*.65;
this.line([Math.cos(a)*r,Math.sin(a)*r,Math.cos(a+.1)*(r-15),Math.sin(a+.1)*(r-15)],'#c7ffe1',2);
}
c.globalAlpha=1;this.rect(-2,-2,4,4,'#d7ffe4');c.restore();
}
drawShip(): void {
const c=this.c,g=this.game,s=g.ship,t=g.visualTime,aim=g.aim();
c.save(); c.translate(Math.round(s.x),Math.round(s.y)); c.rotate(Math.atan2(aim.y,aim.x));
c.scale(TUG.scale,TUG.scale);
if(s.invulnerable>0 && Math.floor(t*16)%2) c.globalAlpha=.5;
// Hand-pixelled salvage tug, not a geometric stand-in.
const flame=12+Math.sin(t*42)*6+Math.abs(s.vx)*.04;
this.rect(-34-flame,-6,flame,12,'#477f6e'); this.rect(-30-flame*.65,-4,flame*.65,8,'#9ce8c4'); this.rect(-33,-3,8,6,'#e6ffe0');
this.rect(-23,-20,21,7,'#344b47'); this.rect(-19,-22,17,5,'#9eb5a1');
this.rect(-17,14,19,8,'#8da58f'); this.rect(-23,13,16,5,'#364c47');
this.rect(-29,-13,42,27,'#152d30'); this.rect(-27,-10,43,19,'#738f7f');
this.rect(-18,-13,25,5,'#b9c8a0'); this.rect(-13,-8,32,14,'#abc0a4');
this.rect(-6,-7,22,10,'#2a655e'); this.rect(1,-7,14,3,'#a8f7d3'); this.rect(13,-4,8,6,'#83d8b9');
this.rect(-22,-6,8,12,'#263f3d'); this.rect(-21,-3,5,5,'#e0aa58');
this.rect(-12,10,22,4,'#4d7164'); this.rect(18,-12,6,6,'#b9d2a9'); this.rect(18,8,6,6,'#b9d2a9');
this.rect(23,-10,5,4,'#91d5b2'); this.rect(23,8,5,4,'#91d5b2');
if(g.input.field) { c.shadowColor='#bdffda'; c.shadowBlur=15; this.rect(24,-2,4,4,'#ddffe0'); c.shadowBlur=0; }
c.restore();
if(g.phase==='ready') { this.text('SALVAGE TUG / M-03',s.x-56,s.y+51,'#a2baa6',10); this.line([s.x,s.y+28,s.x,s.y+37],'#5d8271'); }
}
drawBoss(): void {
const c=this.c,g=this.game,b=g.boss,t=g.visualTime;
if(b.dead || b.x-g.camera>g.viewWidth+170) return;
c.save();c.translate(b.x,b.y);c.rotate(b.angle);c.translate(-b.x,-b.y);
this.rect(b.x-62,b.y-75,122,16,'#172e31'); this.rect(b.x-45,b.y-81,75,8,'#5f7667');
this.rect(b.x-70,b.y+57,140,13,'#1d3636');
c.save(); c.translate(b.x,b.y); c.shadowColor=g.plates.length>2?'#bd7b31':'#f5c879'; c.shadowBlur=25+Math.sin(t*5)*9;
const size=64*b.scale;c.drawImage(g.sprites[10].canvas,-size/2,-size/2,size,size);c.restore();
this.rect(b.x-75,b.y-18,14,36,'#3f5d50'); this.rect(b.x-85,b.y-6,18,10,'#b0854b');
this.rect(b.x+35,b.y-97,6,15,'#849275'); this.rect(b.x+34,b.y-100,8,4,Math.sin(t*3)>0?'#f7c769':'#7e623c');
this.text('W / 07',b.x-25,b.y-65,'#d1c399',11);c.restore();
}
}
