|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Standalone RobotRumble replayer/visualizer. |
| 3 | +
|
| 4 | +Turns a recorded sim_*.json (from a CodeClash RobotRumble game, produced with |
| 5 | +`rumblebot run term --raw`) into a self-contained HTML file you can open in any |
| 6 | +browser — animated grid, play/pause/step, per-team unit counts + health, move/ |
| 7 | +attack indicators, and the winner. No server, no internet, no dependencies. |
| 8 | +
|
| 9 | +Usage: |
| 10 | + python scripts/replay_robotrumble.py path/to/sim.json # writes sim.html |
| 11 | + python scripts/replay_robotrumble.py sim.json -o out.html |
| 12 | + python scripts/replay_robotrumble.py sim.json --ascii # quick terminal dump |
| 13 | + python scripts/replay_robotrumble.py sim.json --blue-name luisa --red-name flail |
| 14 | +
|
| 15 | +The raw JSON format: a single object {winner, errors, turns:[...]}. Each turn has |
| 16 | +`state.objs` (a dict of grid objects — Terrain/Wall and Unit/Soldier, each with |
| 17 | +`coords` [x,y], `team`, `health`), a `state.turn` index, and `robot_actions` |
| 18 | +(unit id -> {"Ok": {"type":"Move"|"Attack","direction":...}} or {"Ok":"None"}). |
| 19 | +Blue is the first (blue) bot, Red is the second. |
| 20 | +""" |
| 21 | +import argparse |
| 22 | +import json |
| 23 | +import sys |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +MAX_HEALTH = 5 |
| 27 | +TEAM_COLORS = {"Blue": "#3B78FF", "Red": "#E5484D"} |
| 28 | + |
| 29 | + |
| 30 | +def load(path, blue_name=None, red_name=None): |
| 31 | + data = json.loads(Path(path).read_text()) |
| 32 | + turns_raw = data.get("turns", []) |
| 33 | + |
| 34 | + # Walls (terrain) are static across the game — grab them from the first frame. |
| 35 | + walls = [] |
| 36 | + max_x = max_y = 0 |
| 37 | + if turns_raw: |
| 38 | + for o in turns_raw[0]["state"]["objs"].values(): |
| 39 | + x, y = o["coords"] |
| 40 | + max_x, max_y = max(max_x, x), max(max_y, y) |
| 41 | + if o["obj_type"] == "Terrain": |
| 42 | + walls.append([x, y]) |
| 43 | + |
| 44 | + frames = [] |
| 45 | + for t in turns_raw: |
| 46 | + objs = t["state"]["objs"] |
| 47 | + actions = t.get("robot_actions") or {} |
| 48 | + units = [] |
| 49 | + for oid, o in objs.items(): |
| 50 | + if o["obj_type"] != "Unit": |
| 51 | + continue |
| 52 | + act = actions.get(oid) |
| 53 | + atype, adir = None, None |
| 54 | + if isinstance(act, dict) and "Ok" in act: |
| 55 | + ok = act["Ok"] |
| 56 | + if isinstance(ok, dict): |
| 57 | + atype, adir = ok.get("type"), ok.get("direction") |
| 58 | + units.append({ |
| 59 | + "team": o.get("team"), |
| 60 | + "hp": o.get("health", 0), |
| 61 | + "x": o["coords"][0], |
| 62 | + "y": o["coords"][1], |
| 63 | + "act": atype, |
| 64 | + "dir": adir, |
| 65 | + }) |
| 66 | + frames.append({"turn": t["state"].get("turn"), "units": units}) |
| 67 | + |
| 68 | + winner_raw = data.get("winner") |
| 69 | + names = {"Blue": blue_name or "Blue", "Red": red_name or "Red"} |
| 70 | + if winner_raw in ("Blue", "Red"): |
| 71 | + winner, draw = names[winner_raw], False |
| 72 | + else: |
| 73 | + winner, draw = None, True |
| 74 | + |
| 75 | + return { |
| 76 | + "w": max_x + 1, |
| 77 | + "h": max_y + 1, |
| 78 | + "walls": walls, |
| 79 | + "frames": frames, |
| 80 | + "names": names, |
| 81 | + "colors": TEAM_COLORS, |
| 82 | + "winner": winner, |
| 83 | + "draw": draw, |
| 84 | + "errors": data.get("errors", {}), |
| 85 | + } |
| 86 | + |
| 87 | + |
| 88 | +def to_ascii(g): |
| 89 | + W, H = g["w"], g["h"] |
| 90 | + wall_set = {(x, y) for x, y in g["walls"]} |
| 91 | + for f in g["frames"]: |
| 92 | + grid = [["#" if (x, y) in wall_set else "." for x in range(W)] for y in range(H)] |
| 93 | + for u in f["units"]: |
| 94 | + grid[u["y"]][u["x"]] = "B" if u["team"] == "Blue" else "R" |
| 95 | + counts = {"Blue": 0, "Red": 0} |
| 96 | + hp = {"Blue": 0, "Red": 0} |
| 97 | + for u in f["units"]: |
| 98 | + counts[u["team"]] += 1 |
| 99 | + hp[u["team"]] += u["hp"] |
| 100 | + print(f"\n--- turn {f['turn']} --- " |
| 101 | + f"{g['names']['Blue']}(B): {counts['Blue']} units / {hp['Blue']} hp " |
| 102 | + f"{g['names']['Red']}(R): {counts['Red']} units / {hp['Red']} hp") |
| 103 | + for row in grid: |
| 104 | + print(" ".join(row)) |
| 105 | + print(f"\nWinner: {'TIE' if g['draw'] else g['winner']}") |
| 106 | + |
| 107 | + |
| 108 | +HTML = """<!doctype html><html><head><meta charset="utf-8"><title>RobotRumble replay</title> |
| 109 | +<style> |
| 110 | + body{{background:#0d1117;color:#e6edf3;font:14px system-ui,sans-serif;margin:0;padding:16px;display:flex;gap:20px;flex-wrap:wrap}} |
| 111 | + canvas{{background:#161b22;border-radius:8px}} |
| 112 | + #side{{min-width:260px}} .row{{margin:8px 0}} button{{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:14px}} |
| 113 | + button:hover{{background:#30363d}} h2{{margin:0 0 4px;font-size:15px}} |
| 114 | + .team{{margin:12px 0;padding:10px;background:#161b22;border-radius:8px}} |
| 115 | + .tname{{display:flex;align-items:center;gap:8px;font-weight:700}} .sw{{width:14px;height:14px;border-radius:3px}} |
| 116 | + .stat{{display:flex;justify-content:space-between;margin:6px 0;font-variant-numeric:tabular-nums}} |
| 117 | + .hb{{height:8px;background:#30363d;border-radius:4px;overflow:hidden;margin-top:4px}} .hf{{height:100%;transition:width .12s}} |
| 118 | + .dead{{opacity:.45}} #winner{{font-weight:700;font-size:16px;margin-top:14px}} .muted{{color:#8b949e}} |
| 119 | +</style></head><body> |
| 120 | +<canvas id="c"></canvas> |
| 121 | +<div id="side"> |
| 122 | + <h2>RobotRumble replay</h2> |
| 123 | + <div class="row muted"><b>Turn <span id="t">0</span></b> / {maxturn}</div> |
| 124 | + <div class="row"> |
| 125 | + <button id="first">⏮</button><button id="prev">◀</button> |
| 126 | + <button id="play">▶ play</button><button id="next">▶</button><button id="last">⏭</button> |
| 127 | + </div> |
| 128 | + <div class="row">speed <input id="speed" type="range" min="1" max="30" value="8"></div> |
| 129 | + <input id="scrub" type="range" min="0" max="{maxturn}" value="0" style="width:100%"> |
| 130 | + <div id="teams"></div> |
| 131 | + <div id="winner"></div> |
| 132 | + <div class="row muted" style="font-size:12px">■ move arrow · ✦ attacking · health = brightness</div> |
| 133 | +</div> |
| 134 | +<script> |
| 135 | +const G = {data}; |
| 136 | +const W=G.w, H=G.h, F=G.frames, COL=G.colors, NAMES=G.names, MAXHP={maxhp}; |
| 137 | +const WALLS=G.walls; |
| 138 | +const cv=document.getElementById('c'), ctx=cv.getContext('2d'); |
| 139 | +const CELL=Math.max(16, Math.min(40, Math.floor(640/Math.max(W,H)))), PAD=CELL*0.14; |
| 140 | +cv.width=W*CELL; cv.height=H*CELL; |
| 141 | +let i=0, playing=false, timer=null; |
| 142 | +const px=(x)=>x*CELL, py=(y)=>y*CELL; // RobotRumble: y=0 at top, render straight down |
| 143 | +const DIRV={{North:[0,-1],South:[0,1],East:[1,0],West:[-1,0]}}; |
| 144 | +const wallSet=new Set(WALLS.map(([x,y])=>x+','+y)); |
| 145 | +function draw(){{ |
| 146 | + const f=F[i]; |
| 147 | + ctx.clearRect(0,0,cv.width,cv.height); |
| 148 | + // grid lines |
| 149 | + ctx.strokeStyle='#21262d'; ctx.lineWidth=1; |
| 150 | + for(let x=0;x<=W;x++){{ctx.beginPath();ctx.moveTo(x*CELL,0);ctx.lineTo(x*CELL,H*CELL);ctx.stroke();}} |
| 151 | + for(let y=0;y<=H;y++){{ctx.beginPath();ctx.moveTo(0,y*CELL);ctx.lineTo(W*CELL,y*CELL);ctx.stroke();}} |
| 152 | + // walls / terrain |
| 153 | + ctx.fillStyle='#2d333b'; |
| 154 | + WALLS.forEach(([x,y])=>{{ctx.fillRect(px(x)+1,py(y)+1,CELL-2,CELL-2);}}); |
| 155 | + // units |
| 156 | + f.units.forEach(u=>{{ |
| 157 | + const base=COL[u.team]||'#888'; |
| 158 | + // health -> opacity (full hp brightest) |
| 159 | + const frac=Math.max(0.28, u.hp/MAXHP); |
| 160 | + ctx.globalAlpha=frac; |
| 161 | + ctx.fillStyle=base; |
| 162 | + const r=CELL*0.28; |
| 163 | + ctx.beginPath(); ctx.roundRect(px(u.x)+PAD,py(u.y)+PAD,CELL-2*PAD,CELL-2*PAD,r); ctx.fill(); |
| 164 | + ctx.globalAlpha=1; |
| 165 | + const cx=px(u.x)+CELL/2, cy=py(u.y)+CELL/2; |
| 166 | + // attack indicator: bright ring + directional spark |
| 167 | + if(u.act==='Attack'){{ |
| 168 | + ctx.strokeStyle='#ffd21f'; ctx.lineWidth=2; |
| 169 | + ctx.beginPath(); ctx.arc(cx,cy,CELL*0.42,0,7); ctx.stroke(); |
| 170 | + const d=DIRV[u.dir]; if(d){{ |
| 171 | + ctx.fillStyle='#ffd21f'; |
| 172 | + ctx.beginPath(); |
| 173 | + ctx.arc(cx+d[0]*CELL*0.5, cy+d[1]*CELL*0.5, CELL*0.12,0,7); ctx.fill(); |
| 174 | + }} |
| 175 | + }} |
| 176 | + // move indicator: small arrow toward move direction |
| 177 | + if(u.act==='Move'){{ |
| 178 | + const d=DIRV[u.dir]; |
| 179 | + if(d){{ |
| 180 | + ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=2; |
| 181 | + ctx.beginPath(); |
| 182 | + ctx.moveTo(cx,cy); ctx.lineTo(cx+d[0]*CELL*0.28, cy+d[1]*CELL*0.28); ctx.stroke(); |
| 183 | + }} |
| 184 | + }} |
| 185 | + // health pips (tiny bar under the unit) |
| 186 | + const bw=CELL-2*PAD, bx=px(u.x)+PAD, by=py(u.y)+CELL-PAD*0.5; |
| 187 | + ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(bx,by-3,bw,3); |
| 188 | + ctx.fillStyle='#eafff0'; ctx.fillRect(bx,by-3,bw*(u.hp/MAXHP),3); |
| 189 | + }}); |
| 190 | + document.getElementById('t').textContent=f.turn; |
| 191 | + document.getElementById('scrub').value=i; |
| 192 | + // side panel: per-team unit count + total health |
| 193 | + const agg={{Blue:{{n:0,hp:0}},Red:{{n:0,hp:0}}}}; |
| 194 | + f.units.forEach(u=>{{ if(agg[u.team]){{ agg[u.team].n++; agg[u.team].hp+=u.hp; }} }}); |
| 195 | + document.getElementById('teams').innerHTML=['Blue','Red'].map(tm=>{{ |
| 196 | + const a=agg[tm], dead=a.n===0, maxhp=8*MAXHP; |
| 197 | + return `<div class="team ${{dead?'dead':''}}"> |
| 198 | + <div class="tname"><span class="sw" style="background:${{COL[tm]}}"></span>${{NAMES[tm]}} <span class="muted">(${{tm}})</span></div> |
| 199 | + <div class="stat"><span>units</span><b>${{a.n}}</b></div> |
| 200 | + <div class="stat"><span>total health</span><b>${{a.hp}}</b></div> |
| 201 | + <div class="hb"><span class="hf" style="width:${{Math.min(100,100*a.hp/maxhp)}}%;background:${{COL[tm]}}"></span></div> |
| 202 | + </div>`; |
| 203 | + }}).join(''); |
| 204 | + document.getElementById('winner').textContent = (i===F.length-1)?('Winner: '+(G.draw?'TIE':G.winner)) : ''; |
| 205 | +}} |
| 206 | +function go(n){{ i=Math.max(0,Math.min(F.length-1,n)); draw(); }} |
| 207 | +function play(){{ playing=!playing; document.getElementById('play').textContent=playing?'⏸ pause':'▶ play'; |
| 208 | + if(playing){{ timer=setInterval(()=>{{ if(i>=F.length-1){{play();return;}} go(i+1); }}, 1000/ +document.getElementById('speed').value); }} |
| 209 | + else clearInterval(timer); }} |
| 210 | +document.getElementById('play').onclick=play; |
| 211 | +document.getElementById('next').onclick=()=>go(i+1); |
| 212 | +document.getElementById('prev').onclick=()=>go(i-1); |
| 213 | +document.getElementById('first').onclick=()=>go(0); |
| 214 | +document.getElementById('last').onclick=()=>go(F.length-1); |
| 215 | +document.getElementById('scrub').oninput=(e)=>go(+e.target.value); |
| 216 | +document.getElementById('speed').oninput=()=>{{ if(playing){{play();play();}} }}; |
| 217 | +document.addEventListener('keydown',(e)=>{{ |
| 218 | + if(e.key==='ArrowRight')go(i+1); else if(e.key==='ArrowLeft')go(i-1); |
| 219 | + else if(e.key===' '){{e.preventDefault();play();}} |
| 220 | +}}); |
| 221 | +if(!CanvasRenderingContext2D.prototype.roundRect){{CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){{this.beginPath();this.moveTo(x+r,y);this.arcTo(x+w,y,x+w,y+h,r);this.arcTo(x+w,y+h,x,y+h,r);this.arcTo(x,y+h,x,y,r);this.arcTo(x,y,x+w,y,r);this.closePath();return this;}};}} |
| 222 | +draw(); |
| 223 | +</script></body></html>""" |
| 224 | + |
| 225 | + |
| 226 | +def main(): |
| 227 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 228 | + ap.add_argument("json", help="Path to a raw RobotRumble sim JSON (rumblebot run term --raw)") |
| 229 | + ap.add_argument("-o", "--out") |
| 230 | + ap.add_argument("--ascii", action="store_true", help="Dump frames to the terminal instead of HTML") |
| 231 | + ap.add_argument("--blue-name", help="Display name for the Blue (first) bot") |
| 232 | + ap.add_argument("--red-name", help="Display name for the Red (second) bot") |
| 233 | + a = ap.parse_args() |
| 234 | + |
| 235 | + g = load(a.json, a.blue_name, a.red_name) |
| 236 | + if not g["frames"]: |
| 237 | + print("No turn frames found in that file.", file=sys.stderr) |
| 238 | + sys.exit(1) |
| 239 | + if a.ascii: |
| 240 | + to_ascii(g) |
| 241 | + return |
| 242 | + out = a.out or str(Path(a.json).with_suffix(".html")) |
| 243 | + html = HTML.format(data=json.dumps(g), maxturn=len(g["frames"]) - 1, maxhp=MAX_HEALTH) |
| 244 | + Path(out).write_text(html) |
| 245 | + print(f"Wrote {out} ({len(g['frames'])} turns, winner={'TIE' if g['draw'] else g['winner']})") |
| 246 | + print(f"Open it in a browser: open {out}") |
| 247 | + |
| 248 | + |
| 249 | +if __name__ == "__main__": |
| 250 | + main() |
0 commit comments