MESH RUNNER v1: arcade mesh-hopping game with synth SFX and hunter-jammer difficulty curve
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
# MESH RUNNER — a packet's odyssey
|
||||||
|
|
||||||
|
An arcade game where **you are a data packet** fighting through a contested radio mesh.
|
||||||
|
Single-file HTML5 canvas game — zero dependencies, zero build step.
|
||||||
|
|
||||||
|
## Play
|
||||||
|
|
||||||
|
Open `index.html` in any browser. That's it.
|
||||||
|
|
||||||
|
## How to play
|
||||||
|
|
||||||
|
- **← ↑ → ↓ / WASD** — hop to the nearest mesh node in that direction
|
||||||
|
- **Cyan diamond** — data fragment. Grab it: +score, +battery, combo up
|
||||||
|
- **Gold HUB** (center) — bank there with **combo ×3+** for a big bonus
|
||||||
|
- **Red triangles** — jammers patrolling the links. They jam nearby nodes (red = deadly),
|
||||||
|
and touching one torches your battery
|
||||||
|
- **HUNTERS** (bright red, bigger, from wave 3) — re-target and chase you across the mesh
|
||||||
|
- **Battery** drains constantly; fragments recharge you. Zero battery → *TRANSMISSION LOST*
|
||||||
|
- **M** — mute / unmute
|
||||||
|
|
||||||
|
## Difficulty curve
|
||||||
|
|
||||||
|
| Wave | What changes |
|
||||||
|
|------|-------------|
|
||||||
|
| 1–2 | Patrol jammers, gentle drain — learn the mesh |
|
||||||
|
| 3+ | **Hunters** appear — they re-route to the edge nearest you |
|
||||||
|
| 4+ | Jammer count scales ~0.7/wave (cap 12), patrol speed +1.2%/wave |
|
||||||
|
| every | Battery drain +0.55/wave, max battery −4/wave (floor 50), wave target grows quadratically |
|
||||||
|
|
||||||
|
## Sound
|
||||||
|
|
||||||
|
All sound effects are synthesized live with the Web Audio API — no asset files.
|
||||||
|
Pickups rise in pitch with your combo; hunters growl; the game-over sweep is a slow sawtooth fall.
|
||||||
|
Audio starts on your first keypress (browser autoplay policy). `M` toggles mute.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `test_headless.py` — 19 logic checks under py_mini_racer (state machine, scoring,
|
||||||
|
jam radius, hunter spawn/chase, hub banking, mute, persistence, audio-safety).
|
||||||
|
Run: `python3 test_headless.py`
|
||||||
|
- Verified in real headless Chromium: autopilot plays, scores, waves advance,
|
||||||
|
hunters chase, no JS errors.
|
||||||
|
|
||||||
|
MIT license. Made by Hermes.
|
||||||
+493
@@ -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>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import re, sys
|
||||||
|
import py_mini_racer as pyr
|
||||||
|
|
||||||
|
html = open('/workspace/mesh-runner/index.html').read()
|
||||||
|
m = re.search(r'<script>(.*)</script>', html, re.S)
|
||||||
|
assert m, 'no inline script found'
|
||||||
|
game_js = m.group(1)
|
||||||
|
|
||||||
|
HARNESS = r"""
|
||||||
|
var ctxCalls = {};
|
||||||
|
function makeCtx(){
|
||||||
|
return new Proxy({}, {
|
||||||
|
get(t, k){
|
||||||
|
if(k === 'canvas') return {width:960, height:640};
|
||||||
|
if(typeof k === 'symbol') return undefined;
|
||||||
|
if(!(k in t)){
|
||||||
|
if(k === 'fillStyle' || k === 'strokeStyle' || k === 'lineWidth' ||
|
||||||
|
k === 'font' || k === 'textAlign' || k === 'shadowColor' || k === 'shadowBlur' ||
|
||||||
|
k === 'globalAlpha'){
|
||||||
|
t[k] = '';
|
||||||
|
return t[k];
|
||||||
|
}
|
||||||
|
t[k] = function(){ ctxCalls[k] = (ctxCalls[k]||0)+1; return undefined; };
|
||||||
|
}
|
||||||
|
return t[k];
|
||||||
|
},
|
||||||
|
set(t, k, v){ t[k] = v; return true; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var _els = { cv: { id:'cv', width:960, height:640, getContext: function(){ return makeCtx(); } } };
|
||||||
|
var listeners = {};
|
||||||
|
var document = {
|
||||||
|
getElementById: function(id){ return _els[id] || null; },
|
||||||
|
addEventListener: function(t,f){ (listeners[t]=listeners[t]||[]).push(f); }
|
||||||
|
};
|
||||||
|
var _vt = 0; // virtual clock: 16.67ms per frame, deterministic
|
||||||
|
var performance = { now: function(){ return _vt; } };
|
||||||
|
var _rafQ = [];
|
||||||
|
function requestAnimationFrame(cb){ _rafQ.push(cb); return _rafQ.length; }
|
||||||
|
function frames(n){
|
||||||
|
for(var i=0;i<n;i++){
|
||||||
|
_vt += 16.67;
|
||||||
|
var q=_rafQ; _rafQ=[];
|
||||||
|
q.forEach(function(cb){ cb(_vt); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var localStorage = { _d:{}, getItem:function(k){return this._d[k]===undefined?null:this._d[k];},
|
||||||
|
setItem:function(k,v){this._d[k]=String(v);} };
|
||||||
|
var window = {
|
||||||
|
addEventListener: function(t,f){ (listeners[t]=listeners[t]||[]).push(f); },
|
||||||
|
localStorage: localStorage,
|
||||||
|
onerror: null,
|
||||||
|
__errs: []
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
function key(type, k){
|
||||||
|
(listeners[type]||[]).forEach(function(f){ f({key:k, preventDefault:function(){}}); });
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
ctx = pyr.MiniRacer()
|
||||||
|
try:
|
||||||
|
ctx.eval(HARNESS)
|
||||||
|
except Exception as e:
|
||||||
|
print('HARNESS FAIL:', e); sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx.eval(game_js)
|
||||||
|
print('LOAD: game script parsed and executed OK')
|
||||||
|
except Exception as e:
|
||||||
|
print('PARSE/LOAD FAIL:', e); sys.exit(1)
|
||||||
|
|
||||||
|
def ev(expr):
|
||||||
|
return ctx.eval(expr)
|
||||||
|
|
||||||
|
def frames(n):
|
||||||
|
ctx.eval(f'frames({n})')
|
||||||
|
|
||||||
|
def key(t, k):
|
||||||
|
ctx.eval(f'key({t!r}, {k!r})')
|
||||||
|
|
||||||
|
results = []
|
||||||
|
def check(name, cond, extra=''):
|
||||||
|
results.append((name, bool(cond)))
|
||||||
|
print(('PASS ' if cond else 'FAIL ') + name + ((' | '+str(extra)) if extra else ''))
|
||||||
|
|
||||||
|
# boot a few frames on title
|
||||||
|
frames(10)
|
||||||
|
check('title state', ev('window.__mr.state') == 'title')
|
||||||
|
check('title drew something', ev('Object.keys(ctxCalls).length') >= 5,
|
||||||
|
ev('JSON.stringify(Object.keys(ctxCalls))'))
|
||||||
|
|
||||||
|
# start with Enter
|
||||||
|
key('keydown', 'Enter')
|
||||||
|
frames(5)
|
||||||
|
check('state -> play after Enter', ev('window.__mr.state') == 'play')
|
||||||
|
check('battery full-ish', ev('window.__mr.battery') > 90, ev('window.__mr.battery'))
|
||||||
|
|
||||||
|
# autopilot soak: autoStep every ~12 frames, top up battery to test scoring not death
|
||||||
|
for i in range(600):
|
||||||
|
if i % 12 == 0:
|
||||||
|
ctx.eval('window.__autoStep();')
|
||||||
|
if ev('window.__mr.battery') < 40:
|
||||||
|
ctx.eval('window.window.__mr.battery = 100;')
|
||||||
|
frames(1)
|
||||||
|
score1 = ev('window.__mr.score'); wave1 = ev('window.__mr.wave'); cb1 = ev('window.__mr.bestCombo')
|
||||||
|
check('autopilot scored points', score1 > 0, f'score={score1} wave={wave1} bestCombo={cb1}')
|
||||||
|
check('autopilot reached wave >= 2', wave1 >= 2, f'wave={wave1}')
|
||||||
|
|
||||||
|
# jam mechanic: park a jammer on an edge touching a node, verify jam rises
|
||||||
|
ctx.eval("""
|
||||||
|
(function(){
|
||||||
|
var j = window.__mr.jammers[0];
|
||||||
|
j.e = window.__mr.edges[0];
|
||||||
|
j.t = 0.5;
|
||||||
|
})();
|
||||||
|
""")
|
||||||
|
frames(30)
|
||||||
|
maxjam = ev('Math.max.apply(null,window.__mr.nodes.map(function(n){return n.jam;}))')
|
||||||
|
check('jammer jams nodes', maxjam > 0.05, f'maxjam={maxjam}')
|
||||||
|
|
||||||
|
# battery death path
|
||||||
|
ctx.eval('window.__mr.battery = 0.5;')
|
||||||
|
frames(10)
|
||||||
|
check('battery death -> over', ev('window.__mr.state') == 'over')
|
||||||
|
check('best saved to localStorage', ev('window.localStorage.getItem("meshrunner_best")') is not None)
|
||||||
|
|
||||||
|
# retry
|
||||||
|
key('keydown', 'r')
|
||||||
|
frames(5)
|
||||||
|
check('retry -> play', ev('window.__mr.state') == 'play')
|
||||||
|
check('score reset', ev('window.__mr.score') == 0)
|
||||||
|
|
||||||
|
# hub bank: combo>=3 landing on hub awards bonus
|
||||||
|
ctx.eval('window.window.__mr.combo = 3; window.__mr.player.node = window.__mr.hub; window.__mr.player.t = 1;')
|
||||||
|
score_before = ev('window.__mr.score')
|
||||||
|
frames(3)
|
||||||
|
score_after = ev('window.__mr.score')
|
||||||
|
check('hub bank awards combo bonus', score_after > score_before, f'{score_before} -> {score_after}')
|
||||||
|
check('combo cleared after bank', ev('window.__mr.combo') == 0)
|
||||||
|
|
||||||
|
# no JS errors
|
||||||
|
errs = ev('JSON.stringify(window.__errs)')
|
||||||
|
check('no JS runtime errors', errs == '[]', errs)
|
||||||
|
|
||||||
|
# audio safety: no AudioContext in harness -> sfx must no-op without throwing
|
||||||
|
check('audio-safe without AudioContext', ev('typeof AudioContext') == 'undefined')
|
||||||
|
|
||||||
|
# mute toggle via M
|
||||||
|
key('keydown', 'm')
|
||||||
|
check('M mutes', ev('window.__mr.muted') == True)
|
||||||
|
key('keydown', 'm')
|
||||||
|
check('M unmutes', ev('window.__mr.muted') == False)
|
||||||
|
|
||||||
|
# hunter mechanic: force wave 3 rebuild, expect at least one hunter jammer
|
||||||
|
ctx.eval('window.__mr.setWave(3,16);')
|
||||||
|
n_hunters = ev('window.__mr.jammers.filter(function(j){return j.hunter;}).length')
|
||||||
|
check('hunters spawn from wave 3', n_hunters >= 1, f'hunters={n_hunters}')
|
||||||
|
|
||||||
|
# hunter re-targets toward player without crashing
|
||||||
|
ctx.eval("""
|
||||||
|
(function(){
|
||||||
|
var h = window.__mr.jammers.filter(function(j){return j.hunter;})[0];
|
||||||
|
h.x = 100; h.y = 100;
|
||||||
|
})();
|
||||||
|
""")
|
||||||
|
frames(120)
|
||||||
|
check('hunter chase runs clean', ev('window.__mr.state') in ('play', 'over'))
|
||||||
|
|
||||||
|
passed = sum(1 for _, ok in results if ok)
|
||||||
|
print(f'\n{passed}/{len(results)} checks passed')
|
||||||
|
sys.exit(0 if passed == len(results) else 1)
|
||||||
Reference in New Issue
Block a user