SOURCE / PINNED RELEASE
Made of little things.
Crossfire
- Release
- 9614abe48623…
- Author-recorded commit
- 6f6c8299e2fb…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-347ab685aef
Archive hash verified: 34d5295141b419db…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {movePlayer,blocked,validInput,validWorld,type Input,type Player,type World,type Bullet} from './game.js';
export const TICK=1/60;
export type Command={n:number,u:Input};
export function validCommands(v:unknown):v is number[][] {
return Array.isArray(v)&&v.length<=12&&v.every(c=>Array.isArray(c)&&c.length===5&&Number.isSafeInteger(c[0])&&c[0]>=0&&validInput({x:c[1],y:c[2],a:c[3],fire:c[4]===1})&&(c[4]===0||c[4]===1));
}
export class Prediction {
pending:Command[]=[];local:Player|undefined;fx:Bullet[]=[];n=0;sent=0;ack=-1;offset={x:0,y:0};
reset(){this.pending=[];this.local=undefined;this.fx=[];this.n=0;this.sent=0;this.ack=-1;this.offset={x:0,y:0};}
reconcile(p:Player,ack:number){
if(ack<this.ack)return;this.ack=ack;
const previous=this.local,teleport=!previous||previous.spawn!==p.spawn||Boolean(previous.dead)!==Boolean(p.dead);
this.pending=this.pending.filter(c=>c.n>ack);
if(teleport){this.pending=[];this.fx=[];this.offset={x:0,y:0};}
this.local={...p};
for(const c of this.pending)movePlayer(this.local,c.u,TICK);
if(previous&&!teleport){
const dx=previous.x-this.local.x,dy=previous.y-this.local.y;
if(Math.hypot(dx,dy)<80){this.offset.x+=dx;this.offset.y+=dy;}else this.offset={x:0,y:0};
this.local.cooldown=previous.cooldown;
}
}
advance(u:Input,winner:number){
const p=this.local;if(!p)return;
p.a=u.a;
if(this.pending.length>=120)return; // Bound replay and freeze during a prolonged outage.
const n=++this.n;this.pending.push({n,u:{...u}});
if(winner<0)movePlayer(p,u,TICK);
p.cooldown=Math.max(0,p.cooldown-TICK);
if(winner<0&&!p.dead&&u.fire&&p.cooldown<=0){p.cooldown=.18;this.fx.push({id:n,x:p.x+Math.cos(u.a)*22,y:p.y+Math.sin(u.a)*22,a:u.a,owner:0,life:1.2});}
}
render(dt:number,angle:number):Player|undefined {
const decay=Math.exp(-dt/0.06);this.offset.x*=decay;this.offset.y*=decay;
this.fx=this.fx.filter(b=>{b.life-=dt;b.x+=Math.cos(b.a)*620*dt;b.y+=Math.sin(b.a)*620*dt;return b.life>0&&!blocked(b.x,b.y,3);});
return this.local?{...this.local,x:this.local.x+this.offset.x,y:this.local.y+this.offset.y,a:angle}:undefined;
}
batch(){return this.pending.filter(c=>c.n>this.sent).slice(0,12).map(c=>[c.n,c.u.x,c.u.y,c.u.a,Number(c.u.fire)]);}
}
const lerp=(a:number,b:number,t:number)=>a+(b-a)*t;
const angleLerp=(a:number,b:number,t:number)=>a+Math.atan2(Math.sin(b-a),Math.cos(b-a))*t;
export class Interpolation {
frames:{t:number,world:World}[]=[];clock=0;latest=0;ready=false;
reset(){this.frames=[];this.clock=0;this.latest=0;this.ready=false;}
push(t:number,world:World){if(t<=this.latest&&this.ready)return;this.latest=t;this.frames.push({t,world:structuredClone(world)});if(this.frames.length>20)this.frames.shift();if(!this.ready){this.clock=t-100;this.ready=true;}}
render(dt:number):World|undefined {
if(!this.frames.length)return;
const target=this.latest-100;
this.clock+=dt*1000*Math.max(.9,Math.min(1.1,1+(target-this.clock)/1000));
this.clock=Math.min(this.clock,this.latest+100); // Do not run away during stalls.
let a=this.frames[0],b=this.frames.at(-1)!;
for(let i=1;i<this.frames.length;i++){if(this.frames[i].t>=this.clock){a=this.frames[i-1];b=this.frames[i];break;}a=this.frames[i];}
const last=this.frames.at(-1)!;
if(this.clock>last.t&&this.frames.length>1){a=this.frames.at(-2)!;b=last;}
const span=Math.max(1,b.t-a.t),mix=Math.max(0,Math.min(1+(100/span),(this.clock-a.t)/span));
const world=structuredClone(b.world);
world.p=world.p.map(p=>{const q=a.world.p.find(q=>q.id===p.id);if(!q||q.spawn!==p.spawn||q.dead||p.dead||Math.hypot(p.x-q.x,p.y-q.y)>80)return p;
const x=lerp(q.x,p.x,mix),y=lerp(q.y,p.y,mix);
return {...p,...(mix>1&&blocked(x,y,15)?{}:{x,y}),a:angleLerp(q.a,p.a,Math.min(1,mix))};});
const bulletDt=Math.max(-.1,Math.min(.1,(this.clock-b.t)/1000));
world.b=world.b.map(shot=>({...shot,x:shot.x+Math.cos(shot.a)*620*bulletDt,y:shot.y+Math.sin(shot.a)*620*bulletDt,life:shot.life-bulletDt})).filter(shot=>shot.life>0&&!blocked(shot.x,shot.y,3));
return world;
}
}
const round=(n:number)=>Math.round(n*100)/100;
const row=(p:Player)=>[round(p.x),round(p.y),round(p.a),p.hp,p.score,round(p.dead),round(p.shield),round(Math.max(-1,p.cooldown)),Number(p.active),p.spawn];
const bullet=(b:Bullet)=>[b.id!,round(b.x),round(b.y),round(b.a),b.owner,round(b.life)];
export type Snapshot={type:'state',n:number,base:number,t:number,full?:boolean,roster?:[string,string][],p:number[][],b:number[][],gone:number[],winner:number,ack:number[]};
export class Encoder {
previous:World|undefined;n=0;lastFull=-Infinity;
reset(){this.previous=undefined;this.n=0;this.lastFull=-Infinity;}
encode(world:World,t:number,ack:number[],force=false):Snapshot {
const previous=this.previous;
const full=force||!previous||t-this.lastFull>=1000||world.p.some((p,i)=>p.id!==previous.p[i]?.id||p.name!==previous.p[i]?.name)||world.p.length!==previous.p.length;
if(full)this.lastFull=t;
const msg:Snapshot={type:'state',n:++this.n,base:full?-1:this.n-1,t,full,p:[],b:[],gone:[],winner:world.winner,ack};
if(full)msg.roster=world.p.map(p=>[p.id,p.name||'Guest']);
world.p.forEach((p,i)=>{if(full||JSON.stringify(row(p))!==JSON.stringify(row(previous!.p[i])))msg.p.push([i,...row(p)]);});
msg.b=world.b.filter(b=>full||!previous!.b.some(old=>old.id===b.id)).map(bullet);
if(!full)msg.gone=previous!.b.filter(b=>!world.b.some(now=>now.id===b.id)).map(b=>b.id!);
this.previous=structuredClone(world);return msg;
}
}
export class Decoder {
world:World|undefined;n=-1;t=0;
reset(){this.world=undefined;this.n=-1;this.t=0;}
decode(v:unknown):{world:World,ack:number[],t:number}|undefined {
if(!v||typeof v!=='object')return;const m=v as Snapshot;
if(m.type!=='state'||!Number.isSafeInteger(m.n)||m.n<=this.n||!Number.isFinite(m.t)||m.t<0||!Array.isArray(m.ack)||m.ack.length>8||m.ack.some(n=>!Number.isSafeInteger(n)||n<0)||!Array.isArray(m.p)||m.p.length>8||!Array.isArray(m.b)||m.b.length>64||!Array.isArray(m.gone)||m.gone.length>64)return;
if(!m.full&&(!this.world||m.base!==this.n))return;
let w:World;
if(m.full){if(!Array.isArray(m.roster)||m.roster.length<1||m.roster.length>8||m.roster.some(r=>!Array.isArray(r)||r.length!==2||typeof r[0]!=='string'||r[0].length>64||typeof r[1]!=='string'||r[1].length>32)||m.p.length!==m.roster.length)return;
w={p:m.roster.map(([id,name])=>({id,name} as Player)),b:[],winner:m.winner};
}else{w=structuredClone(this.world!);const dt=Math.min(2,Math.max(0,(m.t-this.t)/1000));w.b.forEach(b=>{b.x+=Math.cos(b.a)*620*dt;b.y+=Math.sin(b.a)*620*dt;b.life-=dt;});w.b=w.b.filter(b=>b.life>0&&!m.gone.includes(b.id!));w.winner=m.winner;}
const slots=new Set<number>();
for(const r of m.p){if(!Array.isArray(r)||r.length!==11||!r.every(Number.isFinite)||!Number.isInteger(r[0])||r[0]<0||r[0]>=w.p.length||slots.has(r[0])||!Number.isInteger(r[10])||r[10]<0||(r[9]!==0&&r[9]!==1))return;slots.add(r[0]);Object.assign(w.p[r[0]],{x:r[1],y:r[2],a:r[3],hp:r[4],score:r[5],dead:r[6],shield:r[7],cooldown:r[8],active:!!r[9],spawn:r[10]});}
for(const b of m.b){if(!Array.isArray(b)||b.length!==6||!b.every(Number.isFinite)||!Number.isSafeInteger(b[0])||b[0]<0)return;w.b.push({id:b[0],x:b[1],y:b[2],a:b[3],owner:b[4],life:b[5]});}
if(m.ack.length!==w.p.length||!validWorld(w))return;this.world=w;this.n=m.n;this.t=m.t;return {world:w,ack:m.ack,t:m.t};
}
}