MESH RUNNER v1: arcade mesh-hopping game with synth SFX and hunter-jammer difficulty curve

This commit is contained in:
2026-09-26 20:49:29 +00:00
commit 70c401b37b
3 changed files with 709 additions and 0 deletions
+493
View File
@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MESH RUNNER — a packet's odyssey</title>
<style>
html,body{margin:0;padding:0;background:#05070d;height:100%;overflow:hidden;
font-family:'Courier New',ui-monospace,monospace;}
#wrap{display:flex;align-items:center;justify-content:center;height:100vh;}
canvas{background:radial-gradient(ellipse at 50% 40%, #0b1220 0%, #05070d 70%);
box-shadow:0 0 60px rgba(0,255,180,.08);border:1px solid #12263a;border-radius:6px;}
</style>
</head>
<body>
<div id="wrap"><canvas id="cv" width="960" height="640"></canvas></div>
<script>
window.__errs=[];window.onerror=function(m){window.__errs.push(String(m));};
"use strict";
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const W=cv.width,H=cv.height;
const TAU=Math.PI*2;
const rand=(a,b)=>a+Math.random()*(b-a);
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
// ---------- state ----------
let state='title'; // title | play | over
let nodes=[],edges=[],hub=null,jammers=[];
let player=null,frag=null,particles=[];
let score=0,wave=1,combo=0,bestCombo=0;
let battery=100,batteryMax=100;
let shake=0,time=0,best=0;
try{best=+localStorage.getItem('meshrunner_best')||0;}catch(e){}
// ---------- network generation ----------
function buildNetwork(n){
nodes=[];edges=[];jammers=[];frag=null;
const margin=70;
for(let i=0;i<n;i++){
nodes.push({x:rand(margin,W-margin),y:rand(margin+40,H-margin),
jam:0,pulse:rand(0,TAU),id:i});
}
// hub = node closest to center
let bd=1e9;
nodes.forEach(nd=>{const d=Math.hypot(nd.x-W/2,nd.y-H/2);if(d<bd){bd=d;hub=nd;}});
// connect each node to its k nearest neighbours
const k=3;
const eset=new Set();
nodes.forEach(a=>{
const near=nodes.filter(b=>b!==a).sort((x,y)=>dist(a,x)-dist(a,y)).slice(0,k);
near.forEach(b=>{
const key=Math.min(a.id,b.id)+'-'+Math.max(a.id,b.id);
if(!eset.has(key)){eset.add(key);edges.push({a,b,trav:0});}
});
});
// jammers patrol edges; from wave 3 some are HUNTERS that chase the player
const nj=Math.min(2+Math.floor(wave*.7), 12);
for(let i=0;i<nj;i++){
const e=edges[Math.floor(Math.random()*edges.length)];
const hunter=wave>=3&&i%3===0;
jammers.push({e,t:Math.random(),dir:Math.random()<.5?1:-1,
speed:rand(.05,.10)+wave*.012+(hunter?.03:0),hunter});
}
}
function neighborsOf(nd){
const out=[];
edges.forEach(e=>{
if(e.a===nd)out.push(e.b);
else if(e.b===nd)out.push(e.a);
});
return out;
}
// ---------- game start ----------
function startGame(){
score=0;wave=1;combo=0;bestCombo=0;battery=100;batteryMax=100;
buildNetwork(14);
player={node:nodes[Math.floor(Math.random()*nodes.length)],
from:null,t:1,tx:0,ty:0};
player.tx=player.node.x;player.ty=player.node.y;
spawnFragment();
sfx.start();
state='play';
}
function spawnFragment(){
const cands=nodes.filter(n=>n!==hub&&n!==player.node&&n.jam<.5);
if(!cands.length)return;
frag=cands[Math.floor(Math.random()*cands.length)];
frag.fragPulse=0;
}
function nextWave(){
wave++;
const size=clamp(12+wave*2,14,30);
const oldNode=player.node;
buildNetwork(size);
// put player on a random node of the new net
player.node=nodes[Math.floor(Math.random()*nodes.length)];
player.tx=player.node.x;player.ty=player.node.y;player.t=1;player.from=null;
spawnFragment();
batteryMax=Math.max(50,100-wave*4);
battery=batteryMax;
sfx.wave();
burst(hub.x,hub.y,'#ffd166',40);
}
// ---------- input ----------
const keys={};
window.addEventListener('keydown',e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
if(e.key.toLowerCase()==='m'){
muted=!muted;
if(muted&&AC){try{AC.suspend();}catch(err){}}
else if(AC){try{AC.resume();}catch(err){}}
return;
}
initAudio();
if(state==='title'&&(e.key==='Enter'||e.key===' ')){startGame();return;}
if(state==='over'&&(e.key==='Enter'||e.key===' '||e.key.toLowerCase()==='r')){startGame();return;}
if(state!=='play')return;
let dx=0,dy=0;
if(e.key==='ArrowUp'||e.key.toLowerCase()==='w')dy=-1;
if(e.key==='ArrowDown'||e.key.toLowerCase()==='s')dy=1;
if(e.key==='ArrowLeft'||e.key.toLowerCase()==='a')dx=-1;
if(e.key==='ArrowRight'||e.key.toLowerCase()==='d')dx=1;
if(!dx&&!dy)return;
tryHop(dx,dy);
});
function tryHop(dx,dy,prefer){
if(player.t<1)return; // mid-hop
const cur=player.node;
const cands=neighborsOf(cur).filter(n=>n.jam<.9);
if(!cands.length)return;
let bestN=prefer&&cands.includes(prefer)?prefer:null;
if(!bestN){
const want=Math.atan2(dy,dx);
let bestDiff=Infinity;
cands.forEach(n=>{
const a=Math.atan2(n.y-cur.y,n.x-cur.x);
let d=Math.abs(((a-want+Math.PI*3)%TAU)-Math.PI);
if(d<bestDiff){bestDiff=d;bestN=n;}
});
if(!(bestN&&bestDiff<Math.PI/2.2))return;
}
player.from=cur;player.node=bestN;player.t=0;
battery-=1.2; // hops cost a little
sfx.hop();
}
// debug-only autopilot (used for automated testing) — BFS pathfinding
window.__autoStep=function(){
if(state!=='play'||player.t<1)return;
const cur=player.node;
const target=(combo>=3)?hub:frag;
if(!target)return;
// BFS over unjammed nodes to target
const prev=new Map(); const seen=new Set([cur]);
const q=[cur]; let found=false;
while(q.length&&!found){
const n=q.shift();
for(const m of neighborsOf(n)){
if(seen.has(m)||m.jam>=.9)continue;
prev.set(m,n);
if(m===target){found=true;break;}
seen.add(m);q.push(m);
}
}
if(!found){
// target walled off by jam: hop to the open neighbor closest to target
let bestN=null,bd=1e9;
for(const m of neighborsOf(cur)){
if(m.jam>=.9)continue;
const d=Math.hypot(m.x-target.x,m.y-target.y);
if(d<bd){bd=d;bestN=m;}
}
if(bestN)tryHop(0,0,bestN);
return;
}
// walk back to find the first hop from cur
let step=target;
while(prev.get(step)!==cur&&prev.get(step))step=prev.get(step);
tryHop(0,0,step);
};
// ---------- audio (Web Audio synth, no assets) ----------
let AC=null, master=null, muted=false;
function initAudio(){
if(AC||muted)return;
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
master=AC.createGain(); master.gain.value=.25; master.connect(AC.destination);
}catch(e){AC=null;}
}
function beep(freq,dur,type,vol,when){
if(!AC||muted)return;
try{
const t=AC.currentTime+(when||0);
const o=AC.createOscillator(),g=AC.createGain();
o.type=type||'square'; o.frequency.setValueAtTime(freq,t);
g.gain.setValueAtTime(vol||.3,t);
g.gain.exponentialRampToValueAtTime(.001,t+dur);
o.connect(g); g.connect(master);
o.start(t); o.stop(t+dur+.02);
}catch(e){}
}
function sweep(f1,f2,dur,type,vol){
if(!AC||muted)return;
try{
const t=AC.currentTime;
const o=AC.createOscillator(),g=AC.createGain();
o.type=type||'sawtooth';
o.frequency.setValueAtTime(f1,t);
o.frequency.exponentialRampToValueAtTime(f2,t+dur);
g.gain.setValueAtTime(vol||.25,t);
g.gain.exponentialRampToValueAtTime(.001,t+dur);
o.connect(g); g.connect(master);
o.start(t); o.stop(t+dur+.02);
}catch(e){}
}
const sfx={
hop:()=>beep(340,.05,'square',.12),
pickup:c=>{const b=520+Math.min(c,8)*60;
beep(b,.08,'square',.25);beep(b*1.5,.09,'square',.22,.07);beep(b*2,.12,'triangle',.2,.14);},
bank:()=>{[523,659,784,1047].forEach((f,i)=>beep(f,.18,'triangle',.28,i*.06));},
jam:()=>sweep(160,60,.18,'sawtooth',.3),
wave:()=>{[392,523,659,784,1047].forEach((f,i)=>beep(f,.14,'square',.22,i*.08));},
over:()=>{sweep(440,55,.9,'sawtooth',.3);beep(110,.8,'triangle',.25,.1);},
start:()=>{beep(260,.1,'square',.2);beep(520,.14,'square',.2,.09);}
};
// ---------- particles ----------
function burst(x,y,color,n){
for(let i=0;i<n;i++){
const a=rand(0,TAU),s=rand(30,180);
particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,
life:rand(.4,1),color});
}
}
// ---------- update ----------
function update(dt){
time+=dt;
if(shake>0)shake=Math.max(0,shake-dt*3);
particles=particles.filter(p=>{
p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=.96;p.vy*=.96;p.life-=dt*1.4;return p.life>0;
});
if(state!=='play')return;
// battery drain (steeper each wave)
battery-=dt*(3.5+wave*.55);
if(battery<=0){gameOver('BATTERY DEAD');return;}
// player hop animation
if(player.t<1&&player.from){
player.t=Math.min(1,player.t+dt*5);
const e=edges.find(e=>(e.a===player.from&&e.b===player.node)||(e.a===player.node&&e.b===player.from));
const cx=(player.from.x+player.node.x)/2;
const cy=(player.from.y+player.node.y)/2-40; // arc
const t=player.t;
player.tx=(1-t)*(1-t)*player.from.x+2*(1-t)*t*cx+t*t*player.node.x;
player.ty=(1-t)*(1-t)*player.from.y+2*(1-t)*t*cy+t*t*player.node.y;
} else {
player.tx=player.node.x;player.ty=player.node.y;
}
// jammers patrol + jam radius; hunters re-target the edge nearest the player
jammers.forEach(j=>{
if(j.hunter&&Math.random()<dt*1.5){
let be=null,bd=1e9;
edges.forEach(e=>{
const mx=(e.a.x+e.b.x)/2,my=(e.a.y+e.b.y)/2;
const d=Math.hypot(mx-player.tx,my-player.ty);
if(d<bd){bd=d;be=e;}
});
if(be&&be!==j.e){
// enter the new edge at the end closest to current position
const dA=Math.hypot(j.x===undefined?1e9:j.x-be.a.x,j.y===undefined?1e9:j.y-be.a.y);
const dB=Math.hypot(j.x===undefined?1e9:j.x-be.b.x,j.y===undefined?1e9:j.y-be.b.y);
j.e=be; j.t=dA<dB?0:1; j.dir=dA<dB?1:-1;
}
}
j.t+=j.dir*j.speed*dt;
if(j.t>1){j.t=1;j.dir*=-1;}
if(j.t<0){j.t=0;j.dir*=-1;}
const jx=j.e.a.x+(j.e.b.x-j.e.a.x)*j.t;
const jy=j.e.a.y+(j.e.b.y-j.e.a.y)*j.t;
j.x=jx;j.y=jy;
nodes.forEach(n=>{
const d=Math.hypot(n.x-jx,n.y-jy);
if(d<110)n.jam=Math.max(n.jam,1-d/110);
});
// touching a jammer kills battery hard
if(Math.hypot(player.tx-jx,player.ty-jy)<26){
battery-=dt*45;shake=1;combo=0;
if(!j.cool||j.cool<=0){sfx.jam();j.cool=.35;}
burst(player.tx,player.ty,'#ff4d6d',3);
}
if(j.cool>0)j.cool-=dt;
});
// jam decay
nodes.forEach(n=>{n.jam=Math.max(0,n.jam-dt*.5);});
// pickup
if(frag&&player.node===frag&&player.t>=1){
combo++;bestCombo=Math.max(bestCombo,combo);
const pts=10*wave*(1+combo*.5);
score+=Math.round(pts);
battery=clamp(battery+14,0,batteryMax);
sfx.pickup(combo);
burst(frag.x,frag.y,'#4dd2ff',26);
if(score>=wave*220+wave*wave*45){nextWave();}
else spawnFragment();
}
// deliver bonus: landing on hub with combo>=3
if(player.node===hub&&player.t>=1&&combo>=3){
score+=50*combo;battery=clamp(battery+10,0,batteryMax);
sfx.bank();
burst(hub.x,hub.y,'#ffd166',30);combo=0;
}
}
function gameOver(reason){
state='over';
sfx.over();
if(score>best){best=score;try{localStorage.setItem('meshrunner_best',best);}catch(e){}}
burst(player.tx,player.ty,'#ff4d6d',60);
gameOver.reason=reason;
}
// ---------- draw ----------
function draw(){
ctx.save();
if(shake>0)ctx.translate(rand(-4,4)*shake,rand(-4,4)*shake);
ctx.clearRect(-10,-10,W+20,H+20);
// grid backdrop
ctx.strokeStyle='rgba(30,60,90,.15)';ctx.lineWidth=1;
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
if(state==='title'){drawTitle();ctx.restore();return;}
// edges
edges.forEach(e=>{
const jam=Math.max(e.a.jam,e.b.jam);
ctx.strokeStyle=jam>.5?`rgba(255,77,109,${.15+jam*.3})`:'rgba(77,210,255,.22)';
ctx.lineWidth=jam>.5?1:1.5;
ctx.beginPath();ctx.moveTo(e.a.x,e.a.y);ctx.lineTo(e.b.x,e.b.y);ctx.stroke();
});
// nodes
nodes.forEach(n=>{
const isHub=n===hub;
const pulse=Math.sin(time*2+n.pulse)*.5+.5;
let r=isHub?14:8;
if(n.jam>.5){
ctx.fillStyle=`rgba(255,77,109,${.25+n.jam*.4})`;
ctx.beginPath();ctx.arc(n.x,n.y,r+6+n.jam*4,0,TAU);ctx.fill();
}
ctx.fillStyle=isHub?'#ffd166':(n.jam>.5?'#5a2a35':'#1d3a55');
ctx.strokeStyle=isHub?'#ffe9ad':(n.jam>.5?'#ff4d6d':'#4dd2ff');
ctx.lineWidth=isHub?2.5:1.5;
ctx.beginPath();ctx.arc(n.x,n.y,r+pulse*(isHub?2:1),0,TAU);ctx.fill();ctx.stroke();
if(isHub){
ctx.fillStyle='#05070d';ctx.font='bold 11px Courier New';ctx.textAlign='center';
ctx.fillText('HUB',n.x,n.y+4);
}
});
// fragment
if(frag){
const p=Math.sin(time*5)*3;
ctx.save();ctx.translate(frag.x,frag.y-14+p);ctx.rotate(time*2);
ctx.fillStyle='#4dd2ff';ctx.shadowColor='#4dd2ff';ctx.shadowBlur=14;
ctx.beginPath();ctx.moveTo(0,-8);ctx.lineTo(8,0);ctx.lineTo(0,8);ctx.lineTo(-8,0);
ctx.closePath();ctx.fill();ctx.restore();
}
// jammers
jammers.forEach(j=>{
if(j.x===undefined)return;
ctx.fillStyle=j.hunter?'#ff2244':'#ff4d6d';
ctx.shadowColor='#ff4d6d';ctx.shadowBlur=j.hunter?20:12;
ctx.save();ctx.translate(j.x,j.y);ctx.rotate(time*(j.hunter?5:3));
ctx.beginPath();
const R=j.hunter?13:10;
for(let i=0;i<3;i++){const a=i*TAU/3;ctx.lineTo(Math.cos(a)*R,Math.sin(a)*R);}
ctx.closePath();ctx.fill();ctx.restore();
ctx.shadowBlur=0;
// interference radius
ctx.strokeStyle=j.hunter?'rgba(255,34,68,.18)':'rgba(255,77,109,.12)';
ctx.beginPath();ctx.arc(j.x,j.y,110,0,TAU);ctx.stroke();
});
// player packet
ctx.fillStyle='#7cffb2';ctx.shadowColor='#7cffb2';ctx.shadowBlur=18;
ctx.beginPath();ctx.arc(player.tx,player.ty,7,0,TAU);ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle='#053322';ctx.font='bold 8px Courier New';ctx.textAlign='center';
ctx.fillText('PKT',player.tx,player.ty+3);
// particles
particles.forEach(p=>{
ctx.globalAlpha=clamp(p.life,0,1);
ctx.fillStyle=p.color;
ctx.fillRect(p.x-2,p.y-2,4,4);
});
ctx.globalAlpha=1;
drawHUD();
if(state==='over')drawOver();
ctx.restore();
}
function drawHUD(){
ctx.textAlign='left';ctx.font='bold 16px Courier New';
ctx.fillStyle='#e6f3ff';
ctx.fillText('SCORE '+score,16,28);
ctx.fillStyle='#8fb8d8';ctx.font='12px Courier New';
ctx.fillText('WAVE '+wave+' BEST '+best,16,46);
if(combo>1){ctx.fillStyle='#ffd166';ctx.font='bold 14px Courier New';
ctx.fillText('COMBO x'+combo,16,66);}
// battery
const bw=220,bx=W-bw-16,by=18;
ctx.fillStyle='#0e1c2c';ctx.fillRect(bx,by,bw,14);
const frac=clamp(battery/batteryMax,0,1);
const col=frac>.5?'#7cffb2':frac>.25?'#ffd166':'#ff4d6d';
ctx.fillStyle=col;ctx.fillRect(bx,by,bw*frac,14);
ctx.strokeStyle='#2a4a6a';ctx.strokeRect(bx,by,bw,14);
ctx.fillStyle='#05070d';ctx.font='bold 10px Courier New';ctx.textAlign='center';
ctx.fillText('BATTERY',bx+bw/2,by+10);
ctx.textAlign='left';
}
function drawTitle(){
ctx.textAlign='center';
ctx.fillStyle='#4dd2ff';ctx.font='bold 52px Courier New';
ctx.shadowColor='#4dd2ff';ctx.shadowBlur=20;
ctx.fillText('MESH RUNNER',W/2,H/2-90);
ctx.shadowBlur=0;
ctx.fillStyle='#8fb8d8';ctx.font='16px Courier New';
ctx.fillText('You are a data packet. Hop the mesh. Deliver fragments. Don\u2019t get jammed.',W/2,H/2-40);
ctx.fillStyle='#c8e3f5';ctx.font='14px Courier New';
ctx.fillText('\u2190 \u2191 \u2192 \u2193 / WASD hop to nearest node in that direction',W/2,H/2+10);
ctx.fillText('Red triangles = jammers. From wave 3, HUNTERS (bright red) chase you.',W/2,H/2+34);
ctx.fillText('Battery drains over time. Fragments recharge you. Chain combos, bank at HUB. M = mute.',W/2,H/2+58);
const blink=Math.sin(time*4)>0;
if(blink){ctx.fillStyle='#7cffb2';ctx.font='bold 20px Courier New';
ctx.fillText('[ PRESS ENTER TO TRANSMIT ]',W/2,H/2+120);}
ctx.fillStyle='#5a7a95';ctx.font='12px Courier New';
ctx.fillText('best: '+best,W/2,H/2+160);
}
function drawOver(){
ctx.fillStyle='rgba(5,7,13,.75)';ctx.fillRect(0,0,W,H);
ctx.textAlign='center';
ctx.fillStyle='#ff4d6d';ctx.font='bold 44px Courier New';
ctx.shadowColor='#ff4d6d';ctx.shadowBlur=16;
ctx.fillText('TRANSMISSION LOST',W/2,H/2-60);
ctx.shadowBlur=0;
ctx.fillStyle='#e6f3ff';ctx.font='18px Courier New';
ctx.fillText(gameOver.reason||'SIGNAL DROPPED',W/2,H/2-20);
ctx.fillStyle='#8fb8d8';ctx.font='16px Courier New';
ctx.fillText('score '+score+' wave '+wave+' best combo x'+bestCombo,W/2,H/2+16);
if(score>=best&&score>0){ctx.fillStyle='#ffd166';ctx.fillText('\u2605 NEW PERSONAL BEST \u2605',W/2,H/2+44);}
const blink=Math.sin(time*4)>0;
if(blink){ctx.fillStyle='#7cffb2';ctx.font='bold 18px Courier New';
ctx.fillText('[ ENTER / R \u2014 RETRY ]',W/2,H/2+92);}
}
// ---------- loop ----------
let last=performance.now();
function loop(now){
const dt=Math.min((now-last)/1000,.05);last=now;
update(dt);draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// debug hook (harmless; used for automated testing)
window.__mr={get state(){return state;},get score(){return score;},
get wave(){return wave;},get battery(){return battery;},set battery(v){battery=v;},
get combo(){return combo;},set combo(v){combo=v;},
get nodes(){return nodes;},get edges(){return edges;},get hub(){return hub;},
get jammers(){return jammers;},get player(){return player;},
get muted(){return muted;},
setWave:function(w,n){wave=w;buildNetwork(n);},
get bestCombo(){return bestCombo;},get errs(){return window.__errs;}};
</script>
</body>
</html>