|
| 1 | +"""RobotRumble replay renderer. |
| 2 | +
|
| 3 | +Parses a raw RobotRumble sim JSON (``rumblebot run term --raw``) into normalized playback |
| 4 | +data and draws the grid — terrain/walls, units colored by team with health-as-brightness, |
| 5 | +move/attack indicators, per-team unit + health totals. Ported from the former |
| 6 | +``scripts/replay_robotrumble.py``. |
| 7 | +
|
| 8 | +The raw JSON: a single object {winner, errors, turns:[...]}. Each turn has ``state.objs`` |
| 9 | +(grid objects: Terrain/Wall and Unit/Soldier, each with ``coords`` [x,y], ``team``, |
| 10 | +``health``), a ``state.turn`` index, and ``robot_actions`` (unit id -> |
| 11 | +{"Ok": {"type": "Move"|"Attack", "direction": ...}} or {"Ok": "None"}). Blue is the first |
| 12 | +(blue) bot, Red is the second. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import json |
| 18 | + |
| 19 | +from codeclash.replay.base import ReplayData, ReplayRenderer |
| 20 | + |
| 21 | +MAX_HEALTH = 5 |
| 22 | +TEAM_COLORS = {"Blue": "#3B78FF", "Red": "#E5484D"} |
| 23 | + |
| 24 | +DRAW_JS = """ |
| 25 | +const ARENA = (function(){ |
| 26 | + let W, H, CELL, PAD, COL, NAMES, MAXHP, WALLS, px, py; |
| 27 | + const DIRV = {North:[0,-1], South:[0,1], East:[1,0], West:[-1,0]}; |
| 28 | + function setup(cv, G){ |
| 29 | + W = G.w; H = G.h; COL = G.colors; NAMES = G.names; MAXHP = G.maxhp; WALLS = G.walls; |
| 30 | + CELL = Math.max(16, Math.min(40, Math.floor(640 / Math.max(W, H)))); PAD = CELL * 0.14; |
| 31 | + cv.width = W * CELL; cv.height = H * CELL; |
| 32 | + px = (x) => x * CELL; py = (y) => y * CELL; // RobotRumble: y=0 at top, render straight down |
| 33 | + } |
| 34 | + function draw(ctx, cv, G, i){ |
| 35 | + const f = G.frames[i]; |
| 36 | + ctx.clearRect(0, 0, cv.width, cv.height); |
| 37 | + ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1; |
| 38 | + for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CELL,0);ctx.lineTo(x*CELL,H*CELL);ctx.stroke();} |
| 39 | + for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CELL);ctx.lineTo(W*CELL,y*CELL);ctx.stroke();} |
| 40 | + ctx.fillStyle = '#2d333b'; |
| 41 | + WALLS.forEach(([x,y])=>{ctx.fillRect(px(x)+1,py(y)+1,CELL-2,CELL-2);}); |
| 42 | + f.units.forEach(u=>{ |
| 43 | + const base = COL[u.team] || '#888'; |
| 44 | + const frac = Math.max(0.28, u.hp / MAXHP); // health -> opacity (full hp brightest) |
| 45 | + ctx.globalAlpha = frac; |
| 46 | + ctx.fillStyle = base; |
| 47 | + const r = CELL * 0.28; |
| 48 | + ctx.beginPath(); ctx.roundRect(px(u.x)+PAD,py(u.y)+PAD,CELL-2*PAD,CELL-2*PAD,r); ctx.fill(); |
| 49 | + ctx.globalAlpha = 1; |
| 50 | + const cx = px(u.x)+CELL/2, cy = py(u.y)+CELL/2; |
| 51 | + if(u.act === 'Attack'){ |
| 52 | + ctx.strokeStyle = '#ffd21f'; ctx.lineWidth = 2; |
| 53 | + ctx.beginPath(); ctx.arc(cx,cy,CELL*0.42,0,7); ctx.stroke(); |
| 54 | + const d = DIRV[u.dir]; if(d){ |
| 55 | + ctx.fillStyle = '#ffd21f'; |
| 56 | + ctx.beginPath(); ctx.arc(cx+d[0]*CELL*0.5, cy+d[1]*CELL*0.5, CELL*0.12,0,7); ctx.fill(); |
| 57 | + } |
| 58 | + } |
| 59 | + if(u.act === 'Move'){ |
| 60 | + const d = DIRV[u.dir]; |
| 61 | + if(d){ |
| 62 | + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 2; |
| 63 | + ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx+d[0]*CELL*0.28, cy+d[1]*CELL*0.28); ctx.stroke(); |
| 64 | + } |
| 65 | + } |
| 66 | + const bw = CELL-2*PAD, bx = px(u.x)+PAD, by = py(u.y)+CELL-PAD*0.5; |
| 67 | + ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx,by-3,bw,3); |
| 68 | + ctx.fillStyle = '#eafff0'; ctx.fillRect(bx,by-3,bw*(u.hp/MAXHP),3); |
| 69 | + }); |
| 70 | + } |
| 71 | + function side(G, i){ |
| 72 | + const f = G.frames[i], COL = G.colors, NAMES = G.names, MAXHP = G.maxhp; |
| 73 | + const agg = {Blue:{n:0,hp:0}, Red:{n:0,hp:0}}; |
| 74 | + f.units.forEach(u=>{ if(agg[u.team]){ agg[u.team].n++; agg[u.team].hp += u.hp; } }); |
| 75 | + return ['Blue','Red'].map(tm=>{ |
| 76 | + const a = agg[tm], dead = a.n===0, maxhp = 8*MAXHP; |
| 77 | + return `<div class="team ${dead?'tdead':''}"> |
| 78 | + <div class="tname"><span class="sw" style="background:${COL[tm]}"></span>${NAMES[tm]} <span class="muted">(${tm})</span></div> |
| 79 | + <div class="stat"><span>units</span><b>${a.n}</b></div> |
| 80 | + <div class="stat"><span>total health</span><b>${a.hp}</b></div> |
| 81 | + <div class="hb"><span class="hf" style="width:${Math.min(100,100*a.hp/maxhp)}%;background:${COL[tm]}"></span></div> |
| 82 | + </div>`; |
| 83 | + }).join('') + '<div class="row muted" style="font-size:12px">move arrow \\u00b7 \\u2726 attacking \\u00b7 health = brightness</div>'; |
| 84 | + } |
| 85 | + return {setup, draw, side}; |
| 86 | +})(); |
| 87 | +""" |
| 88 | + |
| 89 | + |
| 90 | +class RobotRumbleReplayer(ReplayRenderer): |
| 91 | + arena = "RobotRumble" |
| 92 | + sim_glob = "sim*.json" # matches both sim_<i>.json and an ad-hoc flat sim.json |
| 93 | + DRAW_JS = DRAW_JS |
| 94 | + |
| 95 | + def parse(self, raw: bytes, players=None) -> ReplayData: |
| 96 | + data = json.loads(raw.decode()) |
| 97 | + turns_raw = data.get("turns", []) |
| 98 | + blue_name = players[0]["name"] if players and len(players) > 0 else None |
| 99 | + red_name = players[1]["name"] if players and len(players) > 1 else None |
| 100 | + |
| 101 | + # Walls (terrain) are static across the game — grab them from the first frame. |
| 102 | + walls = [] |
| 103 | + max_x = max_y = 0 |
| 104 | + if turns_raw: |
| 105 | + for o in turns_raw[0]["state"]["objs"].values(): |
| 106 | + x, y = o["coords"] |
| 107 | + max_x, max_y = max(max_x, x), max(max_y, y) |
| 108 | + if o["obj_type"] == "Terrain": |
| 109 | + walls.append([x, y]) |
| 110 | + |
| 111 | + frames = [] |
| 112 | + for t in turns_raw: |
| 113 | + objs = t["state"]["objs"] |
| 114 | + actions = t.get("robot_actions") or {} |
| 115 | + units = [] |
| 116 | + for oid, o in objs.items(): |
| 117 | + if o["obj_type"] != "Unit": |
| 118 | + continue |
| 119 | + act = actions.get(oid) |
| 120 | + atype, adir = None, None |
| 121 | + if isinstance(act, dict) and "Ok" in act: |
| 122 | + ok = act["Ok"] |
| 123 | + if isinstance(ok, dict): |
| 124 | + atype, adir = ok.get("type"), ok.get("direction") |
| 125 | + units.append( |
| 126 | + { |
| 127 | + "team": o.get("team"), |
| 128 | + "hp": o.get("health", 0), |
| 129 | + "x": o["coords"][0], |
| 130 | + "y": o["coords"][1], |
| 131 | + "act": atype, |
| 132 | + "dir": adir, |
| 133 | + } |
| 134 | + ) |
| 135 | + frames.append({"turn": t["state"].get("turn"), "units": units}) |
| 136 | + |
| 137 | + winner_raw = data.get("winner") |
| 138 | + names = {"Blue": blue_name or "Blue", "Red": red_name or "Red"} |
| 139 | + if winner_raw in ("Blue", "Red"): |
| 140 | + winner, draw = names[winner_raw], False |
| 141 | + else: |
| 142 | + winner, draw = None, True |
| 143 | + |
| 144 | + return ReplayData( |
| 145 | + w=max_x + 1, |
| 146 | + h=max_y + 1, |
| 147 | + frames=frames, |
| 148 | + winner=winner, |
| 149 | + draw=draw, |
| 150 | + extra={ |
| 151 | + "walls": walls, |
| 152 | + "names": names, |
| 153 | + "colors": TEAM_COLORS, |
| 154 | + "maxhp": MAX_HEALTH, |
| 155 | + "errors": data.get("errors", {}), |
| 156 | + }, |
| 157 | + ) |
| 158 | + |
| 159 | + def ascii(self, data: ReplayData) -> str: |
| 160 | + W, H = data.w, data.h |
| 161 | + names = data.extra["names"] |
| 162 | + wall_set = {(x, y) for x, y in data.extra["walls"]} |
| 163 | + out = [] |
| 164 | + for f in data.frames: |
| 165 | + grid = [["#" if (x, y) in wall_set else "." for x in range(W)] for y in range(H)] |
| 166 | + for u in f["units"]: |
| 167 | + grid[u["y"]][u["x"]] = "B" if u["team"] == "Blue" else "R" |
| 168 | + counts = {"Blue": 0, "Red": 0} |
| 169 | + hp = {"Blue": 0, "Red": 0} |
| 170 | + for u in f["units"]: |
| 171 | + counts[u["team"]] += 1 |
| 172 | + hp[u["team"]] += u["hp"] |
| 173 | + out.append( |
| 174 | + f"\n--- turn {f['turn']} --- " |
| 175 | + f"{names['Blue']}(B): {counts['Blue']} units / {hp['Blue']} hp " |
| 176 | + f"{names['Red']}(R): {counts['Red']} units / {hp['Red']} hp" |
| 177 | + ) |
| 178 | + for row in grid: |
| 179 | + out.append(" ".join(row)) |
| 180 | + out.append(f"\nWinner: {'TIE' if data.draw else data.winner}") |
| 181 | + return "\n".join(out) |
0 commit comments