|
| 1 | +"""Bomberland replay renderer. |
| 2 | +
|
| 3 | +Parses a per-game Bomberland trace (written by ``run_bomberland.py`` as |
| 4 | +``sim_<idx>.json``) into normalized playback data and draws the grid: metal / |
| 5 | +wood walls, bombs, blasts, and units colored per agent with health-as-brightness. |
| 6 | +A ``side(G, i)`` panel shows per-agent unit counts / total hp / current tick. |
| 7 | +
|
| 8 | +The trace JSON is a single object ``{width, height, winner, sim, player_order, |
| 9 | +frames:[...]}``. Each frame has ``tick``, ``units`` (each with ``unit_id``, |
| 10 | +``agent_id``, ``coordinates`` [x,y], ``hp``) and ``entities`` (each with ``type`` |
| 11 | +in {"m" metal, "w" wood, "b" bomb, "x" blast} and ``coordinates`` [x,y]; bombs |
| 12 | +additionally carry ``timer`` / ``owner`` / ``blast_diameter``, blasts carry |
| 13 | +``ttl``). ``player_order`` is the two agent ids for this sim; the first is Blue, |
| 14 | +the second Red. |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import json |
| 20 | + |
| 21 | +from codeclash.replay.base import ReplayData, ReplayRenderer |
| 22 | + |
| 23 | +START_HP = 3 |
| 24 | +TEAM_COLORS = {"Blue": "#3B78FF", "Red": "#E5484D"} |
| 25 | + |
| 26 | +DRAW_JS = """ |
| 27 | +const ARENA = (function(){ |
| 28 | + let W, H, CELL, PAD, COL, NAMES, AGENTS, MAXHP; |
| 29 | + function teamOf(agentId){ return (AGENTS[0] === agentId) ? 'Blue' : 'Red'; } |
| 30 | + function setup(cv, G){ |
| 31 | + W = G.w; H = G.h; COL = G.colors; NAMES = G.names; AGENTS = G.agents; MAXHP = G.maxhp; |
| 32 | + CELL = Math.max(16, Math.min(40, Math.floor(640 / Math.max(W, H)))); PAD = CELL * 0.14; |
| 33 | + cv.width = W * CELL; cv.height = H * CELL; |
| 34 | + } |
| 35 | + function px(x){ return x * CELL; } |
| 36 | + function py(y){ return y * CELL; } |
| 37 | + function draw(ctx, cv, G, i){ |
| 38 | + const f = G.frames[i]; |
| 39 | + ctx.clearRect(0, 0, cv.width, cv.height); |
| 40 | + ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, cv.width, cv.height); |
| 41 | + ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1; |
| 42 | + for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CELL,0);ctx.lineTo(x*CELL,H*CELL);ctx.stroke();} |
| 43 | + for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CELL);ctx.lineTo(W*CELL,y*CELL);ctx.stroke();} |
| 44 | + // World entities: draw walls first, then blasts, then bombs. |
| 45 | + const bombs = [], blasts = []; |
| 46 | + f.entities.forEach(e=>{ |
| 47 | + const x = e.coordinates[0], y = e.coordinates[1]; |
| 48 | + if(e.type === 'm'){ |
| 49 | + ctx.fillStyle = '#6e7681'; |
| 50 | + ctx.fillRect(px(x)+1, py(y)+1, CELL-2, CELL-2); |
| 51 | + } else if(e.type === 'w'){ |
| 52 | + ctx.fillStyle = '#8a5a2b'; |
| 53 | + ctx.fillRect(px(x)+2, py(y)+2, CELL-4, CELL-4); |
| 54 | + ctx.strokeStyle = '#5c3a1c'; ctx.lineWidth = 1; |
| 55 | + ctx.strokeRect(px(x)+2, py(y)+2, CELL-4, CELL-4); |
| 56 | + } else if(e.type === 'b'){ |
| 57 | + bombs.push(e); |
| 58 | + } else if(e.type === 'x'){ |
| 59 | + blasts.push(e); |
| 60 | + } |
| 61 | + }); |
| 62 | + blasts.forEach(e=>{ |
| 63 | + const x = e.coordinates[0], y = e.coordinates[1]; |
| 64 | + ctx.fillStyle = 'rgba(255,140,0,0.55)'; |
| 65 | + ctx.fillRect(px(x)+1, py(y)+1, CELL-2, CELL-2); |
| 66 | + ctx.fillStyle = 'rgba(255,220,60,0.7)'; |
| 67 | + ctx.beginPath(); ctx.arc(px(x)+CELL/2, py(y)+CELL/2, CELL*0.22, 0, 7); ctx.fill(); |
| 68 | + }); |
| 69 | + bombs.forEach(e=>{ |
| 70 | + const x = e.coordinates[0], y = e.coordinates[1]; |
| 71 | + const cx = px(x)+CELL/2, cy = py(y)+CELL/2; |
| 72 | + ctx.fillStyle = '#0b0b0b'; |
| 73 | + ctx.beginPath(); ctx.arc(cx, cy, CELL*0.3, 0, 7); ctx.fill(); |
| 74 | + ctx.strokeStyle = '#ff5722'; ctx.lineWidth = 2; |
| 75 | + ctx.beginPath(); ctx.arc(cx, cy, CELL*0.3, 0, 7); ctx.stroke(); |
| 76 | + if(e.timer != null){ |
| 77 | + ctx.fillStyle = '#ffd21f'; ctx.font = `${Math.floor(CELL*0.34)}px system-ui`; |
| 78 | + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
| 79 | + ctx.fillText(String(e.timer), cx, cy); |
| 80 | + } |
| 81 | + }); |
| 82 | + // Units on top. |
| 83 | + f.units.forEach(u=>{ |
| 84 | + if(u.hp <= 0) return; |
| 85 | + const tm = teamOf(u.agent_id); |
| 86 | + const base = COL[tm] || '#888'; |
| 87 | + const frac = Math.max(0.3, u.hp / MAXHP); |
| 88 | + ctx.globalAlpha = frac; |
| 89 | + ctx.fillStyle = base; |
| 90 | + const r = CELL * 0.28; |
| 91 | + ctx.beginPath(); ctx.roundRect(px(u.x)+PAD, py(u.y)+PAD, CELL-2*PAD, CELL-2*PAD, r); ctx.fill(); |
| 92 | + ctx.globalAlpha = 1; |
| 93 | + const bw = CELL-2*PAD, bx = px(u.x)+PAD, by = py(u.y)+CELL-PAD*0.5; |
| 94 | + ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx, by-3, bw, 3); |
| 95 | + ctx.fillStyle = '#eafff0'; ctx.fillRect(bx, by-3, bw*Math.min(1, u.hp/MAXHP), 3); |
| 96 | + }); |
| 97 | + } |
| 98 | + function side(G, i){ |
| 99 | + const f = G.frames[i], COL = G.colors, NAMES = G.names, AGENTS = G.agents, MAXHP = G.maxhp; |
| 100 | + const agg = {Blue:{n:0,hp:0}, Red:{n:0,hp:0}}; |
| 101 | + let totalUnits = {Blue:0, Red:0}; |
| 102 | + f.units.forEach(u=>{ |
| 103 | + const tm = (AGENTS[0] === u.agent_id) ? 'Blue' : 'Red'; |
| 104 | + totalUnits[tm]++; |
| 105 | + if(u.hp > 0){ agg[tm].n++; agg[tm].hp += u.hp; } |
| 106 | + }); |
| 107 | + return ['Blue','Red'].map(tm=>{ |
| 108 | + const a = agg[tm], dead = a.n===0, maxhp = Math.max(1, totalUnits[tm]) * MAXHP; |
| 109 | + return `<div class="team ${dead?'tdead':''}"> |
| 110 | + <div class="tname"><span class="sw" style="background:${COL[tm]}"></span>${NAMES[tm]} <span class="muted">(${tm})</span></div> |
| 111 | + <div class="stat"><span>units alive</span><b>${a.n}</b></div> |
| 112 | + <div class="stat"><span>total hp</span><b>${a.hp}</b></div> |
| 113 | + <div class="hb"><span class="hf" style="width:${Math.min(100,100*a.hp/maxhp)}%;background:${COL[tm]}"></span></div> |
| 114 | + </div>`; |
| 115 | + }).join('') + `<div class="row muted" style="font-size:12px">tick ${f.tick} \\u00b7 hp = brightness \\u00b7 \\ud83d\\udca3 bomb (timer) \\u00b7 \\ud83d\\udd25 blast</div>`; |
| 116 | + } |
| 117 | + return {setup, draw, side}; |
| 118 | +})(); |
| 119 | +""" |
| 120 | + |
| 121 | + |
| 122 | +class BomberlandReplayer(ReplayRenderer): |
| 123 | + arena = "Bomberland" |
| 124 | + sim_glob = "sim_*.json" |
| 125 | + DRAW_JS = DRAW_JS |
| 126 | + |
| 127 | + def parse(self, raw: bytes, players=None) -> ReplayData: |
| 128 | + data = json.loads(raw.decode()) |
| 129 | + width = int(data.get("width", 0)) |
| 130 | + height = int(data.get("height", 0)) |
| 131 | + frames_raw = data.get("frames", []) |
| 132 | + player_order = data.get("player_order", []) |
| 133 | + |
| 134 | + frames = [] |
| 135 | + for fr in frames_raw: |
| 136 | + units = [] |
| 137 | + for u in fr.get("units", []): |
| 138 | + coords = u.get("coordinates", [0, 0]) |
| 139 | + units.append( |
| 140 | + { |
| 141 | + "unit_id": u.get("unit_id"), |
| 142 | + "agent_id": u.get("agent_id"), |
| 143 | + "x": coords[0], |
| 144 | + "y": coords[1], |
| 145 | + "hp": u.get("hp", 0), |
| 146 | + } |
| 147 | + ) |
| 148 | + entities = [] |
| 149 | + for e in fr.get("entities", []): |
| 150 | + coords = e.get("coordinates", [0, 0]) |
| 151 | + ent = {"type": e.get("type"), "coordinates": [coords[0], coords[1]]} |
| 152 | + if "timer" in e: |
| 153 | + ent["timer"] = e["timer"] |
| 154 | + if "owner" in e: |
| 155 | + ent["owner"] = e["owner"] |
| 156 | + if "ttl" in e: |
| 157 | + ent["ttl"] = e["ttl"] |
| 158 | + entities.append(ent) |
| 159 | + frames.append({"turn": fr.get("tick"), "tick": fr.get("tick"), "units": units, "entities": entities}) |
| 160 | + |
| 161 | + # First player_order id -> Blue, second -> Red. Fall back to display names. |
| 162 | + blue_id = player_order[0] if len(player_order) > 0 else "Blue" |
| 163 | + red_id = player_order[1] if len(player_order) > 1 else "Red" |
| 164 | + names = {"Blue": blue_id, "Red": red_id} |
| 165 | + |
| 166 | + winner_raw = data.get("winner") |
| 167 | + if winner_raw == blue_id: |
| 168 | + winner, draw = names["Blue"], False |
| 169 | + elif winner_raw == red_id: |
| 170 | + winner, draw = names["Red"], False |
| 171 | + else: # "TIE" or unknown |
| 172 | + winner, draw = None, True |
| 173 | + |
| 174 | + return ReplayData( |
| 175 | + w=width, |
| 176 | + h=height, |
| 177 | + frames=frames, |
| 178 | + winner=winner, |
| 179 | + draw=draw, |
| 180 | + extra={ |
| 181 | + "names": names, |
| 182 | + "colors": TEAM_COLORS, |
| 183 | + "agents": [blue_id, red_id], |
| 184 | + "maxhp": START_HP, |
| 185 | + }, |
| 186 | + ) |
0 commit comments