MESH RUNNER v1: arcade mesh-hopping game with synth SFX and hunter-jammer difficulty curve
This commit is contained in:
@@ -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