From 67a6ceb8ece85f394e5fe951d2b0480ee4e23314 Mon Sep 17 00:00:00 2001 From: Business Goose Date: Wed, 23 Sep 2026 12:12:26 -0700 Subject: [PATCH] game: heap the captured stones, and draw real clouds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bowl held a scatter of stones, which read as a single flat row: the rows sat 6.2 apart while a stone is 15 wide, so they overlapped almost entirely. The stones now form a mound — the widest row at the bottom of the bowl, each row above it higher and shorter, alternating parity so they nest — and the pile rises as a parabolic heap rather than a band. The generated cards drew clouds as two or three flat white ellipses. They now use a shared cumulus: overlapping puffs sharing one userSpaceOnUse gradient, so the silhouette reads as a single lit volume with a shaded base, plus a glow per puff that fades to nothing for a soft edge. No blur filter — resvg drops what it cannot render, and a missing cloud is worse than a crisp one. The reaction card's cloud is the one the card is built around; the OG card keeps its two corner decorations, now recognisably clouds. --- src/lib/components/CaptureBowl.svelte | 56 ++++++++----- src/lib/server/clouds.ts | 78 +++++++++++++++++++ .../api/games/[id]/reaction-image/+server.ts | 11 +-- src/routes/og-image/[id]/+server.ts | 7 +- 4 files changed, 119 insertions(+), 33 deletions(-) create mode 100644 src/lib/server/clouds.ts diff --git a/src/lib/components/CaptureBowl.svelte b/src/lib/components/CaptureBowl.svelte index 30e663e..4df6180 100644 --- a/src/lib/components/CaptureBowl.svelte +++ b/src/lib/components/CaptureBowl.svelte @@ -8,31 +8,45 @@ let { count, stone }: Props = $props(); - const STONE_RADIUS = 8; + const STONE_RADIUS = 7.5; /** - * Heap positions, listed back row first so the front stones are drawn over - * them and the pile reads as a heap. `rank` is the order they appear in as - * captures accumulate, which starts at the front of the bowl and works - * outwards, so a bowl holding one stone does not show it tucked in a corner. + * Stones heaped in the bowl: the widest row sits at the bottom, and every row + * above it is higher and shorter, so the heap rises as a parabolic mound + * instead of a scatter. A row holds one more stone than the row above it, and + * the rows alternate odd/even so they nest rather than line up in columns. * - * A bowl this size reads as a heap, not a tally: the pile fills up and then - * stops, and the engraved number carries the exact count. + * `rank` is the order they appear in as captures accumulate: outwards from the + * middle of the bottom row, then the row above, so a bowl holding one stone + * shows it resting at the bottom rather than tucked under the rim. + * + * A bowl holds a heap, not a tally: the pile stops once the bowl is full and + * the engraved number carries the exact count. */ - const PILE = [ - { x: -30, y: -9, rank: 7 }, - { x: -15, y: -9, rank: 5 }, - { x: 0, y: -9, rank: 2 }, - { x: 15, y: -9, rank: 6 }, - { x: 30, y: -9, rank: 8 }, - { x: -23, y: -1.5, rank: 10 }, - { x: -8, y: -1.5, rank: 1 }, - { x: 8, y: -1.5, rank: 3 }, - { x: 23, y: -1.5, rank: 11 }, - { x: -15, y: 6, rank: 9 }, - { x: 0, y: 6, rank: 0 }, - { x: 15, y: 6, rank: 4 }, - ]; + const ROWS = [6, 5, 4, 3]; // stones per row, bottom row first + const STONE_SPACING = 15; // one diameter, so a row just touches + const BOTTOM_ROW_Y = 11; // at the bottom: half of it behind the front wall + const ROW_RISE = 8; // over half a diameter, so rows read as stacked + + const PILE = (() => { + const rows = ROWS.map((stones, row) => { + const y = BOTTOM_ROW_Y - row * ROW_RISE; + // Middle first, then outwards: the order this row fills in. + const fillOrder = Array.from({ length: stones }, (_, i) => i).sort( + (a, b) => Math.abs(a - (stones - 1) / 2) - Math.abs(b - (stones - 1) / 2) + ); + const alreadyBelow = ROWS.slice(0, row).reduce((sum, n) => sum + n, 0); + + return fillOrder.map((i, position) => ({ + x: (i - (stones - 1) / 2) * STONE_SPACING, + y, + rank: alreadyBelow + position, + })); + }); + + // Draw the back (upper) rows first, so the rows in front overlap them. + return rows.reverse().flat(); + })(); /** * Geometry of the 48x48 stone textures: the stone is not centred in the diff --git a/src/lib/server/clouds.ts b/src/lib/server/clouds.ts new file mode 100644 index 0000000..8f02eb0 --- /dev/null +++ b/src/lib/server/clouds.ts @@ -0,0 +1,78 @@ +/** + * Cumulus clouds for the generated cards. + * + * The shapes are overlapping circles sharing one `userSpaceOnUse` gradient, so + * the silhouette reads as a single lit volume rather than a pile of discs: lit + * from above, duskier along the flat base. Each puff also carries a glow that + * fades to nothing, which is what keeps the silhouette soft at its edges. + * + * No blur filter, deliberately: resvg silently drops what it cannot render, and + * a missing cloud is worse than a crisp one. + */ +export function cloudSvg(options: { + /** Unique per SVG document, so gradients do not collide. */ + uid: string; + x: number; + y: number; + /** 1 draws a cloud about 200x120. */ + scale?: number; + opacity?: number; +}): string { + const { uid, x, y, scale = 1, opacity = 1 } = options; + + const puffs = [ + { cx: 50, cy: 68, r: 38 }, + { cx: 104, cy: 48, r: 46 }, + { cx: 152, cy: 64, r: 34 }, + { cx: 28, cy: 86, r: 28 }, + { cx: 176, cy: 88, r: 24 }, + { cx: 100, cy: 92, r: 42 }, + ]; + + const circles = () => + puffs.map((p) => ``).join(''); + + // A soft glow per puff, fading to nothing: two sized-up passes would read as + // hard concentric outlines instead. + const halo = puffs + .map( + (p, i) => ` + + + + + ` + ) + .join(''); + + const halos = puffs + .map( + (p, i) => + `` + ) + .join(''); + + return ` + + + ${halo} + + + + + + + + + + + + + ${halos} + + + ${circles()} + ${circles()} + `; +} diff --git a/src/routes/api/games/[id]/reaction-image/+server.ts b/src/routes/api/games/[id]/reaction-image/+server.ts index a351afb..ae26f7e 100644 --- a/src/routes/api/games/[id]/reaction-image/+server.ts +++ b/src/routes/api/games/[id]/reaction-image/+server.ts @@ -4,6 +4,7 @@ import { renderPng, emojiSvgFragment, } from '$lib/server/render-png'; +import { cloudSvg } from '$lib/server/clouds'; export const GET: RequestHandler = async (event) => { const { params, url } = event; @@ -17,10 +18,6 @@ export const GET: RequestHandler = async (event) => { const svg = ` - - - - @@ -30,10 +27,8 @@ export const GET: RequestHandler = async (event) => { - - - - + + ${cloudSvg({ uid: 'reaction', x: 58, y: 104, scale: 2.45 })} diff --git a/src/routes/og-image/[id]/+server.ts b/src/routes/og-image/[id]/+server.ts index 6492347..15406b6 100644 --- a/src/routes/og-image/[id]/+server.ts +++ b/src/routes/og-image/[id]/+server.ts @@ -5,6 +5,7 @@ import { gameTitle } from '$lib/game-titles'; import { resolveDidToHandle, fetchGameActionsFromPds } from '$lib/atproto-client'; import { buildBoardStateFromMoves, generateBoardSvg } from '$lib/server/board-svg'; import { renderPng } from '$lib/server/render-png'; +import { cloudSvg } from '$lib/server/clouds'; export const GET: RequestHandler = async (event) => { const gameRkey = event.params.id; @@ -110,10 +111,8 @@ export const GET: RequestHandler = async (event) => { - - - - + ${cloudSvg({ uid: 'og-tl', x: 16, y: 26, scale: 0.95, opacity: 0.85 })} + ${cloudSvg({ uid: 'og-br', x: 978, y: 468, scale: 1.1, opacity: 0.8 })}