mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Board game updated pass 1
This commit is contained in:
@@ -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
|
## Interest pages
|
||||||
|
|
||||||
Each interest is a hand-built page in `src/pages/interests/`. They follow the
|
Each interest is a hand-built page in `src/pages/interests/`. They follow the
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 512 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
@@ -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).');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -49,8 +49,7 @@ const interests = [
|
|||||||
href: "/interests/making",
|
href: "/interests/making",
|
||||||
icon: "palette",
|
icon: "palette",
|
||||||
title: "Making & Creating",
|
title: "Making & Creating",
|
||||||
tagline:
|
tagline: "A magnet for super glue and nice cardboard.",
|
||||||
"If it involves really nice cardboard or mastering a new craft, I am in.",
|
|
||||||
blurb:
|
blurb:
|
||||||
"Resin dice in a pressure pot, fun and functional prints on the P1s, custom TCG proxies and decks. Passion and utility.",
|
"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",
|
image: "/images/making-resin.jpg",
|
||||||
|
|||||||
@@ -5,31 +5,95 @@
|
|||||||
* Per-interest template with the spec's reading highlights table (status pills
|
* Per-interest template with the spec's reading highlights table (status pills
|
||||||
* mapped from spec emoji statuses), anime favorites, and movies/TV note.
|
* mapped from spec emoji statuses), anime favorites, and movies/TV note.
|
||||||
*/
|
*/
|
||||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||||
import Reveal from '@components/motion/Reveal.astro';
|
import Reveal from "@components/motion/Reveal.astro";
|
||||||
import InterestHero from '@components/layout/InterestHero.astro';
|
import InterestHero from "@components/layout/InterestHero.astro";
|
||||||
import InterestCard from '@components/cards/InterestCard.astro';
|
import InterestCard from "@components/cards/InterestCard.astro";
|
||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import RelatedInterests from '@components/layout/RelatedInterests.astro';
|
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||||
import Icon from '@components/Icon.astro';
|
import Icon from "@components/Icon.astro";
|
||||||
|
|
||||||
// Spec reading highlights, with status mapped from the spec's emoji statuses.
|
// Spec reading highlights, with status mapped from the spec's emoji statuses.
|
||||||
const reading = [
|
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: "check-decagram",
|
||||||
{ icon: 'autorenew', title: 'Douglas Preston & Lincoln Child — Pendergast', status: 'Rereading', body: 'Currently rereading. Up to Reliquary.' },
|
title: "Brandon Sanderson — Cosmere",
|
||||||
{ icon: 'autorenew', title: 'Sarah J. Maas — Throne of Glass', status: 'Reading', body: 'Currently on Heir of Fire (Book 3).' },
|
status: "Complete",
|
||||||
{ icon: 'new-box', title: 'Wistoria: Wand and Sword', status: 'New', body: 'Recently added to the reading list.' },
|
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 = [
|
const anime = [
|
||||||
'One Piece', 'Bleach', 'Gundam (all series)', 'Code Geass',
|
"One Piece",
|
||||||
'Magi', 'That Time I Got Reincarnated as a Slime', 'Overlord',
|
"Bleach",
|
||||||
|
"Gundam (all series)",
|
||||||
|
"Code Geass",
|
||||||
|
"Magi",
|
||||||
|
"That Time I Got Reincarnated as a Slime",
|
||||||
|
"Overlord",
|
||||||
];
|
];
|
||||||
|
|
||||||
const related = [
|
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">
|
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||||
{/* Overview */}
|
{/* Overview */}
|
||||||
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
<section
|
||||||
<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>
|
class="flex flex-col gap-5 text-lg leading-relaxed"
|
||||||
<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>
|
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>
|
</section>
|
||||||
|
|
||||||
{/* Reading highlights */}
|
{/* Reading highlights */}
|
||||||
<section class="flex flex-col gap-6">
|
<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">
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{reading.map((r, i) => (
|
{
|
||||||
<Reveal delay={i * 70}>
|
reading.map((r, i) => (
|
||||||
<InterestCard icon={r.icon} title={r.title} status={r.status}>{r.body}</InterestCard>
|
<Reveal delay={i * 70}>
|
||||||
</Reveal>
|
<InterestCard icon={r.icon} title={r.title} status={r.status}>
|
||||||
))}
|
{r.body}
|
||||||
|
</InterestCard>
|
||||||
|
</Reveal>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Anime */}
|
{/* Anime */}
|
||||||
<section class="flex flex-col gap-5">
|
<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}>
|
<Reveal delay={80}>
|
||||||
<ul class="flex flex-wrap gap-3">
|
<ul class="flex flex-wrap gap-3">
|
||||||
{anime.map((a) => (
|
{
|
||||||
<li class="pill text-sm">
|
anime.map((a) => (
|
||||||
<Icon name="play-circle" class="h-3.5 w-3.5" />
|
<li class="pill text-sm">
|
||||||
{a}
|
<Icon name="play-circle" class="h-3.5 w-3.5" />
|
||||||
</li>
|
{a}
|
||||||
))}
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Movies & TV */}
|
{/* Movies & TV */}
|
||||||
<section class="flex flex-col gap-5">
|
<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}>
|
<Reveal delay={80}>
|
||||||
<div class="grid gap-5 sm:grid-cols-[1fr_18rem]">
|
<div class="grid gap-5 sm:grid-cols-[1fr_18rem]">
|
||||||
<blockquote
|
<blockquote
|
||||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
class="border-l-2 pl-5 text-lg leading-relaxed"
|
||||||
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
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>
|
</blockquote>
|
||||||
<figure
|
<figure
|
||||||
class="relative overflow-hidden rounded-xl border"
|
class="relative overflow-hidden rounded-xl border"
|
||||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
|
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>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -6,25 +6,67 @@
|
|||||||
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
|
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
|
||||||
* then the Board Games sub-section, then RelatedInterests cross-links.
|
* then the Board Games sub-section, then RelatedInterests cross-links.
|
||||||
*/
|
*/
|
||||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||||
import Reveal from '@components/motion/Reveal.astro';
|
import Reveal from "@components/motion/Reveal.astro";
|
||||||
import InterestHero from '@components/layout/InterestHero.astro';
|
import InterestHero from "@components/layout/InterestHero.astro";
|
||||||
import InterestCard from '@components/cards/InterestCard.astro';
|
import InterestCard from "@components/cards/InterestCard.astro";
|
||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import RelatedInterests from '@components/layout/RelatedInterests.astro';
|
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||||
import Placeholder from '@components/layout/Placeholder.astro';
|
import Placeholder from "@components/layout/Placeholder.astro";
|
||||||
|
import BoardGameShelf from "@components/ttrpgs/BoardGameShelf.tsx";
|
||||||
|
import boardgames from "@/data/boardgames.json";
|
||||||
|
|
||||||
const highlights = [
|
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: "castle",
|
||||||
{ icon: 'bird', title: 'Crows (Playtest)', body: "MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback." },
|
title: "Dungeons & Dragons",
|
||||||
{ icon: 'monitor', title: 'Foundry VTT', body: 'Self-hosted on the home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns.' },
|
body: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.",
|
||||||
{ icon: 'target', title: 'Draw Steel', body: "MCDM's upcoming tactical combat game. Interested in running it when it releases." },
|
},
|
||||||
|
{
|
||||||
|
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 = [
|
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">
|
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||||
{/* Overview */}
|
{/* Overview */}
|
||||||
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
<section
|
||||||
<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>
|
class="flex flex-col gap-5 text-lg leading-relaxed"
|
||||||
<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>
|
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>
|
</section>
|
||||||
|
|
||||||
{/* Highlights */}
|
{/* Highlights */}
|
||||||
<section class="flex flex-col gap-6">
|
<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">
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{highlights.map((h, i) => (
|
{
|
||||||
<Reveal delay={i * 70}>
|
highlights.map((h, i) => (
|
||||||
<InterestCard icon={h.icon} title={h.title}>{h.body}</InterestCard>
|
<Reveal delay={i * 70}>
|
||||||
</Reveal>
|
<InterestCard icon={h.icon} title={h.title}>
|
||||||
))}
|
{h.body}
|
||||||
|
</InterestCard>
|
||||||
|
</Reveal>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Currently */}
|
{
|
||||||
|
/* Currently
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
|
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -69,42 +159,36 @@ const related = [
|
|||||||
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
|
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
{/* Board games */}
|
{/* Board games */}
|
||||||
<section class="flex flex-col gap-6">
|
<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}>
|
<Reveal delay={80}>
|
||||||
<blockquote
|
<blockquote
|
||||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
class="border-l-2 pl-5 text-lg leading-relaxed"
|
||||||
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
||||||
>
|
>
|
||||||
Beyond RPGs, I maintain a growing board and card game collection.
|
Beyond RPGs, I maintain a growing board and card game collection.
|
||||||
Favorites span strategy, deck-building, and social deduction. Game night
|
Favorites span strategy, deck-building, and social deduction. Game
|
||||||
is a regular event, and yes, there's always room for one more.
|
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>
|
</blockquote>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={140}>
|
<Reveal delay={140}>
|
||||||
<div class="grid gap-4 sm:grid-cols-[2fr_1fr]">
|
<BoardGameShelf
|
||||||
<blockquote
|
client:idle
|
||||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
games={boardgames}
|
||||||
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
shelfImage="/images/ttrpg-boardgame-shelf.jpg"
|
||||||
>
|
shelfAlt="Greg's board game shelf"
|
||||||
Beyond RPGs, I maintain a growing board and card game collection.
|
shelfCaption="The shelf"
|
||||||
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."
|
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user