diff --git a/assets/vk/cover/README.md b/assets/vk/cover/README.md index 9af9648..8d3490d 100644 --- a/assets/vk/cover/README.md +++ b/assets/vk/cover/README.md @@ -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 diff --git a/assets/vk/cover/build-cover.mjs b/assets/vk/cover/build-cover.mjs index 8cf54cb..af97588 100644 --- a/assets/vk/cover/build-cover.mjs +++ b/assets/vk/cover/build-cover.mjs @@ -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-.png -// node build-cover.mjs warm-table # one variant +// node build-cover.mjs # both covers -> cover-.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}`); -} diff --git a/assets/vk/cover/preview/cover-day-spot.png b/assets/vk/cover/cover-day-spot.png similarity index 100% rename from assets/vk/cover/preview/cover-day-spot.png rename to assets/vk/cover/cover-day-spot.png diff --git a/assets/vk/cover/preview/cover-night-spot.png b/assets/vk/cover/cover-night-spot.png similarity index 100% rename from assets/vk/cover/preview/cover-night-spot.png rename to assets/vk/cover/cover-night-spot.png diff --git a/assets/vk/cover/preview/contact-sheet.png b/assets/vk/cover/preview/contact-sheet.png deleted file mode 100644 index 70c4385..0000000 Binary files a/assets/vk/cover/preview/contact-sheet.png and /dev/null differ diff --git a/assets/vk/cover/preview/cover-brand-blue.png b/assets/vk/cover/preview/cover-brand-blue.png deleted file mode 100644 index 2a22cac..0000000 Binary files a/assets/vk/cover/preview/cover-brand-blue.png and /dev/null differ diff --git a/assets/vk/cover/preview/cover-flat-app.png b/assets/vk/cover/preview/cover-flat-app.png deleted file mode 100644 index 3917c90..0000000 Binary files a/assets/vk/cover/preview/cover-flat-app.png and /dev/null differ diff --git a/assets/vk/cover/preview/cover-macro-low.png b/assets/vk/cover/preview/cover-macro-low.png deleted file mode 100644 index 6b88c88..0000000 Binary files a/assets/vk/cover/preview/cover-macro-low.png and /dev/null differ diff --git a/assets/vk/cover/preview/cover-night-wordmark.png b/assets/vk/cover/preview/cover-night-wordmark.png deleted file mode 100644 index 13b473c..0000000 Binary files a/assets/vk/cover/preview/cover-night-wordmark.png and /dev/null differ diff --git a/assets/vk/cover/preview/cover-warm-table.png b/assets/vk/cover/preview/cover-warm-table.png deleted file mode 100644 index 878f5a3..0000000 Binary files a/assets/vk/cover/preview/cover-warm-table.png and /dev/null differ