Merge pull request 'feat(vk): VK community cover' (#285) from feature/vk-group-cover into development
CI / changes (push) Successful in 3s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped

This commit was merged in pull request #285.
This commit is contained in:
2026-07-27 14:03:31 +00:00
5 changed files with 763 additions and 0 deletions
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
# 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.
| File | What |
|------|------|
| `cover-night-spot.png` | the dark cover — board in a warm spotlight |
| `cover-day-spot.png` | the light cover — same camera, same crop, daylight |
| `build-cover.mjs` | the generator both are rendered by |
`build-cover.mjs` is a small software renderer that draws an Эрудит board in perspective with
the opening `ГРАМОТНОСТЬ / СОПЕРНИК / ЭРУДИЦИЯ / УМ` already played, and writes the two PNGs
next to itself.
```sh
cd assets/vk/cover
node build-cover.mjs # both covers
node build-cover.mjs night-spot # one of them
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`).
+699
View File
@@ -0,0 +1,699 @@
'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 # both covers -> cover-<id>.png
// node build-cover.mjs night-spot # one of them
//
// 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';
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 — 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);
const focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ;
// With cy = 0 the projected y is exactly proportional to the focal length, so aiming is a
// closed form: project the target row through a unit-focal camera and scale by the focal.
const unit = makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal: 1, cx: 0, cy: 0 });
const cy = (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) {
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;
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 = `${TEX_CELL * 0.74}px EruditBrand`;
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. `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)), cam, th, HALF_BOARD, step, H);
v.effects?.(ctx, W, H, pal);
}
// ------------------------------------------------------------------- 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',
};
// ------------------------------------------------------------------------------ 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: 'night-spot',
title: 'Ночь — доска в луче света, слова крупно',
palette: NIGHT,
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,
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);
},
},
];
// -------------------------------------------------------------------------- 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;
}
const grid = buildBoard();
const want = process.argv.slice(2);
for (const v of VARIANTS) {
if (want.length && !want.includes(v.id)) continue;
const started = Date.now();
const file = join(DIR, `cover-${v.id}.png`);
render(v, grid).toFileSync(file);
console.log(`wrote ${file} (${Date.now() - started} ms) — ${v.title}`);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB