Board game updated pass 1

This commit is contained in:
Greg Kilmer
2026-07-06 21:50:41 -04:00
parent 7808c2ead8
commit bb4924a988
10 changed files with 997 additions and 85 deletions
+57
View File
@@ -177,6 +177,63 @@ PlayStation, Xbox, and Nintendo in one place.
---
## Board games (TTRPGs page)
The "Board games" section on the TTRPGs page reads from a JSON file and renders
an interactive shelf: a list of games on the left, and a tall image on the
right that cross-fades to a game's BGG cover when you hover its name. Clicking a
name opens the game on BoardGameGeek.
### The list — `src/data/boardgames.json`
A flat JSON array. Each entry needs a `name` and a `bggId`; `note` and `image`
are optional:
```json
[
{ "name": "Wingspan", "bggId": 266192, "note": "Engine builder" },
{ "name": "Brass: Birmingham", "bggId": 224517 }
]
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Display label + link text |
| `bggId` | number | yes | BoardGameGeek numeric id (from its URL) |
| `note` | string | no | One-line caption shown under the name |
| `image` | string | no | Cover path/URL; populated by the fetch tool |
Find a game's `bggId` in its BoardGameGeek URL:
`https://boardgamegeek.com/boardgame/<bggId>/<slug>`.
The shelf photo at rest is `/images/ttrpg-boardgame-shelf.jpg` — drop a real
photo of your shelf at that path. The image is a **progressive enhancement**:
without JS the list still works (links + shelf photo); games without an `image`
just keep the shelf photo on hover.
### Fetching covers (BoardGameGeek)
`scripts/fetch-bgg-covers.mjs` fills in the `image` field for any game missing
one, using BGG's public XML2 API (no key needed):
```bash
# Default: download covers to public/images/boardgames/<bggId>.jpg (recommended)
node scripts/fetch-bgg-covers.mjs
# Instead hotlink BGG's CDN URLs (no download, depends on their CDN)
node scripts/fetch-bgg-covers.mjs --remote
# Re-fetch even games that already have an image
node scripts/fetch-bgg-covers.mjs --force
# Slow down between BGG calls (they rate-limit; default 1200ms)
node scripts/fetch-bgg-covers.mjs --sleep 2000
```
It's idempotent — run it again any time you add games to the JSON.
---
## Interest pages
Each interest is a hand-built page in `src/pages/interests/`. They follow the
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* fetch-bgg-covers.mjs
* ---------------------------------------------------------------------------
* Support tool for the Board games section on the TTRPGs page. For each game
* in src/data/boardgames.json that is missing an `image`, it looks the game up
* on the BoardGameGeek XML2 API (public, no key — just a User-Agent), and
* either:
*
* (default) downloads the cover to public/images/boardgames/<bggId>.jpg and
* writes the local public path back into the JSON, OR
* --remote writes BGG's remote cover URL straight into the JSON (hotlinked;
* no download, but depends on BGG's CDN staying up).
*
* Idempotent: games that already have an `image` are skipped. Be gentle on BGG
* — they rate-limit, so there's a small delay between requests.
*
* USAGE
* node scripts/fetch-bgg-covers.mjs # download covers locally
* node scripts/fetch-bgg-covers.mjs --remote # hotlink BGG URLs instead
* node scripts/fetch-bgg-covers.mjs --force # re-fetch even if image set
* node scripts/fetch-bgg-covers.mjs --sleep 1500 # ms between BGG calls
* node scripts/fetch-bgg-covers.mjs --help
*
* Edit the list itself in src/data/boardgames.json:
* { "name": "Wingspan", "bggId": 266192 }
* Find a game's bggId in its BoardGameGeek URL:
* https://boardgamegeek.com/boardgame/<bggId>/<slug>
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
const DATA_FILE = join(PROJECT_ROOT, 'src', 'data', 'boardgames.json');
const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'boardgames');
const BGG_API = 'https://boardgamegeek.com/xmlapi2';
// --- args -------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
console.log(`Usage: node scripts/fetch-bgg-covers.mjs [options]
Reads src/data/boardgames.json and fills in the "image" field for any game
missing one, using BoardGameGeek's public XML2 API (no key required).
Options:
--remote write BGG's remote cover URL into the JSON instead of downloading
--force re-fetch even for games that already have an image
--sleep <ms> delay between BGG API calls (default 1200; BGG rate-limits)
-h, --help show this help
Examples:
node scripts/fetch-bgg-covers.mjs
node scripts/fetch-bgg-covers.mjs --remote --sleep 2000`);
process.exit(0);
}
const remoteMode = args.includes('--remote');
const force = args.includes('--force');
const sleepIdx = args.indexOf('--sleep');
const sleepMs = sleepIdx !== -1 ? Number(args[sleepIdx + 1]) || 1200 : 1200;
// --- helpers ----------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const fail = (msg, code = 1) => {
console.error(`✖ ${msg}`);
process.exit(code);
};
/** Extract text of the first <tagName> element from an XML string. */
const xmlText = (xml, tagName) => {
const re = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i');
const m = xml.match(re);
return m ? m[1].trim() : null;
};
// --- load data --------------------------------------------------------------
let games;
try {
const raw = await readFile(DATA_FILE, 'utf8');
games = JSON.parse(raw);
} catch (err) {
if (err?.code === 'ENOENT') fail(`Could not read ${DATA_FILE}. Does it exist?`);
fail(`Could not parse ${DATA_FILE}: ${err.message}`);
}
if (!Array.isArray(games)) fail(`${DATA_FILE} is not a JSON array.`);
console.log(
`→ ${games.length} games in ${DATA_FILE.split(/[\\/]/).pop()}` +
(remoteMode ? ' (remote mode: hotlink BGG URLs)' : ' (local mode: download covers)'),
);
// --- process each game ------------------------------------------------------
let changed = false;
const UA = 'GregKilmerProfileSite/1.0 (board-game-cover fetcher)';
await mkdir(OUT_DIR, { recursive: true });
for (let i = 0; i < games.length; i++) {
const g = games[i];
if (typeof g.bggId !== 'number') {
console.warn(` ! "${g.name}" has no bggId; skipping.`);
continue;
}
if (g.image && !force) {
console.log(` ✓ ${g.name} — already has image, skipping.`);
continue;
}
// BGG rate-limits; be polite between calls.
if (i > 0) await sleep(sleepMs);
const url = `${BGG_API}/thing?id=${g.bggId}&type=boardgame`;
console.log(`→ [${i + 1}/${games.length}] ${g.name} (${url})`);
let res;
try {
res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/xml' } });
} catch (err) {
console.warn(` ! network error: ${err?.message ?? err}; skipping.`);
continue;
}
if (res.status === 429) {
console.warn(' ! rate-limited by BGG (429). Wait a bit and re-run; skipping rest.');
break;
}
if (!res.ok) {
console.warn(` ! BGG returned ${res.status} ${res.statusText}; skipping.`);
continue;
}
const xml = await res.text();
const remoteImage = xmlText(xml, 'image'); // BGG's primary box-art URL
if (!remoteImage) {
console.warn(` ! no <image> in BGG response for bggId ${g.bggId}; skipping.`);
continue;
}
if (remoteMode) {
g.image = remoteImage;
changed = true;
console.log(` ✔ ${g.name} → ${remoteImage}`);
} else {
const ext = (remoteImage.match(/\.(jpe?g|png|webp)$/i)?.[1] ?? 'jpg').toLowerCase();
const fileName = `${g.bggId}.${ext === 'jpeg' ? 'jpg' : ext}`;
let buf;
try {
const imgRes = await fetch(remoteImage, { headers: { 'User-Agent': UA } });
if (!imgRes.ok) throw new Error(`${imgRes.status} ${imgRes.statusText}`);
buf = Buffer.from(await imgRes.arrayBuffer());
} catch (err) {
console.warn(` ! image download failed (${err?.message ?? err}); skipping.`);
continue;
}
await writeFile(join(OUT_DIR, fileName), buf);
g.image = `/images/boardgames/${fileName}`;
changed = true;
console.log(` ✔ ${g.name} → ${g.image}`);
}
}
// --- write back -------------------------------------------------------------
if (changed) {
await writeFile(DATA_FILE, JSON.stringify(games, null, 2) + '\n', 'utf8');
console.log(`\n✔ Updated ${DATA_FILE}`);
} else {
console.log('\n— Nothing to update (all games already had an image).');
}
+188
View File
@@ -0,0 +1,188 @@
/**
* BoardGameShelf.tsx
* ---------------------------------------------------------------------------
* Two-column "Board games" island for the TTRPGs page.
*
* Left: a list of game names, each a real <a> to its BoardGameGeek page.
* Hovering (or keyboard-focusing) a name selects it.
* Right: a tall image frame. At rest it shows Greg's board-game shelf photo;
* when a name is selected, that game's BGG cover cross-fades in on
* top of the shelf, with the game's name + note as a caption.
*
* Progressive enhancement: without JS the list is still fully usable — the
* links work and the shelf photo stays put. The cross-fade is the enhancement.
* framer-motion honors prefers-reduced-motion automatically.
*
* Data shape (src/data/boardgames.json):
* { name: string; bggId: number; note?: string; image?: string }
* `image` is optional — populated by scripts/fetch-bgg-covers.mjs. Rows without
* an `image` simply keep the shelf photo on hover (graceful fallback).
*/
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
export interface BoardGame {
name: string;
/** BoardGameGeek numeric game id; drives the link + cover lookup. */
bggId: number;
/** Optional one-line note shown as the caption. */
note?: string;
/** Optional cover image URL/path. If absent, hovering won't swap images. */
image?: string;
}
interface Props {
games: BoardGame[];
/** Public path to the default shelf photo, e.g. "/images/ttrpg-boardgame-shelf.jpg". */
shelfImage: string;
/** Alt text for the shelf photo. */
shelfAlt: string;
/** Optional caption shown at rest over the shelf photo. */
shelfCaption?: string;
}
/**
* Fixed height for BOTH columns. The list shows up to 8 rows in a scroll
* container; the image frame is pinned to this same height so the two
* columns stay aligned regardless of how many games are in the list.
* ~3.25rem per row x 8 rows.
*/
const SHELF_HEIGHT = '44rem';
const crossfade = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
};
export default function BoardGameShelf({
games,
shelfImage,
shelfAlt,
shelfCaption = 'The shelf',
}: Props) {
// Index of the active game, or null when at rest (showing the shelf).
const [active, setActive] = useState<number | null>(null);
const activeGame = active !== null ? games[active] : null;
// Only swap to the cover if the active game actually has an image.
const showCover = activeGame?.image;
return (
<div className="grid gap-5 sm:grid-cols-2 sm:items-stretch">
{/* LEFT: game list — capped at the shelf height with internal scroll */}
<ul
className="flex flex-col divide-y overflow-y-auto pr-1"
style={{
borderColor: 'var(--color-border)',
height: SHELF_HEIGHT,
scrollbarWidth: 'thin',
}}
>
{games.map((g, i) => {
const isActive = active === i;
return (
<li key={g.bggId}>
<a
href={`https://boardgamegeek.com/boardgame/${g.bggId}`}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onMouseLeave={() => setActive((cur) => (cur === i ? null : cur))}
onBlur={() => setActive((cur) => (cur === i ? null : cur))}
className="group flex items-center gap-3 py-3 transition-colors"
style={{
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
}}
>
<span
aria-hidden="true"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors"
style={{
background: isActive
? 'color-mix(in srgb, var(--color-accent) 20%, transparent)'
: 'color-mix(in srgb, var(--color-secondary) 16%, transparent)',
color: isActive ? 'var(--color-accent)' : 'var(--color-secondary)',
}}
>
{/* dice / playing-card style glyph, inline to stay self-contained */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm0 2v14h14V5H5zm4 2.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm9 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-9 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm4.5-3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z" />
</svg>
</span>
<span className="flex flex-col gap-0.5">
<span className="font-sans text-base leading-tight underline-offset-4 group-hover:underline group-focus:underline">
{g.name}
</span>
{g.note && (
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
{g.note}
</span>
)}
</span>
</a>
</li>
);
})}
</ul>
{/* RIGHT: tall image frame, shelf at rest, cover on hover. Pinned to the
same height as the list so the two columns line up. */}
<figure
className="relative w-full overflow-hidden rounded-xl border"
style={{
height: SHELF_HEIGHT,
borderColor: 'color-mix(in srgb, var(--color-accent) 35%, transparent)',
background: 'color-mix(in srgb, var(--color-secondary) 12%, transparent)',
}}
// When the pointer leaves the whole figure, snap back to the shelf.
onMouseLeave={() => setActive(null)}
>
{/* Base layer: shelf photo (always present; covers cross-fade on top). */}
<img
src={shelfImage}
alt={shelfAlt}
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Gradient + caption overlay for legibility. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/3"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
/>
<AnimatePresence>
{showCover && (
<motion.img
key={activeGame!.bggId}
src={activeGame!.image}
alt={`${activeGame!.name} cover`}
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-contain"
initial={crossfade.initial}
animate={crossfade.animate}
exit={crossfade.exit}
transition={crossfade.transition}
/>
)}
</AnimatePresence>
<figcaption
className="absolute inset-x-0 bottom-0 flex flex-col gap-0.5 p-4"
style={{ color: 'var(--color-cream)' }}
>
<span className="font-sans text-lg leading-tight drop-shadow">
{activeGame?.name ?? shelfCaption}
</span>
{activeGame?.note && (
<span className="text-sm opacity-90 drop-shadow">{activeGame.note}</span>
)}
</figcaption>
</figure>
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
[
{
"name": "Wyrmspan",
"bggId": 410201,
"note": "Engine-building with dragons. A Wingspan reimplementation with slightly more complex mechanics.",
"image": "https://cf.geekdo-images.com/original/pic7947338.png"
},
{
"name": "Spirit Island",
"bggId": 162886,
"note": "Cooperative area control. Play as spirits defending an island from invaders. Highly complex and strategic.",
"image": "https://cf.geekdo-images.com/original/pic7013651.jpg"
},
{
"name": "Codenames",
"bggId": 178900,
"note": "Party word game. Teams compete to find their agents using one-word clues.",
"image": "https://cf.geekdo-images.com/original/pic2856478.jpg"
},
{
"name": "Codenames: Duet",
"bggId": 224037,
"note": "Two-player cooperative version of Codenames. Work together to find all the agents.",
"image": "https://cf.geekdo-images.com/original/pic3550436.jpg"
},
{
"name": "Coup",
"bggId": 131357,
"note": "Deduction and bluffing game. Each player has two roles; claim actions and challenge others.",
"image": "https://cf.geekdo-images.com/original/pic1892875.jpg"
},
{
"name": "The Resistance",
"bggId": 41114,
"note": "Social deduction hidden roles game. Resistance members vs spies trying to sabotage missions.",
"image": "https://cf.geekdo-images.com/cAPTkL2BR3snLO71dkH8rw__imagepage/img/IOY562LZXFjsmcVioY5c_r70Ebc=/fit-in/900x600/filters:no_upscale():strip_icc()/pic2576459.jpg"
},
{
"name": "Ticket to Ride",
"bggId": 9209,
"note": "Route-building set collection. Collect train cards to claim railway routes across the country.",
"image": "https://cf.geekdo-images.com/original/pic1000656.jpg"
},
{
"name": "7 Wonders",
"bggId": 68448,
"note": "Card-drafting civilization building. Build one of the seven wonders through three ages.",
"image": "https://cf.geekdo-images.com/original/pic869179.jpg"
},
{
"name": "Pandemic",
"bggId": 30549,
"note": "Cooperative strategy. Work together to stop global disease outbreaks before they spread too far.",
"image": "https://cf.geekdo-images.com/original/pic398633.jpg"
},
{
"name": "King of Tokyo",
"bggId": 70323,
"note": "Dice-rolling monster combat. Be the monster who earns 20 points or eliminates the competition.",
"image": "https://cf.geekdo-images.com/original/pic1142193.jpg"
},
{
"name": "Carcassonne",
"bggId": 822,
"note": "Tile-placement medieval city building. Place tiles to build the city and claim features for points.",
"image": "https://cf.geekdo-images.com/original/pic830344.jpg"
},
{
"name": "Sagrada",
"bggId": 199561,
"note": "Dice-drafting stained glass window building. Draft colored dice to complete patterns on your window.",
"image": "https://cf.geekdo-images.com/original/pic3297563.jpg"
},
{
"name": "Small World",
"bggId": 40692,
"note": "Area control fantasy civilization. Races rise and fall as you compete for territory across the map.",
"image": "https://cf.geekdo-images.com/original/pic699500.jpg"
},
{
"name": "Dead of Winter",
"bggId": 150376,
"note": "Cooperative survival with betrayal. Survive the zombie apocalypse while completing secret objectives.",
"image": "https://cf.geekdo-images.com/g4mV4BH-ZrhMUVgil-yV1A__imagepage/img/CJ6erNGmKfFGhR6ZbxXbBI7XaoE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3016500.jpg"
},
{
"name": "Betrayal at the House on the Hill",
"bggId": 10547,
"note": "Traitor horror exploration. Explore a haunted mansion until one betrays the others in a unique haunt.",
"image": "https://cf.geekdo-images.com/original/pic302310.jpg"
},
{
"name": "Betrayal at Baldur's Gate",
"bggId": 228660,
"note": "Traitor D&D horror. Betrayal set in the Forgotten Realms with fantasy-themed haunts.",
"image": "https://cf.geekdo-images.com/O6TfqmDWCkJvZPZw6pcxpg__imagepage/img/Zif6XP4Ij1qxBH2vHB6V2jBXFHk=/fit-in/900x600/filters:no_upscale():strip_icc()/pic6552099.png"
},
{
"name": "Lords of Waterdeep",
"bggId": 110327,
"note": "Worker placement D&D city management. Assign adventurers to quests to score points for your secret lord.",
"image": "https://cf.geekdo-images.com/original/pic1204394.jpg"
},
{
"name": "Mysterium",
"bggId": 181304,
"note": "Cooperative murder mystery. One ghost communicates clues through abstract art; others guess the murderer.",
"image": "https://cf.geekdo-images.com/1nQ3ZKudtDeAP7IiKE-kNg__imagepage/img/scbgJc5EdSpHJGxGbV9dgpxj9hQ=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8625343.jpg"
},
{
"name": "Hanabi",
"bggId": 98778,
"note": "Cooperative card game where you see everyone's cards but your own. Work together to build the perfect firework display.",
"image": "https://cf.geekdo-images.com/original/pic1598898.jpg"
},
{
"name": "Forbidden Island",
"bggId": 65244,
"note": "Cooperative adventure survival. Work together to collect treasures from a sinking island before it's too late.",
"image": "https://cf.geekdo-images.com/original/pic711958.jpg"
},
{
"name": "Sushi Go Deluxe",
"bggId": 172563,
"note": "Card-drafting sushi set collection. Pick sushi cards to build the best meal; deluxe version with more cards.",
"image": "https://cf.geekdo-images.com/original/pic3246378.jpg"
},
{
"name": "Evolution (with climate)",
"bggId": 182134,
"note": "Species evolution strategy. Build species with traits to adapt to changing food sources and climate.",
"image": "https://cf.geekdo-images.com/original/pic2696435.jpg"
},
{
"name": "Quacks of Quedlinburg (Deluxe)",
"bggId": 244521,
"note": "Push-your luck potion brewing. Draw ingredient chips from your bag without exploding your cauldron.",
"image": "https://cf.geekdo-images.com/original/pic3953887.jpg"
},
{
"name": "Dragonfire",
"bggId": 221217,
"note": "D&D deck-building cooperative. Build your deck and defend the North from dragons and monsters.",
"image": "https://cf.geekdo-images.com/original/pic3519244.jpg"
},
{
"name": "Mistborn: The Deckbuilding Game",
"bggId": 422780,
"note": "Cosmere deck-building strategy. Burn metals in fast-paced allomantic battles in the Final Empire.",
"image": "https://cf.geekdo-images.com/original/pic8148911.jpg"
},
{
"name": "Doomlings",
"bggId": 356834,
"note": "Card game set collection with extinction events. Build species traits through ages until doomsday arrives.",
"image": "https://cf.geekdo-images.com/original/pic6574236.jpg"
},
{
"name": "Dragonology Gems",
"bggId": 329381,
"note": "Dragon-themed set collection. Collect gems to complete dragon research sets.",
"image": "https://cf.geekdo-images.com/original/pic6043991.jpg"
},
{
"name": "Heroes of Barcadia",
"bggId": 341027,
"note": "Dungeon crawl party game. Progress through arcade-themed dungeon rooms and drink along the way.",
"image": "https://cf.geekdo-images.com/htK1yU55RTMFIpT2ivdN2w__imagepage/img/kQBLS2DHkRtJfR0MgrhsNdvGBlU=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8505535.jpg"
},
{
"name": "Icarus",
"bggId": 374025,
"note": "Storytelling civilization collapse. Build a civilization through history, then try to prevent its fall.",
"image": "https://cf.geekdo-images.com/original/pic6860915.jpg"
},
{
"name": "Epic Spellwars of the Battle Wizards: Mt. Skullzfire",
"bggId": 124742,
"note": "Over-the-top card combat wizard battle. Combine spell cards to attack other wizards in gory absurdity.",
"image": "https://cf.geekdo-images.com/original/pic1409236.jpg"
},
{
"name": "Lost Cities: Duel",
"bggId": 192544,
"note": "Two-player expedition card game. Compete to complete expeditions worth more than your opponent's.",
"image": "https://cf.geekdo-images.com/original/pic2798708.jpg"
},
{
"name": "The Fox in the Forest",
"bggId": 233077,
"note": "Two-player trick-taking. Win tricks to score points, but too many wins can hurt your total.",
"image": "https://cf.geekdo-images.com/original/pic3698833.jpg"
},
{
"name": "One Night Werewolf",
"bggId": 92696,
"note": "Fast-paced social deduction. Single night of werewolf chaos; vote to eliminate the werewolf.",
"image": "https://cf.geekdo-images.com/original/pic1212691.jpg"
},
{
"name": "What Do You Meme?",
"bggId": 234699,
"note": "Party game matching caption cards to meme photos. Create the funniest meme combinations.",
"image": "https://cf.geekdo-images.com/original/pic3749917.jpg"
},
{
"name": "If You Know You Know",
"bggId": 318265,
"note": "Pop culture trivia party game. Guess phrases from partial information in categories.",
"image": "https://cf.geekdo-images.com/original/pic5496066.jpg"
},
{
"name": "Cards Against Humanity",
"bggId": 50381,
"note": "Offensive humor party game. Fill in the blank with the most inappropriate card from your hand.",
"image": "https://cf.geekdo-images.com/original/pic899330.jpg"
},
{
"name": "Hues and Cues",
"bggId": 326447,
"note": "Color communication party game. Give one- or two-word cues for others to guess a specific color.",
"image": "https://cf.geekdo-images.com/original/pic5903863.jpg"
},
{
"name": "Wavelength",
"bggId": 250349,
"note": "Telepathy party game. Give clues to help your team locate a hidden concept on a spectrum.",
"image": "https://cf.geekdo-images.com/original/pic4181983.jpg"
},
{
"name": "Lost in Translation",
"bggId": 204238,
"note": "Word communication party game. Describe words while avoiding forbidden translations and synonyms.",
"image": "https://cf.geekdo-images.com/original/pic3073849.jpg"
},
{
"name": "Risk Legacy",
"bggId": 107907,
"note": "Legacy strategy global conquest. Write on the board, open sealed packets, and evolve the game over plays.",
"image": "https://cf.geekdo-images.com/original/pic1086743.jpg"
},
{
"name": "Risk",
"bggId": 181,
"note": "Classic strategy global domination. Conquer territories and eliminate opponents in military world conquest.",
"image": "https://cf.geekdo-images.com/original/pic1059899.jpg"
},
{
"name": "Age of Mythology: The Board Game",
"bggId": 464,
"note": "Area control mythological warfare. Recruit armies, build structures, and invoke gods' favor.",
"image": "https://cf.geekdo-images.com/original/pic224600.jpg"
},
{
"name": "Tsuro",
"bggId": 22138,
"note": "Tile placement path following. Lay tiles to create paths; stay on the board while others fall off.",
"image": "https://cf.geekdo-images.com/original/pic335329.jpg"
},
{
"name": "Kings Cup",
"bggId": 48967,
"note": "Drinking game with card-based rules. Draw cards and follow the associated drinking challenges.",
"image": "https://cf.geekdo-images.com/original/pic886320.jpg"
},
{
"name": "Pocket Farkle",
"bggId": 36139,
"note": "Dice game push your luck. Roll dice to score points, but risk losing everything if you push too far.",
"image": "https://cf.geekdo-images.com/original/pic338874.jpg"
},
{
"name": "Pokertiles",
"bggId": 13327,
"note": "Abstract tile strategy game. Form poker hands using numbered tiles on a board.",
"image": "https://cf.geekdo-images.com/original/pic113334.jpg"
},
{
"name": "The Perilous Parlor Game",
"bggId": 227503,
"note": "Victorian mystery party game. Deduce the culprit through clues and interactions in a parlor setting.",
"image": "https://cf.geekdo-images.com/original/pic3552892.jpg"
},
{
"name": "Deception: Murder in Hong Kong",
"bggId": 156129,
"note": "Social deduction murder mystery. Forensic scientists identify the killer while one investigator secretly helps.",
"image": "https://cf.geekdo-images.com/original/pic2037351.jpg"
},
{
"name": "Deception: Undercover Allies",
"bggId": 290617,
"note": "Expansion for Deception: Murder in Hong Kong. Adds new roles and additional investigator options.",
"image": "https://cf.geekdo-images.com/original/pic4759191.jpg"
},
{
"name": "Settlers of Catan (expansion)",
"bggId": 5602,
"note": "Expansion for Settlers of Catan. Adds additional gameplay content to the base Catan experience.",
"image": "https://cf.geekdo-images.com/original/pic135031.jpg"
}
]
+1 -2
View File
@@ -49,8 +49,7 @@ const interests = [
href: "/interests/making",
icon: "palette",
title: "Making & Creating",
tagline:
"If it involves really nice cardboard or mastering a new craft, I am in.",
tagline: "A magnet for super glue and nice cardboard.",
blurb:
"Resin dice in a pressure pot, fun and functional prints on the P1s, custom TCG proxies and decks. Passion and utility.",
image: "/images/making-resin.jpg",
+143 -29
View File
@@ -5,31 +5,95 @@
* Per-interest template with the spec's reading highlights table (status pills
* mapped from spec emoji statuses), anime favorites, and movies/TV note.
*/
import BaseLayout from '@layouts/BaseLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import InterestHero from '@components/layout/InterestHero.astro';
import InterestCard from '@components/cards/InterestCard.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import RelatedInterests from '@components/layout/RelatedInterests.astro';
import Icon from '@components/Icon.astro';
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import InterestCard from "@components/cards/InterestCard.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Icon from "@components/Icon.astro";
// Spec reading highlights, with status mapped from the spec's emoji statuses.
const reading = [
{ icon: 'check-decagram', title: 'Brandon Sanderson — Cosmere', status: 'Complete', body: 'Complete. Every novel, every era. The gold standard for me.' },
{ icon: 'check-decagram', title: 'Frank Herbert — Dune', status: 'Complete', body: 'Books 1-3 complete.' },
{ icon: 'autorenew', title: 'Douglas Preston & Lincoln Child — Pendergast', status: 'Rereading', body: 'Currently rereading. Up to Reliquary.' },
{ icon: 'autorenew', title: 'Sarah J. Maas — Throne of Glass', status: 'Reading', body: 'Currently on Heir of Fire (Book 3).' },
{ icon: 'new-box', title: 'Wistoria: Wand and Sword', status: 'New', body: 'Recently added to the reading list.' },
{
icon: "check-decagram",
title: "Brandon Sanderson — Cosmere",
status: "Complete",
body: "Complete. Every novel, every era. The gold standard for me.",
},
{
icon: "check-decagram",
title: "Frank Herbert — Dune",
status: "Complete",
body: "Books 1-3 complete.",
},
{
icon: "bookmark",
title: "Emily Rodda — Deltora Quest",
status: "Reread-pending",
body: "Planning to listen to audiobooks to reread.",
},
{
icon: "volume-high",
title: "J.R.R. Tolkein — The Lord of the Rings",
status: "Listening",
body: "Rereading by listening to the Andy Serkis audiobooks.",
},
{
icon: "autorenew",
title: "Douglas Preston & Lincoln Child — Pendergast",
status: "Rereading",
body: "Currently rereading. Up to Cabinet of Curiosities.",
},
{
icon: "autorenew",
title: "Sarah J. Maas — Throne of Glass",
status: "Reading",
body: "Currently Tandem reading Heir of Fire (Book 3) and Tower of Dawn (Book 4).",
},
{
icon: "bookmark",
title: "Fonda Lee — Green Bone Saga",
status: "Next",
body: "Next Series to Read.",
},
{
icon: "bookmark",
title: "That Time I Got Reincarnated as a Slime",
status: "New",
body: "Read up to Volume 13 before, planning to finish now that it is complete.",
},
{
icon: "new-box",
title: "Wistoria: Wand and Sword",
status: "New",
body: "Recently added to the reading list. After finishing season 2",
},
];
const anime = [
'One Piece', 'Bleach', 'Gundam (all series)', 'Code Geass',
'Magi', 'That Time I Got Reincarnated as a Slime', 'Overlord',
"One Piece",
"Bleach",
"Gundam (all series)",
"Code Geass",
"Magi",
"That Time I Got Reincarnated as a Slime",
"Overlord",
];
const related = [
{ href: '/interests/ttrpgs', title: 'TTRPGs & Tabletop', icon: 'dice-multiple', blurb: 'Worldbuilding inspiration flows both ways.' },
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Props and displays for the stories I love.' },
{
href: "/interests/ttrpgs",
title: "TTRPGs & Tabletop",
icon: "dice-multiple",
blurb: "Worldbuilding inspiration flows both ways.",
},
{
href: "/interests/making",
title: "Making & Creating",
icon: "palette",
blurb: "Props and displays for the stories I love.",
},
];
---
@@ -48,54 +112,104 @@ const related = [
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
<Reveal><p>I read like it's a competitive sport. Fantasy is my native language, Brandon Sanderson's Cosmere is fully consumed and eagerly awaiting the next era. Frank Herbert's Dune trilogy shaped how I think about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga is my comfort reread. And I'm currently working through Sarah J. Maas's Throne of Glass series.</p></Reveal>
<Reveal delay={80}><p>Beyond novels, I read light novels, comic books, manhwa, and manga. I watch anime and film extensively. If a story exists in any format, I'm probably consuming it.</p></Reveal>
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
I read like it's a competitive sport. Fantasy is my native language,
Brandon Sanderson's Cosmere is fully consumed and I am eagerly
awaiting the next era. Frank Herbert's Dune trilogy shaped how I think
about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga
showed me that a well-crafted narrative can keep me engaged outside of
a Tolkein-esque fantasy world. And I'm currently working through Sarah
J. Maas's Throne of Glass series.
</p></Reveal
>
<Reveal delay={80}
><p>
Beyond novels, I read light novels, comic books, manhwa, and manga. I
watch anime and film extensively. If a story exists in any format, I'm
probably consuming it in some capacity.
</p></Reveal
>
</section>
{/* Reading highlights */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="On the shelf" icon="bookshelf" title="Reading highlights" /></Reveal>
<Reveal
><SectionHeader
kicker="On the shelf"
icon="bookshelf"
title="Reading highlights"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{reading.map((r, i) => (
{
reading.map((r, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={r.icon} title={r.title} status={r.status}>{r.body}</InterestCard>
<InterestCard icon={r.icon} title={r.title} status={r.status}>
{r.body}
</InterestCard>
</Reveal>
))}
))
}
</div>
</section>
{/* Anime */}
<section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Animated" icon="television-classic" title="Anime favorites" /></Reveal>
<Reveal
><SectionHeader
kicker="Animated"
icon="television-classic"
title="Anime favorites"
/></Reveal
>
<Reveal delay={80}>
<ul class="flex flex-wrap gap-3">
{anime.map((a) => (
{
anime.map((a) => (
<li class="pill text-sm">
<Icon name="play-circle" class="h-3.5 w-3.5" />
{a}
</li>
))}
))
}
</ul>
</Reveal>
</section>
{/* Movies & TV */}
<section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Beyond the page" icon="movie-open" title="Movies & TV" /></Reveal>
<Reveal
><SectionHeader
kicker="Beyond the page"
icon="movie-open"
title="Movies & TV"
/></Reveal
>
<Reveal delay={80}>
<div class="grid gap-5 sm:grid-cols-[1fr_18rem]">
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
>
Watches extensively across genres. Particularly drawn to sci-fi, fantasy, and animated film.
Watches extensively across genres. Particularly drawn to sci-fi,
fantasy, and animated film.
</blockquote>
<figure
class="relative overflow-hidden rounded-xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
>
<img src="/images/fantasy-books.jpg" alt="Fantasy and media collection" loading="lazy" decoding="async" class="aspect-[4/3] w-full object-cover" />
<img
src="/images/fantasy-books.jpg"
alt="Fantasy and media collection"
loading="lazy"
decoding="async"
class="aspect-[4/3] w-full object-cover"
/>
</figure>
</div>
</Reveal>
+130 -46
View File
@@ -6,25 +6,67 @@
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
* then the Board Games sub-section, then RelatedInterests cross-links.
*/
import BaseLayout from '@layouts/BaseLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import InterestHero from '@components/layout/InterestHero.astro';
import InterestCard from '@components/cards/InterestCard.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import RelatedInterests from '@components/layout/RelatedInterests.astro';
import Placeholder from '@components/layout/Placeholder.astro';
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import InterestCard from "@components/cards/InterestCard.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import BoardGameShelf from "@components/ttrpgs/BoardGameShelf.tsx";
import boardgames from "@/data/boardgames.json";
const highlights = [
{ icon: 'castle', title: 'Dungeons & Dragons', body: 'Long-running campaign on Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.' },
{ icon: 'sword-cross', title: 'Daggerheart', body: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D." },
{ icon: 'bird', title: 'Crows (Playtest)', body: "MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback." },
{ icon: 'monitor', title: 'Foundry VTT', body: 'Self-hosted on the home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns.' },
{ icon: 'target', title: 'Draw Steel', body: "MCDM's upcoming tactical combat game. Interested in running it when it releases." },
{
icon: "castle",
title: "Dungeons & Dragons",
body: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.",
},
{
icon: "sword-cross",
title: "Daggerheart",
body: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D.",
},
{
icon: "target",
title: "Draw Steel",
body: "MCDM's fantasy tactical cinematic combat game. Interested in running it when I get a chance.",
},
{
icon: "shield-moon-outline",
title: "Cypher System",
body: "Monte Cook's narrative-focused system. Played a few one-shots and short form campaigns.",
},
{
icon: "knife-military",
title: "Blades in the Dark",
body: "A gritty, cinematic system for heists and criminal enterprises. Interested in playing short campaigns in it one day.",
},
{
icon: "weather-windy",
title: "Avatar the Last Airbender RPG",
body: "A narrative-focused system set in the world of Avatar. Interested in exploring bending mechanics and character-driven stories.",
},
{
icon: "monitor",
title: "Foundry VTT",
body: "Self-hosted on the home server. Building custom modules, maps, and automation for campaigns.",
},
];
const related = [
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Dice and terrain made for campaigns.' },
{ href: '/interests/gaming', title: 'Gaming', icon: 'gamepad-variant', blurb: 'Digital RPGs as another expression of the same impulse.' },
{
href: "/interests/making",
title: "Making & Creating",
icon: "palette",
blurb: "Dice and terrain made for campaigns.",
},
{
href: "/interests/gaming",
title: "Gaming",
icon: "gamepad-variant",
blurb: "Digital RPGs as another expression of the same impulse.",
},
];
---
@@ -43,24 +85,72 @@ const related = [
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
<Reveal><p>Tabletop gaming isn't a hobby for me, it's a practice. I run Dungeons & Dragons and Daggerheart campaigns that span years of real-time play, maintaining worlds, NPCs, and narrative threads that my players remember years later. I host everything on Foundry VTT, which lets me build the kind of dynamic, visually rich experiences that keep players invested session after session.</p></Reveal>
<Reveal delay={80}><p>Right now, I'm also playtesting MCDM's <em>Crows</em>, Matt Colville's forthcoming RPG built around hex-crawling, faction play, and hard travel rules. The playtest has already influenced how I think about overland encounters in my own campaigns.</p></Reveal>
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
I love playing tabletop games from trading card games to role-playing
games. I run Dungeons & Dragons and Daggerheart campaigns that span
years of real-time play, maintaining worlds, NPCs, and narrative
threads that my players remember years later. I host everything on
Foundry VTT, which lets me build the kind of dynamic, visually rich
experiences that keep players invested session after session and lets
me flex my technical skills.
</p></Reveal
>
<Reveal delay={60}
><p>
I also love board games, from strategy to social deduction. My
collection is growing, and I enjoy hosting game nights with friends. I
have a particular fondness for games that challenge players to think
critically and creatively, and I appreciate the social aspect of
gaming as much as the gameplay itself. Although, I do love the ability
to play some of the engine building and deck-building games solo.
Spirit Island and Wyrmspan have been my favorites lately, and I am
always on the lookout for new games to add to my collection.
</p></Reveal
>
<Reveal delay={100}
><p>
I have played a few trading card games and collected many cards. I
have been a lifelong Pokémon and Magic: The Gathering fan, and I enjoy
the strategy and collection aspects of both games in different ways.
While I mostly enjoy looking at Pokemon cards, I spend most of my time
with Magic deck building and playing Elder Dragon Highlander (or
Commander). I recently picked up Riftbound and have had my eye on the
Gundam TCG game as well. The mechanical differences of these games are
fascinating, and I enjoy exploring the different strategies and
deck-building techniques that each game offers.
</p></Reveal
>
</section>
{/* Highlights */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Highlights" icon="view-grid" title="Systems & tools" /></Reveal>
<Reveal
><SectionHeader
kicker="Highlights"
icon="view-grid"
title="Systems & tools"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{highlights.map((h, i) => (
{
highlights.map((h, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={h.icon} title={h.title}>{h.body}</InterestCard>
<InterestCard icon={h.icon} title={h.title}>
{h.body}
</InterestCard>
</Reveal>
))}
))
}
</div>
</section>
{/* Currently */}
{
/* Currently
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
<div class="grid gap-4 sm:grid-cols-3">
@@ -69,42 +159,36 @@ const related = [
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
</div>
</section>
*/
}
{/* Board games */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Beyond RPGs" icon="cards-playing" title="Board games" /></Reveal>
<Reveal
><SectionHeader
kicker="Beyond RPGs"
icon="cards-playing"
title="Board games"
/></Reveal
>
<Reveal delay={80}>
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
>
Beyond RPGs, I maintain a growing board and card game collection.
Favorites span strategy, deck-building, and social deduction. Game night
is a regular event, and yes, there's always room for one more.
Favorites span strategy, deck-building, and social deduction. Game
night is a regular event, and yes, there's always room for one more.
Hover a title to preview its cover; click to open it on BoardGameGeek.
</blockquote>
</Reveal>
<Reveal delay={140}>
<div class="grid gap-4 sm:grid-cols-[2fr_1fr]">
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
>
Beyond RPGs, I maintain a growing board and card game collection.
Favorites span strategy, deck-building, and social deduction. Game night
is a regular event, and yes, there's always room for one more.
</blockquote>
<figure
class="relative overflow-hidden rounded-xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
>
<img src="/images/ttrpg-dice.jpg" alt="Dice and game pieces" loading="lazy" decoding="async" class="h-full w-full object-cover" />
</figure>
</div>
</Reveal>
<Reveal delay={200}>
<Placeholder
label="Board game shelf"
hint="Replace this with a real photo of your game shelf once you have one."
<BoardGameShelf
client:idle
games={boardgames}
shelfImage="/images/ttrpg-boardgame-shelf.jpg"
shelfAlt="Greg's board game shelf"
shelfCaption="The shelf"
/>
</Reveal>
</section>