diff --git a/assets/vk/VK_group_cover_template.fig b/assets/vk/VK_group_cover_template.fig new file mode 100644 index 0000000..95546f8 Binary files /dev/null and b/assets/vk/VK_group_cover_template.fig differ diff --git a/assets/vk/cover/README.md b/assets/vk/cover/README.md new file mode 100644 index 0000000..9af9648 --- /dev/null +++ b/assets/vk/cover/README.md @@ -0,0 +1,58 @@ +# VK community cover + +The cover art for the VK community (`1920×768`). The community avatar — the «Э» tile — is +uploaded separately; this is only the backdrop behind it. + +`build-cover.mjs` is the whole thing: a small software renderer that draws an Эрудит board in +perspective with the opening `ГРАМОТНОСТЬ / СОПЕРНИК / ЭРУДИЦИЯ / УМ` already played, and +emits one PNG per art-direction variant plus a contact sheet. + +```sh +cd assets/vk/cover +node build-cover.mjs # every variant -> preview/ +node build-cover.mjs night-spot # one variant +FAST=1 node build-cover.mjs # 1x instead of 2x supersampling, for quick iteration +ZONES=1 node build-cover.mjs # overlay the VK keep-outs on the render +LAYOUT=cross node build-cover.mjs # the alternative word arrangement +``` + +It needs no install: skia-canvas comes from the render sidecar's `node_modules`, and the +letters are set in `Spectral-Bold.ttf` from the icon brand folder. + +## The position + +`ГРАМОТНОСТЬ` opens through the centre square (its `Т` on H8) and the other three words hook +onto a shared letter. `assertLegal` re-reads the finished grid and fails the build if the +centre is uncovered, if a declared word is missing, or if any maximal run of two or more +letters is a word nobody played — so the board on the cover is always a position the engine +would accept. All four words are in the Эрудит dictionary; tile values come from +`scrabble-solver` `rules.Erudit()`. + +The default `ladder` arrangement puts both long words horizontally. The `cross` arrangement +hangs `ЭРУДИЦИЯ` down the left instead, which looks more symmetric but collides with the VK +mobile avatar (below). + +## VK keep-outs + +Measured off VK's own template (`../VK_group_cover_template.fig`), in cover pixels: + +| Zone | Area | Why | +|------|------|-----| +| Top crop | `y < 130` | trimmed by some clients — nothing important there | +| Mobile avatar | circle, centre `(283, 818)`, r `283` | the community picture sits over the bottom-left in the VK app | + +`ZONES=1` paints both so a composition can be checked against them. + +## Rendering technique + +The camera has pitch only — no roll, no yaw — so a board-space horizontal line always maps to +a screen-space horizontal line. That makes the perspective warp an exact per-scanline remap of +a flat texture rather than a general homography: the board surface and the tile faces are each +drawn once, flat, at high resolution, then replayed band by band (`warpPlane`). Tile thickness +is filled as real 3D quads between the two planes, so the tiles have visible edges. + +Two things that are easy to get wrong here and cost a hundredfold in render time: +`ctx.filter = 'blur(...)'` applies per drawing call, so many blurred shapes must be collected +into one offscreen layer and blurred once (`blurred`); and `drawImage` re-snapshots a live +`Canvas` source on every call, so a texture sampled hundreds of times must be baked into an +`Image` first (`freeze`). diff --git a/assets/vk/cover/build-cover.mjs b/assets/vk/cover/build-cover.mjs new file mode 100644 index 0000000..af539d9 --- /dev/null +++ b/assets/vk/cover/build-cover.mjs @@ -0,0 +1,836 @@ +'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}`); +} diff --git a/assets/vk/cover/preview/contact-sheet.png b/assets/vk/cover/preview/contact-sheet.png new file mode 100644 index 0000000..b06c4e6 Binary files /dev/null and b/assets/vk/cover/preview/contact-sheet.png differ diff --git a/assets/vk/cover/preview/cover-brand-blue.png b/assets/vk/cover/preview/cover-brand-blue.png new file mode 100644 index 0000000..bc6ad28 Binary files /dev/null and b/assets/vk/cover/preview/cover-brand-blue.png differ diff --git a/assets/vk/cover/preview/cover-flat-app.png b/assets/vk/cover/preview/cover-flat-app.png new file mode 100644 index 0000000..235c831 Binary files /dev/null and b/assets/vk/cover/preview/cover-flat-app.png differ diff --git a/assets/vk/cover/preview/cover-macro-low.png b/assets/vk/cover/preview/cover-macro-low.png new file mode 100644 index 0000000..d7f7ea1 Binary files /dev/null and b/assets/vk/cover/preview/cover-macro-low.png differ diff --git a/assets/vk/cover/preview/cover-night-spot.png b/assets/vk/cover/preview/cover-night-spot.png new file mode 100644 index 0000000..f6f965a Binary files /dev/null and b/assets/vk/cover/preview/cover-night-spot.png differ diff --git a/assets/vk/cover/preview/cover-night-wordmark.png b/assets/vk/cover/preview/cover-night-wordmark.png new file mode 100644 index 0000000..462055c Binary files /dev/null and b/assets/vk/cover/preview/cover-night-wordmark.png differ diff --git a/assets/vk/cover/preview/cover-warm-table.png b/assets/vk/cover/preview/cover-warm-table.png new file mode 100644 index 0000000..e649a02 Binary files /dev/null and b/assets/vk/cover/preview/cover-warm-table.png differ