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/main.ts
import { media, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { CoordinatedAudio } from '../docs/examples/coordinated-audio.js';
const coordinatedAudio = new CoordinatedAudio(runtimeHasDomain('media') ? media : undefined, 'GRAV / RIFT');
import { Game, MODE_NAMES, type Mode } from './game/game.js';
import { Renderer } from './game/render.js';
import { createAtlas, createSprites } from './game/sprites.js';
import { RELEASE_NAMES, type ReleaseMode } from './game/launcher.js';
import { RadialMenu } from './controls/radial.js';
import type { Site } from './game/world.js';
import './styles.css';

// The scene and instrumentation retain their authored palette in every host.
const FOLLOW_HOST_THEME = false;
void FOLLOW_HOST_THEME;
const el = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const app = el('app'), canvas = el<HTMLCanvasElement>('game');
const sprites = createSprites(), game = new Game(sprites), renderer = new Renderer(canvas,game);
el('atlas').append(createAtlas(sprites));
const keyState = new Set<string>();
let best = 0, sound = false, audioContext: AudioContext | undefined, blastNoise: AudioBuffer | undefined;
let previousPhase = game.phase, storeMessage = runtimeHasDomain('storage') ? 'Host save available · personal best is saved after each run.' : 'Host save unavailable · personal best lasts for this session.';
let touchMove = {x:0,y:0}, touchField = false, pointerField = false, pointerFire=false, joystickId: number | null = null;
let pointerAim: {x:number;y:number} | null = null;
let frameId = 0, last = 0, accumulator = 0, lastHud = 0, soundAt = 0;
const soundTimes = {grab:0,throw:0,hit:0,boom:0,upgrade:0};
const abort = new AbortController();
const radial=new RadialMenu(el('radial'),choice=>{
  if(choice.field!==undefined)game.setMode(choice.field);
  if(choice.release!==undefined)game.setRelease(choice.release);
  updateHud();
},open=>{
  game.tacticalOpen=open;pointerFire=false;keyState.delete('KeyF');game.input.fire=false;
  if(!open){game.fieldWas=pointerField||touchField||keyState.has('Space');focusGame();}
});

const on = <K extends keyof WindowEventMap>(target: Window | HTMLElement, type: K, handler: (e: WindowEventMap[K]) => void) => target.addEventListener(type,handler as EventListener,{signal:abort.signal});

async function withTimeout<T>(promise: Promise<T>): Promise<T> {
  let timer = 0;
  try { return await Promise.race([promise,new Promise<T>((_,reject) => { timer = window.setTimeout(()=>reject(new Error('Save request timed out')),3500); })]); }
  finally { clearTimeout(timer); }
}
async function save(key: string,value: string): Promise<void> {
  if (!runtimeHasDomain('storage')) return;
  try { await withTimeout(storage.setItem(key,value)); storeMessage = 'Host save connected · flight record saved.'; }
  catch { storeMessage = 'Host save failed · flight record kept for this session. Retry with Save record.'; }
  el('storageStatus').textContent=storeMessage;
}
async function loadRecord(): Promise<void> {
  if (!runtimeHasDomain('storage')) return;
  try {
    const [record,pref] = await Promise.all([withTimeout(storage.getItem('grav-rift.best.v1')),withTimeout(storage.getItem('grav-rift.sound.v1'))]);
    const value=Number(record); if(Number.isFinite(value) && value>=0) best=Math.max(best,Math.min(99999999,Math.floor(value)));
    sound=pref==='true'; updateSound();
  } catch { storeMessage='Host save could not be read · playing with session records.'; }
}
function audioStart(): void {
  if(!sound) return;
  coordinatedAudio.activate(); audioContext = coordinatedAudio.context;
  if (!audioContext) return;
  if(!blastNoise) {
    blastNoise=audioContext.createBuffer(1,Math.floor(audioContext.sampleRate*.7),audioContext.sampleRate);
    const channel=blastNoise.getChannelData(0);for(let i=0;i<channel.length;i++)channel[i]=Math.random()*2-1;
  }
}
game.onSound = name => {
  if(!sound || !audioContext || !coordinatedAudio.audible) return;
  const now=audioContext.currentTime;
  if(now-soundTimes[name]<(name==='grab'?.13:name==='hit'?.08:.05) || now-soundAt<.015) return;
  soundTimes[name]=now; soundAt=now;
  if(name==='boom'&&blastNoise) {
    const noise=audioContext.createBufferSource(),filter=audioContext.createBiquadFilter(),impact=audioContext.createGain();
    noise.buffer=blastNoise;filter.type='lowpass';filter.frequency.setValueAtTime(2200,now);filter.frequency.exponentialRampToValueAtTime(130,now+.65);
    impact.gain.setValueAtTime(.3,now);impact.gain.exponentialRampToValueAtTime(.001,now+.7);
    noise.connect(filter);filter.connect(impact);impact.connect(coordinatedAudio.output!);noise.start(now);noise.stop(now+.7);
  }
  const oscillator=audioContext.createOscillator(), gain=audioContext.createGain();
  const notes={grab:[270,410,.06],throw:[220,45,.25],hit:[120,35,.13],boom:[70,22,.55],upgrade:[440,880,.25]}[name];
  oscillator.type=name==='upgrade'||name==='grab'?'sine':'triangle';
  oscillator.frequency.setValueAtTime(notes[0],now); oscillator.frequency.exponentialRampToValueAtTime(notes[1],now+notes[2]);
  gain.gain.setValueAtTime(name==='boom'?.16:.07,now); gain.gain.exponentialRampToValueAtTime(.001,now+notes[2]);
  oscillator.connect(gain);gain.connect(coordinatedAudio.output!);oscillator.start(now);oscillator.stop(now+notes[2]+.01);
};
function updateSound(): void { coordinatedAudio.setMuted(!sound); el('sound').setAttribute('aria-label',sound?'Mute sound':'Enable sound'); el('sound').title=sound?'Mute sound':'Enable sound'; el('sound').querySelector<HTMLElement>('span')!.style.display=sound?'none':''; el<HTMLInputElement>('soundSetting').checked=sound; }
function clearControls(): void {
  radial.close(false);keyState.clear();pointerFire=false; touchMove={x:0,y:0}; touchField=false; pointerField=false; joystickId=null;pointerAim=null;
  el('stick').style.transform=''; el('fieldButton').classList.remove('held'); game.clearInput();
  game.held.forEach(b=>{b.held=false;b.thrown=0;});
}
function focusGame(): void { canvas.focus({preventScroll:true}); }
function start(practice: boolean): void {clearControls(); audioStart(); game.start(practice); previousPhase='ready'; refreshPhase(); focusGame();}
function pause(): void {game.pause();clearControls();refreshPhase();}
function resume(): void {if(game.phase!=='paused')return;clearControls();game.phase='playing';refreshPhase();focusGame();audioStart();}
function setMode(mode: Mode): void {game.setMode(mode); updateHud();}
function refreshPhase(): void {
  const phase=game.phase;
  app.classList.toggle('intro-open',phase==='ready'); app.classList.toggle('modal-open',phase==='paused'||phase==='won'||phase==='lost');
  app.dataset.phase=phase;
  el('intro').hidden=phase!=='ready';el('modal').hidden=phase==='ready'||phase==='playing';
  el('pause').setAttribute('aria-label',phase==='paused'?'Resume game':'Pause game');
  el('pause').textContent=phase==='paused'?'▷':'Ⅱ';
  el<HTMLButtonElement>('pause').disabled=phase==='ready';
  if(phase==='paused'||phase==='won'||phase==='lost'){
    const finished=phase!=='paused';
    el('modalSerial').textContent=finished?'FLIGHT RECORDER / SECTOR 07':'FLIGHT COMPUTER / STANDBY';
    el('modalTitle').textContent=phase==='won'?'Reactor neutralized.':phase==='lost'?'Tug signal lost.':'Flight paused.';
    el('modalText').textContent=phase==='won'?'The Warden is scrap. Nothing goes to waste.':phase==='lost'?'The wreckage is still out there. Take another run.':'Your cargo and flight progress are safe.';
    el('resume').hidden=finished; el('help').hidden=finished;
    el('retry').textContent=finished?'DEPLOY AGAIN ↗':'RESTART RUN';
    el('results').innerHTML=finished?`<div><small>SCORE</small><b>${game.score}</b></div><div><small>PLATES RIPPED</small><b>${game.torn}</b></div><div><small>WRECKS</small><b>${game.kills}</b></div>`:'';
    el('storageStatus').textContent=storeMessage;
    if(finished && previousPhase!==phase){best=Math.max(best,game.score);void save('grav-rift.best.v1',String(best));}
    if(previousPhase!==phase) el(finished?'retry':'resume').focus({preventScroll:true});
  }
  previousPhase=phase;updateHud();
}
function updateHud(): void {
  const hp=Math.ceil(game.ship.hp),held=game.held.length;
  el('hullValue').textContent=`${hp}%`;el('hull').setAttribute('aria-valuenow',String(hp));
  Array.from(el('hull').children).forEach((node,i)=>node.classList.toggle('empty',i>=Math.ceil(hp/10)));
  el('salvage').textContent=String(game.salvage).padStart(3,'0');
  el('fieldLevel').textContent=`MK. ${['I','II','III','IV'][game.level]}`;
  document.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach(button=>{const active=Number(button.dataset.mode)===game.mode;button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active));});
  radial.update(game.mode,game.launcher.mode);el('releaseName').textContent=RELEASE_NAMES[game.launcher.mode];
  el('payloadCount').textContent=String(held);el('charge').style.width=`${game.charge*100}%`;
  el('chargeLabel').textContent=game.mode===3?(game.focus?.held?'SINGLE OBJECT HELD':'SURGICAL READY'):game.held.some(b=>b.kind==='shell')?'SHELL SECURED':game.input.field?(game.charge>=1?'FULLY CHARGED':'FIELD ACTIVE'):'FIELD IDLE';
  const upgrade=el<HTMLButtonElement>('upgrade');upgrade.disabled=game.level>=3||game.salvage<game.upgradeCost||!['playing','paused'].includes(game.phase);
  el('upgradePrice').textContent=game.level>=3?'MAX':`◇ ${game.upgradeCost}`;
  upgrade.title=game.level>=3?'Field fully upgraded':`Upgrade: ${game.upgradeCost} salvage. More range, stronger vortex, +20 hull. (U)`;
  el('best').textContent=`BEST ${String(best).padStart(5,'0')}`;
  el('runTime').textContent=`${String(Math.floor(game.time/60)).padStart(2,'0')}:${String(Math.floor(game.time%60)).padStart(2,'0')}`;
  el('flightStatus').textContent=game.phase==='ready'?'SYSTEMS STANDBY':game.phase==='paused'?'FLIGHT PAUSED':game.practice?'PRACTICE / HULL REGEN':game.bossAwake?'REACTOR BAY / HOSTILE':'SALVAGE RUN / LIVE';
  el('mission').querySelector('b')!.textContent=game.bossAwake?(game.plates.length>2?`Rip armor · ${6-game.plates.length} / 4 plates`:'Core exposed — hurl debris!'):'Reach the reactor bay';
  el('toast').textContent=game.phase==='playing'&&game.messageTime>0?game.message:'';
  canvas.dataset.payload=String(held);canvas.dataset.plates=String(game.plates.length);canvas.dataset.kills=String(game.kills);canvas.dataset.shots=String(game.shots);
  canvas.dataset.fieldMode=String(game.mode);canvas.dataset.releaseMode=String(game.launcher.mode);canvas.dataset.simTime=String(game.time);canvas.dataset.timeScale=game.tacticalOpen?'0.05':'1';
  canvas.dataset.limbsTorn=String(game.limbsTorn);canvas.dataset.cellsExtracted=String(game.cellsExtracted);canvas.dataset.liveThrows=String(game.liveThrows);canvas.dataset.focus=String(game.focus?.id??'');
  canvas.dataset.focusLabel=game.mode===3?game.focusLabel:'';
  canvas.dataset.rigs=JSON.stringify(game.rigs.map(r=>({id:r.root.id,kind:r.root.kind,x:r.root.x,y:r.root.y,hp:r.root.hp,shielded:r.shielded,legs:r.legs,parts:r.connected().map(p=>({key:p.key,role:p.role,x:p.body.x,y:p.body.y,id:p.body.id}))})));
  canvas.dataset.coreHull=String(Math.ceil(game.boss.hp));
  canvas.dataset.site=game.site;canvas.dataset.demolished=String(game.demolished);canvas.dataset.explosions=String(game.explosions);
  canvas.dataset.structureParts=String(game.bodies.filter(b=>b.kind==='structure'&&b.attached).length);
  canvas.dataset.cellsHeld=String(game.held.filter(b=>b.kind==='cell').length);
  canvas.dataset.shellsCaught=String(game.shellsCaught);canvas.dataset.shellsReturned=String(game.shellsReturned);canvas.dataset.returnKills=String(game.returnKills);
  canvas.dataset.shellsHeld=String(game.held.filter(b=>b.kind==='shell').length);
  canvas.dataset.walkersAlive=String(game.walkers.filter(w=>!w.body.dead).length);
  const walker=game.walkers.find(w=>!w.body.dead),shell=game.bodies.find(b=>!b.dead&&b.ordnance?.state==='hostile');
  canvas.dataset.walkerX=String(walker?.body.x??'');canvas.dataset.walkerY=String(walker?.body.y??'');
  canvas.dataset.shellX=String(shell?.x??'');canvas.dataset.shellY=String(shell?.y??'');
  const field=game.fieldCenter;
  canvas.dataset.fieldX=String(field.x);canvas.dataset.fieldY=String(field.y);
  canvas.dataset.shipX=String(game.ship.x);canvas.dataset.shipY=String(game.ship.y);
  canvas.dataset.camera=String(game.camera);canvas.dataset.cameraY=String(game.cameraY);
  canvas.dataset.viewScale=String(renderer.scale);
  canvas.setAttribute('aria-label',`Salvage combat. ${MODE_NAMES[game.mode]} field. Hull ${hp} percent. ${held} parts held. ${game.plates.length} armor plates remain.`);
}
el('selector').addEventListener('click',()=>{if(game.phase!=='playing')return;radial.isOpen?radial.close(false):radial.open();updateHud();});
el('deploy').addEventListener('click',()=>start(false));el('practice').addEventListener('click',()=>start(true));
el<HTMLSelectElement>('site').addEventListener('change',e=>{
  const site=(e.target as HTMLSelectElement).value as Site;
  if(game.phase==='ready'&&(site==='foundry'||site==='orbit')) { game.site=site;game.reset();updateHud(); }
});
el('changeSite').addEventListener('click',()=>{clearControls();game.phase='ready';game.reset();refreshPhase();el('site').focus();});
el('pause').addEventListener('click',()=>game.phase==='paused'?resume():pause());el('resume').addEventListener('click',resume);el('retry').addEventListener('click',()=>start(game.practice));
el('sound').addEventListener('click',()=>{sound=!sound;updateSound();audioStart();void save('grav-rift.sound.v1',String(sound));});
el('soundSetting').addEventListener('change',()=>{sound=el<HTMLInputElement>('soundSetting').checked;updateSound();audioStart();void save('grav-rift.sound.v1',String(sound));});
el('upgrade').addEventListener('click',()=>{game.upgrade();updateHud();focusGame();});
document.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach(button=>button.addEventListener('click',()=>{setMode(Number(button.dataset.mode) as Mode);focusGame();}));
const reduced=el<HTMLInputElement>('reduced');reduced.checked=matchMedia('(prefers-reduced-motion: reduce)').matches;renderer.reduced=reduced.checked;
reduced.addEventListener('change',()=>renderer.reduced=reduced.checked);el('debug').addEventListener('change',e=>game.debug=(e.target as HTMLInputElement).checked);
const saveButton=document.createElement('button');saveButton.className='text-button';saveButton.textContent='Save record';saveButton.hidden=!runtimeHasDomain('storage');
saveButton.addEventListener('click',()=>void save('grav-rift.best.v1',String(Math.max(best,game.score))));el('storageStatus').after(saveButton);
on(window,'keydown',e=>{
  if(['INPUT','SELECT'].includes((e.target as HTMLElement)?.tagName))return;
  if(game.phase==='playing'&&radial.keyDown(e))return;
  if(['Escape','KeyP'].includes(e.code)){e.preventDefault();if(!e.repeat)game.phase==='paused'?resume():pause();return;}
  if(game.phase!=='playing')return;
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyW','KeyA','KeyS','KeyD','Digit1','Digit2','Digit3','Digit4','KeyU','KeyF','KeyR'].includes(e.code)){
    e.preventDefault();keyState.add(e.code);if(e.code==='KeyU'&&!e.repeat)game.upgrade();
    if(e.code==='KeyR'&&!e.repeat)game.setRelease(((game.launcher.mode+1)%3) as ReleaseMode);
    if(e.code.startsWith('Digit'))setMode((Number(e.code.slice(-1))-1) as Mode);
  }
});
on(window,'keyup',e=>{radial.keyUp(e);keyState.delete(e.code);});
on(canvas,'mousedown',e=>{
  if(game.phase!=='playing'||radial.isOpen||![0,2].includes(e.button))return;
  e.preventDefault();audioStart();focusGame();if(e.button===2)pointerField=true;else pointerFire=true;
  pointerAim={x:e.clientX,y:e.clientY};game.input.aim=renderer.worldPoint(e.clientX,e.clientY);
});
on(window,'mouseup',e=>{if(e.button===2)pointerField=false;if(e.button===0)pointerFire=false;});
on(canvas,'pointerdown',e=>{if(e.pointerType!=='touch'||game.phase!=='playing'||radial.isOpen)return;e.preventDefault();canvas.setPointerCapture(e.pointerId);pointerField=true;pointerAim={x:e.clientX,y:e.clientY};audioStart();});
on(canvas,'pointermove',e=>{if(!radial.isOpen)pointerAim={x:e.clientX,y:e.clientY};});
on(canvas,'pointerup',e=>{if(e.pointerType==='touch'){pointerField=false;if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId);}});
on(canvas,'pointercancel',clearControls);
canvas.addEventListener('wheel',e=>{if(game.mode===3&&game.focus?.held&&!radial.isOpen){e.preventDefault();game.focus.angle+=e.deltaY*.002;}},{passive:false,signal:abort.signal});
canvas.addEventListener('contextmenu',e=>e.preventDefault());
const stick=el('joystick');
on(stick,'pointerdown',e=>{if(game.phase!=='playing'||joystickId!==null)return;joystickId=e.pointerId;stick.setPointerCapture(e.pointerId);audioStart();pointerAim=null;game.input.aim=null;moveStick(e);});
function moveStick(e: PointerEvent):void{if(e.pointerId!==joystickId)return;const r=stick.getBoundingClientRect(),dx=e.clientX-r.left-r.width/2,dy=e.clientY-r.top-r.height/2,d=Math.max(1,Math.hypot(dx,dy)/32);touchMove={x:dx/d/32,y:dy/d/32};el('stick').style.transform=`translate(${dx/d}px,${dy/d}px)`;}
on(stick,'pointermove',moveStick);
const resetStick=(e: PointerEvent)=>{if(e.pointerId!==joystickId)return;joystickId=null;touchMove={x:0,y:0};el('stick').style.transform='';};
on(stick,'pointerup',resetStick);on(stick,'pointercancel',resetStick);on(stick,'lostpointercapture',resetStick);
const fireButton=el('fireButton');
on(fireButton,'pointerdown',e=>{if(game.phase!=='playing'||radial.isOpen)return;fireButton.setPointerCapture(e.pointerId);pointerFire=true;audioStart();});
on(fireButton,'pointerup',()=>pointerFire=false);on(fireButton,'pointercancel',()=>pointerFire=false);on(fireButton,'lostpointercapture',()=>pointerFire=false);
const fieldButton=el('fieldButton');
on(fieldButton,'pointerdown',e=>{if(game.phase!=='playing')return;fieldButton.setPointerCapture(e.pointerId);touchField=true;pointerAim=null;game.input.aim=null;fieldButton.classList.add('held');audioStart();});
on(fieldButton,'pointerup',()=>{touchField=false;fieldButton.classList.remove('held');});
on(fieldButton,'pointercancel',clearControls);
on(fieldButton,'lostpointercapture',()=>{touchField=false;fieldButton.classList.remove('held');});
on(window,'blur',()=>{if(game.phase==='playing')pause();else clearControls();});
document.addEventListener('visibilitychange',()=>{if(document.hidden)pause();},{signal:abort.signal});
const resizeObserver=new ResizeObserver(()=>{renderer.resize();if(innerWidth<280||innerHeight<280)pause();});resizeObserver.observe(el('viewport'));
on(window,'orientationchange',()=>{clearControls();renderer.resize();});
function frame(now: number):void{
  const elapsed=Math.min(.05,last?(now-last)/1000:0);last=now;accumulator+=elapsed*(radial.isOpen?.05:1);
  game.input.x=Math.max(-1,Math.min(1,touchMove.x+(keyState.has('KeyD')||keyState.has('ArrowRight')?1:0)-(keyState.has('KeyA')||keyState.has('ArrowLeft')?1:0)));
  game.input.y=Math.max(-1,Math.min(1,touchMove.y+(keyState.has('KeyS')||keyState.has('ArrowDown')?1:0)-(keyState.has('KeyW')||keyState.has('ArrowUp')?1:0)));
  if(!radial.isOpen)game.input.field=game.phase==='playing'&&(pointerField||touchField||keyState.has('Space'));
  game.input.fire=game.phase==='playing'&&!radial.isOpen&&(pointerFire||keyState.has('KeyF'));
  while(accumulator>=1/120){
    if(pointerAim&&!radial.isOpen)game.input.aim=renderer.worldPoint(pointerAim.x,pointerAim.y);
    game.step(1/120);accumulator-=1/120;
  }
  // A stationary cursor still changes world position as the camera scrolls.
  if(pointerAim&&!radial.isOpen)game.input.aim=renderer.worldPoint(pointerAim.x,pointerAim.y);
  renderer.draw();if(game.phase!==previousPhase){clearControls();refreshPhase();}
  if(now-lastHud>100){updateHud();lastHud=now;}frameId=requestAnimationFrame(frame);
}
on(window,'pagehide',()=>{radial.destroy();abort.abort();resizeObserver.disconnect();cancelAnimationFrame(frameId);coordinatedAudio.dispose();});
renderer.resize();refreshPhase();void loadRecord();frameId=requestAnimationFrame(frame);