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()