|
| 1 | +"""RoboCode replay renderer. |
| 2 | +
|
| 3 | +Parses a compact ``sim_*.jsonl`` (produced by :mod:`codeclash.arenas.robocode.trace` from |
| 4 | +Robocode's ``-recordXML``) into normalized playback data and draws one Robocode round: the |
| 5 | +battlefield, each tank with its body/gun/radar headings and energy, and bullets in flight. |
| 6 | +
|
| 7 | +The jsonl format (see ``trace.py``): a header line ``{w, h, round, robots:{id:name}}``, |
| 8 | +per-turn frames ``{t, u:[{i,x,y,e,bh,gh,rh,v,s}], b:[{o,x,y,p,s}]}``, and a final result |
| 9 | +line ``{winner, draw}``. Robocode uses a y-up field (origin bottom-left) and headings in |
| 10 | +radians measured clockwise from north, both handled in the draw code. |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +import json |
| 16 | + |
| 17 | +from codeclash.replay.base import ReplayData, ReplayRenderer |
| 18 | + |
| 19 | +PALETTE = ["#3B78FF", "#E5484D", "#30A46C", "#F5A623", "#8E4EC6", "#12A594", "#E93D82", "#F76B15"] |
| 20 | + |
| 21 | +DRAW_JS = """ |
| 22 | +const ARENA = (function(){ |
| 23 | + let W, H, S, COL, NAMES, cw, ch; |
| 24 | + const R = 18; // Robocode robots are ~36px across |
| 25 | + function setup(cv, G){ |
| 26 | + W = G.w; H = G.h; COL = G.colors; NAMES = G.names; |
| 27 | + S = Math.min(760 / W, 560 / H); |
| 28 | + cw = cv.width = Math.round(W * S); ch = cv.height = Math.round(H * S); |
| 29 | + } |
| 30 | + const px = (x) => x * S, py = (y) => ch - y * S; // y-up world -> y-down canvas |
| 31 | + // Robocode heading: radians clockwise from north. Unit vector on the y-down canvas. |
| 32 | + const dx = (a) => Math.sin(a), dy = (a) => -Math.cos(a); |
| 33 | + function tank(ctx, u){ |
| 34 | + const cx = px(u.x), cy = py(u.y), c = COL[u.i] || '#888', dead = (u.s === 'DEAD'); |
| 35 | + // radar (thin, faint) and gun (barrel) as rays from the center |
| 36 | + if(!dead){ |
| 37 | + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; |
| 38 | + ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx + dx(u.rh)*R*2.4, cy + dy(u.rh)*R*2.4); ctx.stroke(); |
| 39 | + } |
| 40 | + // body: rounded rect rotated to bodyHeading (north = up before rotation) |
| 41 | + ctx.save(); ctx.translate(cx, cy); ctx.rotate(u.bh); |
| 42 | + ctx.globalAlpha = dead ? 0.25 : 1; |
| 43 | + ctx.fillStyle = c; |
| 44 | + const w = R*1.5, h = R*1.7; |
| 45 | + ctx.beginPath(); ctx.roundRect(-w/2, -h/2, w, h, 4); ctx.fill(); |
| 46 | + ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(-w/2, -h/2, w, h*0.18); // "front" band |
| 47 | + ctx.restore(); |
| 48 | + if(!dead){ |
| 49 | + ctx.strokeStyle = '#e6edf3'; ctx.lineWidth = 3; ctx.lineCap = 'round'; |
| 50 | + ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx + dx(u.gh)*R*1.9, cy + dy(u.gh)*R*1.9); ctx.stroke(); |
| 51 | + ctx.lineCap = 'butt'; |
| 52 | + } else { |
| 53 | + ctx.strokeStyle = '#e5484d'; ctx.lineWidth = 2; |
| 54 | + ctx.beginPath(); ctx.moveTo(cx-6,cy-6); ctx.lineTo(cx+6,cy+6); ctx.moveTo(cx+6,cy-6); ctx.lineTo(cx-6,cy+6); ctx.stroke(); |
| 55 | + } |
| 56 | + ctx.globalAlpha = 1; |
| 57 | + } |
| 58 | + function draw(ctx, cv, G, i){ |
| 59 | + const f = G.frames[i]; |
| 60 | + ctx.clearRect(0,0,cw,ch); |
| 61 | + ctx.fillStyle = 'rgba(255,255,255,0.02)'; ctx.fillRect(0,0,cw,ch); |
| 62 | + (f.b || []).forEach(b=>{ |
| 63 | + const c = COL[b.o] || '#ffd21f', hit = (b.s === 'HIT_VICTIM' || b.s === 'EXPLODED'); |
| 64 | + ctx.fillStyle = hit ? '#ffd21f' : c; |
| 65 | + ctx.globalAlpha = hit ? 0.9 : 1; |
| 66 | + ctx.beginPath(); ctx.arc(px(b.x), py(b.y), hit ? 5 : (1.6 + (b.p||1)*1.3), 0, 7); ctx.fill(); |
| 67 | + ctx.globalAlpha = 1; |
| 68 | + }); |
| 69 | + (f.u || []).forEach(u=>tank(ctx, u)); |
| 70 | + } |
| 71 | + function side(G, i){ |
| 72 | + const f = G.frames[i], ids = Object.keys(G.names); |
| 73 | + const byId = {}; (f.u || []).forEach(u=>byId[u.i]=u); |
| 74 | + return ids.map(id=>{ |
| 75 | + const u = byId[id], dead = !u || u.s === 'DEAD' || u.e <= 0; |
| 76 | + const e = u ? Math.max(0, u.e) : 0, pct = Math.min(100, e); |
| 77 | + return `<div class="sn ${dead?'dead':''}"><span class="sw" style="background:${G.colors[id]}"></span> |
| 78 | + <span style="min-width:120px">${NAMES[id]}</span> |
| 79 | + <span class="hb"><span class="hf" style="width:${pct}%;background:${G.colors[id]}"></span></span> |
| 80 | + <span>${dead?'\\u2620':e.toFixed(0)}</span></div>`; |
| 81 | + }).join('') + '<div class="row muted" style="font-size:12px">bar = energy \\u00b7 white ray = gun \\u00b7 faint ray = radar</div>'; |
| 82 | + } |
| 83 | + return {setup, draw, side}; |
| 84 | +})(); |
| 85 | +""" |
| 86 | + |
| 87 | + |
| 88 | +class RoboCodeReplayer(ReplayRenderer): |
| 89 | + arena = "RoboCode" |
| 90 | + sim_glob = "sim_*.jsonl" |
| 91 | + DRAW_JS = DRAW_JS |
| 92 | + |
| 93 | + def parse(self, raw: bytes, players=None) -> ReplayData: |
| 94 | + rows = [json.loads(line) for line in raw.decode().splitlines() if line.strip()] |
| 95 | + if not rows: |
| 96 | + return ReplayData(w=800, h=600, frames=[]) |
| 97 | + header = rows[0] |
| 98 | + result = rows[-1] if isinstance(rows[-1], dict) and "winner" in rows[-1] else {} |
| 99 | + robots = {str(k): v for k, v in header.get("robots", {}).items()} |
| 100 | + # Stable color per robot id, in id order. |
| 101 | + colors = {rid: PALETTE[i % len(PALETTE)] for i, rid in enumerate(sorted(robots, key=int))} |
| 102 | + |
| 103 | + frames = [] |
| 104 | + for r in rows: |
| 105 | + if not (isinstance(r, dict) and "t" in r): |
| 106 | + continue |
| 107 | + frames.append({"turn": r["t"], "u": r.get("u", []), "b": r.get("b", [])}) |
| 108 | + |
| 109 | + return ReplayData( |
| 110 | + w=header.get("w", 800), |
| 111 | + h=header.get("h", 600), |
| 112 | + frames=frames, |
| 113 | + winner=result.get("winner"), |
| 114 | + draw=result.get("draw", False), |
| 115 | + extra={"colors": colors, "names": robots}, |
| 116 | + ) |
| 117 | + |
| 118 | + def peek_winner(self, raw: bytes, players=None) -> tuple[str | None, bool] | None: |
| 119 | + """Read just the trailing result line — no frame building.""" |
| 120 | + for line in reversed(raw.decode().splitlines()): |
| 121 | + line = line.strip() |
| 122 | + if not line: |
| 123 | + continue |
| 124 | + try: |
| 125 | + obj = json.loads(line) |
| 126 | + except ValueError: |
| 127 | + return None |
| 128 | + if isinstance(obj, dict) and "winner" in obj: |
| 129 | + return obj.get("winner"), obj.get("draw", False) |
| 130 | + return None |
| 131 | + return None |
0 commit comments