Files
scrabble-game/assets/vk/cover/build-cover.mjs
T
Ilia Denisov 70b88c567f feat(vk): move the tile letter to the corner, add the light cover
Sets the letter in the tile's top-left corner with the value staying bottom
right — the in-game tile layout — instead of centring it. The glyph is placed
by its ink box rather than the text origin, so every letter lands on the same
corner whatever its side bearings and ascent.

Adds day-spot: the same crop and camera as night-spot on a light ground, so the
chosen composition exists as a light/dark pair.
2026-07-27 15:46:58 +02:00

857 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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-<id>.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();
// Letter in the top-left corner, value in the bottom-right — the in-game tile layout.
// The glyph is placed by its INK box, not by the text origin, so every letter lines up
// on the same corner regardless of its side bearings and ascent.
ctx.fillStyle = pal.tileText;
ctx.font = brand ? `${TEX_CELL * 0.74}px EruditBrand` : `700 ${TEX_CELL * 0.66}px DejaVu Sans`;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
const m = ctx.measureText(letter);
ctx.fillText(letter, x + w * 0.1 + m.actualBoundingBoxLeft, y + w * 0.1 + m.actualBoundingBoxAscent);
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: 'day-spot',
title: 'День — тот же кадр, светлая пара к night-spot',
palette: WARM,
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, '#fbf3e7'], [0.6, '#f0e2ce'], [1, '#d8bd98']], 0, 0, 0, H);
radialGlow(ctx, W, H, W * 0.5, H * 0.5, W * 0.55, 'rgba(255,250,236,0.85)', 'rgba(255,250,236,0)');
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '251,243,231', 0.5, 0.9);
nearFade(ctx, W, H, '150,113,72', 0.84, 0.35);
vignette(ctx, W, H, 0.3);
},
},
{
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}`);
}