'use strict'; // VK community cover generator — a pseudo-3D perspective view of an Эрудит board with the // opening words ЭРУДИЦИЯ / ГРАМОТНОСТЬ / СОПЕРНИК / УМ already played. // // The board is a real plane in world space: the flat board texture and the flat tile-top // texture are drawn once at high resolution and then warped by an exact per-scanline // perspective transform (the camera has pitch only — no roll, no yaw — so every board-space // horizontal line stays a screen-space horizontal line and the warp is a pure row remap). // Tile thickness is drawn as real 3D quads between the two warped planes. // // Usage: // node build-cover.mjs # every variant -> preview/cover-.png // node build-cover.mjs warm-table # one variant // // skia-canvas is not installed here: the render sidecar already vendors it and this is a // one-off asset build, so we borrow that copy rather than adding a second native module. import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { mkdirSync } from 'node:fs'; const DIR = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const { Canvas, Image, FontLibrary } = require(join(DIR, '..', '..', '..', 'renderer', 'node_modules', 'skia-canvas')); FontLibrary.use('EruditBrand', [join(DIR, '..', '..', 'icons', 'brand', 'Spectral-Bold.ttf')]); // ---------------------------------------------------------------------------- game state /** BOARD_SIZE is the Эрудит board side in cells. */ const BOARD_SIZE = 15; /** ERUDIT_BOARD is the Эрудит premium layout (ui/src/lib/premiums.ts): T/D word, t/d letter, * '+' the non-doubling centre. */ const ERUDIT_BOARD = [ 'T..d...T...d..T', '.D...t...t...D.', '..D...d.d...D..', 'd..D...d...D..d', '....D.....D....', '.t...t...t...t.', '..d...d.d...d..', 'T..d...+...d..T', '..d...d.d...d..', '.t...t...t...t.', '....D.....D....', 'd..D...d...D..d', '..D...d.d...D..', '.D...t...t...D.', 'T..d...T...d..T', ]; /** VALUES maps a Cyrillic letter to its Эрудит tile value (scrabble-solver rules.Erudit). */ const VALUES = { А: 1, Б: 3, В: 2, Г: 3, Д: 2, Е: 1, Ж: 5, З: 5, И: 1, Й: 2, К: 2, Л: 2, М: 2, Н: 1, О: 1, П: 2, Р: 2, С: 2, Т: 2, У: 3, Ф: 10, Х: 5, Ц: 10, Ч: 5, Ш: 10, Щ: 10, Ъ: 10, Ы: 5, Ь: 5, Э: 10, Ю: 10, Я: 3, }; /** * OPENINGS are the legal positions the cover can show. In both, ГРАМОТНОСТЬ opens through * the centre (7,7 — its Т) and every other word hooks onto a shared letter, with no * incidental cross-word anywhere. * * - `ladder` (default): ЭРУДИЦИЯ runs horizontally low, sharing СОПЕРНИК's И. Two long * horizontals suit the 2.5:1 cover, and the bottom-left stays empty — which is where VK * overlays the community avatar in its mobile app. * - `cross`: ЭРУДИЦИЯ and СОПЕРНИК both hang vertically off ГРАМОТНОСТЬ. More symmetric, * but its left column runs into that avatar. */ const OPENINGS = { ladder: [ { word: 'ГРАМОТНОСТЬ', row: 7, col: 2, dir: 'H' }, { word: 'СОПЕРНИК', row: 6, col: 9, dir: 'V' }, { word: 'ЭРУДИЦИЯ', row: 12, col: 5, dir: 'H' }, { word: 'УМ', row: 6, col: 5, dir: 'V' }, ], cross: [ { word: 'ГРАМОТНОСТЬ', row: 7, col: 2, dir: 'H' }, { word: 'ЭРУДИЦИЯ', row: 6, col: 3, dir: 'V' }, { word: 'СОПЕРНИК', row: 6, col: 9, dir: 'V' }, { word: 'УМ', row: 6, col: 5, dir: 'V' }, ], }; /** PLAYS is the opening this run renders (LAYOUT=cross switches to the other one). */ const PLAYS = OPENINGS[process.env.LAYOUT ?? 'ladder']; /** buildBoard lays PLAYS out on an empty grid and fails loudly on a contradiction, so a * future edit of PLAYS cannot silently produce an illegal position. */ function buildBoard() { const g = Array.from({ length: BOARD_SIZE }, () => new Array(BOARD_SIZE).fill(null)); for (const p of PLAYS) { const letters = Array.from(p.word); for (let i = 0; i < letters.length; i++) { const r = p.row + (p.dir === 'V' ? i : 0); const c = p.col + (p.dir === 'H' ? i : 0); if (r >= BOARD_SIZE || c >= BOARD_SIZE) throw new Error(`${p.word} runs off the board`); if (g[r][c] && g[r][c] !== letters[i]) { throw new Error(`${p.word} clashes at ${r},${c}: ${g[r][c]} vs ${letters[i]}`); } g[r][c] = letters[i]; } } assertLegal(g); return g; } /** * assertLegal checks the finished grid the way the engine would: the centre must be covered, * and every maximal run of two or more letters — in either direction — must be one of the * words in PLAYS. That is what rules out an incidental two-letter cross-word appearing * beside a hooked word, which reading PLAYS alone cannot tell you. */ function assertLegal(g) { if (!g[7][7]) throw new Error('the opening does not cover the centre square'); const declared = new Set(PLAYS.map((p) => p.word)); const runs = []; for (let i = 0; i < BOARD_SIZE; i++) { for (const read of [(k) => g[i][k], (k) => g[k][i]]) { let run = ''; for (let k = 0; k <= BOARD_SIZE; k++) { const ch = k < BOARD_SIZE ? read(k) : null; if (ch) run += ch; else { if (run.length > 1) runs.push(run); run = ''; } } } } for (const w of runs) if (!declared.has(w)) throw new Error(`incidental word formed: ${w}`); for (const w of declared) if (!runs.includes(w)) throw new Error(`${w} is not on the board`); } // ------------------------------------------------------------------------------ geometry /** World units are board cells: the 15x15 grid is centred on the origin, +x right, +y away * from the camera, +z up out of the board. */ const HALF_GRID = BOARD_SIZE / 2; /** FRAME_CELLS is the wooden border around the playfield, in cells. */ const FRAME_CELLS = 0.5; /** HALF_BOARD is the half-side of the whole slab (playfield plus frame). */ const HALF_BOARD = HALF_GRID + FRAME_CELLS; /** * makeCamera builds a pitch-only pinhole camera looking at the board centre from elevation * elevDeg at distance dist; focal is in output pixels and (cx, cy) is the screen point the * board centre lands on. */ function makeCamera({ elevDeg, dist, focal, cx, cy }) { const e = (elevDeg * Math.PI) / 180; const se = Math.sin(e); const ce = Math.cos(e); return { project(x, y, z) { const vy = y + dist * ce; const vz = z - dist * se; const Yc = vy * se + vz * ce; const Zc = vy * ce - vz * se; return [cx + (focal * x) / Zc, cy - (focal * Yc) / Zc, Zc]; }, /** boardYAt inverts a screen scanline back to the board y it came from, on plane z. */ boardYAt(sy, z) { const k = z - dist * se; const t = (cy - sy) / focal; const den = t * ce - se; if (Math.abs(den) < 1e-9) return Infinity; return (k * (ce + t * se)) / den - dist * ce; }, }; } /** * frameCamera turns the art-direction knobs into a camera: elevDeg and dist set the look, * widthFrac says how much of the output width the board's NEAR edge — its widest point on * screen, so widthFrac ≤ 1 keeps the whole board inside the frame — should span, and * (aimY, aimXFrac, aimYFrac) put the board-space row aimY at that point of the frame, which * is how a variant keeps the played words centred while the board itself bleeds off-canvas. */ function frameCamera(W, H, c) { // Depth of a board-plane row: Zc(y, 0) = dist + y·cos(elev); the near edge is the widest. const nearZ = c.dist - HALF_BOARD * Math.cos((c.elevDeg * Math.PI) / 180); let focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ; // With cy = 0 the projected y is exactly proportional to the focal length, so the focal // that makes the whole board fit a given share of the frame height is a closed form — and // a fitted variant simply takes whichever of the two limits binds first. const unit = makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal: 1, cx: 0, cy: 0 }); const uFar = unit.project(0, HALF_BOARD, 0)[1]; const uNear = unit.project(0, -HALF_BOARD, 0)[1]; if (c.fitHeightFrac) focal = Math.min(focal, (c.fitHeightFrac * H) / (uNear - uFar)); const cy = c.fitHeightFrac ? H / 2 - (focal * (uFar + uNear)) / 2 : (c.aimYFrac ?? 0.5) * H - focal * unit.project(0, c.aimY ?? 0, 0)[1]; // aimX is a board column placed at aimXFrac, so a variant can slide the played words in // the frame without dragging the whole slab off one side. const cx = (c.aimXFrac ?? 0.5) * W - focal * unit.project(c.aimX ?? 0, c.aimY ?? 0, 0)[0]; return makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal, cx, cy }); } /** * warpPlane draws the flat texture src — which covers the board-space rectangle * x ∈ [-halfW, halfW], y ∈ [-halfW, halfW] with texture row 0 at the FAR edge (y = +halfW) * — onto ctx through cam, as the plane at height z. Output is written in bands of `step` * pixels; the source strip height follows the local compression so the receding rows are * filtered down rather than point-sampled. */ function warpPlane(ctx, src, cam, z, halfW, step, clipH) { const yTop = cam.project(0, halfW, z)[1]; const yBot = cam.project(0, -halfW, z)[1]; const span = 2 * halfW; const from = Math.max(-step, Math.floor(yTop)); const to = Math.min(clipH + step, Math.ceil(yBot)); for (let sy = from; sy <= to; sy += step) { const y0 = cam.boardYAt(sy, z); const y1 = cam.boardYAt(sy + step, z); if (!isFinite(y0) || !isFinite(y1) || y1 > y0) continue; const v0 = ((halfW - y0) / span) * src.height; const v1 = ((halfW - y1) / span) * src.height; if (v1 <= 0 || v0 >= src.height) continue; const top = Math.max(0, v0); const h = Math.min(src.height, v1) - top; if (h <= 0) continue; const xL = cam.project(-halfW, y0, z)[0]; const xR = cam.project(halfW, y0, z)[0]; ctx.drawImage(src, 0, top, src.width, h, xL, sy, xR - xL, step + 0.6); } } /** quad fills the screen-space polygon of the given world-space corners. */ function quad(ctx, cam, pts, fill) { ctx.beginPath(); pts.forEach(([x, y, z], i) => { const [sx, sy] = cam.project(x, y, z); if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); }); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); } // ----------------------------------------------------------------------------- utilities function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /** mix blends two #rrggbb colours, t = 0 → a, t = 1 → b. */ function mix(a, b, t) { const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]; const [ar, ag, ab] = p(a); const [br, bg, bb] = p(b); const h = (v) => Math.round(v).toString(16).padStart(2, '0'); return `#${h(ar + (br - ar) * t)}${h(ag + (bg - ag) * t)}${h(ab + (bb - ab) * t)}`; } /** * freeze bakes a canvas into an immutable Image. warpPlane samples its source hundreds of * times per plane and skia re-snapshots a live Canvas source on every drawImage — two orders * of magnitude slower than sampling a decoded Image. */ function freeze(canvas) { const img = new Image(); img.src = canvas.toBufferSync('png'); return img; } /** * blurred renders draw(ctx) into an offscreen w×h canvas downscaled by `shrink`, blurs it in * a SINGLE pass and returns that small canvas. skia applies ctx.filter per drawing call, so * blurring N shapes one by one costs N full-canvas layers — minutes at cover resolution; * this keeps it to one blur over a small surface. */ function blurred(w, h, radius, shrink, draw) { const cv = new Canvas(Math.max(1, Math.round(w / shrink)), Math.max(1, Math.round(h / shrink))); const ctx = cv.getContext('2d'); ctx.scale(1 / shrink, 1 / shrink); draw(ctx); const out = new Canvas(cv.width, cv.height); const octx = out.getContext('2d'); octx.filter = `blur(${radius / shrink}px)`; octx.drawImage(cv, 0, 0); return out; } // ------------------------------------------------------------------------ flat textures /** TEX_CELL is the flat-texture resolution of one board cell, in texture pixels. */ const TEX_CELL = 128; /** TEX_PAD is the frame width in texture pixels. */ const TEX_PAD = Math.round(FRAME_CELLS * TEX_CELL); /** TEX_SIDE is the full flat-texture side (playfield plus both frame edges). */ const TEX_SIDE = BOARD_SIZE * TEX_CELL + 2 * TEX_PAD; /** boardTexture renders the board surface — frame, premium squares, grid, centre star and * the tiles' contact shadows — as seen straight down; row 0 sits at the texture top, which * warpPlane maps to the far edge. */ function boardTexture(pal, grid) { const play = BOARD_SIZE * TEX_CELL; const cv = new Canvas(TEX_SIDE, TEX_SIDE); const ctx = cv.getContext('2d'); // Wooden frame: a flat fill plus a diagonal light/shade so it does not read as cardboard. ctx.fillStyle = pal.frame; ctx.fillRect(0, 0, TEX_SIDE, TEX_SIDE); const fg = ctx.createLinearGradient(0, 0, TEX_SIDE, TEX_SIDE); fg.addColorStop(0, 'rgba(255,255,255,0.12)'); fg.addColorStop(1, 'rgba(0,0,0,0.18)'); ctx.fillStyle = fg; ctx.fillRect(0, 0, TEX_SIDE, TEX_SIDE); for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { const ch = ERUDIT_BOARD[r][c]; const prem = { T: pal.prem.TW, D: pal.prem.DW, t: pal.prem.TL, d: pal.prem.DL }[ch]; // A covered square is painted neutral, not premium: the tile face is inset and rounded, // so a bonus colour underneath leaks out as a saturated hairline around every tile. ctx.fillStyle = grid[r][c] ? mix(pal.cell, '#000000', 0.3) : (prem ?? ((r + c) % 2 === 1 ? pal.cellDark : pal.cell)); ctx.fillRect(TEX_PAD + c * TEX_CELL, TEX_PAD + r * TEX_CELL, TEX_CELL, TEX_CELL); } } ctx.strokeStyle = pal.grid; ctx.lineWidth = Math.max(1, TEX_CELL * 0.018); for (let i = 0; i <= BOARD_SIZE; i++) { const p = TEX_PAD + i * TEX_CELL; ctx.beginPath(); ctx.moveTo(p, TEX_PAD); ctx.lineTo(p, TEX_PAD + play); ctx.moveTo(TEX_PAD, p); ctx.lineTo(TEX_PAD + play, p); ctx.stroke(); } if (!grid[7][7]) { ctx.fillStyle = pal.premText; ctx.globalAlpha = 0.5; ctx.font = `${TEX_CELL * 0.6}px EruditBrand`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('✻', TEX_PAD + 7.5 * TEX_CELL, TEX_PAD + 7.5 * TEX_CELL); ctx.globalAlpha = 1; } ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = TEX_CELL * 0.05; ctx.strokeRect(TEX_PAD, TEX_PAD, play, play); // Contact shadows, cast down-right along the board plane (light from the upper left). const sh = blurred(TEX_SIDE, TEX_SIDE, TEX_CELL * 0.11, 2, (sctx) => { sctx.fillStyle = 'rgba(0,0,0,0.5)'; for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (!grid[r][c]) continue; roundRect( sctx, TEX_PAD + c * TEX_CELL + TEX_CELL * 0.09, TEX_PAD + r * TEX_CELL + TEX_CELL * 0.16, TEX_CELL * 0.95, TEX_CELL * 0.95, TEX_CELL * 0.12, ); sctx.fill(); } } }); ctx.drawImage(sh, 0, 0, TEX_SIDE, TEX_SIDE); return cv; } /** tileTexture renders only the tile faces (letter + value), aligned to the same board * rectangle as boardTexture so it can be warped as the plane at tile height. */ function tileTexture(pal, grid, opts) { const cv = new Canvas(TEX_SIDE, TEX_SIDE); const ctx = cv.getContext('2d'); const inset = TEX_CELL * TILE_INSET; const w = TEX_CELL - 2 * inset; const rad = TEX_CELL * 0.12; const brand = opts.font === 'brand'; for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { const letter = grid[r][c]; if (!letter) continue; const x = TEX_PAD + c * TEX_CELL + inset; const y = TEX_PAD + r * TEX_CELL + inset; const g = ctx.createLinearGradient(x, y, x + w, y + w); g.addColorStop(0, mix(pal.tile, '#ffffff', 0.18)); g.addColorStop(1, mix(pal.tile, '#000000', 0.07)); ctx.fillStyle = g; roundRect(ctx, x, y, w, w, rad); ctx.fill(); // Chamfer: a bright top-left rim and a dark bottom-right rim inside the face. ctx.save(); roundRect(ctx, x, y, w, w, rad); ctx.clip(); ctx.lineWidth = w * 0.06; ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.beginPath(); ctx.moveTo(x + rad * 0.4, y + w - rad * 0.4); ctx.lineTo(x + rad * 0.4, y + rad * 0.4); ctx.lineTo(x + w - rad * 0.4, y + rad * 0.4); ctx.stroke(); ctx.strokeStyle = 'rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.moveTo(x + w - rad * 0.4, y + rad * 0.4); ctx.lineTo(x + w - rad * 0.4, y + w - rad * 0.4); ctx.lineTo(x + rad * 0.4, y + w - rad * 0.4); ctx.stroke(); ctx.restore(); ctx.fillStyle = pal.tileText; ctx.font = brand ? `${TEX_CELL * 0.74}px EruditBrand` : `700 ${TEX_CELL * 0.66}px DejaVu Sans`; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText(letter, x + w * 0.46, y + w * (brand ? 0.79 : 0.775)); ctx.font = `600 ${TEX_CELL * 0.23}px DejaVu Sans`; ctx.textAlign = 'right'; ctx.globalAlpha = 0.8; ctx.fillText(String(VALUES[letter] ?? 0), x + w * 0.91, y + w * 0.93); ctx.globalAlpha = 1; } } return cv; } // -------------------------------------------------------------------------------- scenes /** TILE_INSET is the gap between a tile and its cell edge, as a fraction of the cell. */ const TILE_INSET = 0.02; /** SLAB is the board's own thickness in cells. */ const SLAB = 0.55; /** * drawScene composes one cover onto ctx at the given supersampled size: background, board * slab, warped board surface, extruded tile sides, warped tile faces, then the variant's * post effects and optional caption. `step` is the warp band height in device pixels. */ function drawScene(ctx, W, H, v, grid, step) { const pal = v.palette; const cam = frameCamera(W, H, v.cam); if (process.env.DEBUG) { const far = cam.project(-HALF_BOARD, HALF_BOARD, 0); const near = cam.project(-HALF_BOARD, -HALF_BOARD, 0); const f = (n, d) => (n / d).toFixed(3); console.log( ` ${v.id}: board x ${f(near[0], W)}..${f(W - near[0], W)} y ${f(far[1], H)}..${f(near[1], H)}`, ); } v.background(ctx, W, H, pal); // Ground shadow under the slab, then the two visible slab walls. const gs = blurred(W, H, W * 0.014, 8, (sctx) => { sctx.beginPath(); const ring = [ [-HALF_BOARD * 1.02, HALF_BOARD, -SLAB], [HALF_BOARD * 1.02, HALF_BOARD, -SLAB], [HALF_BOARD * 1.09, -HALF_BOARD * 1.06, -SLAB], [-HALF_BOARD * 1.09, -HALF_BOARD * 1.06, -SLAB], ]; ring.forEach(([x, y, z], i) => { const [sx, sy] = cam.project(x, y, z); if (i === 0) sctx.moveTo(sx, sy); else sctx.lineTo(sx, sy); }); sctx.closePath(); sctx.fillStyle = 'rgba(0,0,0,0.38)'; sctx.fill(); }); ctx.drawImage(gs, 0, 0, W, H); quad(ctx, cam, [ [-HALF_BOARD, -HALF_BOARD, 0], [HALF_BOARD, -HALF_BOARD, 0], [HALF_BOARD, -HALF_BOARD, -SLAB], [-HALF_BOARD, -HALF_BOARD, -SLAB], ], mix(pal.frame, '#000000', 0.45)); for (const s of [-1, 1]) { quad(ctx, cam, [ [s * HALF_BOARD, -HALF_BOARD, 0], [s * HALF_BOARD, HALF_BOARD, 0], [s * HALF_BOARD, HALF_BOARD, -SLAB], [s * HALF_BOARD, -HALF_BOARD, -SLAB], ], mix(pal.frame, '#000000', 0.3)); } warpPlane(ctx, freeze(boardTexture(pal, grid)), cam, 0, HALF_BOARD, step, H); // Tile extrusion: the near wall always, plus whichever lateral wall faces the camera. const th = v.tileHeight ?? 0.17; for (let r = BOARD_SIZE - 1; r >= 0; r--) { for (let c = 0; c < BOARD_SIZE; c++) { if (!grid[r][c]) continue; const x0 = -HALF_GRID + c + TILE_INSET; const x1 = -HALF_GRID + c + 1 - TILE_INSET; const y1 = HALF_GRID - r - TILE_INSET; const y0 = HALF_GRID - r - 1 + TILE_INSET; // The walls run from just under the board plane to just over the tile plane: both // planes are rasterised in scanline bands, so an exact z match leaves hairline gaps. const zb = -0.02; const zt = th + 0.02; quad(ctx, cam, [[x0, y0, zb], [x1, y0, zb], [x1, y0, zt], [x0, y0, zt]], pal.sideNear); if (x0 > 0) { quad(ctx, cam, [[x0, y0, zb], [x0, y1, zb], [x0, y1, zt], [x0, y0, zt]], pal.sideLat); } else if (x1 < 0) { quad(ctx, cam, [[x1, y0, zb], [x1, y1, zb], [x1, y1, zt], [x1, y0, zt]], pal.sideLat); } } } warpPlane(ctx, freeze(tileTexture(pal, grid, { font: v.font ?? 'brand' })), cam, th, HALF_BOARD, step, H); v.effects?.(ctx, W, H, pal); if (v.caption) drawCaption(ctx, W, H, v); } /** drawCaption sets the variant's wordmark block. VK overlays the community avatar over the * bottom-left of the cover on mobile, so a caption stays out of that corner. */ function drawCaption(ctx, W, H, v) { const cap = v.caption; const x = cap.xFrac * W; const y = cap.yFrac * H; ctx.save(); ctx.textAlign = cap.align ?? 'left'; ctx.textBaseline = 'alphabetic'; ctx.shadowColor = 'rgba(0,0,0,0.6)'; ctx.shadowBlur = W * 0.01; ctx.fillStyle = cap.color; ctx.font = `${H * 0.15}px EruditBrand`; ctx.fillText(cap.text, x, y); if (cap.sub) { ctx.font = `500 ${H * 0.045}px DejaVu Sans`; ctx.globalAlpha = 0.85; ctx.fillText(cap.sub, x, y + H * 0.085); } ctx.restore(); } // ------------------------------------------------------------------- background helpers function linear(ctx, W, H, stops, x0, y0, x1, y1) { const g = ctx.createLinearGradient(x0, y0, x1, y1); stops.forEach(([p, c]) => g.addColorStop(p, c)); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } function radialGlow(ctx, W, H, cx, cy, r, inner, outer) { const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); g.addColorStop(0, inner); g.addColorStop(1, outer); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } function vignette(ctx, W, H, strength) { const g = ctx.createRadialGradient(W / 2, H * 0.55, H * 0.3, W / 2, H * 0.55, W * 0.7); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, `rgba(0,0,0,${strength})`); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } /** farHaze fades the receding top of the frame into the background colour, which reads as * aerial depth without the cost of a real depth-of-field blur. */ function farHaze(ctx, W, H, rgb, until, strength) { const g = ctx.createLinearGradient(0, 0, 0, H * until); g.addColorStop(0, `rgba(${rgb},${strength})`); g.addColorStop(1, `rgba(${rgb},0)`); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H * until); } /** nearFade does the same at the bottom edge, so a board that bleeds out of frame dissolves * instead of ending on a hard crop. */ function nearFade(ctx, W, H, rgb, from, strength) { const g = ctx.createLinearGradient(0, H * from, 0, H); g.addColorStop(0, `rgba(${rgb},0)`); g.addColorStop(1, `rgba(${rgb},${strength})`); ctx.fillStyle = g; ctx.fillRect(0, H * from, W, H * (1 - from)); } // ------------------------------------------------------------------------------ palettes const WARM = { frame: '#8a5a30', cell: '#e7ece8', cellDark: '#d9deda', grid: 'rgba(0,0,0,0.10)', prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' }, premText: '#2a2113', tile: '#f4e2b8', tileText: '#2a2113', sideNear: '#c1a068', sideLat: '#d5bd8b', }; const NIGHT = { ...WARM, frame: '#3d2915', cell: '#2a3330', cellDark: '#232b29', grid: 'rgba(255,255,255,0.07)', prem: { TW: '#a8483d', DW: '#8f625e', TL: '#3a6291', DL: '#546f88' }, premText: '#f2d9a0', tile: '#eed9ab', tileText: '#241b0e', sideNear: '#a3854f', sideLat: '#bda372', }; const BRANDED = { ...WARM, frame: '#c9974a', cell: '#eef2ef', cellDark: '#e2e8e4', grid: 'rgba(0,0,0,0.08)', tile: '#f9edd0', sideNear: '#cfae72', sideLat: '#e0c48f', }; // ------------------------------------------------------------------------------ variants /** WORDS_Y is the board-space y of the played cluster's middle row (rows 6..13), the point a * variant aims the camera at when the board is allowed to bleed out of frame. */ const WORDS_Y = HALF_GRID - 10; const VARIANTS = [ { id: 'warm-table', title: 'Тёплый стол — дневной свет, доска целиком', palette: WARM, font: 'brand', cam: { elevDeg: 34, dist: 30, widthFrac: 0.94, fitHeightFrac: 0.94 }, background(ctx, W, H) { linear(ctx, W, H, [[0, '#f7eee2'], [0.55, '#e9d8c2'], [1, '#c9a880']], 0, 0, 0, H); radialGlow(ctx, W, H, W * 0.74, H * 0.06, W * 0.8, 'rgba(255,247,228,0.9)', 'rgba(255,247,228,0)'); }, effects(ctx, W, H) { farHaze(ctx, W, H, '247,238,226', 0.4, 0.45); vignette(ctx, W, H, 0.26); }, }, { id: 'night-spot', title: 'Ночь — доска в луче света, слова крупно', palette: NIGHT, font: 'brand', cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 }, background(ctx, W, H) { linear(ctx, W, H, [[0, '#0c1411'], [0.6, '#131e19'], [1, '#070c0a']], 0, 0, 0, H); radialGlow(ctx, W, H, W * 0.5, H * 0.55, W * 0.5, 'rgba(255,214,150,0.26)', 'rgba(255,214,150,0)'); }, effects(ctx, W, H) { farHaze(ctx, W, H, '12,20,17', 0.5, 0.9); nearFade(ctx, W, H, '7,12,10', 0.82, 0.75); vignette(ctx, W, H, 0.6); }, }, { id: 'brand-blue', title: 'Бренд-градиент — доска парит, продуктовый вид', palette: BRANDED, font: 'brand', cam: { elevDeg: 44, dist: 32, widthFrac: 0.8, fitHeightFrac: 0.84 }, background(ctx, W, H) { linear(ctx, W, H, [[0, '#173a86'], [0.5, '#2f6df6'], [1, '#1b4bb6']], 0, 0, W, H); radialGlow(ctx, W, H, W * 0.5, H * 0.95, W * 0.62, 'rgba(255,255,255,0.22)', 'rgba(255,255,255,0)'); }, effects(ctx, W, H) { farHaze(ctx, W, H, '23,58,134', 0.4, 0.45); vignette(ctx, W, H, 0.28); }, }, { id: 'macro-low', title: 'Макро — низкая камера, доска уходит за кадр', palette: WARM, font: 'brand', tileHeight: 0.2, cam: { elevDeg: 18, dist: 17, widthFrac: 1.9, aimX: 1.2, aimY: WORDS_Y, aimYFrac: 0.56 }, background(ctx, W, H) { linear(ctx, W, H, [[0, '#f2e6d3'], [0.4, '#dcc6a6'], [1, '#a87f52']], 0, 0, 0, H); }, effects(ctx, W, H) { farHaze(ctx, W, H, '242,230,211', 0.5, 0.92); nearFade(ctx, W, H, '120,84,50', 0.8, 0.5); vignette(ctx, W, H, 0.4); }, }, { id: 'flat-app', title: 'Как в приложении — мягкий наклон, светлый фон', palette: BRANDED, font: 'system', tileHeight: 0.13, cam: { elevDeg: 58, dist: 34, widthFrac: 0.8, fitHeightFrac: 0.95 }, background(ctx, W, H) { linear(ctx, W, H, [[0, '#ffffff'], [1, '#e4eae6']], 0, 0, 0, H); }, effects(ctx, W, H) { vignette(ctx, W, H, 0.14); }, }, { id: 'night-wordmark', title: 'Ночь с подписью — доска справа, слева название', palette: NIGHT, font: 'brand', cam: { elevDeg: 29, dist: 25, widthFrac: 1.05, aimY: WORDS_Y, aimX: 0.8, aimXFrac: 0.64, aimYFrac: 0.56 }, background(ctx, W, H) { linear(ctx, W, H, [[0, '#0a1210'], [0.55, '#14211c'], [1, '#060b09']], 0, 0, W, H); radialGlow(ctx, W, H, W * 0.72, H * 0.55, W * 0.45, 'rgba(255,209,140,0.22)', 'rgba(255,209,140,0)'); }, effects(ctx, W, H) { farHaze(ctx, W, H, '10,18,16', 0.46, 0.9); nearFade(ctx, W, H, '6,11,9', 0.84, 0.7); vignette(ctx, W, H, 0.5); }, caption: { text: 'Эрудит', sub: 'erudit-game.ru', xFrac: 0.055, yFrac: 0.44, color: '#f2d9a0' }, }, ]; // -------------------------------------------------------------------------- VK guides /** * VK_ZONES are the community-cover keep-outs measured off VK's own Figma template * (assets/vk/VK_group_cover_template.fig), in 1920×768 cover pixels: `topCrop` is the band * some clients shave off the top, and `avatar` is the community picture VK overlays on the * bottom-left in the mobile app (its centre sits below the canvas). */ const VK_ZONES = { topCrop: 130, avatar: { cx: 283, cy: 818, r: 283 } }; /** drawZones overlays the VK keep-outs, for checking a composition (ZONES=1). */ function drawZones(ctx, W, H) { const s = W / OUT_W; ctx.save(); ctx.fillStyle = 'rgba(255,0,90,0.22)'; ctx.fillRect(0, 0, W, VK_ZONES.topCrop * s); ctx.beginPath(); ctx.arc(VK_ZONES.avatar.cx * s, VK_ZONES.avatar.cy * s, VK_ZONES.avatar.r * s, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,0,90,0.9)'; ctx.lineWidth = 2 * s; ctx.stroke(); ctx.restore(); } // ---------------------------------------------------------------------------------- main /** OUT_W / OUT_H are the VK community cover dimensions; SS is the supersampling factor * (FAST=1 drops it to 1 for quick art-direction passes — the shipped asset uses 2). */ const OUT_W = 1920; const OUT_H = 768; const SS = process.env.FAST ? 1 : 2; function render(v, grid) { const big = new Canvas(OUT_W * SS, OUT_H * SS); const bctx = big.getContext('2d'); drawScene(bctx, OUT_W * SS, OUT_H * SS, v, grid, SS); if (process.env.ZONES) drawZones(bctx, OUT_W * SS, OUT_H * SS); const out = new Canvas(OUT_W, OUT_H); const octx = out.getContext('2d'); octx.imageSmoothingEnabled = true; octx.imageSmoothingQuality = 'high'; octx.drawImage(big, 0, 0, OUT_W, OUT_H); return out; } /** contactSheet stacks the rendered variants two-up with their titles, so the whole set can * be compared in one file. */ function contactSheet(rendered) { const cols = 2; const cw = OUT_W / 2; const ch = OUT_H / 2; const label = 44; const rows = Math.ceil(rendered.length / cols); const cv = new Canvas(cols * cw, rows * (ch + label)); const ctx = cv.getContext('2d'); ctx.fillStyle = '#14181f'; ctx.fillRect(0, 0, cv.width, cv.height); ctx.imageSmoothingQuality = 'high'; rendered.forEach(({ canvas, v }, i) => { const x = (i % cols) * cw; const y = Math.floor(i / cols) * (ch + label); ctx.fillStyle = '#e7eaf0'; ctx.font = '600 17px DejaVu Sans'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(`${i + 1}. ${v.id} — ${v.title}`, x + 12, y + label / 2); ctx.drawImage(canvas, x, y + label, cw, ch); }); return cv; } const grid = buildBoard(); const want = process.argv.slice(2); const outDir = join(DIR, 'preview'); mkdirSync(outDir, { recursive: true }); const rendered = []; for (const v of VARIANTS) { if (want.length && !want.includes(v.id)) continue; const started = Date.now(); const file = join(outDir, `cover-${v.id}.png`); const canvas = render(v, grid); canvas.toFileSync(file); rendered.push({ canvas, v }); console.log(`wrote ${file} (${Date.now() - started} ms) — ${v.title}`); } if (rendered.length > 1) { const sheet = join(outDir, 'contact-sheet.png'); contactSheet(rendered).toFileSync(sheet); console.log(`wrote ${sheet}`); }