Compare commits
72
Commits
ef0695ed04
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf6785f9e | ||
|
|
d0e4269ad6 | ||
|
|
999b7a0771 | ||
|
|
8eff44712f | ||
|
|
3813287489 | ||
|
|
71ae91960f | ||
|
|
addfc03ab6 | ||
|
|
211c151971 | ||
|
|
345832e389 | ||
|
|
cf8ca07850 | ||
|
|
498cf1b633 | ||
|
|
a9297d8b2b | ||
|
|
7d9b5e49ad | ||
|
|
3dd3db5643 | ||
|
|
fc9f756d52 | ||
|
|
7ff8e3c51d | ||
|
|
82a4f9b645 | ||
|
|
82d5a36497 | ||
|
|
699b89e916 | ||
|
|
0d35ad56f2 | ||
|
|
f01bb5f99b | ||
|
|
8ebb9211c5 | ||
|
|
a3095ead38 | ||
|
|
30ef76632f | ||
|
|
7163451d1f | ||
|
|
2430be9661 | ||
|
|
002bcb324b | ||
|
|
c665212209 | ||
|
|
2403abc07b | ||
|
|
ee7e73c798 | ||
|
|
dd08f901ae | ||
|
|
8bd186df54 | ||
|
|
81c115cb4d | ||
|
|
3167d26bd6 | ||
|
|
91cd3a16d7 | ||
|
|
13b3eaed78 | ||
|
|
23332ab786 | ||
|
|
aab0b66ee8 | ||
|
|
c41c266ac1 | ||
|
|
634a99dd25 | ||
|
|
9b5223686e | ||
|
|
3aa48058f7 | ||
|
|
812b4640e2 | ||
|
|
9d776771b1 | ||
|
|
d663f4afea | ||
|
|
aa23e93b3f | ||
|
|
77e0751554 | ||
|
|
dc721d5c2d | ||
|
|
96c299e498 | ||
|
|
c2693277ac | ||
|
|
bc418b2c73 | ||
|
|
6502fdae4f | ||
|
|
45bf362fbc | ||
|
|
82caa9bb9c | ||
|
|
dfe49bad0c | ||
|
|
2001616d9e | ||
|
|
77ffd8ff00 | ||
|
|
5a3a9c1fcc | ||
|
|
50c6df48b5 | ||
|
|
f12b40e82b | ||
|
|
8d0e393100 | ||
|
|
43c6334413 | ||
|
|
b312bf4f1f | ||
|
|
90baa35c7c | ||
|
|
c5d6dff12d | ||
|
|
2da04940c2 | ||
|
|
4727a00e26 | ||
|
|
5808e15c45 | ||
|
|
abf901418b | ||
|
|
d9d38c2bee | ||
|
|
f494a6f9be | ||
|
|
cd08e6af04 |
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: commit-conventions
|
||||||
|
description: How to write git commits for this repository. Use when committing changes, writing commit messages, or splitting work into commits. Covers conventional commit format and separating commits by concern.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit Conventions
|
||||||
|
|
||||||
|
Follow these rules whenever you create a commit in this repository.
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
Write every commit message using the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **type**: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `style`.
|
||||||
|
- **scope** (optional): the area of the codebase the change touches. Use the workspace package or directory name when it's clear (e.g. `proxy`, `web`, `packages`). Omit when the change spans multiple areas.
|
||||||
|
- **subject**: imperative mood, capitalized, no trailing period, ≤ 50 characters.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(proxy): add TTS streaming endpoint
|
||||||
|
fix(web): handle empty transcript in player
|
||||||
|
docs: document workspace layout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Separate Commits by Concern
|
||||||
|
|
||||||
|
Do not bundle unrelated changes into a single commit. Split work so each commit is a focused, self-contained unit:
|
||||||
|
|
||||||
|
- One logical change per commit (a feature, a fix, a refactor, a doc update).
|
||||||
|
- Keep each commit buildable and independently reviewable.
|
||||||
|
- Separate concerns that have different types, scopes, or reasons to be reverted independently.
|
||||||
|
- If a change is large, split it into a series of smaller commits that each stand on their own.
|
||||||
|
|
||||||
|
## Message Body
|
||||||
|
|
||||||
|
Include a body only when it adds useful context beyond the subject. If the subject fully captures the change, omit it.
|
||||||
|
|
||||||
|
- Separate the subject from the body with a blank line.
|
||||||
|
- Wrap the body at 72 characters.
|
||||||
|
- Explain the *why* and *what* rather than restating the code.
|
||||||
|
- Do not repeat information already in the subject line.
|
||||||
|
- Use `BREAKING CHANGE:` in the body (or `!` after the type/scope) for breaking changes.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
|
||||||
|
- Creating a new commit.
|
||||||
|
- Writing or revising a commit message.
|
||||||
|
- Deciding how to split staged or unstaged changes into commits.
|
||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# Generated TTS save dumps (scripts/dump-save.mjs)
|
# Generated TTS save dumps (scripts/dump-save.mjs)
|
||||||
scripts/dumps/
|
scripts/dumps/
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
|
|||||||
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
||||||
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
||||||
|
|
||||||
See [`docs/architecture.md`](docs/architecture.md) for the architecture and
|
See [`docs/overview.md`](docs/overview.md) for the docs index,
|
||||||
[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan.
|
[`docs/architecture.md`](docs/architecture.md) for the architecture, and
|
||||||
|
[`docs/status/implementation-plan.md`](docs/status/implementation-plan.md) for the plan.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ To run the frontend alongside the proxy, open a second terminal and run
|
|||||||
| GET | `/health` | Liveness |
|
| GET | `/health` | Liveness |
|
||||||
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
|
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
|
||||||
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
||||||
| GET | `/items/:id/file` | Raw save bytes, filename from header |
|
| GET | `/items/:id/file` | Raw save bytes, filename from the URL path |
|
||||||
| GET | `/asset?url=` | CORS-safe proxy for external assets (textures, models) |
|
| GET | `/asset?url=` | CORS-safe proxy for external assets (textures, models) |
|
||||||
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { setGamesRoot } from './config.js';
|
|||||||
import asset from './routes/asset.js';
|
import asset from './routes/asset.js';
|
||||||
import health from './routes/health.js';
|
import health from './routes/health.js';
|
||||||
import items from './routes/items.js';
|
import items from './routes/items.js';
|
||||||
|
import pdf from './routes/pdf.js';
|
||||||
import search from './routes/search.js';
|
import search from './routes/search.js';
|
||||||
import trace from './routes/trace.js';
|
import trace from './routes/trace.js';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ app.route('/health', health);
|
|||||||
app.route('/search', search);
|
app.route('/search', search);
|
||||||
app.route('/items', items);
|
app.route('/items', items);
|
||||||
app.route('/asset', asset);
|
app.route('/asset', asset);
|
||||||
|
app.route('/pdf', pdf);
|
||||||
app.route('/trace', trace);
|
app.route('/trace', trace);
|
||||||
|
|
||||||
serve(
|
serve(
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails } from '@tts/shared';
|
||||||
import items from './items.js';
|
import items from './items.js';
|
||||||
|
|
||||||
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
GameMode: 'Tabletop',
|
||||||
|
Date: '2024-01-01',
|
||||||
|
ObjectStates: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const details: ModDetails = { mod };
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -17,7 +25,7 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new TtsError('STEAM_API_KEY is not configured', 500),
|
new TtsError('STEAM_API_KEY is not configured', 500),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
||||||
@@ -26,11 +34,6 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads from a fileUrl without an API key', async () => {
|
it('loads from a fileUrl without an API key', async () => {
|
||||||
const mod: TTSMod = {
|
|
||||||
GameMode: 'Tabletop',
|
|
||||||
Date: '2024-01-01',
|
|
||||||
ObjectStates: [],
|
|
||||||
};
|
|
||||||
const fetchModFromUrl = vi
|
const fetchModFromUrl = vi
|
||||||
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
||||||
.mockResolvedValue(mod);
|
.mockResolvedValue(mod);
|
||||||
@@ -40,26 +43,26 @@ describe('items route', () => {
|
|||||||
{ STEAM_API_KEY: '', PORT: 3000 },
|
{ STEAM_API_KEY: '', PORT: 3000 },
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual(mod);
|
expect(await res.json()).toEqual(details);
|
||||||
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the parsed mod on success', async () => {
|
it('returns the parsed mod with metadata on success', async () => {
|
||||||
const mod: TTSMod = {
|
const withMeta: ModDetails = {
|
||||||
GameMode: 'Tabletop',
|
mod,
|
||||||
Date: '2024-01-01',
|
title: 'My Mod',
|
||||||
ObjectStates: [],
|
previewImageUrl: 'https://example.com/preview.png',
|
||||||
};
|
};
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod);
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
|
||||||
|
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual(mod);
|
expect(await res.json()).toEqual(withMeta);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps TtsError subclasses to their status', async () => {
|
it('maps TtsError subclasses to their status', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new ItemNotFoundError('123'),
|
new ItemNotFoundError('123'),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
@@ -70,7 +73,7 @@ describe('items route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 500 for unexpected errors', async () => {
|
it('returns 500 for unexpected errors', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||||
new Error('boom'),
|
new Error('boom'),
|
||||||
);
|
);
|
||||||
const res = await items.request('/123', {}, env);
|
const res = await items.request('/123', {}, env);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
import { itemIdSchema, type ModDetails } from '@tts/shared';
|
||||||
import {
|
import {
|
||||||
fetchMod,
|
fetchModDetails,
|
||||||
fetchModFile,
|
fetchModFile,
|
||||||
fetchModFileFromUrl,
|
fetchModFileFromUrl,
|
||||||
fetchModFromUrl,
|
fetchModFromUrl,
|
||||||
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const fileUrl = c.req.query('fileUrl');
|
const fileUrl = c.req.query('fileUrl');
|
||||||
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
||||||
// search result). Otherwise resolve it via the Steam API.
|
// search result). Otherwise resolve it via the Steam API, which also
|
||||||
const mod: TTSMod = fileUrl
|
// yields the Workshop title and preview image.
|
||||||
? await fetchModFromUrl(fileUrl)
|
const details: ModDetails = fileUrl
|
||||||
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
|
? { mod: await fetchModFromUrl(fileUrl) }
|
||||||
return c.json<TTSMod>(mod);
|
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
|
||||||
|
return c.json<ModDetails>(details);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TtsError) {
|
if (err instanceof TtsError) {
|
||||||
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import pdf from './pdf.js';
|
||||||
|
import { setGamesRoot } from '../config.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
|
||||||
|
setGamesRoot(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
setGamesRoot(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pdf route', () => {
|
||||||
|
it('rejects a missing url', async () => {
|
||||||
|
const res = await pdf.request('/');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: 'Missing url query param' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-http url', async () => {
|
||||||
|
const res = await pdf.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams the fetched pdf inline with a pdf content-type', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { 'content-type': 'application/pdf' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('content-disposition')).toBe('inline');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when the upstream fetch fails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
|
||||||
|
);
|
||||||
|
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a local game asset from GAMES_ROOT', async () => {
|
||||||
|
writeFileSync(path.join(dir, 'rules.pdf'), new Uint8Array([1, 2, 3]));
|
||||||
|
const res = await pdf.request('/?url=rules.pdf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('content-disposition')).toBe('inline');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { GAMES_ROOT } from '../config.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a PDF and stream it back inline so the browser renders it instead of
|
||||||
|
* downloading. PDF hosts often send `Content-Disposition: attachment`, which
|
||||||
|
* forces a download even when the URL is opened in a tab or iframe. This route
|
||||||
|
* strips that header and forces `inline` so the PDF can be embedded in the web
|
||||||
|
* app's viewer. Like `/asset`, a relative URL (no scheme) is treated as a game
|
||||||
|
* asset path relative to `GAMES_ROOT`.
|
||||||
|
*/
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
const raw = c.req.query('url');
|
||||||
|
if (!raw) {
|
||||||
|
return c.json({ error: 'Missing url query param' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolveAsset(raw, GAMES_ROOT);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json(
|
||||||
|
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
|
||||||
|
result.reason === 'not-found' ? 404 : 400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const asset = result.asset;
|
||||||
|
|
||||||
|
// A local game asset is streamed from disk; a remote one is fetched.
|
||||||
|
let body: BodyInit;
|
||||||
|
if (asset.stream) {
|
||||||
|
body = asset.stream as unknown as BodyInit;
|
||||||
|
} else {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(asset.url!);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: `Failed to fetch pdf: ${String(err)}` }, 502);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return c.json({ error: `Pdf responded ${res.status}` }, 502);
|
||||||
|
}
|
||||||
|
body = res.body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'inline',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@tts/http": "workspace:*",
|
"@tts/http": "workspace:*",
|
||||||
"@tts/tabletop": "workspace:*",
|
"@tts/tabletop": "workspace:*",
|
||||||
"bson": "^7.3.1",
|
"bson": "^7.3.1",
|
||||||
|
"postprocessing": "^6.36.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
|||||||
+60
-33
@@ -1,49 +1,76 @@
|
|||||||
import { Link, Route, Routes } from 'react-router-dom';
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { Link, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import SearchPage from './pages/SearchPage';
|
import SearchPage from './pages/SearchPage';
|
||||||
import ModPage from './pages/ModPage';
|
import ModPage from './pages/ModPage';
|
||||||
import FullSetupPage from './pages/FullSetupPage';
|
import ModHeader from './components/ModHeader';
|
||||||
import BgmPage from './pages/BgmPage';
|
|
||||||
import BgmPackagePage from './pages/BgmPackagePage';
|
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
|
||||||
import PartsPage from './pages/PartsPage';
|
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
|
||||||
import PartPage from './pages/PartPage';
|
// stack stays code-split out of the main bundle and is only fetched when one
|
||||||
import SurfacesPage from './pages/SurfacesPage';
|
// of those routes is actually visited.
|
||||||
import SurfacePage from './pages/SurfacePage';
|
const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
|
||||||
import SetupsPage from './pages/SetupsPage';
|
const BgmPage = lazy(() => import('./pages/BgmPage'));
|
||||||
import SetupPage from './pages/SetupPage';
|
const BgmPackagePage = lazy(() => import('./pages/BgmPackagePage'));
|
||||||
|
const PartsPage = lazy(() => import('./pages/PartsPage'));
|
||||||
|
const PartPage = lazy(() => import('./pages/PartPage'));
|
||||||
|
const SurfacesPage = lazy(() => import('./pages/SurfacesPage'));
|
||||||
|
const SurfacePage = lazy(() => import('./pages/SurfacePage'));
|
||||||
|
const SetupsPage = lazy(() => import('./pages/SetupsPage'));
|
||||||
|
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const pathname = useLocation().pathname;
|
||||||
|
const isModRoute = pathname.startsWith('/mod/');
|
||||||
|
// The inspector view is full-bleed/viewport-height; the setup sub-route keeps the standard centered layout.
|
||||||
|
const isModView = /^\/mod\/[^/]+$/.test(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||||
<header className="border-b border-zinc-800">
|
{/* For the mod route the layout is full-bleed and fills the viewport so
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
the sidebar can scroll independently and the viewer gets all the space. */}
|
||||||
<Link to="/" className="text-lg font-semibold tracking-tight">
|
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
|
||||||
TTS Workshop
|
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
|
||||||
</Link>
|
{isModRoute ? (
|
||||||
<nav className="flex gap-4 text-sm text-zinc-400">
|
<ModHeader />
|
||||||
<Link to="/" className="hover:text-zinc-100">
|
) : (
|
||||||
Search
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||||
|
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||||
|
TTS Workshop
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/bgm" className="hover:text-zinc-100">
|
<nav className="flex gap-4 text-sm text-zinc-400">
|
||||||
BGM
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
</Link>
|
Search
|
||||||
</nav>
|
</Link>
|
||||||
</div>
|
<Link to="/bgm" className="hover:text-zinc-100">
|
||||||
|
BGM
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
<main className={(isModView ? "min-h-0 flex-1" : "mx-auto max-w-5xl px-4 py-8")}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
<Route
|
||||||
<Route path="/bgm" element={<BgmPage />} />
|
path="/mod/:id/setup"
|
||||||
<Route path="/bgm/:id" element={<BgmPackagePage />} />
|
element={
|
||||||
<Route path="/bgm/:id/parts" element={<PartsPage />} />
|
<Suspense fallback={<p className="text-sm text-zinc-400">Loading full setup…</p>}>
|
||||||
<Route path="/bgm/:id/parts/:type/:part" element={<PartPage />} />
|
<FullSetupPage />
|
||||||
<Route path="/bgm/:id/surfaces" element={<SurfacesPage />} />
|
</Suspense>
|
||||||
<Route path="/bgm/:id/surfaces/:type/:surface" element={<SurfacePage />} />
|
}
|
||||||
<Route path="/bgm/:id/setups" element={<SetupsPage />} />
|
/>
|
||||||
<Route path="/bgm/:id/setups/:type/:setup" element={<SetupPage />} />
|
<Route path="/bgm" element={<Suspense fallback={null}><BgmPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id" element={<Suspense fallback={null}><BgmPackagePage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/parts" element={<Suspense fallback={null}><PartsPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/parts/:type/:part" element={<Suspense fallback={null}><PartPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/surfaces" element={<Suspense fallback={null}><SurfacesPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/surfaces/:type/:surface" element={<Suspense fallback={null}><SurfacePage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/setups" element={<Suspense fallback={null}><SetupsPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import type { SearchResult, TTSMod } from '@tts/shared';
|
import type { ModDetails, SearchResult } from '@tts/shared';
|
||||||
import { traceImage } from '@tts/http';
|
import { traceImage } from '@tts/http';
|
||||||
|
|
||||||
export { traceImage };
|
export { traceImage };
|
||||||
@@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a full parsed TTS save.
|
* Fetch a full parsed TTS save plus its Workshop metadata.
|
||||||
*/
|
*/
|
||||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (fileUrl) params.set('fileUrl', fileUrl);
|
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a URL for the raw save file download. */
|
/** Build a URL for the raw save file download. */
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { buildTree, collectRefs } from '@tts/extract';
|
||||||
|
import { useModStore } from '../stores/modStore';
|
||||||
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
|
import { modFileUrl } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the global site header on the mod page: thumbnail, game name, and
|
||||||
|
* a subheader with the mod id and stats, plus download/setup links styled like
|
||||||
|
* the Search/BGM nav.
|
||||||
|
*/
|
||||||
|
export default function ModHeader() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { mod } = useModStore();
|
||||||
|
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
||||||
|
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||||
|
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
|
||||||
|
// Prefer the metadata fetched with the save (survives a refresh); fall back
|
||||||
|
// to the search result for the brief moment before the save loads.
|
||||||
|
const title = mod?.title ?? item?.title;
|
||||||
|
const previewImageUrl = mod?.previewImageUrl ?? item?.previewImageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
{previewImageUrl && (
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt={title}
|
||||||
|
className="h-12 w-12 shrink-0 rounded-lg border border-zinc-800 object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||||
|
{title ?? `Mod ${id}`}
|
||||||
|
</h1>
|
||||||
|
{mod && (
|
||||||
|
<p className="truncate text-sm text-zinc-400">
|
||||||
|
<span className="font-mono">{id}</span> · {mod.mod.GameMode} ·{' '}
|
||||||
|
{mod.mod.Date} · {tree.length} objects · {refs.length} asset refs
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex shrink-0 gap-4 text-sm text-zinc-400">
|
||||||
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
|
Search
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={modFileUrl(id!, item?.fileUrl)}
|
||||||
|
className="hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<Link to={`/mod/${id}/setup`} className="hover:text-zinc-100">
|
||||||
|
Setup
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import type { ObjectTreeNode } from '@tts/extract';
|
import type { ObjectTreeNode } from '@tts/extract';
|
||||||
import { iconsForObject } from './objectIcons';
|
import { iconsForObject } from './objectIcons';
|
||||||
@@ -10,19 +10,85 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
||||||
|
const [highlighted, setHighlighted] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
const types = useMemo(() => collectTypes(nodes), [nodes]);
|
||||||
|
|
||||||
|
// When nothing is highlighted, every type is shown. Otherwise only the
|
||||||
|
// highlighted types are visible.
|
||||||
|
const visibleNodes = useMemo(
|
||||||
|
() => filterTree(nodes, highlighted),
|
||||||
|
[nodes, highlighted],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleType = (name: string) =>
|
||||||
|
setHighlighted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAll = () => setHighlighted(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{nodes.map((node, index) => (
|
{types.length > 0 && (
|
||||||
<TreeNode
|
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
|
||||||
key={index}
|
<div className="grid grid-cols-6 gap-1">
|
||||||
node={node}
|
{types.map(({ name, count }) => {
|
||||||
depth={0}
|
const active = highlighted.has(name);
|
||||||
path={`${index}`}
|
return (
|
||||||
selectedPath={selectedPath}
|
<button
|
||||||
onSelect={onSelect}
|
key={name}
|
||||||
/>
|
onClick={() => toggleType(name)}
|
||||||
))}
|
aria-pressed={active}
|
||||||
</ul>
|
title={`${name} (${count}) — ${active ? 'unhighlight' : 'highlight'}`}
|
||||||
|
className={`relative flex h-8 items-center justify-center rounded-md border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
|
||||||
|
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{iconsForObject(name).map((icon) => (
|
||||||
|
<Icon key={icon} icon={icon} className="h-4 w-4" />
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-1 -right-1 rounded bg-zinc-950 px-0.5 font-mono text-[9px] leading-tight ${
|
||||||
|
active ? 'text-zinc-200' : 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{highlighted.size > 0 && (
|
||||||
|
<div className="mt-1.5 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-3 pt-2">
|
||||||
|
{visibleNodes.map(({ node, path }) => (
|
||||||
|
<TreeNode
|
||||||
|
key={path}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
path={path}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +150,16 @@ function TreeNode({
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{node.label}</span>
|
<span className="truncate">{node.label}</span>
|
||||||
|
{hasChildren && (
|
||||||
|
<span
|
||||||
|
title={`${node.children.length} ${
|
||||||
|
node.children.length === 1 ? 'child' : 'children'
|
||||||
|
}`}
|
||||||
|
className="ml-1.5 shrink-0 rounded bg-zinc-800 px-1 font-mono text-[10px] leading-4 text-zinc-500"
|
||||||
|
>
|
||||||
|
{node.children.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
@@ -102,4 +178,51 @@ function TreeNode({
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct object types present in the tree, with a count of each. */
|
||||||
|
function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const visit = (node: ObjectTreeNode) => {
|
||||||
|
counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1);
|
||||||
|
node.children.forEach(visit);
|
||||||
|
};
|
||||||
|
nodes.forEach(visit);
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a node when its type is highlighted or any descendant survives, so the
|
||||||
|
* containment hierarchy is preserved and highlighted parents still lead to
|
||||||
|
* highlighted children. With nothing highlighted, every node is kept.
|
||||||
|
*
|
||||||
|
* Each result carries the node's original index path into the full (unfiltered)
|
||||||
|
* tree, so selection stays stable regardless of filtering.
|
||||||
|
*/
|
||||||
|
function filterTree(
|
||||||
|
nodes: ObjectTreeNode[],
|
||||||
|
highlighted: Set<string>,
|
||||||
|
prefix = '',
|
||||||
|
): { node: ObjectTreeNode; path: string }[] {
|
||||||
|
const result: { node: ObjectTreeNode; path: string }[] = [];
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const path = prefix ? `${prefix}-${index}` : `${index}`;
|
||||||
|
const children = filterTree(node.children, highlighted, path);
|
||||||
|
const visible =
|
||||||
|
highlighted.size === 0 ||
|
||||||
|
highlighted.has(node.object.Name) ||
|
||||||
|
children.length > 0;
|
||||||
|
if (visible) {
|
||||||
|
result.push({
|
||||||
|
node:
|
||||||
|
children.length > 0
|
||||||
|
? { ...node, children: children.map((c) => c.node) }
|
||||||
|
: node,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Suspense, useMemo, useState } from 'react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import type { SerializedPackage, Setup } from '@tts/bgm';
|
||||||
|
import {
|
||||||
|
SetupLoader,
|
||||||
|
WorldSurfaceView,
|
||||||
|
HudSurfaceView,
|
||||||
|
resolveMountTree,
|
||||||
|
serializedToPackage,
|
||||||
|
useTabletopStore,
|
||||||
|
MM_TO_WORLD,
|
||||||
|
} from '@tts/tabletop';
|
||||||
|
import Scene from '../viewers/Scene';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a bgm setup as an interactive 3D table.
|
||||||
|
*
|
||||||
|
* Seeds the tabletop store from the setup, resolves the surface mount tree,
|
||||||
|
* and renders each enabled surface — world surfaces in world space, HUD
|
||||||
|
* surfaces as an overlay — with their parts placed on routes. The store is
|
||||||
|
* seeded on every render so a setup change re-seeds it.
|
||||||
|
*/
|
||||||
|
export default function TabletopScene({
|
||||||
|
pkg,
|
||||||
|
setup,
|
||||||
|
}: {
|
||||||
|
pkg: SerializedPackage;
|
||||||
|
setup: Setup;
|
||||||
|
}) {
|
||||||
|
const packageData = useMemo(() => serializedToPackage(pkg), [pkg]);
|
||||||
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
|
const [showSurface, setShowSurface] = useState(false);
|
||||||
|
|
||||||
|
const tree = useMemo(
|
||||||
|
() => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))),
|
||||||
|
[packageData, surfaces],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Size the contact shadow to cover 2x the largest enabled surface, so the
|
||||||
|
// shadow plane always extends past the table. Surface sizes are in mm;
|
||||||
|
// `MM_TO_WORLD` converts to world units.
|
||||||
|
const shadowScale = useMemo(() => {
|
||||||
|
let maxMm = 0;
|
||||||
|
for (const [id, enabled] of Object.entries(surfaces)) {
|
||||||
|
if (!enabled) continue;
|
||||||
|
const surface = packageData.surfaces.get(id);
|
||||||
|
if (!surface?.size) continue;
|
||||||
|
maxMm = Math.max(maxMm, ...surface.size);
|
||||||
|
}
|
||||||
|
return maxMm > 0 ? maxMm * 2 * MM_TO_WORLD : 22;
|
||||||
|
}, [packageData, surfaces]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene
|
||||||
|
autoRotate={false}
|
||||||
|
enablePan
|
||||||
|
fullscreen
|
||||||
|
shadowScale={shadowScale}
|
||||||
|
// Keep the camera above the table so face-down cards can't be peeked
|
||||||
|
// from below. π/2 clamps the polar angle at the horizon.
|
||||||
|
maxPolarAngle={Math.PI / 2}
|
||||||
|
overlay={
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSurface((v) => !v)}
|
||||||
|
title={showSurface ? 'Hide surfaces' : 'Show surfaces'}
|
||||||
|
aria-label={showSurface ? 'Hide surfaces' : 'Show surfaces'}
|
||||||
|
className="absolute right-2 top-10 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showSurface ? 'mdi:view-grid' : 'mdi:view-grid-outline'}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SetupLoader pkg={packageData} setup={setup} />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{tree.world.map((node) => (
|
||||||
|
<WorldSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
|
||||||
|
))}
|
||||||
|
{tree.hud.map((node) => (
|
||||||
|
<HudSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
export interface ObjectViewer {
|
export interface ObjectViewer {
|
||||||
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
|
||||||
name: string;
|
name: string;
|
||||||
component: (props: { object: TTSObject }) => ReactNode;
|
component: (props: ViewerProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props passed to every object viewer. */
|
||||||
|
export interface ViewerProps {
|
||||||
|
object: TTSObject;
|
||||||
|
/** Expand the 3D scene to fill its container instead of the default frame. */
|
||||||
|
fill?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = new Map<string, ObjectViewer['component']>();
|
const registry = new Map<string, ObjectViewer['component']>();
|
||||||
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The default viewer: a plain inspection of the object's fields. */
|
/** The default viewer: a plain inspection of the object's fields. */
|
||||||
export function DefaultViewer({ object }: { object: TTSObject }) {
|
export function DefaultViewer({ object }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
{Object.entries(object).map(([key, value]) => {
|
{Object.entries(object).map(([key, value]) => {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
extrudeShapeParts,
|
||||||
|
roundedRectShape,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
||||||
|
import { applyMapTransform } from './cardMaterial';
|
||||||
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
|
/** Longer card dimension, in world units. */
|
||||||
|
const CARD_LENGTH = 2;
|
||||||
|
/** Corner radius as a fraction of the shorter card edge. */
|
||||||
|
const CORNER_RADIUS = 0.05;
|
||||||
|
/** Thickness of the card, as a fraction of its length (real cards are ~0.3%). */
|
||||||
|
const CARD_THICKNESS = 0.01;
|
||||||
|
|
||||||
|
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
||||||
|
// Without it, the face/back hooks would be called conditionally, which breaks
|
||||||
|
// React's rules of hooks when switching between objects with different URL
|
||||||
|
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
|
||||||
|
const FALLBACK_URL =
|
||||||
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function CardObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
|
||||||
|
resolveCardConfig(object);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardMesh
|
||||||
|
faceUrl={faceUrl}
|
||||||
|
backUrl={backUrl}
|
||||||
|
numWidth={numWidth}
|
||||||
|
numHeight={numHeight}
|
||||||
|
uniqueBack={uniqueBack}
|
||||||
|
cardId={cardId}
|
||||||
|
tint={objectTint(object)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function CardMesh({
|
||||||
|
faceUrl,
|
||||||
|
backUrl,
|
||||||
|
numWidth,
|
||||||
|
numHeight,
|
||||||
|
uniqueBack,
|
||||||
|
cardId,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
faceUrl?: string;
|
||||||
|
backUrl?: string;
|
||||||
|
numWidth?: number;
|
||||||
|
numHeight?: number;
|
||||||
|
uniqueBack: boolean;
|
||||||
|
cardId?: number;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
// Always call both hooks so the hook count is stable across renders. The
|
||||||
|
// placeholder is used only when a URL is absent; presence is checked via the
|
||||||
|
// URL strings below, not the texture objects.
|
||||||
|
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
|
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||||
|
|
||||||
|
// Card art is sRGB-encoded. `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which uploads the texture as linear and then double-decodes
|
||||||
|
// it in the shader, washing out contrast. Mark it sRGB so the GPU decodes it
|
||||||
|
// once, correctly. Clones (e.g. `flipTexture`) inherit this from the source.
|
||||||
|
face.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
back.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// The face/back textures are shared (drei caches them by URL); each card's
|
||||||
|
// sprite cell is selected via a per-material UV transform injected into the
|
||||||
|
// shader, so no per-card texture clone (and no re-upload) is needed. The
|
||||||
|
// transform is baked into the material's shader, so it must be keyed into the
|
||||||
|
// shared-material cache to avoid mutating a material used by another card.
|
||||||
|
const faceMap = faceUrl ? face : null;
|
||||||
|
const backMap = backUrl ? back : null;
|
||||||
|
|
||||||
|
const tintK = tintKey(tint);
|
||||||
|
const faceUv = faceUrl ? spriteUv(cardId, numWidth, numHeight) : null;
|
||||||
|
const backUv = backUrl
|
||||||
|
? uniqueBack
|
||||||
|
? spriteUv(cardId, numWidth, numHeight)
|
||||||
|
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Key by URL + card id + tint: the URL disambiguates different sheets (and
|
||||||
|
// `CardCustom` objects, which have no `CardID`), the card id selects the
|
||||||
|
// sprite cell, and the tint bakes the per-object color in.
|
||||||
|
const faceMat = getSharedMaterial(`card-face:${faceUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
|
||||||
|
color: tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: faceMap ?? undefined,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
if (faceUv) {
|
||||||
|
applyMapTransform(faceMat, new THREE.Vector2(faceUv.repeatX, faceUv.repeatY), new THREE.Vector2(faceUv.offsetX, faceUv.offsetY));
|
||||||
|
}
|
||||||
|
|
||||||
|
const backMat = getSharedMaterial(`card-back:${backUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
|
||||||
|
color: tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: backMap ?? undefined,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
if (backUv) {
|
||||||
|
// The back cap maps with the same planar UVs as the front, so mirror the
|
||||||
|
// sprite cell left/right to read correctly instead of appearing mirrored.
|
||||||
|
// Negating repeat.x and shifting offset.x by one repeat keeps the visible
|
||||||
|
// region in place while mirrored (see `flipTexture`).
|
||||||
|
applyMapTransform(
|
||||||
|
backMat,
|
||||||
|
new THREE.Vector2(-backUv.repeatX, backUv.repeatY),
|
||||||
|
new THREE.Vector2(backUv.offsetX + backUv.repeatX, backUv.offsetY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallMat = getSharedMaterial(`card-wall:${tintK}`, {
|
||||||
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
||||||
|
// front and back faces each get their own material; the walls are a solid
|
||||||
|
// white, matching TTS card tinting. Geometry is shared across cards of the
|
||||||
|
// same size so the full-setup view reuses it; the face/back materials are
|
||||||
|
// shared per card (keyed by card id + tint) and carry the sprite UV transform.
|
||||||
|
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||||
|
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
||||||
|
| HTMLImageElement
|
||||||
|
| undefined;
|
||||||
|
const aspect = cardAspect(img, numWidth, numHeight);
|
||||||
|
const width = CARD_LENGTH * aspect;
|
||||||
|
const height = CARD_LENGTH;
|
||||||
|
// Radius scales with the shorter edge so corners look proportional and
|
||||||
|
// stay circular (no scaling distortion).
|
||||||
|
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
|
||||||
|
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
|
||||||
|
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
|
||||||
|
return {
|
||||||
|
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={frontGeo} material={faceMat} />
|
||||||
|
<mesh geometry={backGeo} material={backMat} />
|
||||||
|
<mesh geometry={wallsGeo} material={wallMat} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
@@ -1,31 +1,7 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
|
||||||
extrudeShapeParts,
|
|
||||||
roundedRectShape,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
import type { ViewerProps } from '../viewers';
|
||||||
import { flipTexture } from './flipTexture';
|
|
||||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
|
||||||
|
|
||||||
/** Longer card dimension, in world units. */
|
|
||||||
const CARD_LENGTH = 2;
|
|
||||||
/** Corner radius as a fraction of the shorter card edge. */
|
|
||||||
const CORNER_RADIUS = 0.05;
|
|
||||||
/** Thickness of the card. */
|
|
||||||
const CARD_THICKNESS = 0.06;
|
|
||||||
|
|
||||||
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
|
||||||
// Without it, the face/back hooks would be called conditionally, which breaks
|
|
||||||
// React's rules of hooks when switching between objects with different URL
|
|
||||||
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
|
|
||||||
const FALLBACK_URL =
|
|
||||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A playing card: a thin rounded rect with the face texture on the front and
|
* A playing card: a thin rounded rect with the face texture on the front and
|
||||||
@@ -49,140 +25,17 @@ const FALLBACK_URL =
|
|||||||
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
||||||
* the card.
|
* the card.
|
||||||
*/
|
*/
|
||||||
export default function CardViewer({ object }: { object: TTSObject }) {
|
export default function CardViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CardObjectMesh object={object} />
|
{/* Cards are authored standing (face in XY, thickness along Z); lay them
|
||||||
|
flat on the ground so the face points up like a card on a table, which
|
||||||
|
is also what the contact shadow needs to project. */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<CardObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { CardObjectMesh, CardMesh } from './CardMesh';
|
||||||
* The card mesh for an object, exported so the full-setup view can compose it
|
|
||||||
* into a shared scene.
|
|
||||||
*/
|
|
||||||
export function CardObjectMesh({ object }: { object: TTSObject }) {
|
|
||||||
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
|
|
||||||
resolveCardConfig(object);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardMesh
|
|
||||||
faceUrl={faceUrl}
|
|
||||||
backUrl={backUrl}
|
|
||||||
numWidth={numWidth}
|
|
||||||
numHeight={numHeight}
|
|
||||||
uniqueBack={uniqueBack}
|
|
||||||
cardId={cardId}
|
|
||||||
tint={objectTint(object)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
||||||
// Exported so the full-setup view can compose it into a shared scene.
|
|
||||||
export function CardMesh({
|
|
||||||
faceUrl,
|
|
||||||
backUrl,
|
|
||||||
numWidth,
|
|
||||||
numHeight,
|
|
||||||
uniqueBack,
|
|
||||||
cardId,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
faceUrl?: string;
|
|
||||||
backUrl?: string;
|
|
||||||
numWidth?: number;
|
|
||||||
numHeight?: number;
|
|
||||||
uniqueBack: boolean;
|
|
||||||
cardId?: number;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
// Always call both hooks so the hook count is stable across renders. The
|
|
||||||
// placeholder is used only when a URL is absent; presence is checked via the
|
|
||||||
// URL strings below, not the texture objects.
|
|
||||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
|
||||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
|
||||||
|
|
||||||
// Front texture: the sprite cell from the sheet (or the full image when there
|
|
||||||
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
|
|
||||||
// that share the same sheet URL (drei caches textures globally by URL).
|
|
||||||
const faceMap = useMemo(() => {
|
|
||||||
if (!faceUrl) return null;
|
|
||||||
const tex = face.clone();
|
|
||||||
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
|
|
||||||
tex.repeat.set(repeatX, repeatY);
|
|
||||||
tex.offset.set(offsetX, offsetY);
|
|
||||||
return tex;
|
|
||||||
}, [faceUrl, face, cardId, numWidth, numHeight]);
|
|
||||||
|
|
||||||
// Back texture: a single full image (tile) unless the deck has unique backs,
|
|
||||||
// in which case it's a sheet too. Flipped left/right so it reads correctly
|
|
||||||
// instead of being mirrored on the back face.
|
|
||||||
const backMap = useMemo(() => {
|
|
||||||
if (!backUrl) return null;
|
|
||||||
const tex = back.clone();
|
|
||||||
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
|
|
||||||
? spriteUv(cardId, numWidth, numHeight)
|
|
||||||
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
|
||||||
tex.repeat.set(repeatX, repeatY);
|
|
||||||
tex.offset.set(offsetX, offsetY);
|
|
||||||
return flipTexture(tex);
|
|
||||||
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
|
|
||||||
|
|
||||||
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
|
||||||
// front and back faces each get their own material; the walls are a solid
|
|
||||||
// white, matching TTS card tinting. Geometry is shared across cards of the
|
|
||||||
// same size so the full-setup view reuses it; the face/back materials stay
|
|
||||||
// per-card because each card clones its texture for sprite UVs.
|
|
||||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
|
||||||
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
|
||||||
| HTMLImageElement
|
|
||||||
| undefined;
|
|
||||||
const aspect = cardAspect(img, numWidth, numHeight);
|
|
||||||
const width = CARD_LENGTH * aspect;
|
|
||||||
const height = CARD_LENGTH;
|
|
||||||
// Radius scales with the shorter edge so corners look proportional and
|
|
||||||
// stay circular (no scaling distortion).
|
|
||||||
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
|
|
||||||
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
|
|
||||||
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
|
|
||||||
return {
|
|
||||||
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
||||||
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
||||||
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
||||||
};
|
|
||||||
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh geometry={frontGeo}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
|
||||||
map={faceMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={backGeo}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
|
||||||
map={backMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={wallsGeo}>
|
|
||||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
||||||
function toGeometry(extruded: ExtrudedGeometry) {
|
|
||||||
const { positions, normals, uvs, indices } = extruded;
|
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
||||||
return geo;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Suspense, useLayoutEffect } from 'react';
|
||||||
|
import { useLoader } from '@react-three/fiber';
|
||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Object3D } from 'three';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||||
|
import { objectTint, tintedColor } from './sharedResources';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mesh content for a custom model, exported so the full-setup view can
|
||||||
|
* compose it into a shared scene. Renders the model from `CustomMesh.MeshURL`
|
||||||
|
* (or a neutral box placeholder when absent).
|
||||||
|
*/
|
||||||
|
export function CustomModelMesh({ object }: { object: TTSObject }) {
|
||||||
|
const meshUrl = object.CustomMesh?.MeshURL;
|
||||||
|
const tint = objectTint(object);
|
||||||
|
if (!meshUrl) {
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Model
|
||||||
|
meshUrl={meshUrl}
|
||||||
|
diffuseUrl={object.CustomMesh?.DiffuseURL}
|
||||||
|
tint={tint}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Model({
|
||||||
|
meshUrl,
|
||||||
|
diffuseUrl,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
meshUrl: string;
|
||||||
|
diffuseUrl?: string;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<primitive object={root} scale={0.5} />
|
||||||
|
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
||||||
|
<Tint root={root} tint={tint} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the object's tint to every material on the loaded model, multiplying
|
||||||
|
// the existing color. Rendered after the model so it runs once the materials
|
||||||
|
// exist.
|
||||||
|
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
root.traverse((child) => {
|
||||||
|
const mesh = child as THREE.Mesh;
|
||||||
|
if (mesh.isMesh) {
|
||||||
|
const material = Array.isArray(mesh.material)
|
||||||
|
? mesh.material[0]
|
||||||
|
: mesh.material;
|
||||||
|
if (material && 'color' in material) {
|
||||||
|
(material as THREE.MeshStandardMaterial).color.multiply(tint);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [root, tint]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
||||||
|
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
||||||
|
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||||
|
const texture = useTexture(assetUrl(url));
|
||||||
|
|
||||||
|
// Diffuse art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
root.traverse((child) => {
|
||||||
|
const mesh = child as THREE.Mesh;
|
||||||
|
if (mesh.isMesh) {
|
||||||
|
const material = Array.isArray(mesh.material)
|
||||||
|
? mesh.material[0]
|
||||||
|
: mesh.material;
|
||||||
|
if (material && 'map' in material) {
|
||||||
|
material.map = texture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [root, texture]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Suspense, useLayoutEffect } from 'react';
|
|
||||||
import { useLoader } from '@react-three/fiber';
|
|
||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { Object3D } from 'three';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { CustomModelMesh } from './CustomModelMesh';
|
||||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
import type { ViewerProps } from '../viewers';
|
||||||
import { objectTint, tintedColor } from './sharedResources';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
@@ -15,104 +9,12 @@ import { objectTint, tintedColor } from './sharedResources';
|
|||||||
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
|
||||||
* model's materials when present.
|
* model's materials when present.
|
||||||
*/
|
*/
|
||||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
export default function CustomModelViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<CustomModelMesh object={object} />
|
<CustomModelMesh object={object} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { CustomModelMesh } from './CustomModelMesh';
|
||||||
* The mesh content for a custom model, exported so the full-setup view can
|
|
||||||
* compose it into a shared scene. Renders the model from `CustomMesh.MeshURL`
|
|
||||||
* (or a neutral box placeholder when absent).
|
|
||||||
*/
|
|
||||||
export function CustomModelMesh({ object }: { object: TTSObject }) {
|
|
||||||
const meshUrl = object.CustomMesh?.MeshURL;
|
|
||||||
const tint = objectTint(object);
|
|
||||||
if (!meshUrl) {
|
|
||||||
return (
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Model
|
|
||||||
meshUrl={meshUrl}
|
|
||||||
diffuseUrl={object.CustomMesh?.DiffuseURL}
|
|
||||||
tint={tint}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Model({
|
|
||||||
meshUrl,
|
|
||||||
diffuseUrl,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
meshUrl: string;
|
|
||||||
diffuseUrl?: string;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<primitive object={root} scale={0.5} />
|
|
||||||
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
|
||||||
<Tint root={root} tint={tint} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the object's tint to every material on the loaded model, multiplying
|
|
||||||
// the existing color. Rendered after the model so it runs once the materials
|
|
||||||
// exist.
|
|
||||||
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
root.traverse((child) => {
|
|
||||||
const mesh = child as THREE.Mesh;
|
|
||||||
if (mesh.isMesh) {
|
|
||||||
const material = Array.isArray(mesh.material)
|
|
||||||
? mesh.material[0]
|
|
||||||
: mesh.material;
|
|
||||||
if (material && 'color' in material) {
|
|
||||||
(material as THREE.MeshStandardMaterial).color.multiply(tint);
|
|
||||||
material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [root, tint]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
|
||||||
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
|
||||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
|
||||||
const texture = useTexture(assetUrl(url));
|
|
||||||
|
|
||||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
root.traverse((child) => {
|
|
||||||
const mesh = child as THREE.Mesh;
|
|
||||||
if (mesh.isMesh) {
|
|
||||||
const material = Array.isArray(mesh.material)
|
|
||||||
? mesh.material[0]
|
|
||||||
: mesh.material;
|
|
||||||
if (material && 'map' in material) {
|
|
||||||
material.map = texture;
|
|
||||||
material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [root, texture]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import type { RefObject, WheelEvent } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import { useBounds } from '@react-three/drei';
|
||||||
|
import Scene from './Scene';
|
||||||
|
import { CardObjectMesh } from './CardMesh';
|
||||||
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
|
/** How many cards to show to each side of the active card. */
|
||||||
|
const HALF_WINDOW = 3;
|
||||||
|
/** Angular spacing between adjacent cards in the arc, in radians. */
|
||||||
|
const ARC_STEP = 0.32;
|
||||||
|
/** Minimum radius of the arc, in world units. */
|
||||||
|
const ARC_RADIUS = 3.2;
|
||||||
|
/** Extra clearance between the active card's edge and its neighbors, in world units. */
|
||||||
|
const ARC_PADDING = 0.2;
|
||||||
|
/**
|
||||||
|
* A deck carousel: the deck's contained cards are fanned in a 3D arc with the
|
||||||
|
* active card front and center. Prev/next controls step through the deck, each
|
||||||
|
* card animating to its new slot. Side cards are turned 90° in y (album flow)
|
||||||
|
* so only the active card's face is framed; all neighbors edge-on around it.
|
||||||
|
*
|
||||||
|
* The camera is fitted to just the active card (not the whole carousel): the
|
||||||
|
* shared scene's auto-fit is disabled and `useBounds` refits whenever the
|
||||||
|
* selection changes.
|
||||||
|
*
|
||||||
|
* Only a window of cards around the active one is rendered (the rest stay
|
||||||
|
* hidden), so large decks stay lean. Falls back to a single card (the deck
|
||||||
|
* object itself) when there are no contained cards.
|
||||||
|
*/
|
||||||
|
export default function DeckViewer({ object, fill }: ViewerProps) {
|
||||||
|
const cards = (object.ContainedObjects ?? []).filter(
|
||||||
|
(o) => o.CardID != null || o.CustomImage != null,
|
||||||
|
);
|
||||||
|
const count = cards.length;
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const hasCards = count > 0;
|
||||||
|
const centerRef = useRef<THREE.Group>(null);
|
||||||
|
// The active card's world-space width, measured once it's laid out. Used to
|
||||||
|
// widen the arc so neighbors clear the card's edges (a fixed radius only fits
|
||||||
|
// square cards; wider cards clip their neighbors).
|
||||||
|
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const step = (dir: number) => setActive((a) => (a + dir + count) % count);
|
||||||
|
const radius = arcRadius(cardWidth);
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
cards
|
||||||
|
.map((card, i) => ({ card, i, k: i - active }))
|
||||||
|
.filter((v) => Math.abs(v.k) <= HALF_WINDOW),
|
||||||
|
[cards, active],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The active card always settles to the arc center, so the camera only needs
|
||||||
|
// to frame it once on mount.
|
||||||
|
const didFit = useRef(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene
|
||||||
|
fit={false}
|
||||||
|
autoRotate={false}
|
||||||
|
fill={fill}
|
||||||
|
shadow={false}
|
||||||
|
overlay={
|
||||||
|
hasCards ? (
|
||||||
|
<CarouselControls active={active} count={count} onStep={step} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasCards ? (
|
||||||
|
<>
|
||||||
|
{visible.map(({ card, i, k }) => (
|
||||||
|
// Key by the card's index (stable across renders) so the element
|
||||||
|
// persists and tweens as its slot changes; the index-path key is
|
||||||
|
// unique even though cards in a deck share the same GUID.
|
||||||
|
<CarouselCard
|
||||||
|
key={i}
|
||||||
|
card={card}
|
||||||
|
k={k}
|
||||||
|
radius={radius}
|
||||||
|
groupRef={k === 0 ? centerRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FitActive
|
||||||
|
targetRef={centerRef}
|
||||||
|
didFit={didFit}
|
||||||
|
onMeasure={setCardWidth}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// No contained cards: show the deck object itself, laid flat so the
|
||||||
|
// shared scene's contact shadow has a surface to project onto.
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<CardObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fits the camera once, on the first frame, to frame the active card from the
|
||||||
|
* front. Runs in a frame callback (not an effect) so the active card has been
|
||||||
|
* moved to its arc slot by its own `useFrame` first — otherwise the group is
|
||||||
|
* still at the origin and the camera would frame the carousel center.
|
||||||
|
*
|
||||||
|
* The card's front face points toward +Z, so the camera is placed directly in
|
||||||
|
* front of it and looks straight at it, rather than keeping its initial side
|
||||||
|
* angle (which drei's `fit()` would do). The active card always settles in the
|
||||||
|
* same slot, so no refit is needed while navigating — that would restart the
|
||||||
|
* camera tween on every step and feel laggy.
|
||||||
|
*/
|
||||||
|
function FitActive({
|
||||||
|
targetRef,
|
||||||
|
didFit,
|
||||||
|
onMeasure,
|
||||||
|
}: {
|
||||||
|
targetRef: RefObject<THREE.Group | null>;
|
||||||
|
didFit: RefObject<boolean>;
|
||||||
|
onMeasure: (width: number) => void;
|
||||||
|
}) {
|
||||||
|
const bounds = useBounds();
|
||||||
|
// Whether the active card's width has been measured once. The fit waits one
|
||||||
|
// frame after measuring so the arc radius (and thus the active card's slot)
|
||||||
|
// has settled before framing it.
|
||||||
|
const measured = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const node = targetRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
// Stop refreshing once fit: drei's `bounds.refresh()` resets the camera
|
||||||
|
// goal, so calling it every frame would wipe the fit we just set before
|
||||||
|
// the camera tween ever runs.
|
||||||
|
if (didFit.current) return;
|
||||||
|
bounds.refresh(node);
|
||||||
|
const { size, center, distance } = bounds.getSize();
|
||||||
|
// Report the active card's width so the arc radius can widen for wide
|
||||||
|
// cards (see `arcRadius`).
|
||||||
|
onMeasure(size.x);
|
||||||
|
if (!measured.current) {
|
||||||
|
measured.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
didFit.current = true;
|
||||||
|
// The card's front face points toward +Z, so put the camera in front of it
|
||||||
|
// and look straight at it.
|
||||||
|
bounds
|
||||||
|
.moveTo([center.x, center.y, center.z + distance])
|
||||||
|
.lookAt({ target: center });
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single card that tweens into its arc slot each frame. */
|
||||||
|
function CarouselCard({
|
||||||
|
card,
|
||||||
|
k,
|
||||||
|
radius,
|
||||||
|
groupRef,
|
||||||
|
}: {
|
||||||
|
card: TTSObject;
|
||||||
|
k: number;
|
||||||
|
radius: number;
|
||||||
|
groupRef?: RefObject<THREE.Group | null>;
|
||||||
|
}) {
|
||||||
|
const localRef = useRef<THREE.Group>(null);
|
||||||
|
const group = groupRef ?? localRef;
|
||||||
|
// Start at the target so the first render doesn't tween into place.
|
||||||
|
const state = useRef(slotTransform(k, radius));
|
||||||
|
const target = useMemo(() => slotTransform(k, radius), [k, radius]);
|
||||||
|
|
||||||
|
useFrame((_, dt) => {
|
||||||
|
const g = group.current;
|
||||||
|
if (!g) return;
|
||||||
|
// Smooth per-frame damping independent of frame rate.
|
||||||
|
const f = 1 - Math.pow(0.0001, dt);
|
||||||
|
const t = target;
|
||||||
|
const s = state.current;
|
||||||
|
s.x += (t.x - s.x) * f;
|
||||||
|
s.z += (t.z - s.z) * f;
|
||||||
|
s.rot += (t.rot - s.rot) * f;
|
||||||
|
s.scale += (t.scale - s.scale) * f;
|
||||||
|
g.position.set(s.x, 0, s.z);
|
||||||
|
g.rotation.y = s.rot;
|
||||||
|
g.scale.setScalar(s.scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group}>
|
||||||
|
<CardObjectMesh object={card} />
|
||||||
|
{/* A soft radial shadow under this card; it inherits the card group's
|
||||||
|
position/rotation/scale, so it tweens with the card. Card meshes are
|
||||||
|
laid flat (face up), so the plane sits just below the card's bottom. */}
|
||||||
|
<CardShadow />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A soft elliptical drop shadow under a single carousel card. The card mesh is
|
||||||
|
// ~2 world-units tall (centered), so its bottom sits at local y = -1; we put a
|
||||||
|
// radial-gradient plane just below it. I can't use `ContactShadows` here
|
||||||
|
// because the card is a thin vertical panel — a top-down capture would only
|
||||||
|
// catch a line. Instead we fake a soft ambient contact shadow with a gradient.
|
||||||
|
const CARD_HALF_HEIGHT = 1;
|
||||||
|
const SHADOW_SCALE = 2.6;
|
||||||
|
|
||||||
|
function CardShadow() {
|
||||||
|
// A soft radial gradient, dark at the center fading to transparent — reads as
|
||||||
|
// a soft blob of shadow rather than a hard cast shadow.
|
||||||
|
const texture = useMemo(makeSoftShadowTexture, []);
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
position={[0, -CARD_HALF_HEIGHT, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
scale={[SHADOW_SCALE, SHADOW_SCALE, 1]}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[1, 1]} />
|
||||||
|
<meshBasicMaterial map={texture} transparent depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a radial-gradient texture: dark center fading out to transparent. */
|
||||||
|
function makeSoftShadowTexture(): THREE.Texture {
|
||||||
|
const size = 256;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return new THREE.Texture();
|
||||||
|
const g = ctx.createRadialGradient(
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
0,
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
);
|
||||||
|
const stops: [number, number][] = [
|
||||||
|
[0, 0.55],
|
||||||
|
[0.45, 0.35],
|
||||||
|
[0.75, 0.12],
|
||||||
|
[1, 0],
|
||||||
|
];
|
||||||
|
for (const [t, a] of stops) {
|
||||||
|
g.addColorStop(t, `rgba(0, 0, 0, ${a})`);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slots {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
rot: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World transform for a card at arc offset `k` (0 = front and center). */
|
||||||
|
function slotTransform(k: number, radius: number): Slots {
|
||||||
|
const ang = k * ARC_STEP;
|
||||||
|
// Side cards turn edge-on (album flow); the active card stays forward.
|
||||||
|
const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2);
|
||||||
|
return {
|
||||||
|
x: Math.sin(ang) * radius,
|
||||||
|
z: Math.cos(ang) * radius,
|
||||||
|
rot: turn,
|
||||||
|
scale: 1.15 - 0.15 * Math.abs(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arc radius that keeps the nearest neighbors clear of the active card's
|
||||||
|
* edges. A neighbor at `k = 1` sits at `x = sin(ARC_STEP) * radius`, so the
|
||||||
|
* radius must exceed `cardWidth / 2 / sin(ARC_STEP)` for the neighbor to clear
|
||||||
|
* the card's half-width (plus padding). Falls back to the minimum when the
|
||||||
|
* card width isn't known yet.
|
||||||
|
*/
|
||||||
|
function arcRadius(cardWidth: number | null): number {
|
||||||
|
if (cardWidth == null) return ARC_RADIUS;
|
||||||
|
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prev/next controls and a sliding-dot pagination, rendered as the scene
|
||||||
|
* overlay. All dots live in a fixed-width track that slides so the active dot
|
||||||
|
* stays centered; dots fade out as they leave the window. A counter above the
|
||||||
|
* bar shows the current index (`1 / 51`). Round first/last and prev/next
|
||||||
|
* buttons flank the dots. Scrolling over the pagination steps through the
|
||||||
|
* deck.
|
||||||
|
*/
|
||||||
|
function CarouselControls({
|
||||||
|
active,
|
||||||
|
count,
|
||||||
|
onStep,
|
||||||
|
}: {
|
||||||
|
active: number;
|
||||||
|
count: number;
|
||||||
|
onStep: (dir: number) => void;
|
||||||
|
}) {
|
||||||
|
const prev = () => onStep(-1);
|
||||||
|
const next = () => onStep(1);
|
||||||
|
const first = () => onStep(-active);
|
||||||
|
const last = () => onStep(count - 1 - active);
|
||||||
|
|
||||||
|
// Dots visible on each side of the active dot.
|
||||||
|
const DOT_WINDOW = 4;
|
||||||
|
const DOT_SIZE = 8;
|
||||||
|
const ACTIVE_WIDTH = 26;
|
||||||
|
const GAP = 6;
|
||||||
|
const SLOTS = DOT_WINDOW * 2 + 1;
|
||||||
|
const slotW = DOT_SIZE + GAP;
|
||||||
|
// Extra width the active dot (plus its gap) pushes beyond a normal slot.
|
||||||
|
const extra = ACTIVE_WIDTH + GAP - slotW;
|
||||||
|
const trackW = SLOTS * slotW + extra;
|
||||||
|
// Left offset of a dot at slot `s` (0 = leftmost). Dots after the active
|
||||||
|
// slot shift right by `extra` to make room for the wider active dot.
|
||||||
|
const slotLeft = (s: number) => s * slotW + (s > DOT_WINDOW ? extra : 0);
|
||||||
|
const dots = Array.from({ length: count }, (_, i) => i);
|
||||||
|
|
||||||
|
const onWheel = (e: React.WheelEvent) => {
|
||||||
|
// Scroll up/left goes back, scroll down/right goes forward.
|
||||||
|
onStep(e.deltaY < 0 ? -1 : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundBtn =
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-zinc-900/80 disabled:hover:text-zinc-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 bottom-2 z-10 flex flex-col items-center gap-2">
|
||||||
|
<span className="rounded-md bg-zinc-900/80 px-2 py-0.5 font-mono text-xs text-zinc-300 backdrop-blur">
|
||||||
|
{active + 1} / {count}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={first}
|
||||||
|
disabled={active === 0}
|
||||||
|
aria-label="First card"
|
||||||
|
className={roundBtn}
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button onClick={prev} aria-label="Previous card" className={roundBtn}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
onWheel={onWheel}
|
||||||
|
role="group"
|
||||||
|
aria-label="Browse cards"
|
||||||
|
className="flex items-center rounded-full bg-zinc-900/80 px-2 py-1.5 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ width: trackW, height: DOT_SIZE }}
|
||||||
|
>
|
||||||
|
{dots.map((i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
const slot = DOT_WINDOW + (i - active);
|
||||||
|
const inWindow = slot >= 0 && slot < SLOTS;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onStep(i - active)}
|
||||||
|
aria-label={`Go to card ${i + 1}`}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
className="absolute top-0 flex items-center justify-center rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
left: slotLeft(slot),
|
||||||
|
width: isActive ? ACTIVE_WIDTH : DOT_SIZE,
|
||||||
|
height: DOT_SIZE,
|
||||||
|
opacity: inWindow ? 1 : 0,
|
||||||
|
pointerEvents: inWindow ? 'auto' : 'none',
|
||||||
|
backgroundColor: isActive ? '#f4f4f5' : '#71717a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={next} aria-label="Next card" className={roundBtn}>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={last}
|
||||||
|
disabled={active === count - 1}
|
||||||
|
aria-label="Last card"
|
||||||
|
className={roundBtn}
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import { pdfUrl } from '@tts/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PDF document (`Custom_PDF`). Renders the document inline in an iframe and
|
||||||
|
* offers a standalone link to open it in a new tab. The URL is routed through
|
||||||
|
* the proxy `/pdf` endpoint, which forces `Content-Disposition: inline` so the
|
||||||
|
* browser displays the PDF instead of downloading it.
|
||||||
|
*/
|
||||||
|
export default function PdfViewer({ object }: { object: TTSObject }) {
|
||||||
|
const url = object.CustomPDF?.PDFUrl;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
This object has no PDF URL.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxied = pdfUrl(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="truncate font-mono text-xs text-zinc-500">{url}</p>
|
||||||
|
<a
|
||||||
|
href={proxied}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 rounded-lg bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
Open PDF in new tab
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={proxied}
|
||||||
|
title="PDF preview"
|
||||||
|
className="min-h-0 w-full flex-1 rounded-lg border border-zinc-800 bg-zinc-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Suspense, type ReactNode } from 'react';
|
import { Suspense, useEffect, useRef, useState, type RefObject, type ReactNode } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Icon } from '@iconify/react';
|
||||||
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
|
import { BrightnessContrast, EffectComposer, ToneMapping, Vignette } from '@react-three/postprocessing';
|
||||||
|
import { ToneMappingMode } from 'postprocessing';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
||||||
@@ -12,43 +15,126 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
|||||||
* The camera is fitted to the bounds of the content on mount. `Bounds` sits
|
* The camera is fitted to the bounds of the content on mount. `Bounds` sits
|
||||||
* inside the Suspense boundary, so it only mounts once the (suspending) content
|
* inside the Suspense boundary, so it only mounts once the (suspending) content
|
||||||
* has loaded and its geometry is present.
|
* has loaded and its geometry is present.
|
||||||
|
*
|
||||||
|
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
|
||||||
|
* example, pans instead of rotating). `fullscreen` adds a toggle button that
|
||||||
|
* `maxPolarAngle` (radians) clamps how
|
||||||
|
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
|
||||||
|
* peeking under face-down cards.
|
||||||
|
*
|
||||||
|
* `fit` (default true) bounds, fits, and clips the camera to the scene's
|
||||||
|
* content on mount and resize. A viewer that needs to frame a specific part of
|
||||||
|
* its content (e.g. the active card in a deck carousel) can set it to false and
|
||||||
|
* call `useBounds()` itself to refit.
|
||||||
|
*
|
||||||
|
* By default the scene renders in an `aspect-[3/4]` frame; set `fill` to expand
|
||||||
|
* to the full height of its container (used by the inspector view).
|
||||||
*/
|
*/
|
||||||
export default function Scene({ children }: { children: ReactNode }) {
|
export default function Scene({
|
||||||
|
children,
|
||||||
|
autoRotate = true,
|
||||||
|
enablePan = false,
|
||||||
|
fullscreen = false,
|
||||||
|
fit = true,
|
||||||
|
fill = false,
|
||||||
|
overlay,
|
||||||
|
shadowScale = 22,
|
||||||
|
maxPolarAngle = Math.PI,
|
||||||
|
shadowBlur = 0.012,
|
||||||
|
shadow = true,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
enablePan?: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
|
||||||
|
fit?: boolean;
|
||||||
|
/** Expand to fill the container height instead of the default aspect-[3/4] frame. */
|
||||||
|
fill?: boolean;
|
||||||
|
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
|
||||||
|
overlay?: ReactNode;
|
||||||
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
|
shadowScale?: number;
|
||||||
|
/** Contact shadow blur as a fraction of the plane size; a larger value is softer. */
|
||||||
|
shadowBlur?: number;
|
||||||
|
/** Whether to render a scene-level contact shadow under the content (default true). */
|
||||||
|
shadow?: boolean;
|
||||||
|
/** Max camera polar angle in radians; defaults to unrestricted (π). */
|
||||||
|
maxPolarAngle?: number;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
// Points at the wrapped children so the shadow can measure the real content
|
||||||
|
// bounding box (independent of drei's `Bounds` camera fit).
|
||||||
|
const contentRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen();
|
||||||
|
else void containerRef.current?.requestFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
|
||||||
|
fill ? 'h-full min-h-0' : 'aspect-3/4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
|
{fullscreen && (
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
className="absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{overlay}
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
|
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
gl={{ antialias: true }}
|
gl={{ antialias: true, alpha: true }}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.1} />
|
||||||
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
|
||||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
<Environment resolution={256}>
|
||||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
<Lightformer intensity={.5} position={[0, 5, 0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[0, 0, 5]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[0, 0, -5]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[-5, 0, 0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[5, 0, -0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={0.6} color="#fff1d6" position={[0, -3, 0]} scale={[6, 6, 1]} />
|
||||||
|
</Environment>
|
||||||
|
|
||||||
|
{shadow && (
|
||||||
|
<AdaptiveContactShadow shadowScale={shadowScale} shadowBlur={shadowBlur} contentRef={contentRef} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Bounds fit observe clip>{children}</Bounds>
|
<Bounds fit={fit} observe={fit} clip={fit}>
|
||||||
|
<group ref={contentRef}>{children}</group>
|
||||||
|
</Bounds>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<ContactShadows
|
|
||||||
position={[0, -0.5, 0]}
|
|
||||||
opacity={0.55}
|
|
||||||
scale={8}
|
|
||||||
blur={2.4}
|
|
||||||
far={3}
|
|
||||||
resolution={256}
|
|
||||||
/>
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enablePan={false}
|
enablePan={enablePan}
|
||||||
minDistance={0.01}
|
minDistance={0.01}
|
||||||
maxDistance={8}
|
maxDistance={8}
|
||||||
autoRotate
|
maxPolarAngle={maxPolarAngle}
|
||||||
|
autoRotate={autoRotate}
|
||||||
makeDefault
|
makeDefault
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EffectComposer>
|
<EffectComposer>
|
||||||
<Bloom intensity={0.25} luminanceThreshold={0.85} mipmapBlur />
|
<ToneMapping mode={ToneMappingMode.AGX} />
|
||||||
|
<BrightnessContrast brightness={0.12} contrast={.5} />
|
||||||
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
@@ -76,3 +162,84 @@ function LoadingOverlay() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact shadow sized and placed to the scene's actual content bounds.
|
||||||
|
*
|
||||||
|
* A fixed-size shadow plane baked for a 2-unit token is invisible to a whole
|
||||||
|
* card or model ten times larger, so the plane is (re)sized to the bounding
|
||||||
|
* box of whatever is wrapped in the scene, measured each frame from a ref.
|
||||||
|
* Keeping it scene-level (instead of per mesh) keeps it world-horizontal at
|
||||||
|
* the table and costs one shadow render per frame even when there are many
|
||||||
|
* meshes, which per-mesh planes would multiply.
|
||||||
|
*
|
||||||
|
* The plane sits on the very bottom (y minimum) of the content, centered on
|
||||||
|
* its footprint, and spans a bit wider so the shadow edge doesn't fall inside
|
||||||
|
* the object. `far` covers the full content height so tall objects aren't
|
||||||
|
* clipped. Falls back to `shadowScale` while the bounds are still empty.
|
||||||
|
*/
|
||||||
|
function AdaptiveContactShadow({
|
||||||
|
shadowScale,
|
||||||
|
shadowBlur,
|
||||||
|
contentRef,
|
||||||
|
}: {
|
||||||
|
shadowScale: number;
|
||||||
|
shadowBlur: number;
|
||||||
|
contentRef: RefObject<THREE.Group | null>;
|
||||||
|
}) {
|
||||||
|
// Current computed shadow config, kept in a ref so it's stable between frames
|
||||||
|
// and only recomputed when the content bounds actually change.
|
||||||
|
const state = useRef({ scale: 0, posX: 0, posY: 0, posZ: 0, far: 0 });
|
||||||
|
// Scratch box, reused each frame to avoid allocations.
|
||||||
|
const box = useRef(new THREE.Box3()).current;
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!content) return;
|
||||||
|
content.updateWorldMatrix(true, true);
|
||||||
|
box.setFromObject(content);
|
||||||
|
if (box.isEmpty()) return;
|
||||||
|
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
// The shadow plane rests on the very bottom of the content, centered on its
|
||||||
|
// footprint. `far` covers the full content height (plus margin) so the
|
||||||
|
// capture doesn't clip tall objects; `scale` covers the footprint.
|
||||||
|
const scale = Math.max(size.x, size.z) * SHADOW_PAD;
|
||||||
|
const posY = box.min.y;
|
||||||
|
const far = size.y + SHADOW_HEIGHT_MARGIN;
|
||||||
|
const s = state.current;
|
||||||
|
if (
|
||||||
|
scale !== s.scale ||
|
||||||
|
center.x !== s.posX ||
|
||||||
|
posY !== s.posY ||
|
||||||
|
center.z !== s.posZ ||
|
||||||
|
far !== s.far
|
||||||
|
) {
|
||||||
|
Object.assign(s, { scale, posX: center.x, posY, posZ: center.z, far });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = state.current;
|
||||||
|
const scale = s.scale || shadowScale;
|
||||||
|
// Sit just below the lowest point of the content so it rests on it without
|
||||||
|
// intersecting; the plane uses a (fixed) world-horizontal orientation.
|
||||||
|
const position = [s.posX, s.posY - 0.01, s.posZ] as [number, number, number];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContactShadows
|
||||||
|
position={position}
|
||||||
|
opacity={0.2}
|
||||||
|
scale={scale}
|
||||||
|
blur={scale * shadowBlur}
|
||||||
|
far={s.far || scale * 0.01}
|
||||||
|
resolution={1024}
|
||||||
|
color="#000000"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the shadow plane extends past the content footprint, as a multiple. */
|
||||||
|
const SHADOW_PAD = 1.1;
|
||||||
|
/** Extra capture depth beyond the content height, so the falloff edge isn't clipped. */
|
||||||
|
const SHADOW_HEIGHT_MARGIN = 0.5;
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
circleShape,
|
||||||
|
extrudeShapeParts,
|
||||||
|
hexShape,
|
||||||
|
rectShape,
|
||||||
|
roundedRectShape,
|
||||||
|
scaleShape,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import { flipTexture } from './flipTexture';
|
||||||
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
|
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
||||||
|
const TileType = {
|
||||||
|
Box: 0,
|
||||||
|
Hex: 1,
|
||||||
|
Circle: 2,
|
||||||
|
Rounded: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TILE_SIZE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function TileObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
||||||
|
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
||||||
|
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
||||||
|
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TileMesh
|
||||||
|
url={url}
|
||||||
|
thickness={thickness}
|
||||||
|
type={type}
|
||||||
|
stretch={stretch}
|
||||||
|
tint={objectTint(object)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function TileMesh({
|
||||||
|
url,
|
||||||
|
thickness,
|
||||||
|
type,
|
||||||
|
stretch,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
thickness: number;
|
||||||
|
type: number;
|
||||||
|
stretch: boolean;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Tile art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Build the extruded geometry from the tile shape. When `stretch` is false
|
||||||
|
// and a texture is available, scale the shape to the image's aspect ratio so
|
||||||
|
// the tile matches the source proportions instead of being square. Shared
|
||||||
|
// across tiles with the same shape so the full-setup view reuses geometry.
|
||||||
|
const { front, back, walls } = useMemo(() => {
|
||||||
|
const img = texture?.image as HTMLImageElement;
|
||||||
|
const aspect = stretch ? img.width / img.height : 1;
|
||||||
|
const shape = tileShape(type, aspect);
|
||||||
|
const parts = extrudeShapeParts(shape, { height: thickness });
|
||||||
|
const key = `tile:${type}:${aspect}:${thickness}`;
|
||||||
|
return {
|
||||||
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [type, thickness, stretch, texture]);
|
||||||
|
|
||||||
|
// The back face maps with the same planar UVs as the front, so flip it
|
||||||
|
// left/right to avoid a mirrored texture when viewed from behind.
|
||||||
|
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
|
||||||
|
|
||||||
|
// Shared materials: the front/back carry the tile texture (or a neutral
|
||||||
|
// color when absent); the walls are a solid white, matching TTS tinting.
|
||||||
|
// The tint is baked into the color and the cache key so tinted variants
|
||||||
|
// don't collide.
|
||||||
|
const tintK = tintKey(tint);
|
||||||
|
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
|
||||||
|
const faceMat = getSharedMaterial(faceKey, {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: texture ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const backMat = getSharedMaterial(faceKey + ':back', {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: backMap ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
|
||||||
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Front face carries the tile texture. */}
|
||||||
|
<mesh geometry={front} material={faceMat} />
|
||||||
|
{/* Back face, flipped so it isn't mirrored. */}
|
||||||
|
<mesh geometry={back} material={backMat} />
|
||||||
|
{/* Sides are a solid white, matching TTS tile tinting. */}
|
||||||
|
<mesh geometry={walls} material={wallMat} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
||||||
|
function tileShape(type: number, aspect: number) {
|
||||||
|
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
||||||
|
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
||||||
|
const sx = aspect;
|
||||||
|
const sy = 1;
|
||||||
|
switch (type) {
|
||||||
|
case TileType.Hex:
|
||||||
|
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Circle:
|
||||||
|
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Rounded:
|
||||||
|
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
||||||
|
case TileType.Box:
|
||||||
|
default:
|
||||||
|
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,7 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
|
||||||
circleShape,
|
|
||||||
extrudeShapeParts,
|
|
||||||
hexShape,
|
|
||||||
rectShape,
|
|
||||||
roundedRectShape,
|
|
||||||
scaleShape,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { TileObjectMesh } from './TileMesh';
|
||||||
import { flipTexture } from './flipTexture';
|
import type { ViewerProps } from '../viewers';
|
||||||
import {
|
|
||||||
getSharedGeometry,
|
|
||||||
getSharedMaterial,
|
|
||||||
objectTint,
|
|
||||||
tintKey,
|
|
||||||
tintedColor,
|
|
||||||
} from './sharedResources';
|
|
||||||
|
|
||||||
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
|
||||||
const TileType = {
|
|
||||||
Box: 0,
|
|
||||||
Hex: 1,
|
|
||||||
Circle: 2,
|
|
||||||
Rounded: 3,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TILE_SIZE = 2;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||||
@@ -40,132 +11,15 @@ const TILE_SIZE = 2;
|
|||||||
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
||||||
* source image instead of being forced square.
|
* source image instead of being forced square.
|
||||||
*/
|
*/
|
||||||
export default function TileViewer({ object }: { object: TTSObject }) {
|
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TileObjectMesh object={object} />
|
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<TileObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { TileObjectMesh, TileMesh } from './TileMesh';
|
||||||
* The tile mesh for an object, exported so the full-setup view can compose it
|
|
||||||
* into a shared scene.
|
|
||||||
*/
|
|
||||||
export function TileObjectMesh({ object }: { object: TTSObject }) {
|
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
||||||
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
|
||||||
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
|
||||||
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TileMesh
|
|
||||||
url={url}
|
|
||||||
thickness={thickness}
|
|
||||||
type={type}
|
|
||||||
stretch={stretch}
|
|
||||||
tint={objectTint(object)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
||||||
// Exported so the full-setup view can compose it into a shared scene.
|
|
||||||
export function TileMesh({
|
|
||||||
url,
|
|
||||||
thickness,
|
|
||||||
type,
|
|
||||||
stretch,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
url?: string;
|
|
||||||
thickness: number;
|
|
||||||
type: number;
|
|
||||||
stretch: boolean;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
|
||||||
|
|
||||||
// Build the extruded geometry from the tile shape. When `stretch` is false
|
|
||||||
// and a texture is available, scale the shape to the image's aspect ratio so
|
|
||||||
// the tile matches the source proportions instead of being square. Shared
|
|
||||||
// across tiles with the same shape so the full-setup view reuses geometry.
|
|
||||||
const { front, back, walls } = useMemo(() => {
|
|
||||||
const img = texture?.image as HTMLImageElement;
|
|
||||||
const aspect = stretch ? img.width / img.height : 1;
|
|
||||||
const shape = tileShape(type, aspect);
|
|
||||||
const parts = extrudeShapeParts(shape, { height: thickness });
|
|
||||||
const key = `tile:${type}:${aspect}:${thickness}`;
|
|
||||||
return {
|
|
||||||
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
||||||
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
||||||
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
||||||
};
|
|
||||||
}, [type, thickness, stretch, texture]);
|
|
||||||
|
|
||||||
// The back face maps with the same planar UVs as the front, so flip it
|
|
||||||
// left/right to avoid a mirrored texture when viewed from behind.
|
|
||||||
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
|
|
||||||
|
|
||||||
// Shared materials: the front/back carry the tile texture (or a neutral
|
|
||||||
// color when absent); the walls are a solid white, matching TTS tinting.
|
|
||||||
// The tint is baked into the color and the cache key so tinted variants
|
|
||||||
// don't collide.
|
|
||||||
const tintK = tintKey(tint);
|
|
||||||
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
|
|
||||||
const faceMat = getSharedMaterial(faceKey, {
|
|
||||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
||||||
map: texture ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
const backMat = getSharedMaterial(faceKey + ':back', {
|
|
||||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
||||||
map: backMap ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
|
|
||||||
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Front face carries the tile texture. */}
|
|
||||||
<mesh geometry={front} material={faceMat} />
|
|
||||||
{/* Back face, flipped so it isn't mirrored. */}
|
|
||||||
<mesh geometry={back} material={backMat} />
|
|
||||||
{/* Sides are a solid white, matching TTS tile tinting. */}
|
|
||||||
<mesh geometry={walls} material={wallMat} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
||||||
function toGeometry(extruded: ExtrudedGeometry) {
|
|
||||||
const { positions, normals, uvs, indices } = extruded;
|
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
||||||
return geo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
|
||||||
function tileShape(type: number, aspect: number) {
|
|
||||||
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
|
||||||
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
|
||||||
const sx = aspect;
|
|
||||||
const sy = 1;
|
|
||||||
switch (type) {
|
|
||||||
case TileType.Hex:
|
|
||||||
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
|
||||||
case TileType.Circle:
|
|
||||||
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
|
||||||
case TileType.Rounded:
|
|
||||||
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
|
||||||
case TileType.Box:
|
|
||||||
default:
|
|
||||||
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
circleShape,
|
||||||
|
extrudeShapeParts,
|
||||||
|
traceToShape,
|
||||||
|
traceToUvBounds,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
|
import { traceImage } from '@tts/http';
|
||||||
|
import { assetUrl } from '@tts/http';
|
||||||
|
import {
|
||||||
|
getSharedGeometry,
|
||||||
|
getSharedMaterial,
|
||||||
|
objectTint,
|
||||||
|
tintKey,
|
||||||
|
tintedColor,
|
||||||
|
} from './sharedResources';
|
||||||
|
|
||||||
|
const TOKEN_SIZE = 1.8;
|
||||||
|
|
||||||
|
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
|
||||||
|
const TRACE_INSET = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function TokenObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
||||||
|
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
||||||
|
|
||||||
|
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function TokenMesh({
|
||||||
|
url,
|
||||||
|
thickness,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
thickness: number;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||||
|
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||||
|
// once the token geometry is present. Falls back to a circle when there's no
|
||||||
|
// image or the trace fails.
|
||||||
|
const trace = useTrace(url);
|
||||||
|
|
||||||
|
const { front, back, walls } = useMemo(() => {
|
||||||
|
// The traced shape and its UV framing share the same transform, so the
|
||||||
|
// full image rectangle maps to the same bounds in mesh coordinates.
|
||||||
|
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
||||||
|
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
||||||
|
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
||||||
|
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
||||||
|
// Shared across tokens with the same source image (the trace is cached per
|
||||||
|
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
||||||
|
// geometry.
|
||||||
|
const key = `token:${url ?? 'none'}:${thickness}`;
|
||||||
|
return {
|
||||||
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [trace, thickness, url]);
|
||||||
|
|
||||||
|
// A token is solid: front, back, and walls all carry the texture (projected
|
||||||
|
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
||||||
|
// baked into the color and cache key so tinted variants don't collide.
|
||||||
|
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: texture ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={front} material={material} />
|
||||||
|
<mesh geometry={back} material={material} />
|
||||||
|
<mesh geometry={walls} material={material} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceData {
|
||||||
|
shape: { outline: number[][]; holes?: number[][][] };
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache traces by URL so Suspense doesn't re-issue the request on every render
|
||||||
|
// while the boundary is held open. A URL maps to either a pending promise (while
|
||||||
|
// loading) or the resolved value (once loaded).
|
||||||
|
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
|
||||||
|
* when there's no URL / the trace fails). Throws the cached promise only while
|
||||||
|
* it's pending; once resolved, the value is returned directly so the retry
|
||||||
|
* render completes instead of suspending forever.
|
||||||
|
*/
|
||||||
|
function useTrace(url: string | undefined): TraceData | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const cached = traceCache.get(url);
|
||||||
|
if (cached === undefined) {
|
||||||
|
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
||||||
|
const value: TraceData | null = result.shape
|
||||||
|
? {
|
||||||
|
shape: result.shape,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
// Replace the pending promise with the resolved value so later renders
|
||||||
|
// return it instead of re-suspending on a settled promise.
|
||||||
|
traceCache.set(url, value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
traceCache.set(url, promise);
|
||||||
|
throw promise;
|
||||||
|
}
|
||||||
|
if (cached instanceof Promise) throw cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
@@ -1,29 +1,7 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
|
||||||
circleShape,
|
|
||||||
extrudeShapeParts,
|
|
||||||
traceToShape,
|
|
||||||
traceToUvBounds,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import { traceImage } from '@tts/http';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { TokenObjectMesh } from './TokenMesh';
|
||||||
import {
|
import type { ViewerProps } from '../viewers';
|
||||||
getSharedGeometry,
|
|
||||||
getSharedMaterial,
|
|
||||||
objectTint,
|
|
||||||
tintKey,
|
|
||||||
tintedColor,
|
|
||||||
} from './sharedResources';
|
|
||||||
|
|
||||||
const TOKEN_SIZE = 1.8;
|
|
||||||
|
|
||||||
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
|
|
||||||
const TRACE_INSET = 2;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A token: a short extruded shape with the texture on its top face. Uses
|
* A token: a short extruded shape with the texture on its top face. Uses
|
||||||
@@ -31,128 +9,15 @@ const TRACE_INSET = 2;
|
|||||||
* color when absent. The footprint is traced from the image's alpha channel via
|
* color when absent. The footprint is traced from the image's alpha channel via
|
||||||
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
|
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
|
||||||
*/
|
*/
|
||||||
export default function TokenViewer({ object }: { object: TTSObject }) {
|
export default function TokenViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene fill={fill}>
|
||||||
<TokenObjectMesh object={object} />
|
{/* Lay the token flat on the ground (see CardViewer). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<TokenObjectMesh object={object} />
|
||||||
|
</group>
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { TokenObjectMesh, TokenMesh } from './TokenMesh';
|
||||||
* The token mesh for an object, exported so the full-setup view can compose it
|
|
||||||
* into a shared scene.
|
|
||||||
*/
|
|
||||||
export function TokenObjectMesh({ object }: { object: TTSObject }) {
|
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
||||||
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
|
||||||
|
|
||||||
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
||||||
// Exported so the full-setup view can compose it into a shared scene.
|
|
||||||
export function TokenMesh({
|
|
||||||
url,
|
|
||||||
thickness,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
url?: string;
|
|
||||||
thickness: number;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
|
||||||
|
|
||||||
// Trace the image's alpha channel into a shape. Suspends until the trace
|
|
||||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
|
||||||
// once the token geometry is present. Falls back to a circle when there's no
|
|
||||||
// image or the trace fails.
|
|
||||||
const trace = useTrace(url);
|
|
||||||
|
|
||||||
const { front, back, walls } = useMemo(() => {
|
|
||||||
// The traced shape and its UV framing share the same transform, so the
|
|
||||||
// full image rectangle maps to the same bounds in mesh coordinates.
|
|
||||||
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
|
||||||
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
|
||||||
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
|
||||||
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
|
||||||
// Shared across tokens with the same source image (the trace is cached per
|
|
||||||
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
|
||||||
// geometry.
|
|
||||||
const key = `token:${url ?? 'none'}:${thickness}`;
|
|
||||||
return {
|
|
||||||
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
||||||
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
||||||
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
||||||
};
|
|
||||||
}, [trace, thickness, url]);
|
|
||||||
|
|
||||||
// A token is solid: front, back, and walls all carry the texture (projected
|
|
||||||
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
|
||||||
// baked into the color and cache key so tinted variants don't collide.
|
|
||||||
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
|
||||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
||||||
map: texture ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh geometry={front} material={material} />
|
|
||||||
<mesh geometry={back} material={material} />
|
|
||||||
<mesh geometry={walls} material={material} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TraceData {
|
|
||||||
shape: { outline: number[][]; holes?: number[][][] };
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache traces by URL so Suspense doesn't re-issue the request on every render
|
|
||||||
// while the boundary is held open. A URL maps to either a pending promise (while
|
|
||||||
// loading) or the resolved value (once loaded).
|
|
||||||
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
|
|
||||||
* when there's no URL / the trace fails). Throws the cached promise only while
|
|
||||||
* it's pending; once resolved, the value is returned directly so the retry
|
|
||||||
* render completes instead of suspending forever.
|
|
||||||
*/
|
|
||||||
function useTrace(url: string | undefined): TraceData | null {
|
|
||||||
if (!url) return null;
|
|
||||||
const cached = traceCache.get(url);
|
|
||||||
if (cached === undefined) {
|
|
||||||
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
|
||||||
const value: TraceData | null = result.shape
|
|
||||||
? {
|
|
||||||
shape: result.shape,
|
|
||||||
width: result.width,
|
|
||||||
height: result.height,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
// Replace the pending promise with the resolved value so later renders
|
|
||||||
// return it instead of re-suspending on a settled promise.
|
|
||||||
traceCache.set(url, value);
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
traceCache.set(url, promise);
|
|
||||||
throw promise;
|
|
||||||
}
|
|
||||||
if (cached instanceof Promise) throw cached;
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
||||||
function toGeometry(extruded: ExtrudedGeometry) {
|
|
||||||
const { positions, normals, uvs, indices } = extruded;
|
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
||||||
return geo;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { applyMapTransform } from './cardMaterial';
|
||||||
|
|
||||||
|
describe('applyMapTransform', () => {
|
||||||
|
it('injects the repeat/offset uniforms and UV transform into the shader', () => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial();
|
||||||
|
applyMapTransform(mat, new THREE.Vector2(0.5, 0.25), new THREE.Vector2(0.1, 0.2));
|
||||||
|
|
||||||
|
expect(mat.onBeforeCompile).toBeTypeOf('function');
|
||||||
|
|
||||||
|
const shader = {
|
||||||
|
uniforms: {} as Record<string, { value: unknown }>,
|
||||||
|
vertexShader: '#include <uv_vertex>\nvoid main() {}',
|
||||||
|
};
|
||||||
|
mat.onBeforeCompile!(shader as never, {} as never);
|
||||||
|
|
||||||
|
// Uniforms are copied, so the caller's vectors stay reusable.
|
||||||
|
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(0.5, 0.25));
|
||||||
|
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0.1, 0.2));
|
||||||
|
// The uniforms are declared in the GLSL (three.js does not auto-declare
|
||||||
|
// uniforms added via `onBeforeCompile`), and the override is injected right
|
||||||
|
// after the chunk include, which stays in place (it declares `vMapUv`/`uv`).
|
||||||
|
expect(shader.vertexShader).toContain('uniform vec2 uMapRepeat;');
|
||||||
|
expect(shader.vertexShader).toContain('uniform vec2 uMapOffset;');
|
||||||
|
expect(shader.vertexShader).toContain('#include <uv_vertex>\n\tvMapUv = uv * uMapRepeat + uMapOffset;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the vectors so later mutation of the inputs has no effect', () => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial();
|
||||||
|
const repeat = new THREE.Vector2(1, 1);
|
||||||
|
const offset = new THREE.Vector2(0, 0);
|
||||||
|
applyMapTransform(mat, repeat, offset);
|
||||||
|
|
||||||
|
repeat.set(9, 9);
|
||||||
|
offset.set(9, 9);
|
||||||
|
|
||||||
|
const shader = { uniforms: {} as Record<string, { value: unknown }>, vertexShader: '' };
|
||||||
|
mat.onBeforeCompile!(shader as never, {} as never);
|
||||||
|
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(1, 1));
|
||||||
|
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-card UV transform injected into a `MeshStandardMaterial` shader.
|
||||||
|
*
|
||||||
|
* Cards share one texture (the deck's sprite sheet, cached by drei) and one
|
||||||
|
* geometry, but each card samples a different sprite cell. Rather than cloning
|
||||||
|
* the texture per card (which re-uploads the sheet on every GPU bind), the
|
||||||
|
* repeat/offset is pushed into the material as a uniform. The shader source is
|
||||||
|
* identical across cards, so three.js still compiles a single shared program.
|
||||||
|
*
|
||||||
|
* We inject our own uniform instead of setting `texture.repeat`/`offset`
|
||||||
|
* because three r185 derives the map UVs from a `mapTransform` matrix that is
|
||||||
|
* refreshed from `map.matrix` every frame, overwriting any per-material
|
||||||
|
* transform we set on the shared texture.
|
||||||
|
*/
|
||||||
|
// Uniforms added via `onBeforeCompile` are not auto-declared by three.js, so
|
||||||
|
// they must be declared in the GLSL explicitly (the built-in `mapTransform` is
|
||||||
|
// declared in `uv_pars_vertex.glsl.js`).
|
||||||
|
const UNIFORM_DECLS = /* glsl */ `
|
||||||
|
uniform vec2 uMapRepeat;
|
||||||
|
uniform vec2 uMapOffset;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VERTEX_INJECT = /* glsl */ `
|
||||||
|
#include <uv_vertex>
|
||||||
|
vMapUv = uv * uMapRepeat + uMapOffset;
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a repeat/offset to a card material's map sampling. Call once per
|
||||||
|
* material (the transform is baked into the shader). `repeat`/`offset` are
|
||||||
|
* copied, so the caller may reuse the vectors.
|
||||||
|
*/
|
||||||
|
export function applyMapTransform(
|
||||||
|
material: THREE.MeshStandardMaterial,
|
||||||
|
repeat: THREE.Vector2,
|
||||||
|
offset: THREE.Vector2,
|
||||||
|
): void {
|
||||||
|
// Clone eagerly so later mutation of the caller's vectors can't leak into
|
||||||
|
// the uniform once the material is compiled.
|
||||||
|
const r = repeat.clone();
|
||||||
|
const o = offset.clone();
|
||||||
|
material.onBeforeCompile = (shader) => {
|
||||||
|
shader.uniforms.uMapRepeat = { value: r };
|
||||||
|
shader.uniforms.uMapOffset = { value: o };
|
||||||
|
shader.vertexShader =
|
||||||
|
UNIFORM_DECLS +
|
||||||
|
shader.vertexShader.replace('#include <uv_vertex>', VERTEX_INJECT);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,16 +8,19 @@ import { registerViewer } from '../viewers';
|
|||||||
const TileViewer = lazy(() => import('./TileViewer'));
|
const TileViewer = lazy(() => import('./TileViewer'));
|
||||||
const TokenViewer = lazy(() => import('./TokenViewer'));
|
const TokenViewer = lazy(() => import('./TokenViewer'));
|
||||||
const CardViewer = lazy(() => import('./CardViewer'));
|
const CardViewer = lazy(() => import('./CardViewer'));
|
||||||
|
const DeckViewer = lazy(() => import('./DeckViewer'));
|
||||||
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
|
||||||
|
const PdfViewer = lazy(() => import('./PdfViewer'));
|
||||||
|
|
||||||
registerViewer('Tile', TileViewer);
|
registerViewer('Tile', TileViewer);
|
||||||
registerViewer('Custom_Tile', TileViewer);
|
registerViewer('Custom_Tile', TileViewer);
|
||||||
registerViewer('Custom_Token', TokenViewer);
|
registerViewer('Custom_Token', TokenViewer);
|
||||||
registerViewer('Card', CardViewer);
|
registerViewer('Card', CardViewer);
|
||||||
registerViewer('CardCustom', CardViewer);
|
registerViewer('CardCustom', CardViewer);
|
||||||
registerViewer('Deck', CardViewer);
|
registerViewer('Deck', DeckViewer);
|
||||||
registerViewer('DeckCustom', CardViewer);
|
registerViewer('DeckCustom', DeckViewer);
|
||||||
registerViewer('Custom_Deck', CardViewer);
|
registerViewer('Custom_Deck', DeckViewer);
|
||||||
registerViewer('Custom_Model', CustomModelViewer);
|
registerViewer('Custom_Model', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Bag', CustomModelViewer);
|
||||||
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
|
||||||
|
registerViewer('Custom_PDF', PdfViewer);
|
||||||
@@ -30,7 +30,7 @@ export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
|
|||||||
* the same image.
|
* the same image.
|
||||||
*
|
*
|
||||||
* These caches live for the session (like drei's global texture cache) and are
|
* These caches live for the session (like drei's global texture cache) and are
|
||||||
* not disposed on unmount; see `docs/full-setup-view.md`.
|
* not disposed on unmount; see `docs/status/full-setup-view.md`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const geometryCache = new Map<string, THREE.BufferGeometry>();
|
const geometryCache = new Map<string, THREE.BufferGeometry>();
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import type { TTSObject } from '@tts/shared';
|
|||||||
import { useModStore } from '../stores/modStore';
|
import { useModStore } from '../stores/modStore';
|
||||||
import { useSearchStore } from '../stores/searchStore';
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
import Scene from '../components/viewers/Scene';
|
import Scene from '../components/viewers/Scene';
|
||||||
import { TileObjectMesh } from '../components/viewers/TileViewer';
|
import { TileObjectMesh } from '../components/viewers/TileMesh';
|
||||||
import { TokenObjectMesh } from '../components/viewers/TokenViewer';
|
import { TokenObjectMesh } from '../components/viewers/TokenMesh';
|
||||||
import { CardObjectMesh } from '../components/viewers/CardViewer';
|
import { CardObjectMesh } from '../components/viewers/CardMesh';
|
||||||
import { CustomModelMesh } from '../components/viewers/CustomModelViewer';
|
import { CustomModelMesh } from '../components/viewers/CustomModelMesh';
|
||||||
import { objectPlacement } from '../components/viewers/transform';
|
import { objectPlacement } from '../components/viewers/transform';
|
||||||
|
|
||||||
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
|
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
|
||||||
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
|
|||||||
}, [id, item?.fileUrl, load]);
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
const objects = useMemo(
|
const objects = useMemo(
|
||||||
() => (mod ? flattenObjects(mod) : []),
|
() => (mod ? flattenObjects(mod.mod) : []),
|
||||||
[mod],
|
[mod],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Link, useParams } from 'react-router-dom';
|
|||||||
import { buildTree, collectRefs } from '@tts/extract';
|
import { buildTree, collectRefs } from '@tts/extract';
|
||||||
import { useModStore } from '../stores/modStore';
|
import { useModStore } from '../stores/modStore';
|
||||||
import { useSearchStore } from '../stores/searchStore';
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
import { modFileUrl } from '../api';
|
|
||||||
import ObjectTree from '../components/ObjectTree';
|
import ObjectTree from '../components/ObjectTree';
|
||||||
import { ErrorBoundary } from '@tts/tabletop';
|
import { ErrorBoundary } from '@tts/tabletop';
|
||||||
import { resolveViewer } from '../components/viewers';
|
import { resolveViewer } from '../components/viewers';
|
||||||
@@ -25,8 +24,8 @@ export default function ModPage() {
|
|||||||
if (id) load(id, item?.fileUrl);
|
if (id) load(id, item?.fileUrl);
|
||||||
}, [id, item?.fileUrl, load]);
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
|
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||||
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
|
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
||||||
@@ -40,78 +39,37 @@ export default function ModPage() {
|
|||||||
const Viewer = selected ? resolveViewer(selected.object) : null;
|
const Viewer = selected ? resolveViewer(selected.object) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex h-full min-h-0">
|
||||||
<div>
|
{/* Independently scrolling tree; the filter bar stays pinned above it. */}
|
||||||
<h1 className="text-2xl font-semibold">Mod {id}</h1>
|
<aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<ObjectTree
|
||||||
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
|
nodes={tree}
|
||||||
asset refs
|
selectedPath={selectedPath}
|
||||||
</p>
|
onSelect={setSelectedPath}
|
||||||
<div className="mt-3 flex gap-2">
|
/>
|
||||||
<a
|
</aside>
|
||||||
href={modFileUrl(id!, item?.fileUrl)}
|
|
||||||
className="inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
|
||||||
>
|
|
||||||
Download save file
|
|
||||||
</a>
|
|
||||||
<Link
|
|
||||||
to={`/mod/${id}/setup`}
|
|
||||||
className="inline-block rounded-lg border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Full setup
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
<section className="h-full min-h-0 flex-1 p-4">
|
||||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
|
{selected && Viewer ? (
|
||||||
<ObjectTree
|
/* Key by selection path so the Canvas remounts and the camera
|
||||||
nodes={tree}
|
refits to the newly selected object. */
|
||||||
selectedPath={selectedPath}
|
<ErrorBoundary key={selectedPath}>
|
||||||
onSelect={setSelectedPath}
|
<Suspense
|
||||||
/>
|
fallback={
|
||||||
</aside>
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
|
Loading viewer…
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
</div>
|
||||||
{selected ? (
|
}
|
||||||
<>
|
>
|
||||||
<header className="mb-4">
|
<Viewer object={selected.object} fill />
|
||||||
<span
|
</Suspense>
|
||||||
title={selected.object.Name}
|
</ErrorBoundary>
|
||||||
className="inline-flex items-center gap-1 text-zinc-400"
|
) : (
|
||||||
>
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
{iconsForObject(selected.object.Name).map((icon) => (
|
Select an object from the tree to inspect it.
|
||||||
<Icon key={icon} icon={icon} className="h-5 w-5" />
|
</div>
|
||||||
))}
|
)}
|
||||||
</span>
|
</section>
|
||||||
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
|
|
||||||
<p className="font-mono text-xs text-zinc-500">
|
|
||||||
{selected.object.GUID}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
{Viewer && (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
|
||||||
Loading viewer…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Key by selection path so the Canvas remounts and the
|
|
||||||
camera refits to the newly selected object. */}
|
|
||||||
<Viewer key={selectedPath} object={selected.object} />
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Select an object from the tree to inspect it.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import Breadcrumbs from '../components/Breadcrumbs';
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
import PackageMissing from '../components/PackageMissing';
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import TabletopScene from '../components/tabletop/TabletopScene';
|
||||||
import { findPackage } from './bgm';
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
/** Detail view for a single setup within a package. */
|
/** Detail view for a single setup within a package. */
|
||||||
@@ -30,7 +31,12 @@ export default function SetupPage() {
|
|||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
{found.type}#{found.id}
|
{found.type}#{found.id}
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{Object.keys(found.setup).length} path
|
||||||
|
{Object.keys(found.setup).length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<TabletopScene pkg={pkg} setup={found} />
|
||||||
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
{JSON.stringify(found.setup, null, 2)}
|
{JSON.stringify(found.setup, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TTSMod } from '@tts/shared';
|
import type { ModDetails } from '@tts/shared';
|
||||||
import { fetchMod } from '../api';
|
import { fetchMod } from '../api';
|
||||||
|
|
||||||
interface ModState {
|
interface ModState {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
mod: TTSMod | null;
|
mod: ModDetails | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
load: (id: string, fileUrl?: string) => Promise<void>;
|
load: (id: string, fileUrl?: string) => Promise<void>;
|
||||||
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
|
|||||||
load: async (id, fileUrl) => {
|
load: async (id, fileUrl) => {
|
||||||
set({ loading: true, error: null, id, fileUrl });
|
set({ loading: true, error: null, id, fileUrl });
|
||||||
try {
|
try {
|
||||||
const mod = await fetchMod(id, fileUrl);
|
const details = await fetchMod(id, fileUrl);
|
||||||
set({ mod, loading: false });
|
set({ mod: details, loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: String(err) });
|
set({ loading: false, error: String(err) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
|
clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
|
||||||
}));
|
}));
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
'/items': 'http://localhost:3000',
|
'/items': 'http://localhost:3000',
|
||||||
'/health': 'http://localhost:3000',
|
'/health': 'http://localhost:3000',
|
||||||
'/asset': 'http://localhost:3000',
|
'/asset': 'http://localhost:3000',
|
||||||
|
'/pdf': 'http://localhost:3000',
|
||||||
'/trace': 'http://localhost:3000',
|
'/trace': 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Scope:** The system's architecture and dependency graph. For implementation
|
> **Scope:** The system's architecture and dependency graph. For implementation
|
||||||
> details (files, endpoints, build order), see
|
> details (files, endpoints, build order), see
|
||||||
> [`implementation-plan.md`](./implementation-plan.md). For the rationale behind
|
> [`status/implementation-plan.md`](./status/implementation-plan.md). For the
|
||||||
> key decisions, see [`decisions.md`](./decisions.md).
|
> rationale behind key decisions, see [`decisions.md`](./decisions.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ shared types/validation package.
|
|||||||
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
||||||
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
|
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
|
||||||
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
|
||||||
|
| `packages/engine` | Message bus, queue/tick, triggers, orchestrators | Isomorphic |
|
||||||
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
| `packages/shared` | Shared types + zod schemas | Isomorphic |
|
||||||
|
|
||||||
## Dependency graph
|
## Dependency graph
|
||||||
@@ -55,7 +56,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
|||||||
- **`apps/web` → `packages/mesh`** — extrudes 2D shapes into 3D geometry
|
- **`apps/web` → `packages/mesh`** — extrudes 2D shapes into 3D geometry
|
||||||
(`{ front, back, walls }`) for the tile, token, and card viewers.
|
(`{ front, back, walls }`) for the tile, token, and card viewers.
|
||||||
- **`apps/proxy` → `packages/tts`** — calls `fetchMod` / `getFileName` to serve
|
- **`apps/proxy` → `packages/tts`** — calls `fetchMod` / `getFileName` to serve
|
||||||
item requests.
|
item requests (the filename is derived from the save URL path).
|
||||||
- **`apps/proxy` → `packages/shared`** — uses shared types and zod schemas for
|
- **`apps/proxy` → `packages/shared`** — uses shared types and zod schemas for
|
||||||
request/response validation.
|
request/response validation.
|
||||||
- **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject`
|
- **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject`
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# bgm-tabletop
|
|
||||||
|
|
||||||
a r3f based interactive component library to work with [bgm](./bgm-format.md) board games. will be used somewhere in the `web` app's bgm inspector routes.
|
|
||||||
|
|
||||||
## 1. stack
|
|
||||||
|
|
||||||
`react` - react, react router, tailwindv4
|
|
||||||
`r3f` - r3f, drei, postprocessing
|
|
||||||
`zustand` - for state management
|
|
||||||
|
|
||||||
## 2. states
|
|
||||||
|
|
||||||
source-of-truth game state:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
surfaces: Record<string, boolean>, // enabled per surface id
|
|
||||||
paths: Record<string, string[]>, // path -> part list
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**assumption:** each piece on the board has a unique id, even tokens of the same type. so each entry in a path's list is a unique piece id, and a piece id never appears twice in the same path. this makes the render list keyed by piece id stable and unambiguous.
|
|
||||||
|
|
||||||
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize }` for rendering on a surface. keys of this map makes a stable render list.
|
|
||||||
|
|
||||||
- `route` - the matched route.
|
|
||||||
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
|
||||||
- `index` - the piece's position in its path's stack.
|
|
||||||
- `stackSize` - the number of pieces on the path.
|
|
||||||
|
|
||||||
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
|
||||||
|
|
||||||
## 3. components
|
|
||||||
|
|
||||||
- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement).
|
|
||||||
- `WorldSurfaceView` mounts a surface to world space.
|
|
||||||
- `HudSurfaceView` mounts a surface to hud space.
|
|
||||||
- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy.
|
|
||||||
- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers.
|
|
||||||
|
|
||||||
## 4. stacking
|
|
||||||
|
|
||||||
the format's stacking strategy (curve / limit / align / steps, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece. `PartPlacement` consumes it.
|
|
||||||
|
|
||||||
## 5. usage
|
|
||||||
|
|
||||||
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
|
||||||
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
|
||||||
- a surface is mounted only when enabled; a disabled surface is not rendered.
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# bgm-commands
|
||||||
|
|
||||||
|
Command execution for [bgm](./format.md) board games, built into
|
||||||
|
[`@tts/tabletop`](./tabletop.md). A command is a unit of scripted
|
||||||
|
interaction — focus the camera, wait for a tap, move a part, show a caption —
|
||||||
|
that runs against the tabletop state store and render layer.
|
||||||
|
|
||||||
|
This doc covers **command execution**: the async lifecycle, run contexts, and
|
||||||
|
tap interaction. The message layer above this — how commands are *declared*
|
||||||
|
and *fired* (triggers, orchestrators, the message queue) — is specified in
|
||||||
|
[`engine.md`](./engine.md). Commands are async functions registered
|
||||||
|
with the engine's handler registry; `@tts/tabletop` provides the concrete
|
||||||
|
commands that mutate the tabletop store and render layer.
|
||||||
|
|
||||||
|
## 1. async commands
|
||||||
|
|
||||||
|
A command is an async function that returns a result. Every command ends in
|
||||||
|
one of three states, emitted as a message discriminated on the type suffix
|
||||||
|
(see `engine.md` §3):
|
||||||
|
|
||||||
|
- `:done` — completed normally.
|
||||||
|
- `:cancel` — interrupted (a newer command superseded it, the user skipped,
|
||||||
|
the surface was disabled). **Not a failure.**
|
||||||
|
- `:error` — genuinely failed (asset missing, bad path, a thrown exception).
|
||||||
|
|
||||||
|
`cancel` is distinct from `error`: a superseded or skipped command stops
|
||||||
|
cleanly, while a broken command surfaces loudly. The runtime treats them
|
||||||
|
differently — a script that is superseded unwinds without alarming the player,
|
||||||
|
but an `error` is reported.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CommandResult<Name extends string, R = void> =
|
||||||
|
| { type: `${Name}:done`; data: R }
|
||||||
|
| { type: `${Name}:cancel` }
|
||||||
|
| { type: `${Name}:error`; error: Error };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. run contexts
|
||||||
|
|
||||||
|
Each command invocation creates its own **run context**: the unit of
|
||||||
|
cancellation and the carrier of command-specific state.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CommandRun {
|
||||||
|
id: string;
|
||||||
|
command: Command;
|
||||||
|
status: 'running' | 'ok' | 'cancel' | 'error';
|
||||||
|
data: unknown; // command-specific state, e.g. a pending tap target
|
||||||
|
cancel(): void;
|
||||||
|
done: Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A command **owns its own state and its own waiting**; the runtime only
|
||||||
|
orchestrates. Its job is to start a run, track its status, cancel it when
|
||||||
|
superseded, and react to its terminal state. This keeps commands
|
||||||
|
self-contained and testable in isolation.
|
||||||
|
|
||||||
|
## 3. fire-and-forget vs self-managed waiting
|
||||||
|
|
||||||
|
Commands fall into two categories:
|
||||||
|
|
||||||
|
- **Fire-and-forget** (`focus`, `highlight`, `caption`) — start and return
|
||||||
|
`ok` immediately (or when their tween settles). The runtime does not block
|
||||||
|
on them.
|
||||||
|
- **Self-managed waiting** (`wait: tap`, a dialog) — the command resolves its
|
||||||
|
own promise when its condition is met. The runtime just awaits it.
|
||||||
|
|
||||||
|
Fire-and-forget commands still get a run context and a cancel path. A `focus`
|
||||||
|
tween superseded by a newer `focus` must be cancellable, or two cameras fight.
|
||||||
|
"Fire-and-forget" means the runtime doesn't await it, not that it has no
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
**Supersede groups** cancel a running command when another in the same group
|
||||||
|
starts. A `focus` command belongs to a `camera` group, so a second `focus`
|
||||||
|
cancels the first. A superseded command's `signal` is aborted, and it emits
|
||||||
|
`:cancel`.
|
||||||
|
|
||||||
|
A command is an async function taking the `RunContext` (with its `args`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine wraps it: it builds the context from the message, runs the function,
|
||||||
|
and emits `:done` on resolve, `:cancel` on abort, `:error` on throw.
|
||||||
|
|
||||||
|
## 4. tap interaction
|
||||||
|
|
||||||
|
Only tap interaction is supported. A tap on a part is detected and reported to
|
||||||
|
the command layer as a `TapEvent`. Parts may declare **trigger points** —
|
||||||
|
named, circular regions the author wants to be tappable.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TriggerPoint {
|
||||||
|
id: string;
|
||||||
|
position: [number, number]; // part-local frame, mm
|
||||||
|
radius: number; // mm
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TapEvent {
|
||||||
|
part: string; // package:type#id
|
||||||
|
position: [number, number]; // part-local frame, mm
|
||||||
|
trigger: TriggerPoint | null; // nearest within radius, or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Trigger points are authored in the **part's local frame** (mm, relative to
|
||||||
|
the part's origin), not world space. A part moves, rotates, and flips
|
||||||
|
(facing), so a world-space point would break the moment it moves. The tap
|
||||||
|
point is transformed into the part's local frame at tap time.
|
||||||
|
- Distance is measured in the part's plane. The reported trigger point is the
|
||||||
|
nearest one within its `radius`; ties go to the first declared.
|
||||||
|
- **Every tap on the part is reported**, with the nearest trigger point (or
|
||||||
|
`null` when none is in range). The command decides how to react — resolve,
|
||||||
|
reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the
|
||||||
|
command owns the UX.
|
||||||
|
|
||||||
|
Commands subscribe to the tap stream via the context and unsubscribe on
|
||||||
|
cancel, so a cancelled `wait: tap` never leaks a handler.
|
||||||
|
|
||||||
|
## 5. run context
|
||||||
|
|
||||||
|
The context a command receives is the handle to everything it can affect. The
|
||||||
|
engine defines the base `RunContext` (see `engine.md` §5): `signal`
|
||||||
|
(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop
|
||||||
|
extends it with the handles commands need to mutate the board:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TabletopRunContext extends RunContext {
|
||||||
|
pkg: Package;
|
||||||
|
store: TabletopStore; // movePart, setPart, enableSurface, ...
|
||||||
|
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
|
||||||
|
// camera, highlight, and overlay handles are added as those subsystems land
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. where trigger points come from
|
||||||
|
|
||||||
|
The tap detector reads trigger points from a runtime map keyed by part id; it
|
||||||
|
does not care where they are declared. Declaration (on the part definition, in
|
||||||
|
a setup, or in a script) is the deferred "how to declare" half and lives in
|
||||||
|
`format.md`.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **Where commands are declared** — the `script` role and its schema
|
||||||
|
(`format.md`), deferred.
|
||||||
|
- **Animation** — a general "ease toward target placement" layer (preferred)
|
||||||
|
vs explicit per-move tweens.
|
||||||
|
- **Camera** — `CameraControls` (drei) vs hand-rolled.
|
||||||
|
- **Triggering** — does a setup reference a script to auto-run, or is a script
|
||||||
|
a separate page the player picks?
|
||||||
|
- **Narration** — pre-recorded audio assets per script, or TTS at runtime?
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# bgm-engine
|
||||||
|
|
||||||
|
The message layer that drives [bgm](./format.md) board games, built into
|
||||||
|
[`@tts/engine`](../architecture.md). It unifies the two halves of scripted
|
||||||
|
interaction — *declaring* what should happen and *executing* it — into a single
|
||||||
|
reactive loop: **messages** flow through a **queue**, and **handlers** react to
|
||||||
|
them.
|
||||||
|
|
||||||
|
This doc covers the message model (what flows), the queue and its tick (how it
|
||||||
|
flows), and the handlers (who reacts). Command *execution* — the async
|
||||||
|
lifecycle, run contexts, and tap interaction — is specified in
|
||||||
|
[`commands.md`](./commands.md); this doc is the layer above it.
|
||||||
|
|
||||||
|
## package split
|
||||||
|
|
||||||
|
The engine is a **pure** package: the message bus, queue, tick, trigger
|
||||||
|
registry, and the handler runner. It has no r3f, no React, and no store, so it
|
||||||
|
is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep
|
||||||
|
style). It defines the contract — `Message`, the handler registry, `Trigger`,
|
||||||
|
`Orchestrator`, and `RunContext`.
|
||||||
|
|
||||||
|
[`@tts/tabletop`](./tabletop.md) is the intended consumer of that contract:
|
||||||
|
it will register the built-in commands (`move`, `focus`, `caption`,
|
||||||
|
`enableSurface`, ...) that mutate the tabletop store and drive the render layer.
|
||||||
|
The engine never imports tabletop, and tabletop is expected to depend on the
|
||||||
|
engine for the message types and the handler registry. **This wiring is designed
|
||||||
|
but not yet landed** — today `@tts/tabletop` has no dependency on the engine;
|
||||||
|
it ships its state store and render layer standalone (see
|
||||||
|
[`../status/bgm-tabletop.md`](../status/bgm-tabletop.md)). A headless sim or bot
|
||||||
|
harness can consume the engine without the render layer.
|
||||||
|
|
||||||
|
## 1. messages
|
||||||
|
|
||||||
|
A **message** is the unit of communication. It is both an *event* (something
|
||||||
|
happened) and an *intent* (something should happen) — the two are the same
|
||||||
|
thing. A message is dispatched to the handlers registered for its `type`; a
|
||||||
|
handler may emit new messages in response.
|
||||||
|
|
||||||
|
Messages are a **discriminated union** on `type`. The engine defines the
|
||||||
|
generic shapes; the host's concrete union extends them with its own command
|
||||||
|
types.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TapMessage {
|
||||||
|
type: 'tap';
|
||||||
|
data: TapEvent; // part, position, trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandMessage<Name extends string, Args> {
|
||||||
|
type: Name;
|
||||||
|
data: Args;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A message is identified by `type`, matching the format's `type#id`
|
||||||
|
convention. A `move` message both *runs* the move command and is *observable*
|
||||||
|
as an event; the command's completion is itself a message, which is what
|
||||||
|
triggers match and orchestrators await.
|
||||||
|
|
||||||
|
The loop is just: **message → handler → message**. Handlers consume messages
|
||||||
|
and emit new ones; the queue serializes them.
|
||||||
|
|
||||||
|
## 2. the queue and ticking
|
||||||
|
|
||||||
|
Messages are not processed inline. They are **enqueued** and handled on the
|
||||||
|
next **tick**. This kills reentrancy (a handler cannot cause unbounded
|
||||||
|
recursion), gives a natural debounce, and makes the whole system a
|
||||||
|
deterministic frame.
|
||||||
|
|
||||||
|
### tick contract
|
||||||
|
|
||||||
|
The engine is pure — it has no render loop and must stay node-testable. It
|
||||||
|
exposes `tick()`, and the host calls it:
|
||||||
|
|
||||||
|
- In `@tts/tabletop`, a `useFrame` drives `tick()`.
|
||||||
|
- In tests, `tick()` is called manually.
|
||||||
|
|
||||||
|
The engine never assumes a render loop.
|
||||||
|
|
||||||
|
### drain semantics
|
||||||
|
|
||||||
|
- **Snapshot-and-drain.** At `tick()`, snapshot the queue and process it.
|
||||||
|
Messages emitted *during* the drain go to the *next* tick. This guarantees
|
||||||
|
no reentrancy within a drain and makes ordering deterministic.
|
||||||
|
- **FIFO within a tick.** Simple and predictable.
|
||||||
|
- **One tick drains the whole snapshot** (not one message per tick), so a
|
||||||
|
burst of messages all resolve in one frame.
|
||||||
|
|
||||||
|
### awaiting
|
||||||
|
|
||||||
|
A handler suspends on `await ctx.wait(pred)` and resumes when a matching
|
||||||
|
message is processed during a drain. Its own emissions go to the next tick, so
|
||||||
|
it cannot re-enter itself.
|
||||||
|
|
||||||
|
## 3. message types
|
||||||
|
|
||||||
|
### interaction messages
|
||||||
|
|
||||||
|
Interaction is the player's input, reported to the engine as messages. Only
|
||||||
|
tap interaction is supported (see `commands.md` §4).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TapMessage {
|
||||||
|
type: 'tap';
|
||||||
|
data: TapEvent;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A tap on a part is reported with the nearest trigger point (or `null` on a
|
||||||
|
miss). The handler decides how to react — resolve, reject with a "wrong spot"
|
||||||
|
shake, or ignore. The runtime stays dumb; the handler owns the UX.
|
||||||
|
|
||||||
|
### command messages
|
||||||
|
|
||||||
|
A command message names a command to run. Its handler is the command
|
||||||
|
implementation; its completion is emitted as a result message. A command's
|
||||||
|
result is a **discriminated union on the type suffix**, carrying the terminal
|
||||||
|
state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CommandResult<Name extends string, R = void> =
|
||||||
|
| { type: `${Name}:done`; data: R }
|
||||||
|
| { type: `${Name}:cancel` }
|
||||||
|
| { type: `${Name}:error`; error: Error };
|
||||||
|
|
||||||
|
// e.g. move:done { data: MoveResult } | move:cancel | move:error
|
||||||
|
```
|
||||||
|
|
||||||
|
The command-id-as-key convention means a message both *is* the intent and
|
||||||
|
*observes* the result. `move:done`, `focus:done`, etc. are the messages that
|
||||||
|
triggers match and orchestrators await. A cancelled command emits `:cancel`, an
|
||||||
|
errored one `:error` — a trigger matching `move:done` does not fire on a
|
||||||
|
cancel.
|
||||||
|
|
||||||
|
## 4. handlers
|
||||||
|
|
||||||
|
There are three kinds of handler. All three consume messages and emit
|
||||||
|
messages; they differ in how they're declared and how they run.
|
||||||
|
|
||||||
|
| Handler | Declared | Runs | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Trigger** | data (yaml) | synchronously on match | declarative reactive glue |
|
||||||
|
| **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow |
|
||||||
|
| **Command** | code (built-in) | async, on its message | atomic execution |
|
||||||
|
|
||||||
|
### triggers — declarative reactive glue
|
||||||
|
|
||||||
|
A trigger matches a message by `type` and named params, and emits messages in
|
||||||
|
response. It is declared as data, keyed by `role+type+id` like other defs, and
|
||||||
|
collision-checked the same way.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: trigger
|
||||||
|
type: tap
|
||||||
|
id: draw
|
||||||
|
match:
|
||||||
|
part: carcassonne:tile#a
|
||||||
|
trigger: draw
|
||||||
|
emit:
|
||||||
|
- move: { part: carcassonne:tile#a, to: /grid/5/5 }
|
||||||
|
- focus: { path: /grid/5/5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type` selects the message kind; `match` binds named params from the
|
||||||
|
payload (like a route's candidates).
|
||||||
|
- `emit` uses the command-id-as-key convention.
|
||||||
|
- Multiple triggers can match the same message — both fire, which is usually
|
||||||
|
what you want.
|
||||||
|
- A trigger is a **pre-registered handler**: it's a message consumer that
|
||||||
|
emits commands. An orchestrator can do the same thing imperatively with
|
||||||
|
`ctx.on(...)`.
|
||||||
|
|
||||||
|
### orchestrators — imperative async flow
|
||||||
|
|
||||||
|
An orchestrator is the code counterpart to a trigger: an async function that
|
||||||
|
emits messages and awaits matching ones. It is a proper TS module, declared
|
||||||
|
per folder as `main.ts` — unique per folder like `package.yaml`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// main.ts
|
||||||
|
export default async function main(ctx: RunContext): Promise<void> {
|
||||||
|
await ctx.focus({ path: '/deck' });
|
||||||
|
await ctx.caption({ text: 'Draw a tile' });
|
||||||
|
const tap = await ctx.wait((m) => m.type === 'tap');
|
||||||
|
await ctx.move({ part: tap.data.part, to: '/grid/5/5' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`main.ts` is executable code, loaded by the host, not the engine.** The
|
||||||
|
engine defines the contract (the orchestrator type and runner); the host
|
||||||
|
dynamically imports `main.ts` and hands the exported orchestrator to the
|
||||||
|
engine. The engine never imports user code.
|
||||||
|
- **A default export async function.** `main.ts` exports a single async
|
||||||
|
function as its default export, taking the `RunContext`. It is the folder's
|
||||||
|
orchestrator.
|
||||||
|
- **Trigger control lives here.** The orchestrator toggles triggers at runtime
|
||||||
|
by their `type#id`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.enableTrigger('tap', 'draw');
|
||||||
|
ctx.disableTrigger('tap', 'draw');
|
||||||
|
```
|
||||||
|
|
||||||
|
Declaration is data; activation is code. The orchestrator owns game-flow
|
||||||
|
logic ("no more placements this turn" → disable the trigger), while the
|
||||||
|
trigger stays a dumb declarative mapping.
|
||||||
|
|
||||||
|
### commands — atomic execution
|
||||||
|
|
||||||
|
A command is an async function, the same shape as an orchestrator. It takes a
|
||||||
|
`RunContext` (with its `args`), returns its result, and throws on error. The
|
||||||
|
engine wraps it: it builds the context from the message, runs the function, and
|
||||||
|
emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on
|
||||||
|
throw.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands are the **single mutation path** — the only way state changes.
|
||||||
|
Triggers and orchestrators never mutate state directly; they emit command
|
||||||
|
messages, and the command handlers execute them.
|
||||||
|
|
||||||
|
## 5. run context
|
||||||
|
|
||||||
|
Every handler runs against a `RunContext`, the handle to everything it can
|
||||||
|
affect and the unit of cancellation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RunContext {
|
||||||
|
signal: AbortSignal; // cancellation: superseded, skipped, surface disabled
|
||||||
|
emit(msg: Message): void;
|
||||||
|
wait(pred: (m: Message) => boolean): Promise<Message>; // rejects on abort
|
||||||
|
enableTrigger(type: string, id?: string): void;
|
||||||
|
disableTrigger(type: string, id?: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Cancellation** is an `AbortSignal`. A superseded command or a disabled
|
||||||
|
surface aborts the signal; a `wait` rejects on abort, and the command's
|
||||||
|
`:cancel` result is emitted.
|
||||||
|
- **Errors** are thrown. A command that throws emits `:error`; an orchestrator
|
||||||
|
that throws surfaces loudly.
|
||||||
|
- Commands and orchestrators are the same shape: an async function taking the
|
||||||
|
context. An orchestrator is a command that returns `void` and is never
|
||||||
|
awaited by a parent.
|
||||||
|
|
||||||
|
## 6. solo-only
|
||||||
|
|
||||||
|
This design is **solo-only** — no multiplayer. Other players either don't
|
||||||
|
exist or are automated with an automata. An automata is just another message
|
||||||
|
consumer that emits commands: a stateful trigger or orchestrator. The engine
|
||||||
|
doesn't care whether a `tap` message came from a human or a bot decision —
|
||||||
|
same queue, same handlers. Solo-only simplifies the design: no network, no
|
||||||
|
sync, no authoritative-server concerns. "Other players" are just more message
|
||||||
|
producers.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **`main.ts` loading.** The host dynamically imports `main.ts`; the exact
|
||||||
|
loading boundary (Vite dynamic import, error handling, HMR) is deferred to
|
||||||
|
implementation. The engine defines the orchestrator type; the host loads the
|
||||||
|
module and hands the exported orchestrator to the engine.
|
||||||
@@ -37,9 +37,11 @@ bruce,[]
|
|||||||
|
|
||||||
### Inline vs file
|
### Inline vs file
|
||||||
|
|
||||||
`$variants` can be a file/URL path *or* an inline CSV string. If the value
|
`$variants` can be a single source or an array of sources. Each source is a
|
||||||
contains a newline it is inline CSV; otherwise it is a path. In YAML a block
|
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
|
||||||
scalar (`|`) is the natural way to write inline CSV; in JSON you'd use `\n`.
|
This keeps the two forms self-documenting and applies the same rule to single
|
||||||
|
values and array elements alike. In YAML a block scalar (`|`) is the natural
|
||||||
|
way to write inline CSV; in JSON you'd use `\n`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
$variants: |
|
$variants: |
|
||||||
@@ -47,9 +49,21 @@ $variants: |
|
|||||||
string,string,[number;number;number;number]
|
string,string,[number;number;number;number]
|
||||||
fish,Fish,[0;0;5;2]
|
fish,Fish,[0;0;5;2]
|
||||||
grain,Grain,[1;0;5;2]
|
grain,Grain,[1;0;5;2]
|
||||||
wood,Wood,[2;0;5;2]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An array of sources concatenates their rows. This lets one part definition
|
||||||
|
pull from several CSVs with different schemas — e.g. a deck where the regular
|
||||||
|
cards share a face sheet but the jokers have their own:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
$variants:
|
||||||
|
- ./cards.csv
|
||||||
|
- ./jokers.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Each source is parsed with its own schema, and its rows extend the original
|
||||||
|
object independently.
|
||||||
|
|
||||||
### CSV conventions
|
### CSV conventions
|
||||||
|
|
||||||
CSV is parsed with `typed-csv`:
|
CSV is parsed with `typed-csv`:
|
||||||
@@ -70,32 +84,77 @@ declaration, then uses its `include` paths to find the definitions.
|
|||||||
|
|
||||||
### Code blocks as virtual files
|
### Code blocks as virtual files
|
||||||
|
|
||||||
A code block is a virtual definition file. To give it a name — so `include:`
|
A code block is a virtual definition file. Its name is derived from the
|
||||||
and `$variants` paths can resolve against it — add a `file=` segment to the
|
`role=` on its info string — `role.type.lang` — so it is discoverable by the
|
||||||
code block's info string. The name is relative to the current markdown file:
|
default `include: ./**/*.yaml` and addressable by that name:
|
||||||
|
|
||||||
````md
|
````md
|
||||||
```yaml file=parts/cargo.yaml
|
```yaml role=part.cargo
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/cargo.csv
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
- A block with `file=` is addressable by that path.
|
- `role=part.cargo` names the block `part.cargo.yaml`.
|
||||||
- A block without `file=` is auto-named `./${hash}.yaml`, where `hash`
|
- `role=surface.game#main` names it `surface.game.yaml`.
|
||||||
is derived from its content. This makes every yaml block naturally
|
- `role=package` names it `package.yaml`.
|
||||||
discoverable by the default `include: ./**/*.yaml`. Identical blocks dedupe
|
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||||
to the same hash.
|
resolve against. When there is a real file in that path, the codeblock wins.
|
||||||
- The `file=` name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
|
||||||
resolve against. When there is a real file in that path, the codeblock wins.
|
names the block `parts/cargo.yaml` regardless of its role.
|
||||||
- `file=` implies the file type from its extension; the language tag is
|
- A block without `role=` is **not** a definition — it is ignored. Discovery
|
||||||
optional and only for editor highlighting.
|
is explicit: a block is a definition only when its `role=` (or, for real
|
||||||
- **Hash vs explicit `file=`:** a hashed name is for auto-discovery, not for
|
files, its filename) declares a known `role.type`.
|
||||||
referencing. To point at a specific yaml block by name, give it an explicit
|
|
||||||
`file=`; otherwise its name is content-derived and unstable.
|
### role= on the info string
|
||||||
|
|
||||||
|
A block's role is declared on the info string, using the same `role.type#id`
|
||||||
|
shape as the block's identity. `type` and `id` are optional — anything not
|
||||||
|
given comes from the content (or from `$variants` rows):
|
||||||
|
|
||||||
|
````md
|
||||||
|
```yaml role=part.cargo
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- `role=part.cargo` declares a part of type `cargo`; its `id` comes from the
|
||||||
|
content or from `$variants`.
|
||||||
|
- `role=surface.game#main` declares a surface of type `game` with id `main`.
|
||||||
|
- `role=package` declares a package; it has no type.
|
||||||
|
- A `role`/`type`/`id` given on the info string **conflicts** with the same
|
||||||
|
key in the content and errors. `id` on the info string cannot combine with
|
||||||
|
`$variants`, since every row supplies its own `id`.
|
||||||
|
- A block without `role=` is not a definition — discovery is explicit (see
|
||||||
|
above).
|
||||||
|
|
||||||
|
### Real files
|
||||||
|
|
||||||
|
A real `role.type.lang` file (e.g. `part.cargo.yaml`) is a definition by its
|
||||||
|
filename, with no `role=` needed. `role` and `type` are parsed from the name;
|
||||||
|
`id` comes from the content or `$variants`. A real file and a code block with
|
||||||
|
the same name are the same definition; the code block wins.
|
||||||
|
|
||||||
|
### Duplicates
|
||||||
|
|
||||||
|
Two definitions with the same `role.type` are grouped under the same name.
|
||||||
|
They must not define the same `id` — a duplicate `type#id` errors. Blocks with
|
||||||
|
the same `role.type` but different ids are fine.
|
||||||
|
|
||||||
### include
|
### include
|
||||||
|
|
||||||
@@ -104,21 +163,35 @@ package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
|
|||||||
folders is discovered with no configuration. This also matches the package
|
folders is discovered with no configuration. This also matches the package
|
||||||
declaration itself, which is fine — it's the package, not a part.
|
declaration itself, which is fine — it's the package, not a part.
|
||||||
|
|
||||||
|
Patterns are resolved **relative to the package declaration's own directory**,
|
||||||
|
not the games root. So a package declared in `carcassonne/carcassonne.md`
|
||||||
|
with the default `./**/*.yaml` only picks up yaml under `carcassonne/` — it
|
||||||
|
never absorbs defs from a sibling game. To reach outside its folder, a
|
||||||
|
package can use a `../`-relative pattern or an absolute-from-root pattern
|
||||||
|
(e.g. `**/shared/*.yaml`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Roles
|
## 3. Roles
|
||||||
|
|
||||||
json objects in yaml blocks are handled if they have a `role:` field for either
|
json objects in yaml blocks are handled if they are declared as a definition
|
||||||
|
by their `role=` (or, for real files, their filename) for either
|
||||||
- `package`
|
- `package`
|
||||||
- `part`
|
- `part`
|
||||||
- `surface`
|
- `surface`
|
||||||
- `setup`
|
- `setup`
|
||||||
|
- `dialog`
|
||||||
|
|
||||||
a valid object can either be the root or in the list of the yaml block.
|
a valid object can either be the root or in the list of the yaml block.
|
||||||
|
|
||||||
for all roles except package, `type` and `id` are needed.
|
for all roles except package, `type` and `id` are needed.
|
||||||
`type#id` is used for identification so that combo must be unique in the package.
|
`type#id` is used for identification so that combo must be unique in the package.
|
||||||
|
|
||||||
|
A block declares its role on the info string — `role=part.cargo` is equivalent
|
||||||
|
to `role: part` + `type: cargo` in the content (see §2). A real file declares
|
||||||
|
it in its filename. The info string/filename and content must not both set the
|
||||||
|
same key.
|
||||||
|
|
||||||
### package
|
### package
|
||||||
|
|
||||||
The package is the container for a game's definitions. It is declared with a
|
The package is the container for a game's definitions. It is declared with a
|
||||||
@@ -164,6 +237,10 @@ placed on the board via `setup`, and visualized by routes.
|
|||||||
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
||||||
ratio is kept, but not z (thickness).
|
ratio is kept, but not z (thickness).
|
||||||
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
|
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
|
||||||
|
- `facing` — the **physical affordance**: the facings the piece supports.
|
||||||
|
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
|
||||||
|
Declares what's *possible*, not what's legal on a given zone (see `surface`
|
||||||
|
`layout`). Defaults to `[face]`.
|
||||||
|
|
||||||
#### example
|
#### example
|
||||||
|
|
||||||
@@ -261,19 +338,81 @@ surfaces:
|
|||||||
- board#harbor
|
- board#harbor
|
||||||
- hud#hand
|
- hud#hand
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:boat#fleet
|
- path: /dock/0
|
||||||
/deck: harbor:card
|
parts: harbor:boat#fleet
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:card
|
||||||
|
facing: back
|
||||||
|
- path: /table
|
||||||
|
parts: harbor:token#wood
|
||||||
|
facing: standing
|
||||||
```
|
```
|
||||||
|
|
||||||
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
`surfaces` lists the surfaces enabled at the start. A surface not listed is
|
||||||
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
||||||
enabled.
|
enabled.
|
||||||
|
|
||||||
The value on a setup path can be either a string, or a string list.
|
`setup` is an **ordered list of placements**. Each placement moves its `parts`
|
||||||
|
to its `path`, and entries are applied in order — so a part listed in a later
|
||||||
|
placement ends up on that placement's path. This makes a setup read like "deal
|
||||||
|
the deck, then move these cards to the flop".
|
||||||
|
|
||||||
The string can either be a one part string, or a type without an id.
|
`parts` can be a single part id, a bare type without an id, or a list of
|
||||||
|
either. A bare type expands to all parts of that type during game state
|
||||||
|
initialization.
|
||||||
|
|
||||||
When id is omitted, it expands to all parts in that type during game state initialization.
|
`facing` sets how the placed parts are oriented on the board, defaulting to
|
||||||
|
`face`:
|
||||||
|
|
||||||
|
- `face` — lay flat, front up, resting on the bottom face.
|
||||||
|
- `back` — lay flat, front down (flipped over), resting on the top face.
|
||||||
|
- `standing` — stand upright on the bottom edge, front texture still showing.
|
||||||
|
|
||||||
|
A part's `facing` is seeded into the game state and can change at runtime; it
|
||||||
|
only affects orientation, never the part's texture.
|
||||||
|
|
||||||
|
A setup also declares the **interaction affordances** — which dialogs are the
|
||||||
|
tool for which open interactions on which paths:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
interactions:
|
||||||
|
- dialog: insert
|
||||||
|
on: [draw, grid] # insertion uses the `insert` dialog on these paths
|
||||||
|
- dialog: shuffle # open the shuffle dialog on any deck
|
||||||
|
```
|
||||||
|
|
||||||
|
`interactions` is a list of declarations: each names a `dialog` (a `role:
|
||||||
|
dialog` definition, see below) and the paths it applies to (`on`, matching by
|
||||||
|
path or by stack). It declares the *interaction surface*, not the legality of
|
||||||
|
the resulting command — rule scripts (later) gate legality. A dialog can be
|
||||||
|
opened by a player gesture or pushed by a rule script; only the trigger differs.
|
||||||
|
|
||||||
|
### dialog
|
||||||
|
|
||||||
|
A `dialog` is declarative content shown in the layer-3 shell. Opening and
|
||||||
|
closing it never issues a command or mutates state; it is pure UI. Its **action
|
||||||
|
buttons** issue commands — the bridge between the dialog and the rule seam.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: dialog
|
||||||
|
type: prompt
|
||||||
|
id: discard
|
||||||
|
title: Choose a card to discard
|
||||||
|
body: |
|
||||||
|
Select a card from your hand.
|
||||||
|
actions:
|
||||||
|
- label: Confirm
|
||||||
|
command: { move: { part: "#chosen", to: /discard } }
|
||||||
|
widget: stack # a stack-of-parts content type
|
||||||
|
```
|
||||||
|
|
||||||
|
A dialog's content is a **title**, **body**, **action buttons**, and an optional
|
||||||
|
`widget`. Supported content types include a **stack of parts** — an ordered,
|
||||||
|
scrolled view of a path's stack with an insertion cursor — which is what powers
|
||||||
|
insertion and shuffling dialogs against the state store.
|
||||||
|
|
||||||
|
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
|
||||||
|
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -293,6 +432,11 @@ A surface is enabled or disabled; a disabled surface is not rendered. Setup
|
|||||||
seeds the enabled set (see §3), and it changes at runtime as the game
|
seeds the enabled set (see §3), and it changes at runtime as the game
|
||||||
progresses (e.g. enabling the main board after an expansion-chooser scene).
|
progresses (e.g. enabling the main board after an expansion-chooser scene).
|
||||||
|
|
||||||
|
Dialogs are **not** part of the state store. The dialog stack is UI state
|
||||||
|
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
|
||||||
|
action buttons issue commands (see `role: dialog` §3), which is the one-way
|
||||||
|
bridge onto the state.
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
A route is a **visualization route**: it maps a part to a location on a
|
A route is a **visualization route**: it maps a part to a location on a
|
||||||
@@ -308,6 +452,15 @@ A route is an express-style URL path with named params, plus the `x`, `y`, and
|
|||||||
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
|
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
|
||||||
same route path may appear more than once:
|
same route path may appear more than once:
|
||||||
|
|
||||||
|
A route may also declare a zone `facing` — the **legal facing** on that zone.
|
||||||
|
This constrains what the placed parts may be, independent of each part's
|
||||||
|
physical affordance (see `part` `facing`). A state is legal only when the part's
|
||||||
|
facing is in both the part's affordance *and* the zone's legal set. When omitted,
|
||||||
|
the zone is unrestricted beyond the part's affordance. An MTG discard pile
|
||||||
|
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
|
||||||
|
`[back]`. The same card is face-up in the discard but tapped in play — the
|
||||||
|
piece's affordance is unchanged, only the zone's rule differs.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
layout:
|
layout:
|
||||||
- route: /dock/:seat
|
- route: /dock/:seat
|
||||||
@@ -341,7 +494,7 @@ string,number,number,number
|
|||||||
|
|
||||||
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
|
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
|
||||||
|
|
||||||
When no candidates match, the whole route fails to match.
|
A candidate inherits the route's `x`, `y`, `rotation`, and `stacking`, and may override any of them with its own values. When no candidates match, the whole route fails to match.
|
||||||
|
|
||||||
### Stacking
|
### Stacking
|
||||||
|
|
||||||
@@ -359,6 +512,9 @@ layout:
|
|||||||
limit: 5
|
limit: 5
|
||||||
align: center
|
align: center
|
||||||
steps: 4
|
steps: 4
|
||||||
|
tilt: 0.1
|
||||||
|
zStart: 0
|
||||||
|
zEnd: 30
|
||||||
```
|
```
|
||||||
|
|
||||||
- `curve` — an SVG path string to spread the content along, relative to the
|
- `curve` — an SVG path string to spread the content along, relative to the
|
||||||
@@ -368,6 +524,12 @@ layout:
|
|||||||
- `align` — `start`, `end`, or `center` of the curve.
|
- `align` — `start`, `end`, or `center` of the curve.
|
||||||
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
||||||
`1`. See the positioning process below.
|
`1`. See the positioning process below.
|
||||||
|
- `tilt` — rotation in degrees applied to every shown part about the card's
|
||||||
|
local Y (long) axis. It applies even without a `curve`, so a bare `tilt`
|
||||||
|
rotates a straight pile. Defaults to `1` when not specified.
|
||||||
|
- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end
|
||||||
|
of the `curve`. The stack ramps linearly between them across its span,
|
||||||
|
lifting it in 3D. Requires a `curve`.
|
||||||
|
|
||||||
#### positioning process
|
#### positioning process
|
||||||
|
|
||||||
@@ -377,6 +539,10 @@ layout:
|
|||||||
`step length × (# of parts − 1)` on the curve.
|
`step length × (# of parts − 1)` on the curve.
|
||||||
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
||||||
each `step length` apart.
|
each `step length` apart.
|
||||||
|
4. **Lift each part.** The part's height is `zStart + (zEnd − zStart) × u`,
|
||||||
|
where `u` is its normalized position along the `curve`.
|
||||||
|
5. **Tilt each part.** Every part is rotated `tilt` about its local Y (long)
|
||||||
|
axis.
|
||||||
|
|
||||||
### Edge cases
|
### Edge cases
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# bgm Interactions
|
||||||
|
|
||||||
|
The free-interaction layer (layer 3 of the layering) — how a player interacts
|
||||||
|
with a bgm game that has no rules yet: a sandbox. It builds on the state model:
|
||||||
|
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
|
||||||
|
facing, and the anchoring scope.
|
||||||
|
|
||||||
|
> **Status:** Implemented (layer 3, sandbox). The operation set, the
|
||||||
|
> command/dialog split, and the deck-pick-up dialog are built in
|
||||||
|
> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer
|
||||||
|
> 4) is not yet wired — the rule seam is a no-op filter over free interaction.
|
||||||
|
|
||||||
|
## 1. The operation set is closed and tiny
|
||||||
|
|
||||||
|
Two assumptions from the state model do most of the work:
|
||||||
|
|
||||||
|
1. **Parts are never created or destroyed** — the set of parts is fixed (the
|
||||||
|
setup's parts).
|
||||||
|
2. **State is only path, stack, and facing** — there is nowhere to store a free
|
||||||
|
position in space.
|
||||||
|
|
||||||
|
Together they collapse the *entire* space of free interaction into three
|
||||||
|
operations:
|
||||||
|
|
||||||
|
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
|
||||||
|
position (default: top of stack).
|
||||||
|
2. **`setFacing(id, facing)`** — change facing, within the part's physical
|
||||||
|
affordance.
|
||||||
|
3. **reorder** — a `move` with an explicit `index`.
|
||||||
|
|
||||||
|
That's it. There is no arbitrary positioning and no free placement in 3D because
|
||||||
|
the state model has nowhere to store one. "Free" means *unconstrained over these
|
||||||
|
three operations*, not "free in space."
|
||||||
|
|
||||||
|
## 2. Free interaction and rule play are the same operations
|
||||||
|
|
||||||
|
This is the payoff. Rule-enforced play (layer 4) is **the same three
|
||||||
|
operations, gated by a legality check**:
|
||||||
|
|
||||||
|
- The interaction layer produces an **intent** — "player wants
|
||||||
|
`move(card, /discard)`."
|
||||||
|
- **Sandbox mode**: apply it directly.
|
||||||
|
- **Rule mode**: validate the intent against the setup's shape + the rule script
|
||||||
|
first; reject if illegal.
|
||||||
|
|
||||||
|
So the rule engine needs no interaction vocabulary of its own — it is a **filter
|
||||||
|
over free interaction**. `move`/`setFacing` are the shared primitives; rules
|
||||||
|
decide which are legal in the current state. Drag-and-drop and a scripted move
|
||||||
|
both funnel through the same store mutation. That is the seam between layer 3
|
||||||
|
and layer 4.
|
||||||
|
|
||||||
|
## 3. Commands vs the dialog stack
|
||||||
|
|
||||||
|
There are two channels, and they do not mix:
|
||||||
|
|
||||||
|
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
|
||||||
|
the rule seam and mutate the store.
|
||||||
|
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
|
||||||
|
discard," a hint prompt). Opening/closing a dialog **never issues a command**
|
||||||
|
and never touches state; it is pure UI.
|
||||||
|
|
||||||
|
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
|
||||||
|
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
|
||||||
|
|
||||||
|
How they connect — one direction only:
|
||||||
|
|
||||||
|
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
|
||||||
|
insert button issues a `move` command.
|
||||||
|
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
|
||||||
|
discard) and awaits the player's `move` through it. The script's open/close
|
||||||
|
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
|
||||||
|
game state.
|
||||||
|
|
||||||
|
So the deck dialog is **one implementation, driven either way** — by a player
|
||||||
|
click or by a rule script. Only the *trigger* differs.
|
||||||
|
|
||||||
|
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
|
||||||
|
definition declares the title, body, action buttons, and an optional widget
|
||||||
|
(a stack of parts). Setups declare which dialogs are the tool for which
|
||||||
|
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
|
||||||
|
rule seam stays clean because a dialog's buttons issue commands while its
|
||||||
|
open/close is stack-only.
|
||||||
|
|
||||||
|
## 4. Compound interactions: the deck pick-up dialog
|
||||||
|
|
||||||
|
A dialog is where compound, multi-step manipulation lives, because it owns the
|
||||||
|
transient sub-state that the store must not. The prime example — inserting a
|
||||||
|
card into the middle of a deck:
|
||||||
|
|
||||||
|
1. pick up the deck
|
||||||
|
2. scroll through it to find the place
|
||||||
|
3. insert the card at the cursor
|
||||||
|
4. put the deck back
|
||||||
|
|
||||||
|
(even 0: put down your held hand of cards first).
|
||||||
|
|
||||||
|
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
|
||||||
|
table into the dialog (it stays on its path; the rest of the table becomes
|
||||||
|
backdrop). Its contents are shown **in order**, with an **insertion cursor**
|
||||||
|
between cards that you scroll. The cursor *is* the index: `move(id, path,
|
||||||
|
index)`'s index is discovered by scrolling the visible deck, not typed.
|
||||||
|
|
||||||
|
This is a real interaction *mode*, owned by the dialog:
|
||||||
|
|
||||||
|
- The scroll position is state.
|
||||||
|
- Everything else pauses while it's open (or the held part is kept visible).
|
||||||
|
- It only exists for **stacks**; single parts just snap onto a path.
|
||||||
|
|
||||||
|
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
|
||||||
|
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
|
||||||
|
same lifted-deck view.
|
||||||
|
|
||||||
|
## 5. The primitives map to concrete gestures
|
||||||
|
|
||||||
|
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
|
||||||
|
resolve the nearest path anchor within a threshold; drop → `move` (drop on
|
||||||
|
nothing returns to origin).
|
||||||
|
- **Facing** — click cycles the part through its physical affordance
|
||||||
|
(`face → back → standing`, or the declared list).
|
||||||
|
- **Reorder / insert** — the deck dialog above.
|
||||||
|
|
||||||
|
The "in hand" state is transient and illegal, so it lives **outside the store**
|
||||||
|
(a UI-level held part); only the committed outcome (drop) mutates the store.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Held source for insertion** — single held card (fits the physical model; the
|
||||||
|
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
|
||||||
|
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
|
||||||
|
fight the hand step.
|
||||||
|
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
|
||||||
|
single-part primitives are the foundation.
|
||||||
|
- **Scroll window** — a window over a subset of the deck can return with the
|
||||||
|
cursor's scroll position; exact widget is a render concern, not a state one.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# bgm State Model
|
||||||
|
|
||||||
|
The format's model of what a board game *is*, and the shape of the states it can
|
||||||
|
be in. This is the organizing model behind the concrete specs:
|
||||||
|
[`format.md`](./format.md) (the manifest), [`tabletop.md`](./tabletop.md) (the
|
||||||
|
render library), and [`engine.md`](./engine.md) / [`commands.md`](./commands.md)
|
||||||
|
(scripted interaction).
|
||||||
|
|
||||||
|
> **Status:** Design. The loader and render layer implement components and a
|
||||||
|
> static setup today; the state-shape framing, zone-facing, and the anchoring
|
||||||
|
> scope cut below are proposals to guide the next phase.
|
||||||
|
|
||||||
|
## 1. Components are constants; setup is the shape of states
|
||||||
|
|
||||||
|
A bgm game is authored two ways, and they answer different questions:
|
||||||
|
|
||||||
|
- **Components** (`part`, `surface`) describe the **constants** — the physical
|
||||||
|
pieces and the board geometry that do not change during play. A tile is a
|
||||||
|
45×45 mm square with a meadow/road/city face; the play board has a draw pile
|
||||||
|
and an 11×11 grid. These are timeless facts about the game, not state.
|
||||||
|
- **setup** describes the **shape of the game's states** — which parts can be
|
||||||
|
where, how many, and with what facing. It is a *schema* over the state space,
|
||||||
|
not a concrete snapshot of one run.
|
||||||
|
|
||||||
|
The runtime store is then an **instance** of the setup's shape: the current
|
||||||
|
state of play, a point in the state space the setup describes. This buys three
|
||||||
|
things:
|
||||||
|
|
||||||
|
- **Validation** — a state is legal iff it matches the setup's shape (on the
|
||||||
|
right paths, right counts, right facing).
|
||||||
|
- **A contract for the rule engine** — legal play is a *transition between
|
||||||
|
shapes*; the rule layer (layer 4 in the layering vision) reasons over them.
|
||||||
|
- **A clean boundary** — components are timeless; setup is the state space; the
|
||||||
|
store is the current point.
|
||||||
|
|
||||||
|
### Open: does setup carry the initial state?
|
||||||
|
|
||||||
|
A game needs a concrete starting position, not just a schema. The default is
|
||||||
|
that a `setup` is **shape + initial instance** — one role that both describes
|
||||||
|
the legal state space and seeds the store. The alternative (schema-only, with
|
||||||
|
the initial state derived from the shape) is explored but not preferred.
|
||||||
|
|
||||||
|
## 2. The state: path → stack × facing
|
||||||
|
|
||||||
|
The state space is made of two axes.
|
||||||
|
|
||||||
|
### Path → stack
|
||||||
|
|
||||||
|
Every part lives on a **path** (a URL-style key like `/grid/5/5` or `/draw`),
|
||||||
|
and multiple parts on a path form an ordered **stack** (a deck, a pile of meeples,
|
||||||
|
a tile with a meeple on it). Placement is one axis. This is already in use.
|
||||||
|
|
||||||
|
### facing — part affordance × zone restriction
|
||||||
|
|
||||||
|
Facing is the second axis, and it has two distinct sources of constraint:
|
||||||
|
|
||||||
|
- **The part** declares the **physical affordance** — the facings the piece
|
||||||
|
physically supports. A card supports face/back/standing; a tile supports
|
||||||
|
face/back but not "tapped".
|
||||||
|
- **The path (zone)** declares the **legal facing** — what's allowed on that
|
||||||
|
zone. An MTG discard pile requires face-up; a play area allows tapped; a
|
||||||
|
facedown deck requires face-down. The same card is face-up in the discard,
|
||||||
|
tapped in play, face-up in exile — the piece's affordance doesn't change, only
|
||||||
|
the zone's rule does.
|
||||||
|
|
||||||
|
A state's facing is legal iff it is **both physically possible (part) and
|
||||||
|
zone-legal (path)** — the effective set is the intersection.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: part
|
||||||
|
type: card
|
||||||
|
id: basic
|
||||||
|
facing: [face, back, standing] # physical affordance
|
||||||
|
|
||||||
|
role: surface
|
||||||
|
type: board
|
||||||
|
id: main
|
||||||
|
layout:
|
||||||
|
- route: /discard
|
||||||
|
facing: [face] # zone restriction
|
||||||
|
- route: /play
|
||||||
|
facing: [face, tap] # zone restriction
|
||||||
|
```
|
||||||
|
|
||||||
|
Deck / the current facing lives in the store and must be in the intersection.
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- **Default when a path declares no `facing`** — unrestricted (only the part's
|
||||||
|
affordance bounds it), or a sensible default like `face`? Lean unrestricted.
|
||||||
|
- **Is "tapped" a facing or a rotation?** In MTG it's a 90° in-plane rotation.
|
||||||
|
Default: fold common rotations into the facing enum (`face` / `back` /
|
||||||
|
`standing` / `tap`) for schema simplicity; arbitrary rotation is a later
|
||||||
|
extension.
|
||||||
|
- **Naming** — the part-side and path-side are different constraints wearing the
|
||||||
|
same word. Worth distinct terms (capability/typ) so they don't collide.
|
||||||
|
|
||||||
|
## 3. Anchoring scope: stacks-on-paths, not part-to-part networks
|
||||||
|
|
||||||
|
Real TTS mods anchor components to each other — a meeple on a tile, a fanned
|
||||||
|
hand, tokens scattered on a board. We are **not** modeling part-aligned-to-part
|
||||||
|
relative placement networks. Instead:
|
||||||
|
|
||||||
|
- **Stacks absorb part-on-part.** "Meeple on tile" is a stack `[tile, meeple]`
|
||||||
|
on a path. Much of TTS's anchoring collapses into the path→stack model.
|
||||||
|
- **Free relative placement is out of scope** — a meeple at an offset on a tile,
|
||||||
|
a fanned hand, arbitrary token scatter are not modeled.
|
||||||
|
|
||||||
|
This is a deliberate scope cut. It keeps the state model closed and simple, but
|
||||||
|
it is a **fidelity loss for converted games** and part of the "with some fixing"
|
||||||
|
cost of the TTS→bgm conversion. Components (layer 1), not the state model, are
|
||||||
|
the place to extend later if needed.
|
||||||
|
|
||||||
|
### The grid compromise
|
||||||
|
|
||||||
|
Because we don't model parts-aligned-to-parts, common layouts are expressed
|
||||||
|
explicitly — and grid layouts become verbose (the Carcassonne board is 121
|
||||||
|
hand-written row coordinates in `grid.csv`). The mitigations:
|
||||||
|
|
||||||
|
- **Grid shorthand** — a declarative `grid` (cols/rows, cell size, origin) that
|
||||||
|
expands to routes, instead of authored coordinates.
|
||||||
|
- **Free-placement shorthand** — a `free`/scatter mode for loose collections,
|
||||||
|
when the exact positions don't matter to play.
|
||||||
|
|
||||||
|
But there is **no general part-to-part network** on the roadmap. If a converted
|
||||||
|
game needs it, that is a format extension to design deliberately, not an
|
||||||
|
implicit assumption.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Concept | Role | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Components | Constants — the physical pieces & board geometry | `part`, `surface` |
|
||||||
|
| Setup | The shape of the state space (+ initial instance) | `setup` |
|
||||||
|
| Store | The current state, an instance of the setup | `@tts/tabletop` |
|
||||||
|
| Facing | Part affordance × zone restriction | part + path/zone restriction |
|
||||||
|
| Anchoring | Stacks-on-paths only; no part networks | — |
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# bgm-tabletop
|
||||||
|
|
||||||
|
> **Status:** Implemented (items 1–8 of the plan). A few features below are
|
||||||
|
> designed but not yet wired (commands, HUD rendering); see §5 and
|
||||||
|
> [`../status/bgm-tabletop.md`](../status/bgm-tabletop.md).
|
||||||
|
|
||||||
|
An r3f-based interactive component library for working with [bgm](./format.md)
|
||||||
|
board games. It is used in the `web` app's bgm inspector routes.
|
||||||
|
|
||||||
|
## 1. stack
|
||||||
|
|
||||||
|
`react` - react, react router, tailwind v4
|
||||||
|
`r3f` - r3f, drei, postprocessing
|
||||||
|
`zustand` - for state management
|
||||||
|
|
||||||
|
## 2. states
|
||||||
|
|
||||||
|
source-of-truth game state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
surfaces: Record<string, boolean>, // enabled per surface id
|
||||||
|
parts: Record<string, PartState>, // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string, // the path key this part is on
|
||||||
|
index: number, // the part's position in its path's stack
|
||||||
|
facing: 'face' | 'back' | 'standing', // how the part is oriented on the board
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
|
||||||
|
|
||||||
|
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, facing }` for rendering on a surface. keys of this map makes a stable render list.
|
||||||
|
|
||||||
|
- `route` - the matched route.
|
||||||
|
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
|
||||||
|
- `index` - the piece's position in its path's stack.
|
||||||
|
- `stackSize` - the number of pieces on the path.
|
||||||
|
- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`).
|
||||||
|
|
||||||
|
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
|
||||||
|
|
||||||
|
## 3. components
|
||||||
|
|
||||||
|
- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement).
|
||||||
|
- `WorldSurfaceView` mounts a surface to world space.
|
||||||
|
- `HudSurfaceView` mounts a surface to hud space.
|
||||||
|
- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy.
|
||||||
|
- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers.
|
||||||
|
|
||||||
|
## 4. stacking
|
||||||
|
|
||||||
|
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||||
|
|
||||||
|
## 5. commands
|
||||||
|
|
||||||
|
Scripted interaction — focus, tap-to-advance, move, caption — is built on an
|
||||||
|
async command layer. See [`commands.md`](./commands.md) for command
|
||||||
|
execution (lifecycle, run contexts, tap interaction), and
|
||||||
|
[`engine.md`](./engine.md) for the message layer above it (the queue,
|
||||||
|
triggers, and orchestrators that declare and fire commands).
|
||||||
|
|
||||||
|
## 6. usage
|
||||||
|
|
||||||
|
- we will inspect individual parts with `PartView` in the web app's part inspection route.
|
||||||
|
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
|
||||||
|
- a surface is mounted only when enabled; a disabled surface is not rendered.
|
||||||
+66
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Scope:** The rationale behind key design decisions. For the system's
|
> **Scope:** The rationale behind key design decisions. For the system's
|
||||||
> architecture, see [`architecture.md`](./architecture.md). For the concrete
|
> architecture, see [`architecture.md`](./architecture.md). For the concrete
|
||||||
> build plan, see [`implementation-plan.md`](./implementation-plan.md).
|
> build plan, see [`status/implementation-plan.md`](./status/implementation-plan.md).
|
||||||
>
|
>
|
||||||
> Each entry records the decision, the context, and the alternatives considered.
|
> Each entry records the decision, the context, and the alternatives considered.
|
||||||
> New entries are appended; existing entries are updated only to correct facts,
|
> New entries are appended; existing entries are updated only to correct facts,
|
||||||
@@ -259,4 +259,68 @@ boundary.
|
|||||||
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
|
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
|
||||||
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
bounds each frame; hand-rolled camera math. Rejected — Suspense already
|
||||||
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
signals content readiness, so `Bounds` inside the boundary fits the loaded
|
||||||
geometry directly.
|
geometry directly.
|
||||||
|
|
||||||
|
## D19 — Commands are async with ok/cancel/error results
|
||||||
|
|
||||||
|
**Decision:** Scripted interaction is built on async commands. Each command
|
||||||
|
returns `ok`, `cancel` (interrupted — superseded, skipped, surface disabled),
|
||||||
|
or `error` (genuinely failed). Each invocation gets its own run context — the
|
||||||
|
unit of cancellation and the carrier of command state. Commands are either
|
||||||
|
fire-and-forget (the runtime doesn't await them) or self-managed waiting (they
|
||||||
|
resolve their own promise when a condition is met); both get a run context and
|
||||||
|
cancel path. Supersede groups cancel a running command when another in the
|
||||||
|
group starts (e.g. a `camera` group so a second focus cancels the first).
|
||||||
|
|
||||||
|
**Context:** The user wants to script interaction sequences — focus, caption,
|
||||||
|
title, highlight, tap-to-advance, move, camera away. The state store and
|
||||||
|
render layer already exist; what's missing is a way to drive them over time
|
||||||
|
and react to input. Design: [`bgm/commands.md`](./bgm/commands.md).
|
||||||
|
|
||||||
|
**Alternatives considered:** A single monolithic script interpreter. Rejected
|
||||||
|
— commands as self-contained async units are testable in isolation and let
|
||||||
|
the runtime stay a thin orchestrator.
|
||||||
|
|
||||||
|
## D20 — Tap interaction reports every tap with the nearest trigger point
|
||||||
|
|
||||||
|
**Decision:** Only tap interaction is supported. A tap on a part is reported
|
||||||
|
to the command layer as a `TapEvent` carrying the part, the tap position in
|
||||||
|
the part's local frame, and the nearest trigger point within its `radius` (or
|
||||||
|
`null` on a miss). Trigger points are authored in the part's local frame with
|
||||||
|
mm radius; distance is measured in the part's plane; ties go to the first
|
||||||
|
declared. The command decides how to react to a miss — resolve, reject, or
|
||||||
|
ignore.
|
||||||
|
|
||||||
|
**Context:** Commands need to wait on player input (`wait: tap`). Reporting
|
||||||
|
every tap with the nearest trigger point keeps the runtime dumb and lets the
|
||||||
|
command own the UX (e.g. a "wrong spot" shake). Authoring trigger points in
|
||||||
|
the part's local frame keeps them valid as the part moves, rotates, and
|
||||||
|
flips.
|
||||||
|
|
||||||
|
**Alternatives considered:** Reporting only a hit and silently dropping
|
||||||
|
misses. Rejected — a command that needs to react to a wrong tap has no way to
|
||||||
|
do so. World-space trigger points. Rejected — they break when the part moves.
|
||||||
|
|
||||||
|
## D21 — Card sprite UVs live in the material shader, not the texture
|
||||||
|
|
||||||
|
**Decision:** A card's sprite cell is selected by a repeat/offset injected into
|
||||||
|
the material's shader (`cardMaterial.ts` extends `MeshStandardMaterial` via
|
||||||
|
`onBeforeCompile`) rather than by cloning the texture and setting its
|
||||||
|
`repeat`/`offset`.
|
||||||
|
|
||||||
|
**Context:** Cards in a deck share one sprite sheet (drei caches the texture by
|
||||||
|
URL), but each card samples a different cell. The previous approach cloned the
|
||||||
|
texture per card to set its UVs; each clone gets its own WebGL texture binding,
|
||||||
|
so navigating a deck re-uploaded the whole sheet on every step. Moving the
|
||||||
|
transform into a per-material uniform lets cards share the texture (one GPU
|
||||||
|
upload), the shader (identical injected source → one program), and the geometry,
|
||||||
|
with only the material uniforms differing.
|
||||||
|
|
||||||
|
We inject our own uniform rather than setting `texture.repeat`/`offset` because
|
||||||
|
three r185 derives map UVs from a `mapTransform` matrix refreshed from
|
||||||
|
`map.matrix` every frame, which would overwrite a per-material transform set on
|
||||||
|
the shared texture.
|
||||||
|
|
||||||
|
**Alternatives considered:** Cloning the texture per card (previous approach).
|
||||||
|
Rejected — re-uploads the sheet per card. A module-level cache of per-card
|
||||||
|
clones. Rejected — still one upload per unique card instead of one per sheet.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Docs
|
||||||
|
|
||||||
|
The documentation is split into the living specs, which describe how the
|
||||||
|
system works today, and the status/plans, which track development iterations
|
||||||
|
and go stale as work lands.
|
||||||
|
|
||||||
|
## Specs — how the system works
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [`architecture.md`](./architecture.md) | System architecture and the package dependency graph |
|
||||||
|
| [`decisions.md`](./decisions.md) | Key design decisions and the rationale behind them |
|
||||||
|
| [`bgm/format.md`](./bgm/format.md) | The board game manifest (bgm) format spec |
|
||||||
|
| [`bgm/engine.md`](./bgm/engine.md) | The bgm message layer: queue, triggers, orchestrators |
|
||||||
|
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
|
||||||
|
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
|
||||||
|
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
|
||||||
|
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
|
||||||
|
|
||||||
|
## Status & plans (dev logs)
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [`status/implementation-plan.md`](./status/implementation-plan.md) | Original build plan |
|
||||||
|
| [`status/bgm-loader.md`](./status/bgm-loader.md) | bgm loader — what's built, works, missing |
|
||||||
|
| [`status/bgm-tabletop.md`](./status/bgm-tabletop.md) | bgm tabletop — implementation plan / status |
|
||||||
|
| [`status/full-setup-view.md`](./status/full-setup-view.md) | Full-setup view plan |
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# bgm Loader — Status
|
# bgm Loader — Status
|
||||||
|
|
||||||
> WIP. What's built, what works, what's missing, and the known issues.
|
> Status: current. What's built, what works, what's missing, and the known
|
||||||
> Spec: [`bgm-format.md`](./bgm-format.md).
|
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
|
||||||
|
|
||||||
## What's built
|
## What's built
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
|
|
||||||
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
||||||
|
|
||||||
### `games/harbor/harbor.md` — example game (new)
|
### Example game: harbor (fixture)
|
||||||
|
|
||||||
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Same content duplicated as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/`.
|
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Lives as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/` (and a copy under `packages/tabletop/src/__fixtures__/vite-build/games/harbor/`).
|
||||||
|
|
||||||
### `games/poker/poker.md` — example game (new)
|
### `games/poker/poker.md` — example game (new)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards,
|
|||||||
|
|
||||||
### `packages/tabletop` — rendering library (new)
|
### `packages/tabletop` — rendering library (new)
|
||||||
|
|
||||||
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop-plan.md`](./bgm-tabletop-plan.md).
|
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
||||||
|
|
||||||
### `packages/http` — shared proxy HTTP (new)
|
### `packages/http` — shared proxy HTTP (new)
|
||||||
|
|
||||||
@@ -71,5 +71,5 @@ The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/
|
|||||||
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
|
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
|
||||||
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
|
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
|
||||||
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
|
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
|
||||||
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `docs/bgm-tabletop.md`).
|
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `../bgm/tabletop.md`).
|
||||||
- Docs for the loader itself (this file is the start).
|
- Docs for the loader itself (this file is the start).
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
# bgm-tabletop — Implementation Plan / Status
|
# bgm-tabletop — Implementation Plan / Status
|
||||||
|
|
||||||
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
> **Scope:** A standalone r3f component library that renders
|
||||||
> board games: a state store, surface mounting, part placement with stacking,
|
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
|
||||||
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
> placement with stacking, and per-part meshes. Design:
|
||||||
> **Status:** planning — no code yet.
|
> [`../bgm/tabletop.md`](../bgm/tabletop.md).
|
||||||
|
> **Status:** items 1–8 implemented and the full tabletop scene is wired into
|
||||||
|
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
|
||||||
|
> part-inspection route renders `PartView` from the library.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ packages/tabletop/
|
|||||||
|
|
||||||
## Work items
|
## Work items
|
||||||
|
|
||||||
### 1. Package scaffold
|
### 1. Package scaffold ✅
|
||||||
|
|
||||||
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
|
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
|
||||||
globs `packages/*`).
|
globs `packages/*`).
|
||||||
@@ -51,7 +54,7 @@ packages/tabletop/
|
|||||||
`@types/three`.
|
`@types/three`.
|
||||||
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
|
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
|
||||||
|
|
||||||
### 2. Part meshes + export + web integration
|
### 2. Part meshes + export + web integration ✅
|
||||||
|
|
||||||
First deliverable: `PartView` renders a single part's mesh from its definition,
|
First deliverable: `PartView` renders a single part's mesh from its definition,
|
||||||
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
|
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
|
||||||
@@ -71,34 +74,41 @@ useful slice and unblocks the web app's part inspection route immediately.
|
|||||||
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
|
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
|
||||||
it end-to-end.
|
it end-to-end.
|
||||||
|
|
||||||
### 3. State store (`state.ts`)
|
### 3. State store (`state.ts`) ✅
|
||||||
|
|
||||||
Source-of-truth game state per `bgm-tabletop.md` §2:
|
Source-of-truth game state per `../bgm/tabletop.md` §2:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface GameState {
|
interface GameState {
|
||||||
surfaces: Record<string, boolean>; // enabled per surface id
|
surfaces: Record<string, boolean>; // enabled per surface id
|
||||||
paths: Record<string, string[]>; // path -> part list
|
parts: Record<string, PartState>; // part id -> placement state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartState {
|
||||||
|
path: string; // the path key this part is on
|
||||||
|
index: number; // the part's position in its path's stack
|
||||||
|
facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- A zustand store holding `GameState`.
|
- A zustand store holding `GameState`.
|
||||||
- **Derived render state**: `game state + surface routes => map of piece id to
|
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||||
`{ surface, route, candidate, index, stackSize }``, per enabled surface.
|
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||||
Computed with a selector/memo so the render list is stable.
|
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||||
- **Assumption**: each piece id is unique within a path (documented in
|
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||||
`bgm-tabletop.md`); the render map is keyed by piece id.
|
- **Assumption**: each piece id is unique on the board (documented in
|
||||||
|
`../bgm/tabletop.md`); the render map is keyed by piece id.
|
||||||
|
|
||||||
### 4. Setup seeding (`setup.ts`)
|
### 4. Setup seeding (`setup.ts`) ✅
|
||||||
|
|
||||||
- `SetupLoader`: side-effect-only component that seeds the store from a
|
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||||
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
|
`Setup` — enables its `surfaces` (or all when omitted) and applies its
|
||||||
`setup` paths.
|
ordered `setup` placements (each moves its `parts` to a `path`).
|
||||||
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
|
||||||
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
|
type (documented in `../bgm/format.md` §3; the loader doesn't do this — it's a
|
||||||
game-state init concern, so it lives here).
|
game-state init concern, so it lives here).
|
||||||
|
|
||||||
### 5. Surface mounting (`mount.ts`)
|
### 5. Surface mounting (`mount.ts`) ✅
|
||||||
|
|
||||||
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
|
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
|
||||||
- `kind: table` — root, world space.
|
- `kind: table` — root, world space.
|
||||||
@@ -108,21 +118,23 @@ interface GameState {
|
|||||||
surface isn't rendered. Child surfaces mount relative to their parent's
|
surface isn't rendered. Child surfaces mount relative to their parent's
|
||||||
anchor (`x`/`y`/`rotation`).
|
anchor (`x`/`y`/`rotation`).
|
||||||
|
|
||||||
### 6. Part placement (`placement.ts`)
|
### 6. Part placement (`placement.ts`) ✅
|
||||||
|
|
||||||
- `PartPlacement`: stable per-part component that positions a part on a surface
|
- `PartPlacement`: stable per-part component that positions a part on a surface
|
||||||
location from the derived render state (route anchor + candidate anchor).
|
location from the derived render state (route anchor + candidate anchor).
|
||||||
- Applies the route's stacking strategy via `useStacking`.
|
- Applies the route's stacking strategy via `useStacking`.
|
||||||
|
|
||||||
### 7. Stacking (`stacking.ts`)
|
### 7. Stacking (`stacking.ts`) ✅
|
||||||
|
|
||||||
- `useStacking(route.stacking, index, stackSize)` → `{ offset, rotation }`.
|
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
|
||||||
- Implements the format's positioning process (`bgm-format.md` §4): step
|
- Implements the format's positioning process (`../bgm/format.md` §4): step
|
||||||
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||||
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
||||||
|
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
|
||||||
|
rotates each shown part about its local Y (long) axis.
|
||||||
- Curve length from an SVG path string (small helper; no new dep).
|
- Curve length from an SVG path string (small helper; no new dep).
|
||||||
|
|
||||||
### 8. Public API (`index.ts`)
|
### 8. Public API (`index.ts`) ✅
|
||||||
|
|
||||||
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
|
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
|
||||||
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
|
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
|
||||||
@@ -146,7 +158,8 @@ consumers share them (see Open decisions).
|
|||||||
|
|
||||||
- `state.ts` — derived render state: enabled surfaces, route matching,
|
- `state.ts` — derived render state: enabled surfaces, route matching,
|
||||||
candidate selection, stacking index/stackSize.
|
candidate selection, stacking index/stackSize.
|
||||||
- `stacking.ts` — positioning process: step length, alignment, limit.
|
- `stacking.ts` — positioning process: step length, alignment, limit, z ramp,
|
||||||
|
tilt.
|
||||||
- `setup.ts` — seeding + bare-type expansion.
|
- `setup.ts` — seeding + bare-type expansion.
|
||||||
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
||||||
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
||||||
@@ -160,6 +173,36 @@ consumers share them (see Open decisions).
|
|||||||
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||||
from the library (work item 2), proving it end-to-end.
|
from the library (work item 2), proving it end-to-end.
|
||||||
|
|
||||||
|
## Free interaction (layer 3) ✅
|
||||||
|
|
||||||
|
Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md):
|
||||||
|
|
||||||
|
- `interactions.ts` — the held part + dialog stack (UI state, outside the game
|
||||||
|
store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`,
|
||||||
|
`partFacings`, `nextFacing`).
|
||||||
|
- `state.ts` — `setFacing` alongside `movePart` (the `move(id, path, index?)`
|
||||||
|
primitive, defaulting to top of stack).
|
||||||
|
- `placement.tsx` — `PartPlacement` is now interactive: pick up a part (held,
|
||||||
|
lifted above the board), drag to a path anchor to `move`, click to cycle
|
||||||
|
facing. A drop on a path with a stack-dialog interaction opens the deck
|
||||||
|
dialog instead of moving directly.
|
||||||
|
- `dialog.tsx` — `DialogLayer`, the stack-inspector dialog: an alternate view
|
||||||
|
of a stack with an insertion cursor; its insert button issues a `move`.
|
||||||
|
- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs`
|
||||||
|
(schema + collection + serialization).
|
||||||
|
- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack
|
||||||
|
insert) on the deck.
|
||||||
|
|
||||||
|
The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
|
||||||
|
|
||||||
|
## Commands (not yet implemented)
|
||||||
|
|
||||||
|
Scripted interaction is designed in [`../bgm/commands.md`](../bgm/commands.md):
|
||||||
|
async commands with `ok`/`cancel`/`error` results, per-invocation run
|
||||||
|
contexts, fire-and-forget vs self-managed waiting, and tap interaction with
|
||||||
|
part-local trigger points. Implementation order: types + run-context manager,
|
||||||
|
tap detection, then the first commands (`wait: tap`, `focus`).
|
||||||
|
|
||||||
## Open decisions (defaults in bold)
|
## Open decisions (defaults in bold)
|
||||||
|
|
||||||
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
||||||
@@ -81,9 +81,10 @@ view and the full-setup view.
|
|||||||
`textureUrl + color + roughness`. drei already caches textures by URL
|
`textureUrl + color + roughness`. drei already caches textures by URL
|
||||||
globally, so sharing the material on top avoids per-object material
|
globally, so sharing the material on top avoids per-object material
|
||||||
allocation for tiles/tokens with the same image.
|
allocation for tiles/tokens with the same image.
|
||||||
- **Cards are the exception:** each card clones its texture for sprite UVs, so
|
- **Cards:** the face/back textures are shared (drei caches them by URL) and
|
||||||
its face material cannot be shared — but its geometry still can (same card
|
the sprite cell is selected via a per-material UV transform injected into the
|
||||||
size).
|
shader (`cardMaterial.ts`), so cards share texture, shader, and geometry —
|
||||||
|
only the material uniforms differ. Materials are cached per card id + tint.
|
||||||
- Dispose shared resources on page unmount, or accept a module-level cache for
|
- Dispose shared resources on page unmount, or accept a module-level cache for
|
||||||
the session (see Open decisions).
|
the session (see Open decisions).
|
||||||
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
|
||||||
> order. For the system's architecture and dependency graph, see
|
> order. For the system's architecture and dependency graph, see
|
||||||
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
|
> [`../architecture.md`](../architecture.md). For the rationale behind key decisions,
|
||||||
> see [`decisions.md`](./decisions.md).
|
> see [`../decisions.md`](../decisions.md).
|
||||||
|
|
||||||
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
|
||||||
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
Steam Workshop, fetching full TTS save files, and analyzing their contents.
|
||||||
@@ -55,7 +55,16 @@ tts-workshop/
|
|||||||
├── .npmrc
|
├── .npmrc
|
||||||
├── .env.example # STEAM_API_KEY, PORT
|
├── .env.example # STEAM_API_KEY, PORT
|
||||||
├── docs/
|
├── docs/
|
||||||
│ └── implementation-plan.md # this file
|
│ ├── overview.md
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── decisions.md
|
||||||
|
│ ├── bgm/
|
||||||
|
│ │ ├── format.md
|
||||||
|
│ │ ├── engine.md
|
||||||
|
│ │ ├── commands.md
|
||||||
|
│ │ └── tabletop.md
|
||||||
|
│ └── status/
|
||||||
|
│ └── implementation-plan.md # this file
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── proxy/
|
│ ├── proxy/
|
||||||
│ │ ├── package.json
|
│ │ ├── package.json
|
||||||
@@ -155,8 +164,8 @@ Low-level fetcher, extracted from the existing scraper.
|
|||||||
(no Steam API call).
|
(no Steam API call).
|
||||||
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
|
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
|
||||||
bytes + derived filename, with and without the Steam API.
|
bytes + derived filename, with and without the Steam API.
|
||||||
- `getFileName(url: string): Promise<string>` — derive filename from the
|
- `getFileName(url: string): string` — derive a filename from the save URL
|
||||||
`content-disposition` header.
|
path (the upstream `content-disposition` header is ignored).
|
||||||
- `errors.ts`
|
- `errors.ts`
|
||||||
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
|
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
|
||||||
- Notes
|
- Notes
|
||||||
@@ -237,8 +246,8 @@ Hono server exposing search + fetch.
|
|||||||
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
||||||
query param to download the save directly (no Steam API key needed);
|
query param to download the save directly (no Steam API key needed);
|
||||||
otherwise resolves via the Steam API.
|
otherwise resolves via the Steam API.
|
||||||
- `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also
|
- `GET /items/:id/file` — raw save bytes, filename from `getFileName` (the
|
||||||
accepts `fileUrl`.
|
URL path). Also accepts `fileUrl`.
|
||||||
- `routes/asset.ts`
|
- `routes/asset.ts`
|
||||||
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
|
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
|
||||||
it back with a `Content-Type` header. Workshop hosts often omit CORS
|
it back with a `Content-Type` header. Workshop hosts often omit CORS
|
||||||
@@ -325,7 +334,7 @@ proxy API and `packages/extract` directly for analysis.
|
|||||||
| GET | `/health` | Liveness | — |
|
| GET | `/health` | Liveness | — |
|
||||||
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
||||||
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
|
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
|
||||||
| GET | `/items/:id/file` | Raw save bytes, filename from header | key* |
|
| GET | `/items/:id/file` | Raw save bytes, filename from URL path | key* |
|
||||||
| GET | `/asset?url=` | CORS-safe proxy for external assets | — |
|
| GET | `/asset?url=` | CORS-safe proxy for external assets | — |
|
||||||
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,226 @@
|
|||||||
|
# Carcassonne
|
||||||
|
|
||||||
|
The base game's 24 landscape tiles, laid out on a table with a draw pile and a
|
||||||
|
grid of placed tiles. Each tile is a single `110×110` image (A–X), sized to a
|
||||||
|
standard `45×45` mm square.
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
id: carcassonne
|
||||||
|
title: Carcassonne
|
||||||
|
designer: Klaus-Jürgen Wrede
|
||||||
|
publisher: Hans im Glück
|
||||||
|
players: 5
|
||||||
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parts
|
||||||
|
|
||||||
|
One `tile` part per distinct tile type (A–X). All tiles share the same square
|
||||||
|
face image and a uniform size; the `$variants` CSV expands them into the 24
|
||||||
|
tile parts.
|
||||||
|
|
||||||
|
```yaml role=part.tile
|
||||||
|
face: ./20AE_Base_Game_C2_Tile_A.png
|
||||||
|
size: [45, 45, 3]
|
||||||
|
fillet: 1
|
||||||
|
$variants: ./tiles.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=tiles.csv
|
||||||
|
id,face
|
||||||
|
string,string
|
||||||
|
a,./20AE_Base_Game_C2_Tile_A.png
|
||||||
|
b,./20AE_Base_Game_C2_Tile_B.png
|
||||||
|
c,./20AE_Base_Game_C2_Tile_C.png
|
||||||
|
d,./20AE_Base_Game_C2_Tile_D.png
|
||||||
|
e,./20AE_Base_Game_C2_Tile_E.png
|
||||||
|
f,./20AE_Base_Game_C2_Tile_F.png
|
||||||
|
g,./20AE_Base_Game_C2_Tile_G.png
|
||||||
|
h,./20AE_Base_Game_C2_Tile_H.png
|
||||||
|
i,./20AE_Base_Game_C2_Tile_I.png
|
||||||
|
j,./20AE_Base_Game_C2_Tile_J.png
|
||||||
|
k,./20AE_Base_Game_C2_Tile_K.png
|
||||||
|
l,./20AE_Base_Game_C2_Tile_L.png
|
||||||
|
m,./20AE_Base_Game_C2_Tile_M.png
|
||||||
|
n,./20AE_Base_Game_C2_Tile_N.png
|
||||||
|
o,./20AE_Base_Game_C2_Tile_O.png
|
||||||
|
p,./20AE_Base_Game_C2_Tile_P.png
|
||||||
|
q,./20AE_Base_Game_C2_Tile_Q.png
|
||||||
|
r,./20AE_Base_Game_C2_Tile_R.png
|
||||||
|
s,./20AE_Base_Game_C2_Tile_S.png
|
||||||
|
t,./20AE_Base_Game_C2_Tile_T.png
|
||||||
|
u,./20AE_Base_Game_C2_Tile_U.png
|
||||||
|
v,./20AE_Base_Game_C2_Tile_V.png
|
||||||
|
w,./20AE_Base_Game_C2_Tile_W.png
|
||||||
|
x,./20AE_Base_Game_C2_Tile_X.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Board
|
||||||
|
|
||||||
|
A table with a draw pile on the left and an `11×11` grid of placed tiles in the
|
||||||
|
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
|
||||||
|
so tiles sit edge to edge.
|
||||||
|
|
||||||
|
```yaml role=surface.board
|
||||||
|
id: board
|
||||||
|
size: [600, 600]
|
||||||
|
layout:
|
||||||
|
- route: /draw
|
||||||
|
x: -280
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
stacking:
|
||||||
|
align: center
|
||||||
|
zStart: 0
|
||||||
|
curve: M -50 -200 C 50 -150 450 -150 550 -200
|
||||||
|
- route: /grid/:col/:row
|
||||||
|
candidates:
|
||||||
|
$variants: ./grid.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=grid.csv
|
||||||
|
col,row,x,y,rotation
|
||||||
|
string,string,number,number,number
|
||||||
|
0,0,-225,-225,0
|
||||||
|
0,1,-225,-180,0
|
||||||
|
0,2,-225,-135,0
|
||||||
|
0,3,-225,-90,0
|
||||||
|
0,4,-225,-45,0
|
||||||
|
0,5,-225,0,0
|
||||||
|
0,6,-225,45,0
|
||||||
|
0,7,-225,90,0
|
||||||
|
0,8,-225,135,0
|
||||||
|
0,9,-225,180,0
|
||||||
|
0,10,-225,225,0
|
||||||
|
1,0,-180,-225,0
|
||||||
|
1,1,-180,-180,0
|
||||||
|
1,2,-180,-135,0
|
||||||
|
1,3,-180,-90,0
|
||||||
|
1,4,-180,-45,0
|
||||||
|
1,5,-180,0,0
|
||||||
|
1,6,-180,45,0
|
||||||
|
1,7,-180,90,0
|
||||||
|
1,8,-180,135,0
|
||||||
|
1,9,-180,180,0
|
||||||
|
1,10,-180,225,0
|
||||||
|
2,0,-135,-225,0
|
||||||
|
2,1,-135,-180,0
|
||||||
|
2,2,-135,-135,0
|
||||||
|
2,3,-135,-90,0
|
||||||
|
2,4,-135,-45,0
|
||||||
|
2,5,-135,0,0
|
||||||
|
2,6,-135,45,0
|
||||||
|
2,7,-135,90,0
|
||||||
|
2,8,-135,135,0
|
||||||
|
2,9,-135,180,0
|
||||||
|
2,10,-135,225,0
|
||||||
|
3,0,-90,-225,0
|
||||||
|
3,1,-90,-180,0
|
||||||
|
3,2,-90,-135,0
|
||||||
|
3,3,-90,-90,0
|
||||||
|
3,4,-90,-45,0
|
||||||
|
3,5,-90,0,0
|
||||||
|
3,6,-90,45,0
|
||||||
|
3,7,-90,90,0
|
||||||
|
3,8,-90,135,0
|
||||||
|
3,9,-90,180,0
|
||||||
|
3,10,-90,225,0
|
||||||
|
4,0,-45,-225,0
|
||||||
|
4,1,-45,-180,0
|
||||||
|
4,2,-45,-135,0
|
||||||
|
4,3,-45,-90,0
|
||||||
|
4,4,-45,-45,0
|
||||||
|
4,5,-45,0,0
|
||||||
|
4,6,-45,45,0
|
||||||
|
4,7,-45,90,0
|
||||||
|
4,8,-45,135,0
|
||||||
|
4,9,-45,180,0
|
||||||
|
4,10,-45,225,0
|
||||||
|
5,0,0,-225,0
|
||||||
|
5,1,0,-180,0
|
||||||
|
5,2,0,-135,0
|
||||||
|
5,3,0,-90,0
|
||||||
|
5,4,0,-45,0
|
||||||
|
5,5,0,0,0
|
||||||
|
5,6,0,45,0
|
||||||
|
5,7,0,90,0
|
||||||
|
5,8,0,135,0
|
||||||
|
5,9,0,180,0
|
||||||
|
5,10,0,225,0
|
||||||
|
6,0,45,-225,0
|
||||||
|
6,1,45,-180,0
|
||||||
|
6,2,45,-135,0
|
||||||
|
6,3,45,-90,0
|
||||||
|
6,4,45,-45,0
|
||||||
|
6,5,45,0,0
|
||||||
|
6,6,45,45,0
|
||||||
|
6,7,45,90,0
|
||||||
|
6,8,45,135,0
|
||||||
|
6,9,45,180,0
|
||||||
|
6,10,45,225,0
|
||||||
|
7,0,90,-225,0
|
||||||
|
7,1,90,-180,0
|
||||||
|
7,2,90,-135,0
|
||||||
|
7,3,90,-90,0
|
||||||
|
7,4,90,-45,0
|
||||||
|
7,5,90,0,0
|
||||||
|
7,6,90,45,0
|
||||||
|
7,7,90,90,0
|
||||||
|
7,8,90,135,0
|
||||||
|
7,9,90,180,0
|
||||||
|
7,10,90,225,0
|
||||||
|
8,0,135,-225,0
|
||||||
|
8,1,135,-180,0
|
||||||
|
8,2,135,-135,0
|
||||||
|
8,3,135,-90,0
|
||||||
|
8,4,135,-45,0
|
||||||
|
8,5,135,0,0
|
||||||
|
8,6,135,45,0
|
||||||
|
8,7,135,90,0
|
||||||
|
8,8,135,135,0
|
||||||
|
8,9,135,180,0
|
||||||
|
8,10,135,225,0
|
||||||
|
9,0,180,-225,0
|
||||||
|
9,1,180,-180,0
|
||||||
|
9,2,180,-135,0
|
||||||
|
9,3,180,-90,0
|
||||||
|
9,4,180,-45,0
|
||||||
|
9,5,180,0,0
|
||||||
|
9,6,180,45,0
|
||||||
|
9,7,180,90,0
|
||||||
|
9,8,180,135,0
|
||||||
|
9,9,180,180,0
|
||||||
|
9,10,180,225,0
|
||||||
|
10,0,225,-225,0
|
||||||
|
10,1,225,-180,0
|
||||||
|
10,2,225,-135,0
|
||||||
|
10,3,225,-90,0
|
||||||
|
10,4,225,-45,0
|
||||||
|
10,5,225,0,0
|
||||||
|
10,6,225,45,0
|
||||||
|
10,7,225,90,0
|
||||||
|
10,8,225,135,0
|
||||||
|
10,9,225,180,0
|
||||||
|
10,10,225,225,0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
|
||||||
|
every tile of that type), then seed the grid with a few opening tiles.
|
||||||
|
|
||||||
|
```yaml role=setup.game
|
||||||
|
id: main
|
||||||
|
surfaces:
|
||||||
|
- board#board
|
||||||
|
setup:
|
||||||
|
- path: /draw
|
||||||
|
parts: carcassonne:tile
|
||||||
|
- path: /grid/5/5
|
||||||
|
parts: carcassonne:tile#a
|
||||||
|
- path: /grid/5/6
|
||||||
|
parts: carcassonne:tile#b
|
||||||
|
- path: /grid/6/5
|
||||||
|
parts: carcassonne:tile#c
|
||||||
|
```
|
||||||
+44
-20
@@ -4,13 +4,13 @@ A standard 52-card poker deck, laid out on a table with a draw pile and
|
|||||||
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
community-card slots. Exercises the bgm loader's `$variants` expansion to
|
||||||
generate a full deck from a single part definition.
|
generate a full deck from a single part definition.
|
||||||
|
|
||||||
```yaml file=poker.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: poker
|
id: poker
|
||||||
title: Poker
|
title: Poker
|
||||||
designer: Public Domain
|
designer: Public Domain
|
||||||
players: 9
|
players: 9
|
||||||
language: en
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
## Parts
|
## Parts
|
||||||
@@ -20,12 +20,10 @@ picks a cell from the `13×4` face sheet (`cards-13x4.jpg`) — 13 ranks across,
|
|||||||
4 suits down. All cards share the same back from the `4×1` back sheet
|
4 suits down. All cards share the same back from the `4×1` back sheet
|
||||||
(`back-4x1.png`).
|
(`back-4x1.png`).
|
||||||
|
|
||||||
```yaml file=cards.yaml
|
```yaml role=part.card
|
||||||
role: part
|
|
||||||
type: card
|
|
||||||
face: ./cards-13x4.jpg
|
face: ./cards-13x4.jpg
|
||||||
back: ./back-4x1.png
|
back: ./back-4x1.png
|
||||||
size: [63, 88, 3]
|
size: [63, 88, 0.3]
|
||||||
fillet: 2
|
fillet: 2
|
||||||
$variants: ./cards.csv
|
$variants: ./cards.csv
|
||||||
```
|
```
|
||||||
@@ -92,10 +90,8 @@ ac,A,clubs,[12;3;13;4],[0;0;4;1]
|
|||||||
A table with a draw pile on the left and five community-card slots across the
|
A table with a draw pile on the left and five community-card slots across the
|
||||||
middle. The deck pile fans its stacked cards along a curve.
|
middle. The deck pile fans its stacked cards along a curve.
|
||||||
|
|
||||||
```yaml file=board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: poker
|
id: poker
|
||||||
role: surface
|
|
||||||
size: [600, 400]
|
size: [600, 400]
|
||||||
layout:
|
layout:
|
||||||
- route: /deck
|
- route: /deck
|
||||||
@@ -103,9 +99,10 @@ layout:
|
|||||||
y: 0
|
y: 0
|
||||||
rotation: 0
|
rotation: 0
|
||||||
stacking:
|
stacking:
|
||||||
curve: M 0 0 C 20 -20 40 -20 60 0
|
|
||||||
limit: 0
|
|
||||||
align: center
|
align: center
|
||||||
|
zStart: 0
|
||||||
|
#zEnd: 100
|
||||||
|
curve: M -50 -200 C 50 -150 450 -150 550 -200
|
||||||
- route: /community/:slot
|
- route: /community/:slot
|
||||||
candidates:
|
candidates:
|
||||||
$variants: ./community.csv
|
$variants: ./community.csv
|
||||||
@@ -123,16 +120,43 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Deal the whole deck onto the draw pile (`poker:card` expands to every card of
|
Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
|
||||||
that type), then flip a flop onto the community slots.
|
card of that type, `facing: back`), then flip a flop onto the community slots
|
||||||
|
and stand a couple of drawn cards on their bottom edge.
|
||||||
|
|
||||||
```yaml file=main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/deck: poker:card
|
- path: /deck
|
||||||
/community/0: poker:card#as
|
parts: poker:card
|
||||||
/community/1: poker:card#kh
|
facing: back
|
||||||
/community/2: poker:card#7d
|
- path: /community/0
|
||||||
|
parts: poker:card#as
|
||||||
|
- path: /community/1
|
||||||
|
parts: poker:card#kh
|
||||||
|
- path: /community/2
|
||||||
|
parts: poker:card#7d
|
||||||
|
- path: /community/3
|
||||||
|
parts: poker:card#jc
|
||||||
|
facing: standing
|
||||||
|
- path: /community/4
|
||||||
|
parts: poker:card#2h
|
||||||
|
facing: standing
|
||||||
|
interactions:
|
||||||
|
- dialog: prompt#insert
|
||||||
|
on: [/deck]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dialog
|
||||||
|
|
||||||
|
Inserting a card into the middle of the deck is a compound interaction: the
|
||||||
|
deck is lifted into a stack-inspector dialog with an insertion cursor. The
|
||||||
|
cursor *is* the index — `move(id, /deck, index)` is discovered by scrolling,
|
||||||
|
not typed.
|
||||||
|
|
||||||
|
```yaml role=dialog.prompt
|
||||||
|
id: insert
|
||||||
|
title: Insert into deck
|
||||||
|
body: Scroll to the insertion point, then insert the held card.
|
||||||
|
widget: stack
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
A tiny example game used to exercise the bgm loader.
|
A tiny example game used to exercise the bgm loader.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,9 +24,7 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: grain
|
id: grain
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [0, 0, 5, 2]
|
faceCrop: [0, 0, 5, 2]
|
||||||
@@ -41,10 +37,8 @@ fillet: 2
|
|||||||
|
|
||||||
## Board
|
## Board
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: table
|
kind: table
|
||||||
@@ -63,10 +57,8 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/player.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: player
|
id: player
|
||||||
role: surface
|
|
||||||
size: [200, 200]
|
size: [200, 200]
|
||||||
mount:
|
mount:
|
||||||
kind: child
|
kind: child
|
||||||
@@ -79,14 +71,14 @@ layout:
|
|||||||
$variants: ./hand.csv
|
$variants: ./hand.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/hand.csv
|
```csv file=hand.csv
|
||||||
slot,x,y,rotation
|
slot,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,0,0,0
|
0,0,0,0
|
||||||
1,0,20,0
|
1,0,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
@@ -95,14 +87,14 @@ string,number,number,number
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
surfaces:
|
surfaces:
|
||||||
- board#harbor
|
- board#harbor
|
||||||
- board#player
|
- board#player
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:token#wood
|
- path: /dock/0
|
||||||
/deck: harbor:token#grain
|
parts: harbor:token#wood
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:token#grain
|
||||||
```
|
```
|
||||||
@@ -3,19 +3,16 @@
|
|||||||
A second tiny example game, used to exercise the loader's collection of
|
A second tiny example game, used to exercise the loader's collection of
|
||||||
multiple packages.
|
multiple packages.
|
||||||
|
|
||||||
```yaml file=azul.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: azul
|
id: azul
|
||||||
title: Azul
|
title: Azul
|
||||||
designer: Michael Kiesling
|
designer: Michael Kiesling
|
||||||
players: 4
|
players: 4
|
||||||
language: en
|
language: en
|
||||||
include: ['**/azul/**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tiles.yaml
|
```yaml role=part.tile
|
||||||
role: part
|
|
||||||
type: tile
|
|
||||||
id: blue
|
id: blue
|
||||||
face: ./assets/tiles.png
|
face: ./assets/tiles.png
|
||||||
faceCrop: [0, 0, 5, 5]
|
faceCrop: [0, 0, 5, 5]
|
||||||
@@ -23,10 +20,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 1
|
fillet: 1
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: azul
|
id: azul
|
||||||
role: surface
|
|
||||||
size: [400, 300]
|
size: [400, 300]
|
||||||
layout:
|
layout:
|
||||||
- route: /factory/:n
|
- route: /factory/:n
|
||||||
@@ -34,7 +29,7 @@ layout:
|
|||||||
$variants: ./factories.csv
|
$variants: ./factories.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/factories.csv
|
```csv file=factories.csv
|
||||||
n,x,y,rotation
|
n,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,-150,0,0
|
0,-150,0,0
|
||||||
@@ -43,10 +38,9 @@ string,number,number,number
|
|||||||
3,150,0,0
|
3,150,0,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/factory/0: azul:tile#blue
|
- path: /factory/0
|
||||||
|
parts: azul:tile#blue
|
||||||
```
|
```
|
||||||
@@ -3,19 +3,16 @@
|
|||||||
A tiny example game used to exercise the bgm loader end-to-end through a real
|
A tiny example game used to exercise the bgm loader end-to-end through a real
|
||||||
vite build.
|
vite build.
|
||||||
|
|
||||||
```yaml file=harbor.yaml
|
```yaml role=package
|
||||||
role: package
|
|
||||||
id: harbor
|
id: harbor
|
||||||
title: Harbor
|
title: Harbor
|
||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
include: ['**/harbor/**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml role=part.token
|
||||||
role: part
|
|
||||||
type: token
|
|
||||||
id: wood
|
id: wood
|
||||||
face: ./assets/tokens.png
|
face: ./assets/tokens.png
|
||||||
faceCrop: [1, 0, 5, 2]
|
faceCrop: [1, 0, 5, 2]
|
||||||
@@ -26,10 +23,8 @@ size: [20, 20, 3]
|
|||||||
fillet: 2
|
fillet: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/board.yaml
|
```yaml role=surface.board
|
||||||
type: board
|
|
||||||
id: harbor
|
id: harbor
|
||||||
role: surface
|
|
||||||
size: [300, 200]
|
size: [300, 200]
|
||||||
layout:
|
layout:
|
||||||
- route: /dock/:seat
|
- route: /dock/:seat
|
||||||
@@ -41,18 +36,18 @@ layout:
|
|||||||
rotation: 0
|
rotation: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
```csv file=parts/seats.csv
|
```csv file=seats.csv
|
||||||
seat,x,y,rotation
|
seat,x,y,rotation
|
||||||
string,number,number,number
|
string,number,number,number
|
||||||
0,40,0,0
|
0,40,0,0
|
||||||
1,40,20,0
|
1,40,20,0
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=setup/main.yaml
|
```yaml role=setup.game
|
||||||
role: setup
|
|
||||||
type: game
|
|
||||||
id: main
|
id: main
|
||||||
setup:
|
setup:
|
||||||
/dock/0: harbor:token#wood
|
- path: /dock/0
|
||||||
/deck: harbor:token#grain
|
parts: harbor:token#wood
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:token#grain
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,71 +1,134 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadDefs, collectPackages } from './collect.js';
|
import { loadDefs, collectPackages } from "./collect.js";
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
|
const fixtureRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"__fixtures__",
|
||||||
|
"harbor",
|
||||||
|
);
|
||||||
|
const multiRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"__fixtures__",
|
||||||
|
"vite-build",
|
||||||
|
"games",
|
||||||
|
);
|
||||||
|
|
||||||
describe('collectPackages', () => {
|
describe("collectPackages", () => {
|
||||||
it('collects the harbor package from markdown code blocks', () => {
|
it("collects the harbor package from markdown code blocks", () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs("", fixtureRoot);
|
||||||
const packages = collectPackages(defMap, fixtureRoot);
|
const packages = collectPackages(defMap, fixtureRoot);
|
||||||
|
|
||||||
expect(packages).toHaveLength(1);
|
expect(packages).toHaveLength(1);
|
||||||
const harbor = packages[0]!;
|
const harbor = packages[0]!;
|
||||||
expect(harbor.meta).toMatchObject({ id: 'harbor', title: 'Harbor', designer: 'Jane Doe' });
|
expect(harbor.meta).toMatchObject({
|
||||||
|
id: "harbor",
|
||||||
|
title: "Harbor",
|
||||||
|
designer: "Jane Doe",
|
||||||
|
});
|
||||||
|
|
||||||
// Two tokens from two yaml blocks sharing a `file=` name.
|
// Two tokens from two yaml blocks sharing a `role=part.token` name.
|
||||||
expect([...harbor.parts.keys()].sort()).toEqual(['token#grain', 'token#wood']);
|
expect([...harbor.parts.keys()].sort()).toEqual([
|
||||||
const wood = harbor.parts.get('token#wood')!;
|
"token#grain",
|
||||||
|
"token#wood",
|
||||||
|
]);
|
||||||
|
const wood = harbor.parts.get("token#wood")!;
|
||||||
expect(wood).toMatchObject({
|
expect(wood).toMatchObject({
|
||||||
type: 'token',
|
type: "token",
|
||||||
id: 'wood',
|
id: "wood",
|
||||||
size: [20, 20, 3],
|
size: [20, 20, 3],
|
||||||
fillet: 2,
|
fillet: 2,
|
||||||
});
|
});
|
||||||
expect(wood.face).toBe('./assets/tokens.png');
|
expect(wood.face).toBe("./assets/tokens.png");
|
||||||
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
expect(wood.faceCrop).toEqual([1, 0, 5, 2]);
|
||||||
// Relative assets resolve against the source file's directory. The fixture
|
// Relative assets resolve against the markdown file's directory. The
|
||||||
// markdown sits at the games root, so the virtual file is `parts/tokens.yaml`.
|
// fixture markdown sits at the games root, so baseUrl is empty.
|
||||||
expect(wood.baseUrl).toBe('parts/');
|
expect(wood.baseUrl).toBe("");
|
||||||
|
|
||||||
// Two surfaces: the table board and its child player board.
|
// Two surfaces: the table board and its child player board.
|
||||||
expect([...harbor.surfaces.keys()].sort()).toEqual(['board#harbor', 'board#player']);
|
expect([...harbor.surfaces.keys()].sort()).toEqual([
|
||||||
const board = harbor.surfaces.get('board#harbor')!;
|
"board#harbor",
|
||||||
|
"board#player",
|
||||||
|
]);
|
||||||
|
const board = harbor.surfaces.get("board#harbor")!;
|
||||||
expect(board.size).toEqual([300, 200]);
|
expect(board.size).toEqual([300, 200]);
|
||||||
expect(board.mount).toEqual({ kind: 'table', x: 0, y: 0, rotation: 0 });
|
expect(board.mount).toEqual({ kind: "table", x: 0, y: 0, rotation: 0 });
|
||||||
expect(board.children).toEqual(['board#player']);
|
expect(board.children).toEqual(["board#player"]);
|
||||||
expect(board.layout).toHaveLength(2);
|
expect(board.layout).toHaveLength(2);
|
||||||
const dock = board.layout[0]!;
|
const dock = board.layout[0]!;
|
||||||
expect(dock.route).toBe('/dock/:seat');
|
expect(dock.route).toBe("/dock/:seat");
|
||||||
expect(dock.candidates).toEqual([
|
expect(dock.candidates).toEqual([
|
||||||
{ seat: '0', x: 40, y: 0, rotation: 0 },
|
{ seat: "0", x: 40, y: 0, rotation: 0 },
|
||||||
{ seat: '1', x: 40, y: 20, rotation: 0 },
|
{ seat: "1", x: 40, y: 20, rotation: 0 },
|
||||||
]);
|
]);
|
||||||
const deck = board.layout[1]!;
|
const deck = board.layout[1]!;
|
||||||
expect(deck.route).toBe('/deck');
|
expect(deck.route).toBe("/deck");
|
||||||
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
|
expect(deck).toMatchObject({ x: -100, y: 0, rotation: 0 });
|
||||||
|
|
||||||
const player = harbor.surfaces.get('board#player')!;
|
const player = harbor.surfaces.get("board#player")!;
|
||||||
expect(player.mount).toEqual({ kind: 'child', x: 100, y: 50, rotation: 0 });
|
expect(player.mount).toEqual({ kind: "child", x: 100, y: 50, rotation: 0 });
|
||||||
expect(player.layout).toHaveLength(1);
|
expect(player.layout).toHaveLength(1);
|
||||||
|
|
||||||
// One setup, declaring the enabled surfaces.
|
// One setup, declaring the enabled surfaces.
|
||||||
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
|
||||||
const setup = harbor.setups.get('game#main')!;
|
const setup = harbor.setups.get("game#main")!;
|
||||||
expect(setup.surfaces).toEqual(['board#harbor', 'board#player']);
|
expect(setup.surfaces).toEqual(["board#harbor", "board#player"]);
|
||||||
expect(setup.setup).toEqual({
|
expect(setup.setup).toEqual([
|
||||||
'/dock/0': 'harbor:token#wood',
|
{ path: "/dock/0", parts: "harbor:token#wood" },
|
||||||
'/deck': 'harbor:token#grain',
|
{ path: "/deck", parts: "harbor:token#grain" },
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on a duplicate type#id', () => {
|
it("scopes include patterns to the package declaration directory", () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
// Two packages share a games root. Each uses the default `./**/*.yaml`
|
||||||
// Inject a duplicate part into the map under a new file name.
|
// include, which must resolve relative to its own folder so neither
|
||||||
const tokensKey = [...defMap.defs.keys()].find((k) => k.endsWith('parts/tokens.yaml'))!;
|
// absorbs the other's defs (both define a `game#main` setup).
|
||||||
const tokens = defMap.defs.get(tokensKey)!;
|
const defMap = loadDefs("", multiRoot);
|
||||||
defMap.defs.set(tokensKey.replace('tokens.yaml', 'dup.yaml'), [tokens[0]!]);
|
const packages = collectPackages(defMap, multiRoot);
|
||||||
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(/Duplicate part/);
|
|
||||||
|
expect(packages).toHaveLength(2);
|
||||||
|
const azul = packages.find((p) => p.meta.id === "azul")!;
|
||||||
|
const harbor = packages.find((p) => p.meta.id === "harbor")!;
|
||||||
|
|
||||||
|
expect([...azul.parts.keys()]).toEqual(["tile#blue"]);
|
||||||
|
expect([...azul.setups.keys()]).toEqual(["game#main"]);
|
||||||
|
expect([...harbor.parts.keys()]).toEqual(["token#wood"]);
|
||||||
|
expect([...harbor.setups.keys()]).toEqual(["game#main"]);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it("throws on a duplicate type#id", () => {
|
||||||
|
const defMap = loadDefs("", fixtureRoot);
|
||||||
|
// Inject a duplicate part into the map under a new file name.
|
||||||
|
const tokensKey = [...defMap.defs.keys()].find((k) =>
|
||||||
|
k.endsWith("part.token.yaml"),
|
||||||
|
)!;
|
||||||
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
|
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
|
||||||
|
tokens[0]!,
|
||||||
|
]);
|
||||||
|
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
|
||||||
|
/Duplicate part/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when an info-string id combines with $variants", () => {
|
||||||
|
const defMap = loadDefs("", fixtureRoot);
|
||||||
|
// A part whose `id` comes from $variants rows, but with an info-string id.
|
||||||
|
const tokensKey = [...defMap.defs.keys()].find((k) =>
|
||||||
|
k.endsWith("part.token.yaml"),
|
||||||
|
)!;
|
||||||
|
const tokens = defMap.defs.get(tokensKey)!;
|
||||||
|
const variant = {
|
||||||
|
...tokens[0]!,
|
||||||
|
value: { ...tokens[0]!.value, id: undefined, $variants: "./seats.csv" },
|
||||||
|
role: { role: "part" as const, type: "token", id: "wood" },
|
||||||
|
};
|
||||||
|
defMap.defs.set(tokensKey.replace("part.token.yaml", "part.dup.yaml"), [
|
||||||
|
variant,
|
||||||
|
]);
|
||||||
|
expect(() => collectPackages(defMap, fixtureRoot)).toThrow(
|
||||||
|
/can't combine with \$variants/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+137
-47
@@ -11,17 +11,25 @@
|
|||||||
* their `include` patterns, and assembles the package's parts, surfaces,
|
* their `include` patterns, and assembles the package's parts, surfaces,
|
||||||
* and setups.
|
* and setups.
|
||||||
*
|
*
|
||||||
* See docs/bgm-format.md for the format's concrete behavior.
|
* See docs/bgm/format.md for the format's concrete behavior.
|
||||||
*/
|
*/
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import picomatch from 'picomatch';
|
import picomatch from "picomatch";
|
||||||
import { collectVirtualFiles } from './markdown.js';
|
import { collectVirtualFiles } from "./markdown.js";
|
||||||
import { parseDefText, readDefFiles } from './parse.js';
|
import { parseDefText, readDefFiles } from "./parse.js";
|
||||||
import { validatePackage, validatePart, validateSetup, validateSurface } from './schemas.js';
|
import {
|
||||||
import { expandVariants } from './variants.js';
|
validateDialog,
|
||||||
|
validatePackage,
|
||||||
|
validatePart,
|
||||||
|
validateSetup,
|
||||||
|
validateSurface,
|
||||||
|
} from "./schemas.js";
|
||||||
|
import { expandVariants } from "./variants.js";
|
||||||
import {
|
import {
|
||||||
BgmError,
|
BgmError,
|
||||||
|
ROLES,
|
||||||
type DefFile,
|
type DefFile,
|
||||||
|
type Dialog,
|
||||||
type ParsedDef,
|
type ParsedDef,
|
||||||
type Package,
|
type Package,
|
||||||
type PackageDef,
|
type PackageDef,
|
||||||
@@ -29,9 +37,7 @@ import {
|
|||||||
type Role,
|
type Role,
|
||||||
type Setup,
|
type Setup,
|
||||||
type Surface,
|
type Surface,
|
||||||
} from './types.js';
|
} from "./types.js";
|
||||||
|
|
||||||
const ROLES = new Set<Role>(['package', 'part', 'surface', 'setup']);
|
|
||||||
|
|
||||||
/** Every definition parsed from a def file, keyed by its path-style name. */
|
/** Every definition parsed from a def file, keyed by its path-style name. */
|
||||||
export interface DefMap {
|
export interface DefMap {
|
||||||
@@ -53,7 +59,7 @@ export function loadDefs(root: string, rootDir: string): DefMap {
|
|||||||
const others: DefFile[] = [];
|
const others: DefFile[] = [];
|
||||||
|
|
||||||
for (const file of realFiles) {
|
for (const file of realFiles) {
|
||||||
if (file.kind === 'markdown') markdownFiles.set(file.name, file.text);
|
if (file.kind === "markdown") markdownFiles.set(file.name, file.text);
|
||||||
else others.push(file);
|
else others.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +93,12 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
|||||||
for (const [file, defs] of defMap.defs) {
|
for (const [file, defs] of defMap.defs) {
|
||||||
const list: ParsedDef[] = [];
|
const list: ParsedDef[] = [];
|
||||||
for (const def of defs) {
|
for (const def of defs) {
|
||||||
const role = def.value['role'];
|
const role = def.value["role"];
|
||||||
if (role !== undefined && typeof role === 'string' && ROLES.has(role as Role)) {
|
if (
|
||||||
|
role !== undefined &&
|
||||||
|
typeof role === "string" &&
|
||||||
|
ROLES.has(role as Role)
|
||||||
|
) {
|
||||||
list.push(def);
|
list.push(def);
|
||||||
byRole.set(file, list);
|
byRole.set(file, list);
|
||||||
}
|
}
|
||||||
@@ -98,10 +108,11 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
|||||||
const accs: PackageAcc[] = [];
|
const accs: PackageAcc[] = [];
|
||||||
for (const [file, defs] of byRole) {
|
for (const [file, defs] of byRole) {
|
||||||
for (const def of defs) {
|
for (const def of defs) {
|
||||||
const role = def.value['role'] as Role;
|
const role = def.value["role"] as Role;
|
||||||
if (role === 'package') {
|
if (role === "package") {
|
||||||
const pkg = asPackage(def, file);
|
const pkg = asPackage(def, file);
|
||||||
accs.push(new PackageAcc(pkg, defMap, rootDir));
|
const baseDir = path.posix.dirname(file).replace(/^\/+/, "");
|
||||||
|
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,32 +130,49 @@ class PackageAcc {
|
|||||||
readonly parts = new Map<string, Part>();
|
readonly parts = new Map<string, Part>();
|
||||||
readonly surfaces = new Map<string, Surface>();
|
readonly surfaces = new Map<string, Surface>();
|
||||||
readonly setups = new Map<string, Setup>();
|
readonly setups = new Map<string, Setup>();
|
||||||
|
readonly dialogs = new Map<string, Dialog>();
|
||||||
readonly byRole = new Map<string, string[]>();
|
readonly byRole = new Map<string, string[]>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly pkg: PackageDef,
|
readonly pkg: PackageDef,
|
||||||
private readonly defs: DefMap,
|
private readonly defs: DefMap,
|
||||||
private readonly rootDir: string,
|
private readonly rootDir: string,
|
||||||
|
/** Path-style directory of the package declaration, e.g. `carcassonne`. */
|
||||||
|
private readonly baseDir: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
collect() {
|
collect() {
|
||||||
const include = this.pkg.include ?? ['./**/*.yaml'];
|
const include = this.pkg.include ?? ["./**/*.yaml"];
|
||||||
const names = this.expandIncludes(include);
|
const names = this.expandIncludes(include);
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const fileDefs = this.defs.defs.get(name);
|
const fileDefs = this.defs.defs.get(name);
|
||||||
if (!fileDefs) continue;
|
if (!fileDefs) continue;
|
||||||
for (const def of fileDefs) {
|
for (const def of fileDefs) {
|
||||||
const role = def.value['role'];
|
const role = def.value["role"];
|
||||||
if (typeof role !== 'string' || !ROLES.has(role as Role) || role === 'package') continue;
|
if (
|
||||||
|
typeof role !== "string" ||
|
||||||
|
!ROLES.has(role as Role) ||
|
||||||
|
role === "package"
|
||||||
|
)
|
||||||
|
continue;
|
||||||
this.add(role as Role, def, name);
|
this.add(role as Role, def, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expand `$variants` on a def object into a list of concrete objects. */
|
/** Expand `$variants` on a def object into a list of concrete objects. */
|
||||||
private expand(obj: Record<string, unknown>, baseName: string, source: string): Record<string, unknown>[] {
|
private expand(
|
||||||
if (!('$variants' in obj)) return [obj];
|
obj: Record<string, unknown>,
|
||||||
const rows = expandVariants(obj['$variants'], baseName, this.defs.files, source);
|
baseDir: string,
|
||||||
|
source: string,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
if (!("$variants" in obj)) return [obj];
|
||||||
|
const rows = expandVariants(
|
||||||
|
obj["$variants"],
|
||||||
|
baseNameFor(baseDir),
|
||||||
|
this.defs.files,
|
||||||
|
source,
|
||||||
|
);
|
||||||
const { $variants: _v, ...base } = obj;
|
const { $variants: _v, ...base } = obj;
|
||||||
return rows.map((row) => ({ ...base, ...row }));
|
return rows.map((row) => ({ ...base, ...row }));
|
||||||
}
|
}
|
||||||
@@ -152,10 +180,23 @@ class PackageAcc {
|
|||||||
private expandIncludes(patterns: string[]): string[] {
|
private expandIncludes(patterns: string[]): string[] {
|
||||||
// Match include patterns against the parsed definitions' names, which
|
// Match include patterns against the parsed definitions' names, which
|
||||||
// cover both real files and markdown code blocks. Patterns are relative
|
// cover both real files and markdown code blocks. Patterns are relative
|
||||||
// to the games root (e.g. `./**/*.yaml`).
|
// to the package declaration's own directory (e.g. `./**/*.yaml` means
|
||||||
|
// this package's folder and below), so a package never absorbs defs from
|
||||||
|
// a sibling game. A leading `/` marks a pattern as root-relative.
|
||||||
|
// Def names carry a leading `/` (from the empty games root), so resolved
|
||||||
|
// patterns are prefixed to match.
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const matcher = picomatch(pattern, { dot: true });
|
// A leading `/` marks a pattern as root-relative. Otherwise it's
|
||||||
|
// relative to the package declaration's directory. When the package is
|
||||||
|
// at the games root (empty baseDir), the pattern has no leading slash:
|
||||||
|
// `**/*.yaml` matches root-level files, whereas `/**/*.yaml` does not.
|
||||||
|
const resolved = pattern.startsWith("/")
|
||||||
|
? pattern
|
||||||
|
: this.baseDir
|
||||||
|
? `/${path.posix.join(this.baseDir, pattern)}`
|
||||||
|
: path.posix.join(this.baseDir, pattern);
|
||||||
|
const matcher = picomatch(resolved, { dot: true });
|
||||||
for (const name of this.defs.defs.keys()) {
|
for (const name of this.defs.defs.keys()) {
|
||||||
if (matcher(name)) names.add(name);
|
if (matcher(name)) names.add(name);
|
||||||
}
|
}
|
||||||
@@ -164,11 +205,19 @@ class PackageAcc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private add(role: Role, def: ParsedDef, fileName: string) {
|
private add(role: Role, def: ParsedDef, fileName: string) {
|
||||||
const expanded = this.expand(def.value, def.file, def.source);
|
// `id` on the info string can't combine with `$variants`, since every
|
||||||
|
// row supplies its own `id` and would override it.
|
||||||
|
if (def.role?.id && "$variants" in def.value) {
|
||||||
|
throw new BgmError(
|
||||||
|
`id on the info string can't combine with $variants`,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expanded = this.expand(def.value, def.baseDir ?? "", def.source);
|
||||||
for (const obj of expanded) {
|
for (const obj of expanded) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'part': {
|
case "part": {
|
||||||
const part = asPart(obj, fileName);
|
const part = asPart(obj, def.baseDir ?? "");
|
||||||
const key = `${part.type}#${part.id}`;
|
const key = `${part.type}#${part.id}`;
|
||||||
if (this.parts.has(key)) {
|
if (this.parts.has(key)) {
|
||||||
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
throw new BgmError(`Duplicate part "${key}"`, fileName);
|
||||||
@@ -176,8 +225,8 @@ class PackageAcc {
|
|||||||
this.parts.set(key, part);
|
this.parts.set(key, part);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'surface': {
|
case "surface": {
|
||||||
const surface = asSurface(obj, fileName, this.defs.files);
|
const surface = asSurface(obj, def.baseDir ?? "", this.defs.files);
|
||||||
const key = `${surface.type}#${surface.id}`;
|
const key = `${surface.type}#${surface.id}`;
|
||||||
if (this.surfaces.has(key)) {
|
if (this.surfaces.has(key)) {
|
||||||
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
throw new BgmError(`Duplicate surface "${key}"`, fileName);
|
||||||
@@ -185,7 +234,7 @@ class PackageAcc {
|
|||||||
this.surfaces.set(key, surface);
|
this.surfaces.set(key, surface);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'setup': {
|
case "setup": {
|
||||||
const setup = asSetup(obj, fileName);
|
const setup = asSetup(obj, fileName);
|
||||||
const key = `${setup.type}#${setup.id}`;
|
const key = `${setup.type}#${setup.id}`;
|
||||||
if (this.setups.has(key)) {
|
if (this.setups.has(key)) {
|
||||||
@@ -194,12 +243,27 @@ class PackageAcc {
|
|||||||
this.setups.set(key, setup);
|
this.setups.set(key, setup);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "dialog": {
|
||||||
|
const dialog = asDialog(obj, fileName);
|
||||||
|
const key = `${dialog.type}#${dialog.id}`;
|
||||||
|
if (this.dialogs.has(key)) {
|
||||||
|
throw new BgmError(`Duplicate dialog "${key}"`, fileName);
|
||||||
|
}
|
||||||
|
this.dialogs.set(key, dialog);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toPackage(): Package {
|
toPackage(): Package {
|
||||||
return { meta: metaOf(this.pkg), parts: this.parts, surfaces: this.surfaces, setups: this.setups };
|
return {
|
||||||
|
meta: metaOf(this.pkg),
|
||||||
|
parts: this.parts,
|
||||||
|
surfaces: this.surfaces,
|
||||||
|
setups: this.setups,
|
||||||
|
dialogs: this.dialogs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,36 +281,52 @@ function asPackage(def: ParsedDef, source: string): PackageDef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asPart(obj: Record<string, unknown>, source: string): Part {
|
/** A base name whose directory is `baseDir`, for resolving `$variants` paths. */
|
||||||
|
function baseNameFor(baseDir: string): string {
|
||||||
|
const dir = baseDir.replace(/^\/+/, "");
|
||||||
|
return dir ? `/${dir}/def.yaml` : "/def.yaml";
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPart(obj: Record<string, unknown>, baseDir: string): Part {
|
||||||
try {
|
try {
|
||||||
const part = validatePart(obj) as unknown as Part;
|
const part = validatePart(obj) as unknown as Part;
|
||||||
// Resolve relative asset paths against the directory of the source file
|
// Resolve relative asset paths against the directory of the source file
|
||||||
// (path-style name relative to the games root, e.g. `harbor/parts/`).
|
// (path-style name relative to the games root). For a code block this is
|
||||||
// Real files may carry a leading slash from an empty root; strip it.
|
// the markdown file's directory; for a real file, its own directory.
|
||||||
const dir = path.posix.dirname(source).replace(/^\/+/, '');
|
const dir = baseDir.replace(/^\/+/, "");
|
||||||
part.baseUrl = dir ? `${dir}/` : '';
|
part.baseUrl = dir ? `${dir}/` : "";
|
||||||
return part;
|
return part;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSurface(
|
function asSurface(
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
source: string,
|
baseDir: string,
|
||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
): Surface {
|
): Surface {
|
||||||
const value: Record<string, unknown> = { ...obj };
|
const value: Record<string, unknown> = { ...obj };
|
||||||
delete value['role'];
|
delete value["role"];
|
||||||
|
|
||||||
// Expand `candidates.$variants` on each route into a concrete array.
|
// Expand `candidates.$variants` on each route into a concrete array.
|
||||||
if (Array.isArray(value['layout'])) {
|
if (Array.isArray(value["layout"])) {
|
||||||
value['layout'] = value['layout'].map((route) => {
|
value["layout"] = value["layout"].map((route) => {
|
||||||
if (typeof route !== 'object' || route === null) return route;
|
if (typeof route !== "object" || route === null) return route;
|
||||||
const r = route as Record<string, unknown>;
|
const r = route as Record<string, unknown>;
|
||||||
const cand = r['candidates'];
|
const cand = r["candidates"];
|
||||||
if (cand && typeof cand === 'object' && !Array.isArray(cand) && '$variants' in cand) {
|
if (
|
||||||
const rows = expandVariants(cand['$variants'], source, defs, source);
|
cand &&
|
||||||
|
typeof cand === "object" &&
|
||||||
|
!Array.isArray(cand) &&
|
||||||
|
"$variants" in cand
|
||||||
|
) {
|
||||||
|
const rows = expandVariants(
|
||||||
|
cand["$variants"],
|
||||||
|
baseNameFor(baseDir),
|
||||||
|
defs,
|
||||||
|
baseDir,
|
||||||
|
);
|
||||||
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
const { $variants: _v, ...base } = cand as Record<string, unknown>;
|
||||||
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
return { ...r, candidates: rows.map((row) => ({ ...base, ...row })) };
|
||||||
}
|
}
|
||||||
@@ -256,13 +336,13 @@ function asSurface(
|
|||||||
try {
|
try {
|
||||||
return validateSurface(value) as unknown as Surface;
|
return validateSurface(value) as unknown as Surface;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw wrapZod(err, source);
|
throw wrapZod(err, baseDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
||||||
const value: Record<string, unknown> = { ...obj };
|
const value: Record<string, unknown> = { ...obj };
|
||||||
delete value['role'];
|
delete value["role"];
|
||||||
try {
|
try {
|
||||||
return validateSetup(value) as unknown as Setup;
|
return validateSetup(value) as unknown as Setup;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -270,6 +350,16 @@ function asSetup(obj: Record<string, unknown>, source: string): Setup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asDialog(obj: Record<string, unknown>, source: string): Dialog {
|
||||||
|
const value: Record<string, unknown> = { ...obj };
|
||||||
|
delete value["role"];
|
||||||
|
try {
|
||||||
|
return validateDialog(value) as unknown as Dialog;
|
||||||
|
} catch (err) {
|
||||||
|
throw wrapZod(err, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Wrap a zod error with the source location. */
|
/** Wrap a zod error with the source location. */
|
||||||
function wrapZod(err: unknown, source: string): BgmError {
|
function wrapZod(err: unknown, source: string): BgmError {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { scanMarkdown } from './markdown.js';
|
import { scanMarkdown } from './markdown.js';
|
||||||
|
|
||||||
describe('scanMarkdown', () => {
|
describe('scanMarkdown', () => {
|
||||||
it('extracts a fenced code block with a file= name', () => {
|
it('names a block from its role= as role.type.lang', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'# Title',
|
'# Title',
|
||||||
'',
|
'',
|
||||||
'```yaml file=parts/cargo.yaml',
|
'```yaml role=part.cargo',
|
||||||
'role: part',
|
'id: wood',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'text after',
|
'text after',
|
||||||
@@ -17,26 +17,70 @@ describe('scanMarkdown', () => {
|
|||||||
|
|
||||||
expect(fences).toHaveLength(1);
|
expect(fences).toHaveLength(1);
|
||||||
expect(fences[0]).toMatchObject({
|
expect(fences[0]).toMatchObject({
|
||||||
info: 'yaml file=parts/cargo.yaml',
|
info: 'yaml role=part.cargo',
|
||||||
content: 'role: part',
|
content: 'id: wood',
|
||||||
startLine: 3,
|
startLine: 3,
|
||||||
endLine: 5,
|
endLine: 5,
|
||||||
});
|
});
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0]).toMatchObject({
|
expect(files[0]).toMatchObject({
|
||||||
name: 'harbor/parts/cargo.yaml',
|
name: 'harbor/part.cargo.yaml',
|
||||||
kind: 'yaml',
|
kind: 'yaml',
|
||||||
text: 'role: part',
|
text: 'id: wood',
|
||||||
source: 'harbor/harbor.md:3-5',
|
source: 'harbor/harbor.md:3-5',
|
||||||
|
role: { role: 'part', type: 'cargo' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-names a block without file= from its content hash', () => {
|
it('names a package block package.yaml', () => {
|
||||||
|
const md = '```yaml role=package\nid: harbor\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
|
expect(files[0]).toMatchObject({ name: 'harbor/package.yaml', role: { role: 'package' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a surface block with type and id', () => {
|
||||||
|
const md = '```yaml role=surface.game#main\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'poker/poker.md');
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
name: 'poker/surface.game.yaml',
|
||||||
|
role: { role: 'surface', type: 'game', id: 'main' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses file= to override the role.type name', () => {
|
||||||
|
const md = '```yaml file=parts/cargo.yaml role=part.cargo\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
name: 'harbor/parts/cargo.yaml',
|
||||||
|
role: { role: 'part', type: 'cargo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a block without role= or file=', () => {
|
||||||
const md = '```yaml\nrole: part\n```';
|
const md = '```yaml\nrole: part\n```';
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(0);
|
||||||
expect(files[0]!.name).toMatch(/^harbor\/[0-9a-f]{8}\.yaml$/);
|
});
|
||||||
expect(files[0]!.kind).toBe('yaml');
|
|
||||||
|
it('names a csv block with file= as csv', () => {
|
||||||
|
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
||||||
|
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
|
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an unknown role=', () => {
|
||||||
|
const md = '```yaml role=widget\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Invalid role/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on role=package with a type', () => {
|
||||||
|
const md = '```yaml role=package.foo\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/Package role takes no type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a role without a type', () => {
|
||||||
|
const md = '```yaml role=part\n```';
|
||||||
|
expect(() => scanMarkdown(md, 'poker/poker.md')).toThrow(/requires a type/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-definition languages', () => {
|
it('ignores non-definition languages', () => {
|
||||||
@@ -52,23 +96,15 @@ describe('scanMarkdown', () => {
|
|||||||
expect(files).toHaveLength(0);
|
expect(files).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names a csv block with file= as csv', () => {
|
|
||||||
const md = '```csv file=parts/seats.csv\nseat,x\nstring,number\n0,40\n```';
|
|
||||||
const { files } = scanMarkdown(md, 'harbor/harbor.md');
|
|
||||||
expect(files[0]).toMatchObject({ name: 'harbor/parts/seats.csv', kind: 'csv' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks line numbers across multiple blocks', () => {
|
it('tracks line numbers across multiple blocks', () => {
|
||||||
const md = [
|
const md = [
|
||||||
'```yaml file=a.yaml',
|
'```yaml role=part.a',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'```yaml file=b.yaml',
|
'```yaml role=part.b',
|
||||||
'role: part',
|
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
const { fences } = scanMarkdown(md, 'harbor/harbor.md');
|
||||||
expect(fences.map((f) => f.startLine)).toEqual([1, 5]);
|
expect(fences.map((f) => f.startLine)).toEqual([1, 4]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Extract virtual definition files from markdown code blocks.
|
* Extract virtual definition files from markdown code blocks.
|
||||||
*
|
*
|
||||||
* Each fenced code block is a virtual definition file:
|
* Each fenced code block is a virtual definition file. A block is a
|
||||||
* - With a `file=` segment in its info string, named relative to the
|
* definition only when its info string declares a `role=` (or a `file=`):
|
||||||
* current markdown file: a yaml block with `file=parts/cargo.yaml`.
|
* - With `role=part.cargo`, named `part.cargo.yaml` (the `role.type.lang`
|
||||||
* - Without one, auto-named `./<hash>.yaml` from its content, so every yaml
|
* form), discoverable by the default include pattern (all yaml in the
|
||||||
* block is discoverable by the default include pattern (all yaml in the
|
* same and sub folders).
|
||||||
* same and sub folders). Identical blocks dedupe to the same hash.
|
* - With `file=parts/cargo.yaml`, named that path regardless of its role.
|
||||||
|
* - Without either, the block is not a definition and is ignored.
|
||||||
*
|
*
|
||||||
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
* Markdown is tokenized with `marked`; each `code` token is a candidate
|
||||||
* virtual file.
|
* virtual file.
|
||||||
*/
|
*/
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import { posix } from 'node:path';
|
import { posix } from 'node:path';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { BgmError, type DefFile } from './types.js';
|
import { BgmError, ROLES, roleToName, type DefFile, type Role, type RoleMeta } from './types.js';
|
||||||
|
|
||||||
/** The languages that count as definition files; others are ignored. */
|
/** The languages that count as definition files; others are ignored. */
|
||||||
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
const DEF_LANGS = new Set(['yaml', 'yml', 'json', 'toml']);
|
||||||
@@ -25,7 +25,7 @@ export interface Fence {
|
|||||||
startLine: number;
|
startLine: number;
|
||||||
/** Line number (1-based) of the closing fence. */
|
/** Line number (1-based) of the closing fence. */
|
||||||
endLine: number;
|
endLine: number;
|
||||||
/** The info string content (e.g. `yaml file=parts/cargo.yaml`). */
|
/** The info string content (e.g. `yaml role=part.cargo`). */
|
||||||
info: string;
|
info: string;
|
||||||
/** The code block's content (without the fences). */
|
/** The code block's content (without the fences). */
|
||||||
content: string;
|
content: string;
|
||||||
@@ -60,13 +60,17 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
|
|
||||||
fences.push({ startLine, endLine, info, content: token.text });
|
fences.push({ startLine, endLine, info, content: token.text });
|
||||||
|
|
||||||
const name = parseInfo(info);
|
const parsed = parseInfo(info, `${sourcePath}:${startLine}`);
|
||||||
if (name) {
|
if (parsed) {
|
||||||
files.push({
|
files.push({
|
||||||
name: posix.join(posix.dirname(sourcePath), name),
|
name: posix.join(posix.dirname(sourcePath), parsed.name),
|
||||||
text: token.text,
|
text: token.text,
|
||||||
source: `${sourcePath}:${startLine}-${endLine}`,
|
source: `${sourcePath}:${startLine}-${endLine}`,
|
||||||
kind: kindOf(name),
|
kind: parsed.kind,
|
||||||
|
role: parsed.role,
|
||||||
|
// Relative asset paths resolve against the markdown file's directory,
|
||||||
|
// not the virtual `role.type` name (which has no directory).
|
||||||
|
baseDir: posix.dirname(sourcePath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,17 +78,54 @@ export function scanMarkdown(text: string, sourcePath: string): MarkdownResult {
|
|||||||
return { fences, files };
|
return { fences, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ParsedInfo {
|
||||||
* Parse a fence's info string for a `file=` segment and derive the virtual
|
name: string;
|
||||||
* file name. Blocks without `file=` are auto-named from their content hash.
|
kind: DefFile['kind'];
|
||||||
*/
|
role?: RoleMeta;
|
||||||
function parseInfo(info: string): string | null {
|
}
|
||||||
const fileMatch = /file=(\S+)/.exec(info);
|
|
||||||
if (fileMatch) return fileMatch[1]!;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a fence's info string into a virtual file name and role metadata.
|
||||||
|
* A block is a definition when it has a `role=` or a `file=`. `file=`
|
||||||
|
* overrides the auto `role.type.lang` name.
|
||||||
|
*/
|
||||||
|
function parseInfo(info: string, source: string): ParsedInfo | null {
|
||||||
const lang = info.split(/\s+/)[0];
|
const lang = info.split(/\s+/)[0];
|
||||||
if (!lang || !DEF_LANGS.has(lang)) return null;
|
const fileMatch = /file=(\S+)/.exec(info);
|
||||||
return `./${hash(info)}.yaml`;
|
const role = parseRole(info, source);
|
||||||
|
|
||||||
|
if (fileMatch) {
|
||||||
|
const name = fileMatch[1]!;
|
||||||
|
return { name, kind: kindOf(name), role };
|
||||||
|
}
|
||||||
|
if (!role) return null;
|
||||||
|
if (!lang || !DEF_LANGS.has(lang)) {
|
||||||
|
throw new BgmError(`Definition block needs a definition language tag`, source);
|
||||||
|
}
|
||||||
|
return { name: roleToName(role, extOf(lang)), kind: kindOfLang(lang), role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a fence's info string for a `role=` segment into role metadata.
|
||||||
|
* `role=part.cargo` -> `{ role: 'part', type: 'cargo' }`;
|
||||||
|
* `role=surface.game#main` -> `{ role: 'surface', type: 'game', id: 'main' }`;
|
||||||
|
* `role=package` -> `{ role: 'package' }`. Returns `undefined` when absent.
|
||||||
|
*/
|
||||||
|
function parseRole(info: string, source: string): RoleMeta | undefined {
|
||||||
|
const match = /role=(\S+)/.exec(info);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const spec = match[1]!;
|
||||||
|
const [role, rest] = spec.split('.');
|
||||||
|
if (!role || !ROLES.has(role as Role)) {
|
||||||
|
throw new BgmError(`Invalid role "${spec}"`, source);
|
||||||
|
}
|
||||||
|
if (role === 'package') {
|
||||||
|
if (rest) throw new BgmError(`Package role takes no type or id`, source);
|
||||||
|
return { role: 'package' };
|
||||||
|
}
|
||||||
|
const [type, id] = (rest ?? '').split('#');
|
||||||
|
if (!type) throw new BgmError(`Role "${role}" requires a type`, source);
|
||||||
|
return { role: role as Role, type, id: id || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive the def file type from its name's extension. */
|
/** Derive the def file type from its name's extension. */
|
||||||
@@ -96,9 +137,21 @@ function kindOf(name: string): DefFile['kind'] {
|
|||||||
return 'yaml';
|
return 'yaml';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stable content hash for auto-named blocks. */
|
/** The file extension for a definition language tag. */
|
||||||
function hash(text: string): string {
|
function extOf(lang: string): string {
|
||||||
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 8);
|
return lang === 'yml' ? 'yml' : lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The def file kind for a definition language tag. */
|
||||||
|
function kindOfLang(lang: string): DefFile['kind'] {
|
||||||
|
switch (lang) {
|
||||||
|
case 'json':
|
||||||
|
return 'json';
|
||||||
|
case 'toml':
|
||||||
|
return 'toml';
|
||||||
|
default:
|
||||||
|
return 'yaml';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 1-based line number where `raw` starts within `text`. */
|
/** The 1-based line number where `raw` starts within `text`. */
|
||||||
@@ -110,9 +163,8 @@ function lineOf(text: string, raw: string): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
* Virtual files gathered from markdown code blocks, keyed by path-style name.
|
||||||
* Multiple blocks may share a name (e.g. several `file=parts/tokens.yaml`
|
* Multiple blocks may share a name (e.g. several `role=part.token` blocks);
|
||||||
* blocks); each is kept as a separate entry. Identical blocks dedupe to the
|
* each is kept as a separate entry.
|
||||||
* same hash name.
|
|
||||||
*/
|
*/
|
||||||
export type VirtualFiles = Map<string, DefFile[]>;
|
export type VirtualFiles = Map<string, DefFile[]>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseDefText } from './parse.js';
|
||||||
|
import type { DefFile } from './types.js';
|
||||||
|
|
||||||
|
function file(overrides: Partial<DefFile> = {}): DefFile {
|
||||||
|
return {
|
||||||
|
name: 'poker/cards.yaml',
|
||||||
|
text: 'id: 2s',
|
||||||
|
source: 'poker/poker.md:3-5',
|
||||||
|
kind: 'yaml',
|
||||||
|
baseDir: 'poker',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseDefText', () => {
|
||||||
|
it('merges role= metadata into the parsed object', () => {
|
||||||
|
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
|
||||||
|
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'poker-card', id: '2s' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges role= with type and id', () => {
|
||||||
|
const defs = parseDefText(
|
||||||
|
file({ text: 'id: main', role: { role: 'surface', type: 'game', id: 'main' } }),
|
||||||
|
);
|
||||||
|
expect(defs[0]!.value).toMatchObject({ role: 'surface', type: 'game', id: 'main' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges role= into every item of a list block', () => {
|
||||||
|
const defs = parseDefText(
|
||||||
|
file({
|
||||||
|
text: '- id: a\n- id: b',
|
||||||
|
role: { role: 'part', type: 'tile' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(defs.map((d) => d.value)).toEqual([
|
||||||
|
{ role: 'part', type: 'tile', id: 'a' },
|
||||||
|
{ role: 'part', type: 'tile', id: 'b' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the role metadata on the parsed def', () => {
|
||||||
|
const defs = parseDefText(file({ role: { role: 'part', type: 'poker-card' } }));
|
||||||
|
expect(defs[0]!.role).toEqual({ role: 'part', type: 'poker-card' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the base directory on the parsed def', () => {
|
||||||
|
const defs = parseDefText(file({ baseDir: 'poker/parts' }));
|
||||||
|
expect(defs[0]!.baseDir).toBe('poker/parts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on a role conflict with the content', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDefText(file({ text: 'role: surface', role: { role: 'part' } })),
|
||||||
|
).toThrow(/Conflicting role/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on a type conflict with the content', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDefText(file({ text: 'type: tile', role: { role: 'part', type: 'card' } })),
|
||||||
|
).toThrow(/Conflicting type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on an id conflict with the content', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseDefText(file({ text: 'id: 2s', role: { role: 'part', type: 'card', id: '3s' } })),
|
||||||
|
).toThrow(/Conflicting id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts matching role metadata and content', () => {
|
||||||
|
const defs = parseDefText(
|
||||||
|
file({ text: 'role: part\ntype: card', role: { role: 'part', type: 'card' } }),
|
||||||
|
);
|
||||||
|
expect(defs[0]!.value).toMatchObject({ role: 'part', type: 'card' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the object unchanged without role metadata', () => {
|
||||||
|
const defs = parseDefText(file({ text: 'role: part\ntype: card\nid: 2s' }));
|
||||||
|
expect(defs[0]!.value).toEqual({ role: 'part', type: 'card', id: '2s' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,18 +2,22 @@
|
|||||||
* Parse raw definition files (yaml/json/toml text) into JSON objects.
|
* Parse raw definition files (yaml/json/toml text) into JSON objects.
|
||||||
*
|
*
|
||||||
* A def file's document can be either a single JSON object (the root) or a
|
* A def file's document can be either a single JSON object (the root) or a
|
||||||
* list of objects; both are handled per docs/bgm-format.md §3. In list mode,
|
* list of objects; both are handled per docs/bgm/format.md §3. In list mode,
|
||||||
* each object is a separate definition.
|
* each object is a separate definition.
|
||||||
*/
|
*/
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { parse as parseToml } from 'smol-toml';
|
import { parse as parseToml } from 'smol-toml';
|
||||||
import { BgmError, type DefFile, type ParsedDef } from './types.js';
|
import { BgmError, roleFromName, type DefFile, type ParsedDef } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a def file's text into a list of definition objects.
|
* Parse a def file's text into a list of definition objects.
|
||||||
*
|
*
|
||||||
|
* A code block's `role=` (or a real file's `role.type` name) metadata is
|
||||||
|
* merged into every parsed object, erroring on a conflict with the same key
|
||||||
|
* in the content.
|
||||||
|
*
|
||||||
* @returns the parsed objects; the root object (index `-1`) or the list
|
* @returns the parsed objects; the root object (index `-1`) or the list
|
||||||
* items (index `0..n`)
|
* items (index `0..n`)
|
||||||
*/
|
*/
|
||||||
@@ -33,7 +37,8 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
|
|
||||||
const push = (value: unknown, index: number) => {
|
const push = (value: unknown, index: number) => {
|
||||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
out.push({ file: file.name, index, value: value as Record<string, unknown>, source: file.source });
|
const merged = mergeRole(file, value as Record<string, unknown>);
|
||||||
|
out.push({ file: file.name, index, value: merged, source: file.source, role: file.role, baseDir: file.baseDir });
|
||||||
} else if (value !== null) {
|
} else if (value !== null) {
|
||||||
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source);
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,27 @@ export function parseDefText(file: DefFile): ParsedDef[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge a definition's `role=`/filename metadata into a parsed object, erroring on conflict. */
|
||||||
|
function mergeRole(file: DefFile, value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const meta = file.role;
|
||||||
|
if (!meta) return value;
|
||||||
|
const out = { ...value };
|
||||||
|
const keys: Array<[key: string, fromMeta: string | undefined]> = [
|
||||||
|
['role', meta.role],
|
||||||
|
['type', meta.type],
|
||||||
|
['id', meta.id],
|
||||||
|
];
|
||||||
|
for (const [key, fromMeta] of keys) {
|
||||||
|
if (fromMeta === undefined) continue;
|
||||||
|
const fromContent = out[key];
|
||||||
|
if (fromContent !== undefined && fromContent !== fromMeta) {
|
||||||
|
throw new BgmError(`Conflicting ${key}: "${fromMeta}" in the name vs "${fromContent}" in content`, file.source);
|
||||||
|
}
|
||||||
|
out[key] = fromMeta;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseText(kind: DefFile['kind'], text: string): unknown {
|
function parseText(kind: DefFile['kind'], text: string): unknown {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'json':
|
case 'json':
|
||||||
@@ -92,6 +118,8 @@ export function readDefFiles(dir: string, root: string): DefFile[] {
|
|||||||
text: fs.readFileSync(abs, 'utf8'),
|
text: fs.readFileSync(abs, 'utf8'),
|
||||||
source: abs,
|
source: abs,
|
||||||
kind,
|
kind,
|
||||||
|
role: roleFromName(entry.name),
|
||||||
|
baseDir: root && relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
*
|
*
|
||||||
* These validate the raw definition objects (after `$variants` expansion)
|
* These validate the raw definition objects (after `$variants` expansion)
|
||||||
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
|
* and produce the typed `Part` / `Surface` / `Setup` / `PackageDef` values.
|
||||||
* See docs/bgm-format.md for the format's concrete behavior.
|
* See docs/bgm/format.md for the format's concrete behavior.
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import type { PackageDef, Part, Setup, Surface } from './types.js';
|
import type { Dialog, PackageDef, Part, Setup, Surface } from "./types.js";
|
||||||
|
|
||||||
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
const crop = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
||||||
const size = z.tuple([z.number(), z.number(), z.number()]);
|
const size = z.tuple([z.number(), z.number(), z.number()]);
|
||||||
@@ -15,8 +15,11 @@ const surfaceSize = z.tuple([z.number(), z.number()]);
|
|||||||
const stacking = z.object({
|
const stacking = z.object({
|
||||||
curve: z.string().optional(),
|
curve: z.string().optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
align: z.enum(['start', 'end', 'center']).optional(),
|
align: z.enum(["start", "end", "center"]).optional(),
|
||||||
steps: z.number().optional(),
|
steps: z.number().optional(),
|
||||||
|
tilt: z.number().optional(),
|
||||||
|
zStart: z.number().optional(),
|
||||||
|
zEnd: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = z.object({
|
const route = z.object({
|
||||||
@@ -41,7 +44,7 @@ const partSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const surfaceMount = z.object({
|
const surfaceMount = z.object({
|
||||||
kind: z.enum(['table', 'hud', 'child']),
|
kind: z.enum(["table", "hud", "child"]),
|
||||||
x: z.number().optional(),
|
x: z.number().optional(),
|
||||||
y: z.number().optional(),
|
y: z.number().optional(),
|
||||||
rotation: z.number().optional(),
|
rotation: z.number().optional(),
|
||||||
@@ -57,15 +60,43 @@ const surfaceSchema = z.object({
|
|||||||
layout: z.array(route),
|
layout: z.array(route),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setupValue = z.union([z.string(), z.array(z.string())]);
|
||||||
|
|
||||||
|
const setupPlacement = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
parts: setupValue,
|
||||||
|
facing: z.enum(["face", "back", "standing"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const interaction = z.object({
|
||||||
|
dialog: z.string().min(1),
|
||||||
|
on: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const setupSchema = z.object({
|
const setupSchema = z.object({
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
surfaces: z.array(z.string()).optional(),
|
surfaces: z.array(z.string()).optional(),
|
||||||
setup: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
|
setup: z.array(setupPlacement),
|
||||||
|
interactions: z.array(interaction).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogAction = z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
command: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogSchema = z.object({
|
||||||
|
type: z.string().min(1),
|
||||||
|
id: z.string().min(1),
|
||||||
|
title: z.string().optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
|
actions: z.array(dialogAction).optional(),
|
||||||
|
widget: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const packageSchema = z.object({
|
const packageSchema = z.object({
|
||||||
role: z.literal('package'),
|
role: z.literal("package"),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
designer: z.string().optional(),
|
designer: z.string().optional(),
|
||||||
@@ -91,6 +122,11 @@ export function validateSetup(value: Record<string, unknown>): Setup {
|
|||||||
return setupSchema.parse(value) as unknown as Setup;
|
return setupSchema.parse(value) as unknown as Setup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate a raw dialog definition. */
|
||||||
|
export function validateDialog(value: Record<string, unknown>): Dialog {
|
||||||
|
return dialogSchema.parse(value) as unknown as Dialog;
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate a raw package definition. */
|
/** Validate a raw package definition. */
|
||||||
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
export function validatePackage(value: Record<string, unknown>): PackageDef {
|
||||||
return packageSchema.parse(value) as unknown as PackageDef;
|
return packageSchema.parse(value) as unknown as PackageDef;
|
||||||
|
|||||||
+157
-16
@@ -5,11 +5,11 @@
|
|||||||
* discovered as JSON objects from yaml/json/toml files and from markdown
|
* discovered as JSON objects from yaml/json/toml files and from markdown
|
||||||
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
|
* code blocks, then assembled into a `Package` (see `collect.ts` / `emit.ts`).
|
||||||
*
|
*
|
||||||
* The concrete behavior of the format is described in `docs/bgm-format.md`.
|
* The concrete behavior of the format is described in `docs/bgm/format.md`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Part value types. */
|
/** Part value types. */
|
||||||
export type PartValueType = 'image' | 'crop' | 'size' | 'sprite';
|
export type PartValueType = "image" | "crop" | "size" | "sprite";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A crop tuple `[col, row, cols, rows]`. Divides the image into a
|
* A crop tuple `[col, row, cols, rows]`. Divides the image into a
|
||||||
@@ -84,6 +84,8 @@ export interface Candidate {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
rotation?: number;
|
rotation?: number;
|
||||||
|
/** Stacking strategy; overrides the route's when set, else inherits it. */
|
||||||
|
stacking?: Stacking;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
@@ -104,13 +106,27 @@ export interface Stacking {
|
|||||||
/** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
|
/** How many parts to display. `0` shows all, `3` the first 3, `-3` the last 3. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** `start`, `end`, or `center` of the curve. */
|
/** `start`, `end`, or `center` of the curve. */
|
||||||
align?: 'start' | 'end' | 'center';
|
align?: "start" | "end" | "center";
|
||||||
/** Maximum parts per curve length unit; defaults to `1`. */
|
/** Maximum parts per curve length unit; defaults to `1`. */
|
||||||
steps?: number;
|
steps?: number;
|
||||||
|
/**
|
||||||
|
* Rotation in degrees per shown part about the card's local Y (long) axis.
|
||||||
|
* Each part tilts `tilt` more than the previous, fanning the stack so its
|
||||||
|
* edges stay visible. Works with or without a `curve`.
|
||||||
|
*/
|
||||||
|
tilt?: number;
|
||||||
|
/**
|
||||||
|
* Height (surface-normal) in mm at the start of the `curve`. The stack
|
||||||
|
* ramps linearly to `zEnd` across its span, lifting it in 3D. Requires a
|
||||||
|
* `curve`.
|
||||||
|
*/
|
||||||
|
zStart?: number;
|
||||||
|
/** Height (surface-normal) in mm at the end of the `curve`. */
|
||||||
|
zEnd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a surface is mounted. `kind` selects the mount type. */
|
/** How a surface is mounted. `kind` selects the mount type. */
|
||||||
export type SurfaceMountKind = 'table' | 'hud' | 'child';
|
export type SurfaceMountKind = "table" | "hud" | "child";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a surface is mounted. Always an object, anchored by `x`/`y`/`rotation`
|
* How a surface is mounted. Always an object, anchored by `x`/`y`/`rotation`
|
||||||
@@ -141,16 +157,118 @@ export interface Surface {
|
|||||||
|
|
||||||
export type SetupValue = string | string[];
|
export type SetupValue = string | string[];
|
||||||
|
|
||||||
/** Seeds the state store: the enabled surfaces and a map from path to parts. */
|
/**
|
||||||
|
* How a part is oriented on the board. `face` lays it flat front-up, `back`
|
||||||
|
* flips it over front-down, and `standing` stands it on its bottom edge.
|
||||||
|
*/
|
||||||
|
export type Facing = "face" | "back" | "standing";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One setup placement: move `parts` to `path`. Entries are applied in order,
|
||||||
|
* so a part listed in a later entry ends up on that entry's path.
|
||||||
|
*/
|
||||||
|
export interface SetupPlacement {
|
||||||
|
/** The path key to place the parts on. */
|
||||||
|
path: string;
|
||||||
|
/** Parts to place: a part id, a bare type (expands to all of that type), or a list of either. */
|
||||||
|
parts: SetupValue;
|
||||||
|
/** Initial facing for the placed parts; defaults to `face`. */
|
||||||
|
facing?: Facing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds the state store: the enabled surfaces and an ordered list of placements. */
|
||||||
export interface Setup {
|
export interface Setup {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
/** Surfaces (`type#id` refs) enabled at the start; omitted = all enabled. */
|
||||||
surfaces?: string[];
|
surfaces?: string[];
|
||||||
setup: Record<string, SetupValue>;
|
/** Ordered placements; each moves its parts to its path. */
|
||||||
|
setup: SetupPlacement[];
|
||||||
|
/**
|
||||||
|
* Interaction affordances: which dialogs are the tool for which open
|
||||||
|
* interactions on which paths. Declares the interaction surface, not the
|
||||||
|
* legality of the resulting command (rules gate that, later).
|
||||||
|
*/
|
||||||
|
interactions?: Interaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Role = 'package' | 'part' | 'surface' | 'setup';
|
/**
|
||||||
|
* One interaction affordance: a `role: dialog` ref (`type#id`) and the paths
|
||||||
|
* it applies to. `on` omitted means the dialog applies to any path.
|
||||||
|
*/
|
||||||
|
export interface Interaction {
|
||||||
|
/** A `role: dialog` ref (`type#id`). */
|
||||||
|
dialog: string;
|
||||||
|
/** Paths this interaction applies to; omitted = any path. */
|
||||||
|
on?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A dialog's action button: a label and the command it issues. */
|
||||||
|
export interface DialogAction {
|
||||||
|
label: string;
|
||||||
|
/** The command the button issues (e.g. a `move`); the rule seam gates it. */
|
||||||
|
command: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `role: dialog` definition: declarative content shown in the layer-3 shell.
|
||||||
|
* Opening/closing it never issues a command or mutates state; its action
|
||||||
|
* buttons issue commands. `widget` selects the content type (e.g. `stack`).
|
||||||
|
*/
|
||||||
|
export interface Dialog {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
/** Action buttons; each issues a command. */
|
||||||
|
actions?: DialogAction[];
|
||||||
|
/** Content widget type, e.g. `stack` for a stack-of-parts view. */
|
||||||
|
widget?: string;
|
||||||
|
/** Extra fields from the source definition, kept for forwards compatibility. */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Role = "package" | "part" | "surface" | "setup" | "dialog";
|
||||||
|
|
||||||
|
/** The five definition roles. */
|
||||||
|
export const ROLES: ReadonlySet<Role> = new Set([
|
||||||
|
"package",
|
||||||
|
"part",
|
||||||
|
"surface",
|
||||||
|
"setup",
|
||||||
|
"dialog",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role metadata declared on a code block's info string (`role=part.cargo`) or
|
||||||
|
* a real file's name (`part.cargo.yaml`). `type` is required for all roles
|
||||||
|
* except `package`; `id` is optional — anything not given comes from the
|
||||||
|
* content or from `$variants` rows.
|
||||||
|
*/
|
||||||
|
export interface RoleMeta {
|
||||||
|
role: Role;
|
||||||
|
type?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical file name for a role, e.g. `part.cargo.yaml`. */
|
||||||
|
export function roleToName(role: RoleMeta, ext = "yaml"): string {
|
||||||
|
if (role.role === "package") return `package.${ext}`;
|
||||||
|
return `${role.role}.${role.type}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `role.type.lang` file name into role metadata, or `undefined` when
|
||||||
|
* the name is not a definition (`package.yaml`, `part.cargo.yaml`, ...).
|
||||||
|
*/
|
||||||
|
export function roleFromName(name: string): RoleMeta | undefined {
|
||||||
|
if (/^package\.(ya?ml|json|toml)$/i.test(name)) return { role: "package" };
|
||||||
|
const m = /^(\w+)\.(.+)\.(ya?ml|json|toml)$/i.exec(name);
|
||||||
|
if (m && ROLES.has(m[1] as Role) && m[1] !== "package") {
|
||||||
|
return { role: m[1] as Role, type: m[2] };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A raw definition object as written by the author. Definitions can be the
|
* A raw definition object as written by the author. Definitions can be the
|
||||||
@@ -161,25 +279,29 @@ export interface RawDef {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The four definition roles. */
|
/** The five definition roles. */
|
||||||
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef;
|
export type RoleDef = PackageDef | PartDef | SurfaceDef | SetupDef | DialogDef;
|
||||||
|
|
||||||
export interface PackageDef extends PackageMeta {
|
export interface PackageDef extends PackageMeta {
|
||||||
role: 'package';
|
role: "package";
|
||||||
/** Git-style path patterns of the defs that make up the package. */
|
/** Git-style path patterns of the defs that make up the package. */
|
||||||
include?: string[];
|
include?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartDef extends Part {
|
export interface PartDef extends Part {
|
||||||
role: 'part';
|
role: "part";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurfaceDef extends Surface {
|
export interface SurfaceDef extends Surface {
|
||||||
role: 'surface';
|
role: "surface";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupDef extends Setup {
|
export interface SetupDef extends Setup {
|
||||||
role: 'setup';
|
role: "setup";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogDef extends Dialog {
|
||||||
|
role: "dialog";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A virtual definition file: a real file or a markdown code block. */
|
/** A virtual definition file: a real file or a markdown code block. */
|
||||||
@@ -191,7 +313,15 @@ export interface DefFile {
|
|||||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||||
source: string;
|
source: string;
|
||||||
/** File type derived from the name's extension. */
|
/** File type derived from the name's extension. */
|
||||||
kind: 'yaml' | 'json' | 'toml' | 'markdown' | 'csv';
|
kind: "yaml" | "json" | "toml" | "markdown" | "csv";
|
||||||
|
/** Role declared on a code block's info string or a real file's name. */
|
||||||
|
role?: RoleMeta;
|
||||||
|
/**
|
||||||
|
* Directory (path-style, relative to the games root) that relative asset
|
||||||
|
* paths resolve against. For a code block, the markdown file's directory;
|
||||||
|
* for a real file, its own directory.
|
||||||
|
*/
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single parsed definition (one JSON object from a def file). */
|
/** A single parsed definition (one JSON object from a def file). */
|
||||||
@@ -203,6 +333,10 @@ export interface ParsedDef {
|
|||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
/** Source location for error messages (real path or `file.md:12-19`). */
|
/** Source location for error messages (real path or `file.md:12-19`). */
|
||||||
source: string;
|
source: string;
|
||||||
|
/** Role declared on the code block's info string or a real file's name. */
|
||||||
|
role?: RoleMeta;
|
||||||
|
/** Directory relative asset paths resolve against (see `DefFile.baseDir`). */
|
||||||
|
baseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,6 +351,8 @@ export interface Package {
|
|||||||
surfaces: Map<string, Surface>;
|
surfaces: Map<string, Surface>;
|
||||||
/** All setups by `type#id`. */
|
/** All setups by `type#id`. */
|
||||||
setups: Map<string, Setup>;
|
setups: Map<string, Setup>;
|
||||||
|
/** All dialogs by `type#id`. */
|
||||||
|
dialogs: Map<string, Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,12 +368,17 @@ export interface SerializedPackage {
|
|||||||
surfaces: Record<string, Surface>;
|
surfaces: Record<string, Surface>;
|
||||||
/** All setups by `type#id`. */
|
/** All setups by `type#id`. */
|
||||||
setups: Record<string, Setup>;
|
setups: Record<string, Setup>;
|
||||||
|
/** All dialogs by `type#id`. */
|
||||||
|
dialogs: Record<string, Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Errors during loading, carrying the source location when available. */
|
/** Errors during loading, carrying the source location when available. */
|
||||||
export class BgmError extends Error {
|
export class BgmError extends Error {
|
||||||
constructor(message: string, readonly location?: string) {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly location?: string,
|
||||||
|
) {
|
||||||
super(location ? `${location}: ${message}` : message);
|
super(location ? `${location}: ${message}` : message);
|
||||||
this.name = 'BgmError';
|
this.name = "BgmError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('parseCsvData', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('expandVariants', () => {
|
describe('expandVariants', () => {
|
||||||
it('parses inline CSV when the value contains a newline', () => {
|
it('parses inline CSV when the first line does not end in .csv', () => {
|
||||||
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
const rows = expandVariants('a,b\nstring,number\nx,1', 'pkg/def.yaml', new Map(), 'src');
|
||||||
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
expect(rows).toEqual([{ a: 'x', b: 1 }]);
|
||||||
});
|
});
|
||||||
@@ -61,15 +61,30 @@ describe('expandVariants', () => {
|
|||||||
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
expect(rows).toEqual([{ seat: 0 }, { seat: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('concatenates rows from an array of csv paths', () => {
|
||||||
|
const defs = new Map<string, DefFile[]>([
|
||||||
|
['pkg/parts/a.csv', [defFile('pkg/parts/a.csv', 'id\nstring\nred')]],
|
||||||
|
['pkg/parts/b.csv', [defFile('pkg/parts/b.csv', 'id\nstring\nblack')]],
|
||||||
|
]);
|
||||||
|
const rows = expandVariants(['./a.csv', './b.csv'], 'pkg/parts/board.yaml', defs, 'src');
|
||||||
|
expect(rows).toEqual([{ id: 'red' }, { id: 'black' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when the referenced csv is missing', () => {
|
it('throws when the referenced csv is missing', () => {
|
||||||
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
expect(() => expandVariants('./nope.csv', 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
/CSV not found/,
|
/CSV not found/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when $variants is not a string', () => {
|
it('throws when $variants is not a string or string array', () => {
|
||||||
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
expect(() => expandVariants(42, 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
/must be a path or inline CSV/,
|
/must be a path or inline CSV string, or an array of them/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an empty $variants array', () => {
|
||||||
|
expect(() => expandVariants([], 'pkg/def.yaml', new Map(), 'src')).toThrow(
|
||||||
|
/must not be empty/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
* The `$variants` directive: parse a CSV into a typed object array and
|
* The `$variants` directive: parse a CSV into a typed object array and
|
||||||
* extend the original object with each row.
|
* extend the original object with each row.
|
||||||
*
|
*
|
||||||
* Per docs/bgm-format.md §1:
|
* Per docs/bgm/format.md §1:
|
||||||
* - The CSV's first row is the header, the second row is the type declaration
|
* - The CSV's first row is the header, the second row is the type declaration
|
||||||
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
|
* (`string`, `number`, `string[]`, `[number;number;number;number]`, ...),
|
||||||
* the remaining rows are data.
|
* the remaining rows are data.
|
||||||
* - Rows are validated against a schema derived from the type row.
|
* - Rows are validated against a schema derived from the type row.
|
||||||
* - A cell for an array/tuple type uses `;` as the element separator
|
* - A cell for an array/tuple type uses `;` as the element separator
|
||||||
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
* (`[0;0;5;2]`), because `,` is the CSV delimiter.
|
||||||
* - `$variants` can be a file/URL path *or* an inline CSV string: a value
|
* - `$variants` can be a single source or an array of them. A source is a
|
||||||
* containing a newline is inline CSV, otherwise it is a path.
|
* file/URL path if its first line ends in `.csv`, otherwise inline CSV.
|
||||||
*
|
*
|
||||||
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
* Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly
|
||||||
* this header/schema/data layout and validates each row against a schema
|
* this header/schema/data layout and validates each row against a schema
|
||||||
@@ -74,7 +74,8 @@ export function parseCsvByName(
|
|||||||
/**
|
/**
|
||||||
* Expand a `$variants` value into rows.
|
* Expand a `$variants` value into rows.
|
||||||
*
|
*
|
||||||
* @param value the `$variants` value: a path or inline CSV
|
* @param value the `$variants` value: a path or inline CSV string, or an
|
||||||
|
* array of them
|
||||||
* @param baseName the path-style name of the referencing def file; a path
|
* @param baseName the path-style name of the referencing def file; a path
|
||||||
* value resolves relative to its directory
|
* value resolves relative to its directory
|
||||||
* @param defs the virtual def map, for resolving the path
|
* @param defs the virtual def map, for resolving the path
|
||||||
@@ -86,14 +87,41 @@ export function expandVariants(
|
|||||||
defs: Map<string, DefFile[]>,
|
defs: Map<string, DefFile[]>,
|
||||||
source: string,
|
source: string,
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
if (typeof value !== 'string') {
|
const sources = Array.isArray(value) ? value : [value];
|
||||||
throw new BgmError('`$variants` must be a path or inline CSV string', source);
|
if (sources.length === 0) {
|
||||||
|
throw new BgmError('`$variants` array must not be empty', source);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.includes('\n')) {
|
const rows: Record<string, unknown>[] = [];
|
||||||
return parseCsvData(value, source).rows;
|
for (const item of sources) {
|
||||||
|
if (typeof item !== 'string') {
|
||||||
|
throw new BgmError(
|
||||||
|
'`$variants` must be a path or inline CSV string, or an array of them',
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(...expandVariantsOne(item, baseName, defs, source));
|
||||||
}
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
const name = path.posix.join(path.posix.dirname(baseName), value);
|
/**
|
||||||
return parseCsvByName(name, defs, source).rows;
|
* Expand a single `$variants` source: a path or inline CSV.
|
||||||
|
*
|
||||||
|
* A source is a path when its first line ends in `.csv`; otherwise it is
|
||||||
|
* inline CSV. This keeps the two forms self-documenting and applies the same
|
||||||
|
* rule to single values and array elements alike.
|
||||||
|
*/
|
||||||
|
function expandVariantsOne(
|
||||||
|
value: string,
|
||||||
|
baseName: string,
|
||||||
|
defs: Map<string, DefFile[]>,
|
||||||
|
source: string,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const firstLine = value.split('\n', 1)[0] ?? value;
|
||||||
|
if (/[.]csv$/i.test(firstLine)) {
|
||||||
|
const name = path.posix.join(path.posix.dirname(baseName), value);
|
||||||
|
return parseCsvByName(name, defs, source).rows;
|
||||||
|
}
|
||||||
|
return parseCsvData(value, source).rows;
|
||||||
}
|
}
|
||||||
+38
-12
@@ -15,16 +15,17 @@
|
|||||||
* be mistaken for real installed packages. Editing a game definition
|
* be mistaken for real installed packages. Editing a game definition
|
||||||
* hot-reloads the app via `addWatchFile`.
|
* hot-reloads the app via `addWatchFile`.
|
||||||
*/
|
*/
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import type { Plugin } from 'vite';
|
import { normalizePath, type ModuleNode, type Plugin } from "vite";
|
||||||
import { collectPackages, loadDefs } from './collect.js';
|
import { collectPackages, loadDefs } from "./collect.js";
|
||||||
import type { Package, SerializedPackage } from './types.js';
|
import { readDefFiles } from "./parse.js";
|
||||||
|
import type { Package, SerializedPackage } from "./types.js";
|
||||||
|
|
||||||
const VIRTUAL_PREFIX = '\0bgm:';
|
const VIRTUAL_PREFIX = "\0bgm:";
|
||||||
/** Public specifier for the module that lists every package. */
|
/** Public specifier for the module that lists every package. */
|
||||||
const PACKAGES = 'virtual:bgm/packages';
|
const PACKAGES = "virtual:bgm/packages";
|
||||||
/** Public specifier prefix for a single package module. */
|
/** Public specifier prefix for a single package module. */
|
||||||
const PACKAGE = 'virtual:bgm/package/';
|
const PACKAGE = "virtual:bgm/package/";
|
||||||
|
|
||||||
export interface BgmOptions {
|
export interface BgmOptions {
|
||||||
/** Absolute path to the games root (e.g. `<repo>/games`). */
|
/** Absolute path to the games root (e.g. `<repo>/games`). */
|
||||||
@@ -32,26 +33,50 @@ export interface BgmOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bgm(options: BgmOptions): Plugin {
|
export function bgm(options: BgmOptions): Plugin {
|
||||||
const root = options.root;
|
// Vite normalizes `ctx.file` to POSIX separators before HMR hooks run, but
|
||||||
|
// `fileURLToPath` retains backslashes on Windows. Normalize `root` to the
|
||||||
|
// same form so `ctx.file.startsWith(root)` matches regardless of platform.
|
||||||
|
const root = normalizePath(options.root);
|
||||||
|
|
||||||
const collect = (): Package[] => {
|
const collect = (): Package[] => {
|
||||||
const defMap = loadDefs('', root);
|
const defMap = loadDefs("", root);
|
||||||
return collectPackages(defMap, root);
|
return collectPackages(defMap, root);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'bgm',
|
name: "bgm",
|
||||||
buildStart() {
|
buildStart() {
|
||||||
// Watch every source file so edits trigger a reload/re-collect.
|
// Watch every real definition source under the games root so edits
|
||||||
const defMap = loadDefs('', root);
|
// trigger a re-collect. `defMap.files` only holds the virtual code-block
|
||||||
|
// files plus real non-markdown files; the markdown files themselves are
|
||||||
|
// consumed for their code blocks and never appear there, so watch the
|
||||||
|
// real files on disk too (markdown and anything else the loader reads).
|
||||||
|
const defMap = loadDefs("", root);
|
||||||
for (const name of defMap.files.keys()) {
|
for (const name of defMap.files.keys()) {
|
||||||
this.addWatchFile(path.join(root, name));
|
this.addWatchFile(path.join(root, name));
|
||||||
}
|
}
|
||||||
|
for (const file of readDefFiles(root, "")) {
|
||||||
|
this.addWatchFile(file.source);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resolveId(id) {
|
resolveId(id) {
|
||||||
if (id === PACKAGES) return VIRTUAL_PREFIX + PACKAGES;
|
if (id === PACKAGES) return VIRTUAL_PREFIX + PACKAGES;
|
||||||
if (id.startsWith(PACKAGE)) return VIRTUAL_PREFIX + id;
|
if (id.startsWith(PACKAGE)) return VIRTUAL_PREFIX + id;
|
||||||
},
|
},
|
||||||
|
handleHotUpdate(ctx) {
|
||||||
|
// Re-collect and invalidate the virtual modules when a game definition
|
||||||
|
// changes, so edits hot-reload instead of requiring a manual refresh.
|
||||||
|
if (!ctx.file.startsWith(root)) return;
|
||||||
|
const invalidated: ModuleNode[] = [];
|
||||||
|
const mod = ctx.server.moduleGraph.getModuleById(
|
||||||
|
VIRTUAL_PREFIX + PACKAGES,
|
||||||
|
);
|
||||||
|
if (mod) {
|
||||||
|
ctx.server.moduleGraph.invalidateModule(mod);
|
||||||
|
invalidated.push(mod);
|
||||||
|
}
|
||||||
|
return invalidated;
|
||||||
|
},
|
||||||
load(id) {
|
load(id) {
|
||||||
if (!id.startsWith(VIRTUAL_PREFIX)) return;
|
if (!id.startsWith(VIRTUAL_PREFIX)) return;
|
||||||
const virtual = id.slice(VIRTUAL_PREFIX.length);
|
const virtual = id.slice(VIRTUAL_PREFIX.length);
|
||||||
@@ -79,5 +104,6 @@ function toJson(pkg: Package): SerializedPackage {
|
|||||||
parts: Object.fromEntries(pkg.parts),
|
parts: Object.fromEntries(pkg.parts),
|
||||||
surfaces: Object.fromEntries(pkg.surfaces),
|
surfaces: Object.fromEntries(pkg.surfaces),
|
||||||
setups: Object.fromEntries(pkg.setups),
|
setups: Object.fromEntries(pkg.setups),
|
||||||
|
dialogs: Object.fromEntries(pkg.dialogs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
import { bgm } from './vite.js';
|
import { bgm } from "./vite.js";
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__');
|
const fixtureRoot = path.resolve(
|
||||||
const gamesRoot = path.join(fixtureRoot, 'harbor');
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"__fixtures__",
|
||||||
|
);
|
||||||
|
const gamesRoot = path.join(fixtureRoot, "harbor");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or
|
* Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or
|
||||||
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
|
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
|
||||||
* hook to a callable for direct invocation in tests.
|
* hook to a callable for direct invocation in tests.
|
||||||
*/
|
*/
|
||||||
type Callable<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : never;
|
type Callable<T> = T extends (...args: infer A) => infer R
|
||||||
|
? (...args: A) => R
|
||||||
|
: never;
|
||||||
|
|
||||||
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(id, undefined, {
|
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
|
||||||
isEntry: false,
|
id,
|
||||||
});
|
undefined,
|
||||||
|
{
|
||||||
|
isEntry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||||
@@ -25,70 +34,94 @@ function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
|||||||
|
|
||||||
/** Parse the JSON payload out of an emitted `export default <json>` module. */
|
/** Parse the JSON payload out of an emitted `export default <json>` module. */
|
||||||
function parseModule(code: unknown): unknown {
|
function parseModule(code: unknown): unknown {
|
||||||
expect(String(code).startsWith('export default ')).toBe(true);
|
expect(String(code).startsWith("export default ")).toBe(true);
|
||||||
return JSON.parse(String(code).slice('export default '.length));
|
return JSON.parse(String(code).slice("export default ".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('bgm vite plugin', () => {
|
describe("bgm vite plugin", () => {
|
||||||
it('resolves bgm imports to the virtual module', () => {
|
it("resolves bgm imports to the virtual module", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
expect(resolveId(plugin, 'virtual:bgm/packages')).toBe('\0bgm:virtual:bgm/packages');
|
expect(resolveId(plugin, "virtual:bgm/packages")).toBe(
|
||||||
expect(resolveId(plugin, 'virtual:bgm/package/harbor')).toBe('\0bgm:virtual:bgm/package/harbor');
|
"\0bgm:virtual:bgm/packages",
|
||||||
expect(resolveId(plugin, 'virtual:bgm/package/nope')).toBe('\0bgm:virtual:bgm/package/nope');
|
);
|
||||||
expect(resolveId(plugin, 'other')).toBeUndefined();
|
expect(resolveId(plugin, "virtual:bgm/package/harbor")).toBe(
|
||||||
|
"\0bgm:virtual:bgm/package/harbor",
|
||||||
|
);
|
||||||
|
expect(resolveId(plugin, "virtual:bgm/package/nope")).toBe(
|
||||||
|
"\0bgm:virtual:bgm/package/nope",
|
||||||
|
);
|
||||||
|
expect(resolveId(plugin, "other")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads every package as a JSON module', () => {
|
it("loads every package as a JSON module", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const packages = parseModule(load(plugin, '\0bgm:virtual:bgm/packages')) as Array<Record<string, any>>;
|
const packages = parseModule(
|
||||||
|
load(plugin, "\0bgm:virtual:bgm/packages"),
|
||||||
|
) as Array<Record<string, any>>;
|
||||||
expect(packages).toHaveLength(1);
|
expect(packages).toHaveLength(1);
|
||||||
expect(packages[0].meta.id).toBe('harbor');
|
expect(packages[0].meta.id).toBe("harbor");
|
||||||
expect(packages[0].parts).toHaveProperty('token#wood');
|
expect(packages[0].parts).toHaveProperty("token#wood");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a package as a JSON module', () => {
|
it("loads a package as a JSON module", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
|
const pkg = parseModule(
|
||||||
expect(pkg.meta.id).toBe('harbor');
|
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
|
||||||
expect(pkg.parts).toHaveProperty('token#wood');
|
) as Record<string, any>;
|
||||||
expect(pkg.surfaces).toHaveProperty('board#harbor');
|
expect(pkg.meta.id).toBe("harbor");
|
||||||
|
expect(pkg.parts).toHaveProperty("token#wood");
|
||||||
|
expect(pkg.surfaces).toHaveProperty("board#harbor");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes maps as plain objects, not Map instances', () => {
|
it("serializes maps as plain objects, not Map instances", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
|
const pkg = parseModule(
|
||||||
|
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
|
||||||
|
) as Record<string, any>;
|
||||||
|
|
||||||
// The emitted shape must be JSON-serializable: plain objects keyed by
|
// The emitted shape must be JSON-serializable: plain objects keyed by
|
||||||
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`).
|
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`).
|
||||||
for (const key of ['parts', 'surfaces', 'setups'] as const) {
|
for (const key of ["parts", "surfaces", "setups", "dialogs"] as const) {
|
||||||
expect(pkg[key]).not.toBeInstanceOf(Map);
|
expect(pkg[key]).not.toBeInstanceOf(Map);
|
||||||
expect(pkg[key]).toEqual(expect.any(Object));
|
expect(pkg[key]).toEqual(expect.any(Object));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consumers read the collections with Object.values / Object.keys.
|
// Consumers read the collections with Object.values / Object.keys.
|
||||||
expect(Object.values(pkg.parts).map((p: any) => p.id).sort()).toEqual(['grain', 'wood']);
|
expect(
|
||||||
expect(Object.keys(pkg.surfaces)).toEqual(['board#harbor', 'board#player']);
|
Object.values(pkg.parts)
|
||||||
expect(Object.keys(pkg.setups)).toEqual(['game#main']);
|
.map((p: any) => p.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["grain", "wood"]);
|
||||||
|
expect(Object.keys(pkg.surfaces)).toEqual(["board#harbor", "board#player"]);
|
||||||
|
expect(Object.keys(pkg.setups)).toEqual(["game#main"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('errors on an unknown package', () => {
|
it("errors on an unknown package", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
expect(() => load(plugin, '\0bgm:virtual:bgm/package/unknown')).toThrow(/not found/);
|
expect(() => load(plugin, "\0bgm:virtual:bgm/package/unknown")).toThrow(
|
||||||
|
/not found/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('watches every source file for reloads', () => {
|
it("watches every source file for reloads", () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const watched: string[] = [];
|
const watched: string[] = [];
|
||||||
const context = { addWatchFile: (file: string) => watched.push(file) };
|
const context = { addWatchFile: (file: string) => watched.push(file) };
|
||||||
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
|
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
|
||||||
// the options, so pass a placeholder.
|
// the options, so pass a placeholder.
|
||||||
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never);
|
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(
|
||||||
|
context,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
// Every def file (real + virtual code blocks) is watched so edits
|
// Every def file (real + virtual code blocks) is watched so edits
|
||||||
// trigger a re-collect.
|
// trigger a re-collect. The real markdown source must be watched too:
|
||||||
|
// `defMap.files` only lists virtual code-block files, so without watching
|
||||||
|
// the on-disk `.md` file the dev server would never notice an edit.
|
||||||
expect(watched.length).toBeGreaterThan(0);
|
expect(watched.length).toBeGreaterThan(0);
|
||||||
expect(watched.some((f) => f.endsWith('.yaml'))).toBe(true);
|
expect(watched.some((f) => f.endsWith(".yaml"))).toBe(true);
|
||||||
expect(watched.some((f) => f.endsWith('.csv'))).toBe(true);
|
expect(watched.some((f) => f.endsWith(".csv"))).toBe(true);
|
||||||
|
expect(watched.some((f) => f.endsWith(".md"))).toBe(true);
|
||||||
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
|
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@tts/engine",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "echo \"no lint configured\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Engine, type Message } from './engine.js';
|
||||||
|
|
||||||
|
describe('Engine', () => {
|
||||||
|
it('dispatches a message to handlers registered for its type', () => {
|
||||||
|
const engine = new Engine();
|
||||||
|
const seen: Message[] = [];
|
||||||
|
engine.on('move', (m) => seen.push(m));
|
||||||
|
engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
|
||||||
|
engine.tick();
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]!.data).toEqual({ part: 'a', to: '/grid/5/5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a trigger reacts to a message and emits on the next tick', () => {
|
||||||
|
const engine = new Engine();
|
||||||
|
const seen: Message[] = [];
|
||||||
|
engine.on('move', (m) => seen.push(m));
|
||||||
|
engine.registerTrigger({
|
||||||
|
type: 'tap',
|
||||||
|
id: 'draw',
|
||||||
|
match: { part: 'carcassonne:tile#a' },
|
||||||
|
emit: [{ type: 'move', data: { part: 'carcassonne:tile#a', to: '/grid/5/5' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.enqueue({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } });
|
||||||
|
engine.tick(); // tap processed; move emitted to the next tick
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
engine.tick(); // move runs
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runCommand runs the command and emits its result message', async () => {
|
||||||
|
const engine = new Engine();
|
||||||
|
const results: Message[] = [];
|
||||||
|
engine.on('move:done', (m) => results.push(m));
|
||||||
|
engine.on('move:error', (m) => results.push(m));
|
||||||
|
|
||||||
|
engine.runCommand('move', async ({ args }) => {
|
||||||
|
expect(args).toEqual({ part: 'a', to: '/grid/5/5' });
|
||||||
|
return 'moved';
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
|
||||||
|
engine.tick(); // command runs; result emitted to the next tick
|
||||||
|
await Promise.resolve(); // flush the command's async resolution
|
||||||
|
engine.tick(); // result processed
|
||||||
|
expect(results).toEqual([{ type: 'move:done', data: 'moved' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runCommand emits :error when the command throws', async () => {
|
||||||
|
const engine = new Engine();
|
||||||
|
const results: Message[] = [];
|
||||||
|
engine.on('move:error', (m) => results.push(m));
|
||||||
|
|
||||||
|
engine.runCommand('move', async () => {
|
||||||
|
throw new Error('bad path');
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.enqueue({ type: 'move', data: {} });
|
||||||
|
engine.tick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve(); // rejection hops through .then before .catch
|
||||||
|
engine.tick();
|
||||||
|
expect(results).toEqual([{ type: 'move:error', error: new Error('bad path') }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an orchestrator awaits a matching message and resumes on tick', async () => {
|
||||||
|
const engine = new Engine();
|
||||||
|
const log: string[] = [];
|
||||||
|
|
||||||
|
const done = engine.runOrchestrator(async (ctx) => {
|
||||||
|
log.push('start');
|
||||||
|
const tap = await ctx.wait((m) => m.type === 'tap');
|
||||||
|
log.push(`tap:${(tap.data as { part: string }).part}`);
|
||||||
|
ctx.emit({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing enqueued yet — the orchestrator is suspended.
|
||||||
|
engine.tick();
|
||||||
|
expect(log).toEqual(['start']);
|
||||||
|
|
||||||
|
engine.enqueue({ type: 'tap', data: { part: 'carcassonne:tile#a' } });
|
||||||
|
engine.tick();
|
||||||
|
await done;
|
||||||
|
expect(log).toEqual(['start', 'tap:carcassonne:tile#a']);
|
||||||
|
// The move emitted by the orchestrator runs on the next tick.
|
||||||
|
engine.tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an orchestrator wait rejects when its signal aborts', async () => {
|
||||||
|
const engine = new Engine();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const log: string[] = [];
|
||||||
|
|
||||||
|
const done = engine.runOrchestrator(
|
||||||
|
async (ctx) => {
|
||||||
|
try {
|
||||||
|
await ctx.wait((m) => m.type === 'tap');
|
||||||
|
log.push('resolved');
|
||||||
|
} catch {
|
||||||
|
log.push('aborted');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await done;
|
||||||
|
expect(log).toEqual(['aborted']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* The engine: the message bus that ties the queue, triggers, and handlers
|
||||||
|
* together.
|
||||||
|
*
|
||||||
|
* The engine is pure — no r3f, no React, no store. It defines the contract;
|
||||||
|
* `@tts/tabletop` registers the built-in commands (`move`, `focus`, `caption`,
|
||||||
|
* ...) that mutate the tabletop store and drive the render layer. The engine
|
||||||
|
* never imports tabletop.
|
||||||
|
*/
|
||||||
|
import { MessageQueue, type CommandResult, type Message, type MessageHandler } from './message.js';
|
||||||
|
import { TriggerRegistry, type Trigger } from './trigger.js';
|
||||||
|
import { runOrchestrator } from './orchestrator.js';
|
||||||
|
import { type Command, type Orchestrator, type RunContext } from './run.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine. `enqueue` adds a message; `tick` drains the queue and dispatches
|
||||||
|
* each message to the handlers registered for its `type`, then to matching
|
||||||
|
* triggers. Orchestrators run against the same queue and suspend on `wait`
|
||||||
|
* until a matching message is processed.
|
||||||
|
*/
|
||||||
|
export class Engine {
|
||||||
|
private queue = new MessageQueue();
|
||||||
|
private triggers = new TriggerRegistry();
|
||||||
|
private handlers = new Map<string, MessageHandler[]>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Every message goes to the handlers for its type (they run commands), then
|
||||||
|
// to triggers (they react to the message, including the `:done` a command
|
||||||
|
// emits). Emissions from either land on the next tick.
|
||||||
|
this.queue.on((msg) => {
|
||||||
|
for (const handler of this.handlers.get(msg.type) ?? []) handler(msg);
|
||||||
|
for (const t of this.triggers.match(msg)) {
|
||||||
|
for (const emit of t.emit) this.queue.enqueue(emit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a handler for a message type. Returns an unsubscribe. */
|
||||||
|
on(type: string, handler: MessageHandler): () => void {
|
||||||
|
const list = this.handlers.get(type) ?? [];
|
||||||
|
list.push(handler);
|
||||||
|
this.handlers.set(type, list);
|
||||||
|
return () => {
|
||||||
|
const cur = this.handlers.get(type);
|
||||||
|
if (!cur) return;
|
||||||
|
const next = cur.filter((h) => h !== handler);
|
||||||
|
if (next.length) this.handlers.set(type, next);
|
||||||
|
else this.handlers.delete(type);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a command. The engine builds a `RunContext` from the message,
|
||||||
|
* runs the command, and emits its result — `:done` on resolve, `:cancel` on
|
||||||
|
* abort, `:error` on throw.
|
||||||
|
*/
|
||||||
|
runCommand<Name extends string, Args, Result>(
|
||||||
|
type: Name,
|
||||||
|
command: Command<Args, Result>,
|
||||||
|
): () => void {
|
||||||
|
return this.on(type, (msg) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const ctx: RunContext = {
|
||||||
|
signal: controller.signal,
|
||||||
|
emit: (m) => this.queue.enqueue(m),
|
||||||
|
wait: (pred) =>
|
||||||
|
new Promise<Message>((resolve, reject) => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
reject(new Error('aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const off = this.on('*', (m) => {
|
||||||
|
if (pred(m)) {
|
||||||
|
off();
|
||||||
|
resolve(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
controller.signal.addEventListener('abort', () => {
|
||||||
|
off();
|
||||||
|
reject(new Error('aborted'));
|
||||||
|
}, { once: true });
|
||||||
|
}),
|
||||||
|
enableTrigger: (type, id) => this.triggers.enable(type, id),
|
||||||
|
disableTrigger: (type, id) => this.triggers.disable(type, id),
|
||||||
|
};
|
||||||
|
const args = msg.data as Args;
|
||||||
|
command({ ...ctx, args })
|
||||||
|
.then((result) => this.queue.enqueue({ type: `${type}:done`, data: result } as CommandResult<Name, Result>))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
this.queue.enqueue({ type: `${type}:cancel` } as CommandResult<Name, Result>);
|
||||||
|
} else {
|
||||||
|
this.queue.enqueue({
|
||||||
|
type: `${type}:error`,
|
||||||
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
|
} as CommandResult<Name, Result>);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(msg: Message): void {
|
||||||
|
this.queue.enqueue(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain the queue and process the snapshot. Returns the processed count. */
|
||||||
|
tick(): number {
|
||||||
|
return this.queue.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTrigger(t: Trigger): void {
|
||||||
|
this.triggers.register(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterTrigger(t: Trigger): void {
|
||||||
|
this.triggers.unregister(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableTrigger(type: string, id?: string): void {
|
||||||
|
this.triggers.enable(type, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableTrigger(type: string, id?: string): void {
|
||||||
|
this.triggers.disable(type, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an orchestrator against this engine's queue. */
|
||||||
|
runOrchestrator(o: Orchestrator, signal?: AbortSignal): Promise<void> {
|
||||||
|
return runOrchestrator(
|
||||||
|
o,
|
||||||
|
(msg) => this.queue.enqueue(msg),
|
||||||
|
(handler) => this.queue.on(handler),
|
||||||
|
(type, id) => this.triggers.enable(type, id),
|
||||||
|
(type, id) => this.triggers.disable(type, id),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Message, MessageHandler, CommandMessage, CommandResult } from './message.js';
|
||||||
|
export type { Trigger } from './trigger.js';
|
||||||
|
export { triggerMatches, TriggerRegistry } from './trigger.js';
|
||||||
|
export type { Orchestrator, RunContext, Command } from './run.js';
|
||||||
|
export { runOrchestrator } from './orchestrator.js';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
MessageQueue,
|
||||||
|
type Message,
|
||||||
|
type MessageHandler,
|
||||||
|
type CommandMessage,
|
||||||
|
type CommandResult,
|
||||||
|
} from './message.js';
|
||||||
|
export { TriggerRegistry, triggerMatches, type Trigger } from './trigger.js';
|
||||||
|
export { runOrchestrator } from './orchestrator.js';
|
||||||
|
export type { Orchestrator, RunContext, Command } from './run.js';
|
||||||
|
export { Engine } from './engine.js';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MessageQueue } from './message.js';
|
||||||
|
|
||||||
|
describe('MessageQueue', () => {
|
||||||
|
it('processes messages in FIFO order on tick', () => {
|
||||||
|
const q = new MessageQueue();
|
||||||
|
const seen: string[] = [];
|
||||||
|
q.on((m) => seen.push(m.type));
|
||||||
|
q.enqueue({ type: 'a' });
|
||||||
|
q.enqueue({ type: 'b' });
|
||||||
|
expect(q.size).toBe(2);
|
||||||
|
expect(q.tick()).toBe(2);
|
||||||
|
expect(seen).toEqual(['a', 'b']);
|
||||||
|
expect(q.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshots and drains: emissions during a drain go to the next tick', () => {
|
||||||
|
const q = new MessageQueue();
|
||||||
|
const seen: string[] = [];
|
||||||
|
q.on((m) => {
|
||||||
|
seen.push(m.type);
|
||||||
|
if (m.type === 'a') q.enqueue({ type: 'b' });
|
||||||
|
});
|
||||||
|
q.enqueue({ type: 'a' });
|
||||||
|
// The 'b' emitted during the drain must NOT be processed in the same tick.
|
||||||
|
expect(q.tick()).toBe(1);
|
||||||
|
expect(seen).toEqual(['a']);
|
||||||
|
expect(q.tick()).toBe(1);
|
||||||
|
expect(seen).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes a handler', () => {
|
||||||
|
const q = new MessageQueue();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const off = q.on((m) => seen.push(m.type));
|
||||||
|
q.enqueue({ type: 'a' });
|
||||||
|
q.tick();
|
||||||
|
off();
|
||||||
|
q.enqueue({ type: 'b' });
|
||||||
|
q.tick();
|
||||||
|
expect(seen).toEqual(['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* The message model and the queue that serializes it.
|
||||||
|
*
|
||||||
|
* A message is both an event (something happened) and an intent (something
|
||||||
|
* should happen). It is identified by `type`, matching the format's `type#id`
|
||||||
|
* convention. A `move` message both runs the move command and is observable as
|
||||||
|
* an event; the command's completion is itself a message (`move:done`), which
|
||||||
|
* is what triggers match and orchestrators await.
|
||||||
|
*
|
||||||
|
* Messages are a discriminated union on `type`. The engine defines the generic
|
||||||
|
* shapes; the host's concrete union extends them with its own command types.
|
||||||
|
*
|
||||||
|
* Messages are not processed inline. They are enqueued and handled on the next
|
||||||
|
* `tick()`. This kills reentrancy (a handler cannot cause unbounded
|
||||||
|
* recursion), gives a natural debounce, and makes the whole system a
|
||||||
|
* deterministic frame. The engine is pure — it has no render loop — so the
|
||||||
|
* host calls `tick()` (a `useFrame` in `@tts/tabletop`, manually in tests).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A command message names a command to run. Its handler is the command. */
|
||||||
|
export interface CommandMessage<Name extends string, Args> {
|
||||||
|
type: Name;
|
||||||
|
data: Args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command's result, discriminated on the type suffix. `:done` on resolve,
|
||||||
|
* `:cancel` on abort (superseded, skipped, surface disabled), `:error` on
|
||||||
|
* throw. A trigger matching `move:done` does not fire on a cancel.
|
||||||
|
*/
|
||||||
|
export type CommandResult<Name extends string, R = void> =
|
||||||
|
| { type: `${Name}:done`; data: R }
|
||||||
|
| { type: `${Name}:cancel` }
|
||||||
|
| { type: `${Name}:error`; error: Error };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base message type. The engine is host-agnostic, so this is a permissive
|
||||||
|
* structural type; the host defines a concrete discriminated union on `type`
|
||||||
|
* that extends it with its own command and interaction messages.
|
||||||
|
*/
|
||||||
|
export interface Message {
|
||||||
|
type: string;
|
||||||
|
/** Command-specific payload. */
|
||||||
|
data?: unknown;
|
||||||
|
/** Present on `:error` result messages. */
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A handler consumes a message and may emit new ones. */
|
||||||
|
export type MessageHandler = (msg: Message) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message queue. `enqueue` adds a message to the pending set; `tick` drains
|
||||||
|
* the snapshot and processes it. Messages emitted during a drain go to the
|
||||||
|
* next tick (snapshot-and-drain), so a handler can never re-enter mid-drain.
|
||||||
|
*/
|
||||||
|
export class MessageQueue {
|
||||||
|
private pending: Message[] = [];
|
||||||
|
private handlers: MessageHandler[] = [];
|
||||||
|
|
||||||
|
/** Register a handler for every message. Returns an unsubscribe. */
|
||||||
|
on(handler: MessageHandler): () => void {
|
||||||
|
this.handlers.push(handler);
|
||||||
|
return () => {
|
||||||
|
this.handlers = this.handlers.filter((h) => h !== handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(msg: Message): void {
|
||||||
|
this.pending.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain the current snapshot and process it. Returns the processed count. */
|
||||||
|
tick(): number {
|
||||||
|
const batch = this.pending;
|
||||||
|
this.pending = [];
|
||||||
|
for (const msg of batch) {
|
||||||
|
for (const handler of this.handlers) {
|
||||||
|
handler(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The number of messages waiting to be processed. */
|
||||||
|
get size(): number {
|
||||||
|
return this.pending.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Orchestrators — imperative async flow.
|
||||||
|
*
|
||||||
|
* An orchestrator is the code counterpart to a trigger: an async function that
|
||||||
|
* emits messages and awaits matching ones. It is a proper TS module, declared
|
||||||
|
* per folder as `main.ts`, unique per folder like `package.yaml`, exported as a
|
||||||
|
* default async function.
|
||||||
|
*
|
||||||
|
* An orchestrator is a long-running command: it awaits events instead of
|
||||||
|
* resolving immediately, so it inherits the run-context machinery (supersede
|
||||||
|
* groups, cancellation, tap subscription) for free. Trigger control lives here
|
||||||
|
* — declaration is data, activation is code.
|
||||||
|
*/
|
||||||
|
import type { Message } from './message.js';
|
||||||
|
import type { Orchestrator, RunContext } from './run.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an orchestrator against a queue. `emit` enqueues; `wait` suspends until
|
||||||
|
* a matching message is processed during a `tick()`, rejecting on abort.
|
||||||
|
* Returns a promise that resolves when the orchestrator completes.
|
||||||
|
*/
|
||||||
|
export function runOrchestrator(
|
||||||
|
orchestrator: Orchestrator,
|
||||||
|
emit: (msg: Message) => void,
|
||||||
|
on: (handler: (msg: Message) => void) => () => void,
|
||||||
|
enableTrigger: (type: string, id?: string) => void,
|
||||||
|
disableTrigger: (type: string, id?: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending: Array<{ pred: (msg: Message) => boolean; resolve: (m: Message) => void; reject: (e: Error) => void }> = [];
|
||||||
|
const abort = () => {
|
||||||
|
controller.abort();
|
||||||
|
// Reject every pending wait so the orchestrator unwinds on cancel.
|
||||||
|
for (const p of pending.splice(0)) p.reject(new Error('aborted'));
|
||||||
|
};
|
||||||
|
if (signal?.aborted) abort();
|
||||||
|
else signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
|
||||||
|
const unsubscribe = on((msg) => {
|
||||||
|
for (let i = 0; i < pending.length; i++) {
|
||||||
|
const p = pending[i]!;
|
||||||
|
if (p.pred(msg)) {
|
||||||
|
pending.splice(i, 1);
|
||||||
|
p.resolve(msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx: RunContext = {
|
||||||
|
signal: controller.signal,
|
||||||
|
emit,
|
||||||
|
wait: (pred) =>
|
||||||
|
new Promise<Message>((resolve, reject) => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
reject(new Error('aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.push({ pred, resolve, reject });
|
||||||
|
}),
|
||||||
|
enableTrigger,
|
||||||
|
disableTrigger,
|
||||||
|
};
|
||||||
|
|
||||||
|
return orchestrator(ctx).finally(() => {
|
||||||
|
unsubscribe();
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* The run context and the unified command/orchestrator shape.
|
||||||
|
*
|
||||||
|
* A command and an orchestrator are the same thing: an async function taking a
|
||||||
|
* `RunContext`. A command returns a result and is awaited by the engine, which
|
||||||
|
* emits `:done`/`:cancel`/`:error`; an orchestrator returns `void` and is never
|
||||||
|
* awaited by a parent. Cancellation is an `AbortSignal` — a superseded command
|
||||||
|
* or disabled surface aborts it, `wait` rejects on abort, and the `:cancel`
|
||||||
|
* result is emitted. Errors are thrown: a command that throws emits `:error`.
|
||||||
|
*/
|
||||||
|
import type { Message } from './message.js';
|
||||||
|
|
||||||
|
/** The handle every handler runs against. */
|
||||||
|
export interface RunContext {
|
||||||
|
/** Cancellation: superseded, skipped, surface disabled. */
|
||||||
|
signal: AbortSignal;
|
||||||
|
/** Emit a message onto the queue. */
|
||||||
|
emit(msg: Message): void;
|
||||||
|
/** Await the next message matching `pred`. Rejects on abort. */
|
||||||
|
wait(pred: (m: Message) => boolean): Promise<Message>;
|
||||||
|
/** Enable/disable a trigger by `type#id`. */
|
||||||
|
enableTrigger(type: string, id?: string): void;
|
||||||
|
disableTrigger(type: string, id?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A command: an async function taking the context plus its args. */
|
||||||
|
export type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
|
||||||
|
|
||||||
|
/** An orchestrator: an async function that emits and awaits messages. */
|
||||||
|
export type Orchestrator = (ctx: RunContext) => Promise<void>;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TriggerRegistry, triggerMatches, type Trigger } from './trigger.js';
|
||||||
|
|
||||||
|
const tap: Trigger = {
|
||||||
|
type: 'tap',
|
||||||
|
id: 'draw',
|
||||||
|
match: { part: 'carcassonne:tile#a', trigger: 'draw' },
|
||||||
|
emit: [{ type: 'move', data: { part: 'carcassonne:tile#a', to: '/grid/5/5' } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('triggerMatches', () => {
|
||||||
|
it('matches on type and every match param', () => {
|
||||||
|
expect(
|
||||||
|
triggerMatches(tap, { type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a different type', () => {
|
||||||
|
expect(triggerMatches(tap, { type: 'focus' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a mismatched param', () => {
|
||||||
|
expect(
|
||||||
|
triggerMatches(tap, { type: 'tap', data: { part: 'carcassonne:tile#b', trigger: 'draw' } }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches any message of the type when there is no match block', () => {
|
||||||
|
const any = { type: 'focus', emit: [] };
|
||||||
|
expect(triggerMatches(any, { type: 'focus', data: { path: '/deck' } })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TriggerRegistry', () => {
|
||||||
|
it('registers and matches enabled triggers', () => {
|
||||||
|
const reg = new TriggerRegistry();
|
||||||
|
reg.register(tap);
|
||||||
|
expect(reg.match({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } })).toEqual([
|
||||||
|
tap,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collision-checks duplicate type#id', () => {
|
||||||
|
const reg = new TriggerRegistry();
|
||||||
|
reg.register(tap);
|
||||||
|
expect(() => reg.register({ ...tap })).toThrow(/Duplicate trigger: tap#draw/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disable/enable toggles a trigger at runtime', () => {
|
||||||
|
const reg = new TriggerRegistry();
|
||||||
|
reg.register(tap);
|
||||||
|
reg.disable('tap', 'draw');
|
||||||
|
expect(reg.match({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } })).toEqual([]);
|
||||||
|
reg.enable('tap', 'draw');
|
||||||
|
expect(reg.match({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } })).toEqual([
|
||||||
|
tap,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user