|
| 1 | +"""PaintVolley replay renderer. |
| 2 | +
|
| 3 | +Parses a per-game ``sim_*.json`` written by the engine into one frame per recorded |
| 4 | +tick and renders the painted tile grid, the flying balls, and the helmet characters |
| 5 | +patrolling the bottom, with a live scoreboard. |
| 6 | +
|
| 7 | +The JSON format (see engine.py ``write_replay``):: |
| 8 | +
|
| 9 | + {"cols":32, "rows":24, "num_players":2, "max_ticks":1500, |
| 10 | + "names":["p1","p2"], "winner": 0 | null, "final_scores": {"0":399,"1":255}, |
| 11 | + "frames":[{"tick":0, "grid":["...01.",...], "balls":[{"x","y","c"}], |
| 12 | + "players":[{"x","y","id"}], "scores":{"0":n,"1":m}}, ...]} |
| 13 | +
|
| 14 | +Each frame's ``grid`` is one string per row; each char is the tile owner encoded in |
| 15 | +base36 (``'0'``..), or ``'.'`` for a neutral (unpainted) tile. Ball/player ``color`` |
| 16 | +values are player ids (0-based) or ``-1`` for an unclaimed ball. ``winner`` is the |
| 17 | +winning player id, or ``null`` for a draw. |
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import json |
| 23 | + |
| 24 | +from codeclash.replay.base import ReplayData, ReplayRenderer |
| 25 | + |
| 26 | +DRAW_JS = """ |
| 27 | +const ARENA = (function(){ |
| 28 | + // Visual constants mirror the engine (BALL_RADIUS, PLAYER_HALF_WIDTH, PLAYER_HEIGHT). |
| 29 | + const CELL = 18, BALL_R = 0.6, BODY_HW = 0.9, PH = 2.5; |
| 30 | + // Player palette (index by player id); neutral tiles use the dark background. |
| 31 | + const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594']; |
| 32 | + const NEUTRAL = '#161b22'; |
| 33 | + let COLS, ROWS; |
| 34 | + function col(c){ return c < 0 ? NEUTRAL : PAL[c % PAL.length]; } |
| 35 | + function setup(cv, G){ |
| 36 | + COLS = G.w; ROWS = G.h; |
| 37 | + cv.width = COLS * CELL; cv.height = ROWS * CELL; |
| 38 | + } |
| 39 | + function draw(ctx, cv, G, i){ |
| 40 | + const f = G.frames[i]; |
| 41 | + // background |
| 42 | + ctx.fillStyle = NEUTRAL; ctx.fillRect(0, 0, cv.width, cv.height); |
| 43 | + // painted tiles (each row is a string; '.' = neutral, else base36 owner id) |
| 44 | + for(let r=0;r<ROWS;r++){ |
| 45 | + const row = f.grid[r]; |
| 46 | + for(let c=0;c<COLS;c++){ |
| 47 | + const ch = row[c]; |
| 48 | + if(ch !== '.'){ ctx.fillStyle = col(parseInt(ch, 36)); ctx.fillRect(c*CELL, r*CELL, CELL, CELL); } |
| 49 | + } |
| 50 | + } |
| 51 | + // faint grid lines |
| 52 | + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; |
| 53 | + for(let c=0;c<=COLS;c++){ ctx.beginPath(); ctx.moveTo(c*CELL,0); ctx.lineTo(c*CELL,cv.height); ctx.stroke(); } |
| 54 | + for(let r=0;r<=ROWS;r++){ ctx.beginPath(); ctx.moveTo(0,r*CELL); ctx.lineTo(cv.width,r*CELL); ctx.stroke(); } |
| 55 | + // players — each a distinct fixed-size character (helmet head + body) whose feet |
| 56 | + // rest at helmet_y + PH. When it jumps, helmet_y decreases so the whole figure |
| 57 | + // lifts off the floor (a gap opens under its feet) instead of stretching. A shadow |
| 58 | + // on the floor shrinks with height to sell the jump. |
| 59 | + const FLOOR = ROWS*CELL; |
| 60 | + (f.players||[]).forEach(p=>{ |
| 61 | + const px = p.x*CELL, py = p.y*CELL, bw = BODY_HW*2*CELL, cc = col(p.id); |
| 62 | + const feet = py + PH*CELL; // fixed offset below the helmet |
| 63 | + const lift = Math.max(0, FLOOR - feet); // how far off the floor (px) |
| 64 | + // floor shadow (shrinks as the character rises) |
| 65 | + const sw = (bw/2) * (1 - Math.min(0.6, lift/(6*CELL))); |
| 66 | + ctx.fillStyle = 'rgba(0,0,0,0.28)'; |
| 67 | + ctx.beginPath(); ctx.ellipse(px, FLOOR-3, Math.max(3, sw), 3.5, 0, 0, 7); ctx.fill(); |
| 68 | + // body (fixed height, from just under the head down to the feet) |
| 69 | + const hr = bw/2, hcy = py + hr*0.85; |
| 70 | + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 2; ctx.fillStyle = cc; |
| 71 | + ctx.beginPath(); ctx.roundRect(px-bw/2, hcy, bw, Math.max(6, feet-hcy), Math.min(7, bw/2)); ctx.fill(); ctx.stroke(); |
| 72 | + // head + helmet rim |
| 73 | + ctx.beginPath(); ctx.arc(px, hcy, hr, 0, 7); ctx.fillStyle = cc; ctx.fill(); ctx.stroke(); |
| 74 | + ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 3; |
| 75 | + ctx.beginPath(); ctx.arc(px, hcy, hr, Math.PI, 2*Math.PI); ctx.stroke(); |
| 76 | + // eyes |
| 77 | + ctx.fillStyle = '#fff'; |
| 78 | + ctx.beginPath(); ctx.arc(px-hr*0.33, hcy+2, 2.4, 0, 7); ctx.arc(px+hr*0.33, hcy+2, 2.4, 0, 7); ctx.fill(); |
| 79 | + ctx.fillStyle = '#111'; |
| 80 | + ctx.beginPath(); ctx.arc(px-hr*0.33, hcy+2, 1.1, 0, 7); ctx.arc(px+hr*0.33, hcy+2, 1.1, 0, 7); ctx.fill(); |
| 81 | + }); |
| 82 | + // balls — glow ring + body + highlight so the color (and who owns it) reads clearly |
| 83 | + (f.balls||[]).forEach(b=>{ |
| 84 | + const bx = b.x*CELL, by = b.y*CELL, br = BALL_R*CELL, cc = b.c < 0 ? '#c9d1d9' : col(b.c); |
| 85 | + ctx.globalAlpha = 0.25; ctx.fillStyle = cc; |
| 86 | + ctx.beginPath(); ctx.arc(bx, by, br+3, 0, 7); ctx.fill(); |
| 87 | + ctx.globalAlpha = 1; |
| 88 | + ctx.beginPath(); ctx.arc(bx, by, br, 0, 7); ctx.fillStyle = cc; ctx.fill(); |
| 89 | + ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2; ctx.stroke(); |
| 90 | + ctx.beginPath(); ctx.arc(bx-br*0.3, by-br*0.3, br*0.35, 0, 7); ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.fill(); |
| 91 | + }); |
| 92 | + } |
| 93 | + function side(G, i){ |
| 94 | + const f = G.frames[i], NM = G.names || [], total = COLS*ROWS; |
| 95 | + const done = i === G.frames.length - 1; |
| 96 | + let rows = ''; |
| 97 | + const n = G.num_players || NM.length; |
| 98 | + for(let p=0;p<n;p++){ |
| 99 | + const s = (f.scores && (f.scores[p] != null ? f.scores[p] : f.scores[String(p)])) || 0; |
| 100 | + const pct = total ? (100*s/total).toFixed(1) : '0.0'; |
| 101 | + rows += `<div class="team"><div class="tname">` |
| 102 | + + `<span class="sw" style="background:${col(p)}"></span>` |
| 103 | + + `${NM[p] || ('player'+(p+1))}</div>` |
| 104 | + + `<div class="stat"><span>tiles</span><b>${s}</b></div>` |
| 105 | + + `<div class="stat"><span>share</span><b>${pct}%</b></div></div>`; |
| 106 | + } |
| 107 | + const res = done ? (G.draw ? 'TIE' : (G.winner || '\\u2014')) : ''; |
| 108 | + return rows |
| 109 | + + `<div class="stat"><span>tick</span><b>${f.turn} / ${G.max_ticks||''}</b></div>` |
| 110 | + + (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : ''); |
| 111 | + } |
| 112 | + return {setup, draw, side}; |
| 113 | +})(); |
| 114 | +""" |
| 115 | + |
| 116 | + |
| 117 | +class PaintVolleyReplayer(ReplayRenderer): |
| 118 | + arena = "PaintVolley" |
| 119 | + sim_glob = "sim_*.json" |
| 120 | + DRAW_JS = DRAW_JS |
| 121 | + |
| 122 | + def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None: |
| 123 | + """Cheap per-sim winner for the index (reads the top-level ``winner`` without |
| 124 | + touching the frames). ``winner`` is a 0-based player id, or null for a draw.""" |
| 125 | + log = json.loads(raw.decode(errors="replace")) |
| 126 | + win = log.get("winner") |
| 127 | + if win is None: |
| 128 | + return (None, True) |
| 129 | + names = list(log.get("names", [])) |
| 130 | + if players: |
| 131 | + names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)] |
| 132 | + name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win) |
| 133 | + return (name, False) |
| 134 | + |
| 135 | + def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData: |
| 136 | + log = json.loads(raw.decode(errors="replace")) |
| 137 | + cols = log.get("cols", 32) |
| 138 | + rows = log.get("rows", 24) |
| 139 | + n = log.get("num_players", 2) |
| 140 | + |
| 141 | + # Prefer the tournament's real player names; fall back to what the engine recorded. |
| 142 | + names = list(log.get("names", [])) |
| 143 | + if players: |
| 144 | + names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)] |
| 145 | + while len(names) < n: |
| 146 | + names.append(f"player{len(names) + 1}") |
| 147 | + |
| 148 | + frames = [ |
| 149 | + { |
| 150 | + "turn": fr.get("tick", idx), |
| 151 | + "grid": fr["grid"], |
| 152 | + "balls": fr.get("balls", []), |
| 153 | + "players": fr.get("players", []), |
| 154 | + "scores": fr.get("scores", {}), |
| 155 | + } |
| 156 | + for idx, fr in enumerate(log.get("frames", [])) |
| 157 | + ] |
| 158 | + |
| 159 | + win = log.get("winner") |
| 160 | + draw = win is None |
| 161 | + winner = None if draw else names[win] if 0 <= win < len(names) else str(win) |
| 162 | + |
| 163 | + return ReplayData( |
| 164 | + w=cols, |
| 165 | + h=rows, |
| 166 | + frames=frames, |
| 167 | + winner=winner, |
| 168 | + draw=draw, |
| 169 | + extra={"names": names, "num_players": n, "max_ticks": log.get("max_ticks")}, |
| 170 | + ) |
0 commit comments