File size: 9,621 Bytes
f1caa47 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | """Make a capture copy of matrix-shooter-js for paired graybox / styled data.
usage: matrix_capture_game.py PACKAGE_DIR OUT_DIR
PACKAGE_DIR is the unpacked delivery (start.py, games/, .local/). OUT_DIR gets
the same tree with game.mjs patched; the package itself is never modified.
The patched game, when the page sets window.__CAPTURE before loading:
* does not run its own requestAnimationFrame loop; window.__cap.step()
advances it by exactly 1/24 s, so pages fed the same inputs stay in lockstep
(the simulation has no randomness of its own; respawns use a seeded PRNG);
* takes the camera from window.__camOverride when set, so every style is
rendered from the graybox page's camera (styles normally pull the camera
further back and widen the field of view);
* fires bullets from a style-independent point beside the actor, since each
style's gun model puts its muzzle elsewhere and hits would otherwise diverge;
* runs team battles: allies (player look) and enemies, each bot hunting the
nearest living foe, bullets hurting the other team only, the dead respawning
around the player, and the player refilled instead of dying.
"""
import argparse
import shutil
from pathlib import Path
GAME = 'games/matrix-shooter-js/game.mjs'
BOT_STEP = r'''function respawnBot(bot){for(let i=0;i<8;i++){const a=capRandom()*Math.PI*2,r=8+capRandom()*17,p=world.nav.nearest(player.p.x+Math.cos(a)*r,player.p.z+Math.sin(a)*r,6);if(p){bot.p=p;bot.alive=true;bot.health=100;bot.root.visible=true;bot.path=[];bot.think=0;return;}}}
function botStep(bot,dt){
if(!bot.alive){bot.respawn-=dt;if(bot.respawn<=2)respawnBot(bot);return;}
let foe=null,distance=1e9;for(const a of [player,...bots])if(a&&a.alive&&a!==bot&&a.team!==bot.team){const d=Math.hypot(a.p.x-bot.p.x,a.p.z-bot.p.z);if(d<distance){distance=d;foe=a;}}
if(!foe){animateActor(bot,dt,false);return;}
const dx=foe.p.x-bot.p.x,dz=foe.p.z-bot.p.z;bot.root.rotation.y=Math.atan2(dx,dz);
bot.think-=dt;if(bot.think<=0){bot.think=.9+(bots.indexOf(bot)%4)*.15;
bot.path=distance>bot.engage+1?world.nav.path(bot.p,foe.p,3500):[];bot.pathAt=0;
}
const target=bot.path[bot.pathAt];let moving=false;
if(target&&distance>bot.engage){const x=target.x-bot.p.x,z=target.z-bot.p.z,d=Math.hypot(x,z);
if(d<.2)bot.pathAt++;else{const before=bot.p;bot.p=world.nav.move(bot.p,x/d*dt*2.6,z/d*dt*2.6);moving=Math.hypot(bot.p.x-before.x,bot.p.z-before.z)>.001;}}
else if(distance<=bot.engage){const s=Math.sin(elapsed*bot.strafeRate+bot.strafePhase),before=bot.p;bot.p=world.nav.move(bot.p,dz/distance*s*dt*1.6,-dx/distance*s*dt*1.6);moving=Math.hypot(bot.p.x-before.x,bot.p.z-before.z)>.001;}
bot.fireAt-=dt;if(bot.fireAt<=0&&distance<35){bot.fireAt=.8+capRandom()*.9;
const origin=new THREE.Vector3(bot.p.x,bot.p.y+1.2,-bot.p.z),point=new THREE.Vector3(foe.p.x,foe.p.y+1.1+(foe.height||0),-foe.p.z),dir=point.clone().sub(origin).normalize();
const wall=nearestWall(origin,dir,distance);if(!wall||wall.distance>distance-.6){bot.root.position.set(bot.p.x,bot.p.y,bot.p.z);const from=muzzle(bot,0);audio.play('shot',{weapon:0,style:currentSkin(),position:from,level:.45});launch(from,point.sub(from).normalize(),0,bot.team?'enemy':'ally');}
}
animateActor(bot,dt,moving);'''
HOOK = r'''
// ---- capture hook (matrix_capture_game.py) ----
if(window.__CAPTURE){
const pages=[];
window.__cap={
ready:()=>!loading&&!!world&&!!player,
points:()=>(world.config.points??[]).map(p=>({id:p.id,name:p.name,x:p.x,z:p.z,yaw:p.yaw??null,pitch:p.pitch??null})),
blocks(size){const b=world.config.bounds??{min:[-78,0,-78],max:[78,0,78]},out=[];for(let x=b.min[0]+size/2;x<b.max[0];x+=size)for(let z=b.min[2]+size/2;z<b.max[2];z+=size){const p=world.nav.nearest(x,z,size/2);if(p&&Math.abs(p.x-x)<=size/2&&Math.abs(p.z-z)<=size/2)out.push([p.x,p.z]);}return out;},
nearest:(x,z,r)=>world.nav.nearest(x,z,r??4),
setup(cfg){
capSeed=cfg.seed>>>0||1;
canvas.style.cssText='position:fixed;left:0;top:0;width:'+cfg.width+'px;height:'+cfg.height+'px;z-index:2147483647';resize();
resetActors();for(const b of bots)removeActor(b);bots=[];player.team=0;
const p=world.nav.nearest(cfg.player.x,cfg.player.z,6);if(!p)throw Error('no walkable cell near player start');
player.p=p;yaw=cfg.player.yaw;pitch=cfg.player.pitch;player.root.rotation.y=yaw;
for(const [team,list] of [[0,cfg.allies],[1,cfg.enemies]])for(const [x,z] of list){const q=world.nav.nearest(x,z,6);if(!q)continue;
const b=actor(q,team===1);b.enemy=true;b.team=team;b.fireAt=.4+capRandom()*1.2;b.engage=cfg.engage[0]+capRandom()*(cfg.engage[1]-cfg.engage[0]);
b.strafeRate=.7+capRandom()*.9;b.strafePhase=capRandom()*6.283;bots.push(b);}
started=true;paused=false;overview=false;$('#overlay').hidden=true;window.__camOverride=null;
animateActor(player,0,false);for(const b of bots)animateActor(b,0,false);updateCamera(true);render();
return this.state();
},
state(){return {elapsed,yaw,pitch,weapon,ammo:ammo[weapon],reloading,shots,hits,kills,bullets:ballistics.items.length,grenades:grenades.items.length,
player:{x:player.p.x,y:player.p.y,z:player.p.z,h:player.height,health:player.health},
actors:bots.map(b=>({team:b.team,alive:b.alive,x:b.p.x,y:b.p.y,z:b.p.z,health:b.health}))};},
camera(){return {p:camera.position.toArray(),q:camera.quaternion.toArray(),fov:camera.fov,v:player.root.visible,calls:renderer.info.render.calls,near:camera.near,
s:cameraState?{distance:cameraState.distance,desiredDistance:cameraState.desiredDistance,obstructed:cameraState.obstructed}:null};},
shot(){render();return canvas.toDataURL('image/png');},
step(input){
for(const k of input.up)window.dispatchEvent(new KeyboardEvent('keyup',{key:k}));
for(const k of input.down)window.dispatchEvent(new KeyboardEvent('keydown',{key:k}));
yaw=input.yaw;pitch=input.pitch;window.__camOverride=input.cam||null;
step(1/24);render();
const out={state:this.state(),camera:this.camera()};
if(input.capture)out.image=canvas.toDataURL('image/png');
return out;
},
};
}
'''
PATCHES = [
# Own loop off in capture mode.
('requestAnimationFrame(frame);\ncanvas.addEventListener', 'if(!window.__CAPTURE)requestAnimationFrame(frame);\ncanvas.addEventListener'),
# Seeded PRNG for respawns and bot timing.
('let player=null,bots=[]', 'let capSeed=1;function capRandom(){capSeed=capSeed+0x6D2B79F5|0;let t=Math.imul(capSeed^capSeed>>>15,1|capSeed);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;}\nlet player=null,bots=[]'),
# Camera from the graybox page.
('function updateCamera(instant=false){if(!player||!world)return;',
'function updateCamera(instant=false){if(!player||!world)return;'
'if(window.__camOverride&&!overview){const o=window.__camOverride;aimVectors();camera.position.fromArray(o.p);camera.quaternion.fromArray(o.q);'
'camera.fov=o.fov;camera.updateProjectionMatrix();camera.updateMatrixWorld();player.root.visible=o.v;cameraState=o.s;return;}'),
# Style-independent bullet origin.
('a.flash.visible=true;return point;\n}',
'a.flash.visible=true;return window.__CAPTURE?a.root.localToWorld(new THREE.Vector3(.35,1.2,.9)):point;\n}'),
# Bullets hurt the other team.
("for(const a of p.owner==='player'?bots:[player]){if(!a.alive)continue;",
"for(const a of p.owner==='enemy'?[player,...bots.filter(b=>b.team===0)]:bots.filter(b=>b.team===1)){if(!a.alive)continue;"),
("if(p.owner!=='player'||hit.kind!=='actor')return;\n hits++;audio.play('hit',{style:currentSkin()});hitTime=.18;const target=hit.target;target.health-=p.damage;\n if(target.health<=0){",
"if(hit.kind!=='actor')return;const target=hit.target;\n if(p.owner==='player'){hits++;audio.play('hit',{style:currentSkin()});hitTime=.18;}target.health-=p.damage;\n "
"if(target===player){if(player.health<=0)player.health=100;return;}\n if(target.health<=0){"),
# Background pages must not pause themselves or drop held keys.
("window.addEventListener('blur',()=>{release();if(started)pause(true);});",
"if(!window.__CAPTURE)window.addEventListener('blur',()=>{release();if(started)pause(true);});"),
("document.addEventListener('visibilitychange',()=>{if(document.hidden&&started)pause(true);});",
"if(!window.__CAPTURE)document.addEventListener('visibilitychange',()=>{if(document.hidden&&started)pause(true);});"),
# The player's hitscan and grenades only look at enemies.
('for(const bot of bots){if(!bot.alive)continue;targetBox.min', 'for(const bot of bots.filter(b=>b.team===1)){if(!bot.alive)continue;targetBox.min'),
('for(const bot of bots){if(!bot.alive)continue;\n const target={x:bot.p.x', 'for(const bot of bots.filter(b=>b.team===1)){if(!bot.alive)continue;\n const target={x:bot.p.x'),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('package', type=Path)
parser.add_argument('out', type=Path)
args = parser.parse_args()
if args.out.exists():
shutil.rmtree(args.out)
shutil.copytree(args.package, args.out)
game = args.out / GAME
text = game.read_text()
start = text.index('function botStep(bot,dt){')
end = text.index(' animateActor(bot,dt,moving);', start) + len(' animateActor(bot,dt,moving);')
text = text[:start] + BOT_STEP + text[end:]
for old, new in PATCHES:
if text.count(old) != 1:
raise SystemExit(f'patch target found {text.count(old)} times: {old[:70]!r}')
text = text.replace(old, new)
game.write_text(text + HOOK)
print('capture game at', args.out)
if __name__ == '__main__':
main()
|