|
| 1 | +"""Ants replay renderer. |
| 2 | +
|
| 3 | +Parses a per-game ``sim_*.json`` written by the engine and renders the toroidal grid |
| 4 | +from the spectator's view (no fog): water, food, hills, and every player's ants. |
| 5 | +
|
| 6 | +The JSON format (see engine.py ``write_replay``):: |
| 7 | +
|
| 8 | + {"rows":32, "cols":32, "num_players":2, "water":[[r,c], ...], |
| 9 | + "names":["p1","p2"], "winner": 0 | null, |
| 10 | + "frames":[{"t":0, "ants":[[r,c,owner], ...], "hills":[[r,c,owner], ...], |
| 11 | + "food":[[r,c], ...]}, ...]} |
| 12 | +
|
| 13 | +Water is static (recorded once). Each frame lists the live ants, living hills, and |
| 14 | +food. ``winner`` is the winning player id, or ``null`` for a draw. Grid cells are |
| 15 | +``[row, col]`` (row = y, col = x); the board wraps, but the renderer just draws it flat. |
| 16 | +""" |
| 17 | + |
| 18 | +from __future__ import annotations |
| 19 | + |
| 20 | +import json |
| 21 | + |
| 22 | +from codeclash.replay.base import ReplayData, ReplayRenderer |
| 23 | + |
| 24 | +DRAW_JS = """ |
| 25 | +const ARENA = (function(){ |
| 26 | + const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594']; |
| 27 | + const BG = '#0d1117', WATER = '#15304a', FOOD = '#e6edf3', LINE = 'rgba(255,255,255,0.04)'; |
| 28 | + let COLS, ROWS, CELL, WATERSET; |
| 29 | + function col(i){ return PAL[i % PAL.length]; } |
| 30 | + function setup(cv, G){ |
| 31 | + COLS = G.w; ROWS = G.h; |
| 32 | + CELL = Math.max(8, Math.min(22, Math.floor(640 / COLS))); |
| 33 | + cv.width = COLS * CELL; cv.height = ROWS * CELL; |
| 34 | + WATERSET = G.water || []; |
| 35 | + } |
| 36 | + function draw(ctx, cv, G, i){ |
| 37 | + const f = G.frames[i]; |
| 38 | + ctx.fillStyle = BG; ctx.fillRect(0, 0, cv.width, cv.height); |
| 39 | + // water |
| 40 | + ctx.fillStyle = WATER; |
| 41 | + WATERSET.forEach(w => ctx.fillRect(w[1]*CELL, w[0]*CELL, CELL, CELL)); |
| 42 | + // grid lines |
| 43 | + ctx.strokeStyle = LINE; ctx.lineWidth = 1; |
| 44 | + for(let x=0;x<=COLS;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); } |
| 45 | + for(let y=0;y<=ROWS;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); } |
| 46 | + // hills (large bordered square in the owner's color) |
| 47 | + (f.hills||[]).forEach(h => { |
| 48 | + const x = h[1]*CELL, y = h[0]*CELL; |
| 49 | + ctx.fillStyle = col(h[2]); ctx.globalAlpha = 0.35; ctx.fillRect(x, y, CELL, CELL); ctx.globalAlpha = 1; |
| 50 | + ctx.strokeStyle = col(h[2]); ctx.lineWidth = 2; ctx.strokeRect(x+1.5, y+1.5, CELL-3, CELL-3); |
| 51 | + }); |
| 52 | + // food (small white diamonds) |
| 53 | + ctx.fillStyle = FOOD; |
| 54 | + (f.food||[]).forEach(fd => { |
| 55 | + const cx = fd[1]*CELL + CELL/2, cy = fd[0]*CELL + CELL/2, s = Math.max(2, CELL*0.22); |
| 56 | + ctx.beginPath(); ctx.moveTo(cx, cy-s); ctx.lineTo(cx+s, cy); ctx.lineTo(cx, cy+s); ctx.lineTo(cx-s, cy); ctx.closePath(); ctx.fill(); |
| 57 | + }); |
| 58 | + // ants (filled rounded cells in the owner's color) |
| 59 | + (f.ants||[]).forEach(a => { |
| 60 | + ctx.fillStyle = col(a[2]); |
| 61 | + const x = a[1]*CELL, y = a[0]*CELL, p = Math.max(1, CELL*0.12); |
| 62 | + ctx.fillRect(x+p, y+p, CELL-2*p, CELL-2*p); |
| 63 | + }); |
| 64 | + } |
| 65 | + function side(G, i){ |
| 66 | + const f = G.frames[i], NM = G.names || [], n = G.num_players || NM.length; |
| 67 | + const done = i === G.frames.length - 1; |
| 68 | + const ants = {}, hills = {}; |
| 69 | + (f.ants||[]).forEach(a => ants[a[2]] = (ants[a[2]]||0) + 1); |
| 70 | + (f.hills||[]).forEach(h => hills[h[2]] = (hills[h[2]]||0) + 1); |
| 71 | + let rows = ''; |
| 72 | + for(let p=0;p<n;p++){ |
| 73 | + const dead = (ants[p]||0) === 0 && (hills[p]||0) === 0; |
| 74 | + rows += `<div class="team ${dead?'tdead':''}"><div class="tname">` |
| 75 | + + `<span class="sw" style="background:${col(p)}"></span>${NM[p] || ('player'+(p+1))}</div>` |
| 76 | + + `<div class="stat"><span>ants</span><b>${ants[p]||0}</b></div>` |
| 77 | + + `<div class="stat"><span>hills</span><b>${hills[p]||0}</b></div></div>`; |
| 78 | + } |
| 79 | + const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : ''; |
| 80 | + return rows |
| 81 | + + `<div class="stat"><span>turn</span><b>${f.turn} / ${G.max_turns||''}</b></div>` |
| 82 | + + (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : ''); |
| 83 | + } |
| 84 | + return {setup, draw, side}; |
| 85 | +})(); |
| 86 | +""" |
| 87 | + |
| 88 | + |
| 89 | +class AntsReplayer(ReplayRenderer): |
| 90 | + arena = "Ants" |
| 91 | + sim_glob = "sim_*.json" |
| 92 | + DRAW_JS = DRAW_JS |
| 93 | + |
| 94 | + def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData: |
| 95 | + log = json.loads(raw.decode(errors="replace")) |
| 96 | + rows = log.get("rows", 32) |
| 97 | + cols = log.get("cols", 32) |
| 98 | + n = log.get("num_players", 2) |
| 99 | + |
| 100 | + names = list(log.get("names", [])) |
| 101 | + if players: |
| 102 | + names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)] |
| 103 | + while len(names) < n: |
| 104 | + names.append(f"player{len(names) + 1}") |
| 105 | + |
| 106 | + frames = [ |
| 107 | + {"turn": fr.get("t", idx), "ants": fr.get("ants", []), "hills": fr.get("hills", []), "food": fr.get("food", [])} |
| 108 | + for idx, fr in enumerate(log.get("frames", [])) |
| 109 | + ] |
| 110 | + |
| 111 | + win = log.get("winner") |
| 112 | + draw = win is None |
| 113 | + winner = None if draw else names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win) |
| 114 | + |
| 115 | + return ReplayData( |
| 116 | + w=cols, |
| 117 | + h=rows, |
| 118 | + frames=frames, |
| 119 | + winner=winner, |
| 120 | + draw=draw, |
| 121 | + extra={ |
| 122 | + "names": names, |
| 123 | + "num_players": n, |
| 124 | + "max_turns": log.get("max_turns", 500), |
| 125 | + "water": log.get("water", []), |
| 126 | + }, |
| 127 | + ) |
| 128 | + |
| 129 | + def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None: |
| 130 | + log = json.loads(raw.decode(errors="replace")) |
| 131 | + win = log.get("winner") |
| 132 | + if win is None: |
| 133 | + return (None, True) |
| 134 | + names = list(log.get("names", [])) |
| 135 | + if players: |
| 136 | + names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)] |
| 137 | + name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win) |
| 138 | + return (name, False) |
0 commit comments