feat(vk): VK community cover #285
@@ -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`).
|
||||
@@ -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-<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();
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
After Width: | Height: | Size: 960 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 473 KiB |