// The share card: a 1200x630 OpenGraph image of the shared turn's maze, // painted from a spectator view and encoded as PNG with nothing but // node's own zlib — no image libraries on this small box. The unfurl // text carries the words; this carries the board. import { deflateSync } from "node:zlib"; import type { GameView } from "@wizwar/engine"; // --- Minimal PNG encoding (truecolor, filter 0) ---------------------------- const CRC_TABLE = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; CRC_TABLE[n] = c; } function crc32(buf: Uint8Array): number { let c = ~0; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8); return ~c >>> 0; } function chunk(type: string, data: Uint8Array): Uint8Array { const out = new Uint8Array(12 + data.length); const dv = new DataView(out.buffer); dv.setUint32(0, data.length); for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); out.set(data, 8); dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); return out; } function encodePng(width: number, height: number, rgb: Uint8Array): Buffer { const ihdr = new Uint8Array(13); const dv = new DataView(ihdr.buffer); dv.setUint32(0, width); dv.setUint32(4, height); ihdr[8] = 8; // bit depth ihdr[9] = 2; // truecolor const raw = new Uint8Array(height * (1 + width * 3)); for (let y = 0; y < height; y++) { raw.set(rgb.subarray(y * width * 3, (y + 1) * width * 3), y * (1 + width * 3) + 1); } return Buffer.concat([ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", new Uint8Array(0)), ]); } // --- The painting ---------------------------------------------------------- type Rgb = [number, number, number]; const hex = (s: string): Rgb => [ parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16), ]; const BG = hex("#171a20"); const CELL = hex("#e9e1cb"); const CELL_LINE = hex("#cfc4a8"); const WALL = hex("#43331f"); const DOOR = hex("#a2703a"); const FIRE = hex("#d65c1c"); const STONE = hex("#8a8a94"); const WARP = hex("#7a4ccc"); const CREATURE = hex("#4a4438"); const TREASURE = hex("#c9a72a"); const HAZARD = hex("#7c8a4a"); /** Standee colors, mirroring the client's PLAYER_COLORS. */ const PLAYER: Rgb[] = [ hex("#1a9c46"), hex("#d3352b"), hex("#c9308f"), hex("#3a3ac0"), hex("#2ab0c9"), hex("#c9a72a"), ]; export function renderSharePng(view: GameView): Buffer { const W = 1200, H = 630; const img = new Uint8Array(W * H * 3); const rect = (x: number, y: number, w: number, h: number, c: Rgb) => { const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y)); const x1 = Math.min(W, Math.round(x + w)), y1 = Math.min(H, Math.round(y + h)); for (let yy = y0; yy < y1; yy++) { let o = (yy * W + x0) * 3; for (let xx = x0; xx < x1; xx++) { img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2]; o += 3; } } }; const disc = (cx: number, cy: number, r: number, c: Rgb) => { for (let yy = Math.round(cy - r); yy <= cy + r; yy++) { for (let xx = Math.round(cx - r); xx <= cx + r; xx++) { if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; if ((xx - cx) ** 2 + (yy - cy) ** 2 > r * r) continue; const o = (yy * W + xx) * 3; img[o] = c[0]; img[o + 1] = c[1]; img[o + 2] = c[2]; } } }; rect(0, 0, W, H, BG); const bw = view.board.width, bh = view.board.height; const cell = Math.floor(Math.min((W - 120) / bw, (H - 80) / bh)); const ox = Math.round((W - bw * cell) / 2); const oy = Math.round((H - bh * cell) / 2); const px = (cx: number, cy: number) => [ox + cx * cell, oy + cy * cell] as const; const line = Math.max(3, Math.round(cell * 0.1)); // The rooms of the maze. const has = (cx: number, cy: number) => !!view.board.cells[`${cx},${cy}`]; for (const k of Object.keys(view.board.cells)) { const [cx, cy] = k.split(",").map(Number) as [number, number]; const [x, y] = px(cx, cy); rect(x, y, cell, cell, CELL); rect(x, y, cell, 1, CELL_LINE); rect(x, y, 1, cell, CELL_LINE); } // Home bases wear their owner's color as a floor border. for (const p of view.players) { if (!p.home) continue; const [x, y] = px(p.home.x, p.home.y); const c = PLAYER[p.colorIndex] ?? WALL; const t = Math.max(2, Math.round(cell * 0.08)); rect(x + 2, y + 2, cell - 4, t, c); rect(x + 2, y + cell - 2 - t, cell - 4, t, c); rect(x + 2, y + 2, t, cell - 4, c); rect(x + cell - 2 - t, y + 2, t, cell - 4, c); } // The floor's furniture: filled stone reads solid; other hazards dot. for (const [k, content] of Object.entries(view.squareContents)) { const [cx, cy] = k.split(",").map(Number) as [number, number]; const [x, y] = px(cx, cy); if (content.kind === "stone") rect(x + 1, y + 1, cell - 2, cell - 2, STONE); else disc(x + cell / 2, y + cell / 2, cell * 0.16, HAZARD); } // Interior walls, doors, and fire, on their edges. for (const [k, e] of Object.entries(view.board.edges)) { if (e === "open") continue; const [kind, coords] = k.split(":") as [string, string]; const [ex, ey] = coords.split(",").map(Number) as [number, number]; const c = e === "door" ? DOOR : e === "firewall" ? FIRE : WALL; if (kind === "V") { const [x, y] = px(ex + 1, ey); rect(x - line / 2, y, line, cell, c); } else { const [x, y] = px(ex, ey + 1); rect(x, y - line / 2, cell, line, c); } } // The rim: every open side facing nothing is a wall, except warp mouths. const mouth = new Set(view.board.warps.map((w) => `${w.from.cell.x},${w.from.cell.y}:${w.from.side}`)); for (const k of Object.keys(view.board.cells)) { const [cx, cy] = k.split(",").map(Number) as [number, number]; const [x, y] = px(cx, cy); const side = (missing: boolean, sx: number, sy: number, w: number, h: number, s: string) => { if (!missing) return; rect(sx, sy, w, h, mouth.has(`${cx},${cy}:${s}`) ? WARP : WALL); }; side(!has(cx + 1, cy), x + cell - line / 2, y, line, cell, "E"); side(!has(cx - 1, cy), x - line / 2, y, line, cell, "W"); side(!has(cx, cy + 1), x, y + cell - line / 2, cell, line, "S"); side(!has(cx, cy - 1), x, y - line / 2, cell, line, "N"); } // Loose treasures glint gold. for (const t of view.treasures) { if (!t.position || t.carriedBy) continue; const [x, y] = px(t.position.x, t.position.y); rect(x + cell * 0.6, y + cell * 0.6, cell * 0.25, cell * 0.25, TREASURE); } // Creatures crouch dark; wizards stand in their colors. for (const c of view.creatures) { const [x, y] = px(c.position.x, c.position.y); disc(x + cell * 0.3, y + cell * 0.68, cell * 0.18, CREATURE); } const standing = new Map(); for (const p of view.players) { if (!p.alive) continue; const key = `${p.position.x},${p.position.y}`; const n = standing.get(key) ?? 0; standing.set(key, n + 1); const [x, y] = px(p.position.x, p.position.y); const cx = x + cell / 2 + (n - 0.5) * cell * 0.16; disc(cx, y + cell * 0.42, cell * 0.26, PLAYER[p.colorIndex] ?? WALL); disc(cx, y + cell * 0.42, cell * 0.26 - 2, PLAYER[p.colorIndex] ?? WALL); } return encodePng(W, H, img); }