chore(vk): keep the two chosen covers, drop the rest
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
The dark and light covers move up beside the generator as the shipped assets; the six exploratory variants and the contact sheet go. Trims the generator to match: VARIANTS is now only the two covers, and with the retired variants gone so are the knobs nothing reaches any more — the wordmark caption, the branded palette, the whole-board height fit and the non-brand tile face. Left behind, they would read as supported options that no output exercises. `node build-cover.mjs` now reproduces exactly the two committed PNGs.
@@ -3,14 +3,20 @@
|
||||
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.
|
||||
| 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 # every variant -> preview/
|
||||
node build-cover.mjs night-spot # one variant
|
||||
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
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
// 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
|
||||
// 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.
|
||||
@@ -18,7 +18,6 @@
|
||||
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);
|
||||
@@ -177,26 +176,19 @@ function makeCamera({ elevDeg, dist, focal, cx, cy }) {
|
||||
/**
|
||||
* 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
|
||||
* 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);
|
||||
let focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ;
|
||||
const 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.
|
||||
// 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 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];
|
||||
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];
|
||||
@@ -384,13 +376,12 @@ function boardTexture(pal, grid) {
|
||||
|
||||
/** 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) {
|
||||
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;
|
||||
const brand = opts.font === 'brand';
|
||||
|
||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||||
@@ -429,7 +420,7 @@ function tileTexture(pal, grid, opts) {
|
||||
// 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.font = `${TEX_CELL * 0.74}px EruditBrand`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
const m = ctx.measureText(letter);
|
||||
@@ -456,7 +447,7 @@ 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.
|
||||
* post effects. `step` is the warp band height in device pixels.
|
||||
*/
|
||||
function drawScene(ctx, W, H, v, grid, step) {
|
||||
const pal = v.palette;
|
||||
@@ -532,32 +523,9 @@ function drawScene(ctx, W, H, v, grid, step) {
|
||||
}
|
||||
}
|
||||
|
||||
warpPlane(ctx, freeze(tileTexture(pal, grid, { font: v.font ?? 'brand' })), cam, th, HALF_BOARD, step, H);
|
||||
warpPlane(ctx, freeze(tileTexture(pal, grid)), 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
|
||||
@@ -634,16 +602,6 @@ const NIGHT = {
|
||||
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
|
||||
|
||||
@@ -652,26 +610,10 @@ const BRANDED = {
|
||||
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);
|
||||
@@ -687,7 +629,6 @@ const VARIANTS = [
|
||||
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);
|
||||
@@ -699,68 +640,6 @@ const VARIANTS = [
|
||||
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
|
||||
@@ -809,48 +688,12 @@ function render(v, grid) {
|
||||
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 });
|
||||
const file = join(DIR, `cover-${v.id}.png`);
|
||||
render(v, grid).toFileSync(file);
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 447 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 474 KiB |