Skip to content

Commit 169a4be

Browse files
committed
Add replay for rr
1 parent 837ca8d commit 169a4be

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

scripts/replay_robotrumble.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)