Compare commits
93
Commits
a6e9d2dd07
...
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 | ||
|
|
ef0695ed04 | ||
|
|
15c45e7106 | ||
|
|
b4cdcdeb42 | ||
|
|
87e6eee9fe | ||
|
|
521519ce3d | ||
|
|
eefce5487d | ||
|
|
4d14120d54 | ||
|
|
91d4b6c999 | ||
|
|
0503ac84a7 | ||
|
|
43b69133cc | ||
|
|
599fb6a76d | ||
|
|
4e18ab5bc8 | ||
|
|
64c8bb60b3 | ||
|
|
c86d7444b8 | ||
|
|
7f3f758c52 | ||
|
|
845d510948 | ||
|
|
ba16f17d8a | ||
|
|
c0967ab71f | ||
|
|
1cfec40c99 | ||
|
|
664079528c | ||
|
|
82a81c9c1a |
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Game assets are large binaries; store them in Git LFS.
|
||||||
|
games/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.webp filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
games/**/*.gltf filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Runtime config for the proxy. `GAMES_ROOT` is set at startup (from env or a
|
||||||
|
* default) and read by the asset/trace routes to serve local game assets. It's
|
||||||
|
* a module-level value because the node-server adapter injects its own
|
||||||
|
* `HttpBindings` as the Hono env, not custom bindings.
|
||||||
|
*/
|
||||||
|
export let GAMES_ROOT: string | undefined;
|
||||||
|
|
||||||
|
/** Set the games root at startup. */
|
||||||
|
export function setGamesRoot(root: string | undefined): void {
|
||||||
|
GAMES_ROOT = root;
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import { loadEnv } from './env.js';
|
|||||||
|
|
||||||
describe('loadEnv', () => {
|
describe('loadEnv', () => {
|
||||||
it('parses a valid environment', () => {
|
it('parses a valid environment', () => {
|
||||||
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000' })).toEqual({
|
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000', GAMES_ROOT: '/games' })).toEqual({
|
||||||
STEAM_API_KEY: 'key',
|
STEAM_API_KEY: 'key',
|
||||||
PORT: 4000,
|
PORT: 4000,
|
||||||
|
GAMES_ROOT: '/games',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
STEAM_API_KEY: z.string().min(1).optional(),
|
STEAM_API_KEY: z.string().min(1).optional(),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
/** Absolute path to the games root, for serving local game assets. */
|
||||||
|
GAMES_ROOT: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
@@ -11,6 +13,7 @@ export type Env = z.infer<typeof envSchema>;
|
|||||||
export interface Bindings {
|
export interface Bindings {
|
||||||
STEAM_API_KEY?: string;
|
STEAM_API_KEY?: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
|
GAMES_ROOT?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { loadEnv, type Bindings } from './env.js';
|
import { loadEnv, type Bindings } from './env.js';
|
||||||
|
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';
|
||||||
|
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
|
|
||||||
|
// Default GAMES_ROOT to the repo's `games` folder so local game assets work
|
||||||
|
// without configuration; override via env.
|
||||||
|
setGamesRoot(
|
||||||
|
env.GAMES_ROOT ??
|
||||||
|
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'games'),
|
||||||
|
);
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
app.use('*', cors());
|
app.use('*', cors());
|
||||||
@@ -17,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,8 +1,20 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 asset from './asset.js';
|
import asset from './asset.js';
|
||||||
|
import { setGamesRoot } from '../config.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
|
||||||
|
setGamesRoot(dir);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
setGamesRoot(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('asset route', () => {
|
describe('asset route', () => {
|
||||||
@@ -40,4 +52,22 @@ describe('asset route', () => {
|
|||||||
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||||
expect(res.status).toBe(502);
|
expect(res.status).toBe(502);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serves a local game asset from GAMES_ROOT', async () => {
|
||||||
|
writeFileSync(path.join(dir, 'cards.png'), new Uint8Array([1, 2, 3]));
|
||||||
|
const res = await asset.request('/?url=cards.png');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('image/png');
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for a missing local asset', async () => {
|
||||||
|
const res = await asset.request('/?url=nope.png');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path traversal outside GAMES_ROOT', async () => {
|
||||||
|
const res = await asset.request('/?url=..%2F..%2Fetc%2Fpasswd');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { GAMES_ROOT } from '../config.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -7,6 +9,10 @@ const app = new Hono();
|
|||||||
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
|
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
|
||||||
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
|
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
|
||||||
* Routing through the proxy makes those assets loadable.
|
* Routing through the proxy makes those assets loadable.
|
||||||
|
*
|
||||||
|
* A relative URL (no scheme) is treated as a game asset path relative to the
|
||||||
|
* `GAMES_ROOT` directory and served from disk, so bgm parts can reference
|
||||||
|
* local files (e.g. `poker/parts/assets/cards.png`).
|
||||||
*/
|
*/
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
const raw = c.req.query('url');
|
const raw = c.req.query('url');
|
||||||
@@ -14,20 +20,28 @@ app.get('/', async (c) => {
|
|||||||
return c.json({ error: 'Missing url query param' }, 400);
|
return c.json({ error: 'Missing url query param' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let url: URL;
|
const result = await resolveAsset(raw, GAMES_ROOT);
|
||||||
try {
|
if (!result.ok) {
|
||||||
url = new URL(raw);
|
// A missing local file vs an invalid reference.
|
||||||
} catch {
|
return c.json(
|
||||||
return c.json({ error: 'Invalid url query param' }, 400);
|
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
|
||||||
|
result.reason === 'not-found' ? 404 : 400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
const asset = result.asset;
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
if (asset.stream) {
|
||||||
|
return new Response(asset.stream as unknown as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': asset.contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url);
|
res = await fetch(asset.url!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
|
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
/** Content-type by extension for local game assets. */
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.gltf': 'model/gltf+json',
|
||||||
|
'.glb': 'model/gltf-binary',
|
||||||
|
'.obj': 'text/plain',
|
||||||
|
'.fbx': 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResolvedAsset {
|
||||||
|
/** The http(s) URL to fetch, when the asset is remote. */
|
||||||
|
url?: string;
|
||||||
|
/** A readable stream of a local file, when the asset is on disk. */
|
||||||
|
stream?: NodeJS.ReadableStream;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolveResult =
|
||||||
|
| { ok: true; asset: ResolvedAsset }
|
||||||
|
| { ok: false; reason: 'invalid' | 'not-found' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an asset reference to either a remote http(s) URL or a local file
|
||||||
|
* under `gamesRoot`. A reference with a scheme is remote; otherwise it's a
|
||||||
|
* game asset path relative to `gamesRoot`. Returns `{ ok: false }` with a
|
||||||
|
* reason when the reference is invalid (non-http scheme, path traversal) or
|
||||||
|
* the file is missing.
|
||||||
|
*/
|
||||||
|
export async function resolveAsset(
|
||||||
|
raw: string,
|
||||||
|
gamesRoot: string | undefined,
|
||||||
|
): Promise<ResolveResult> {
|
||||||
|
// A relative path (no scheme) is a local game asset.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
||||||
|
if (!gamesRoot) return { ok: false, reason: 'invalid' };
|
||||||
|
const rel = raw.replace(/^\/+/, '');
|
||||||
|
const abs = path.resolve(gamesRoot, rel);
|
||||||
|
if (!abs.startsWith(path.resolve(gamesRoot) + path.sep)) {
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stat(abs);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'not-found' };
|
||||||
|
}
|
||||||
|
const ext = path.extname(abs).toLowerCase();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
asset: {
|
||||||
|
stream: createReadStream(abs),
|
||||||
|
contentType: CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
// Only allow http(s) to avoid SSRF via file://, etc.
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return { ok: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
|
return { ok: true, asset: { url: raw, contentType: 'application/octet-stream' } };
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import sharp from 'sharp';
|
|||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
import { traceRequestSchema, type TraceResult } from '@tts/shared';
|
import { traceRequestSchema, type TraceResult } from '@tts/shared';
|
||||||
import { offsetShape, parseSvgShape } from './svgShape.js';
|
import { offsetShape, parseSvgShape } from './svgShape.js';
|
||||||
|
import { resolveAsset } from './resolveAsset.js';
|
||||||
|
import { GAMES_ROOT } from '../config.js';
|
||||||
|
|
||||||
// vtracer is a CommonJS package; load it via require so the wasm initializes
|
// vtracer is a CommonJS package; load it via require so the wasm initializes
|
||||||
// with the correct `__dirname`.
|
// with the correct `__dirname`.
|
||||||
@@ -39,19 +41,29 @@ app.get('/', async (c) => {
|
|||||||
const { url, mode, threshold, format, simplify, maxColors, offset } =
|
const { url, mode, threshold, format, simplify, maxColors, offset } =
|
||||||
parsed.data;
|
parsed.data;
|
||||||
|
|
||||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
// Resolve the image: a relative path is a local game asset under GAMES_ROOT;
|
||||||
const parsedUrl = new URL(url);
|
// otherwise it must be an http(s) URL.
|
||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
const resolved = await resolveAsset(url, GAMES_ROOT);
|
||||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
if (!resolved.ok) {
|
||||||
|
return c.json({ error: 'Invalid or missing image url' }, 400);
|
||||||
}
|
}
|
||||||
|
const asset = resolved.asset;
|
||||||
|
|
||||||
let image: Buffer;
|
let image: Buffer;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
if (asset.stream) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of asset.stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
image = Buffer.concat(chunks);
|
||||||
|
} else {
|
||||||
|
const res = await fetch(asset.url!);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return c.json({ error: `Image responded ${res.status}` }, 502);
|
return c.json({ error: `Image responded ${res.status}` }, 502);
|
||||||
}
|
}
|
||||||
image = Buffer.from(await res.arrayBuffer());
|
image = Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
"@tts/extract": "workspace:*",
|
"@tts/extract": "workspace:*",
|
||||||
"@tts/mesh": "workspace:*",
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
|
"@tts/bgm": "workspace:*",
|
||||||
|
"@tts/http": "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",
|
||||||
|
|||||||
+51
-5
@@ -1,12 +1,38 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
|
||||||
|
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
|
||||||
|
// stack stays code-split out of the main bundle and is only fetched when one
|
||||||
|
// of those routes is actually visited.
|
||||||
|
const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
|
||||||
|
const BgmPage = lazy(() => import('./pages/BgmPage'));
|
||||||
|
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
|
||||||
|
the sidebar can scroll independently and the viewer gets all the space. */}
|
||||||
|
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
|
||||||
|
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
|
||||||
|
{isModRoute ? (
|
||||||
|
<ModHeader />
|
||||||
|
) : (
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
<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">
|
<Link to="/" className="text-lg font-semibold tracking-tight">
|
||||||
TTS Workshop
|
TTS Workshop
|
||||||
@@ -15,16 +41,36 @@ export default function App() {
|
|||||||
<Link to="/" className="hover:text-zinc-100">
|
<Link to="/" className="hover:text-zinc-100">
|
||||||
Search
|
Search
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/bgm" className="hover:text-zinc-100">
|
||||||
|
BGM
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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
|
||||||
|
path="/mod/:id/setup"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<p className="text-sm text-zinc-400">Loading full setup…</p>}>
|
||||||
|
<FullSetupPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+7
-24
@@ -1,5 +1,7 @@
|
|||||||
import { deserialize } from 'bson';
|
import type { ModDetails, SearchResult } from '@tts/shared';
|
||||||
import type { SearchResult, TraceResult, TTSMod } from '@tts/shared';
|
import { traceImage } from '@tts/http';
|
||||||
|
|
||||||
|
export { traceImage };
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -19,32 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trace an image into a vector shape, BSON-deserializing the proxy response.
|
* Fetch a full parsed TTS save plus its Workshop metadata.
|
||||||
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
|
|
||||||
* non-zero `offset` insets (negative) or outsets (positive) the shape in
|
|
||||||
* pixels.
|
|
||||||
*/
|
*/
|
||||||
export async function traceImage(
|
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
|
||||||
url: string,
|
|
||||||
mode: 'alpha' | 'bw' | 'color' = 'alpha',
|
|
||||||
offset?: number,
|
|
||||||
): Promise<TraceResult> {
|
|
||||||
const params = new URLSearchParams({ url, mode });
|
|
||||||
if (offset !== undefined) params.set('offset', String(offset));
|
|
||||||
const res = await fetch(`${BASE}/trace?${params.toString()}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
||||||
throw new Error(body?.error ?? `Trace failed (${res.status})`);
|
|
||||||
}
|
|
||||||
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch a full parsed TTS save. */
|
|
||||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
|
||||||
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,33 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export interface Crumb {
|
||||||
|
label: string;
|
||||||
|
/** Where the crumb links; omit for the current (last) crumb. */
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A breadcrumb navigation trail. The last crumb is the current page and is
|
||||||
|
* rendered as plain text; earlier crumbs link to their routes.
|
||||||
|
*/
|
||||||
|
export default function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-zinc-400">
|
||||||
|
{crumbs.map((crumb, i) => {
|
||||||
|
const last = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-zinc-600">/</span>}
|
||||||
|
{crumb.to && !last ? (
|
||||||
|
<Link to={crumb.to} className="hover:text-zinc-100">
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={last ? 'text-zinc-100' : ''}>{crumb.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{types.map(({ name, count }) => {
|
||||||
|
const active = highlighted.has(name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => toggleType(name)}
|
||||||
|
aria-pressed={active}
|
||||||
|
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
|
<TreeNode
|
||||||
key={index}
|
key={path}
|
||||||
node={node}
|
node={node}
|
||||||
depth={0}
|
depth={0}
|
||||||
path={`${index}`}
|
path={path}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 && (
|
||||||
@@ -103,3 +179,50 @@ 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,15 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
|
/** Shown when a bgm package id doesn't resolve to a known package. */
|
||||||
|
export default function PackageMissing({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Breadcrumbs crumbs={[{ label: 'Board games', to: '/bgm' }, { label: id }]} />
|
||||||
|
<p className="text-sm text-zinc-400">No package “{id}”.</p>
|
||||||
|
<Link to="/bgm" className="text-sm text-zinc-300 underline">
|
||||||
|
Back to all packages
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 './assetUrl';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
import type { ViewerProps } from '../viewers';
|
||||||
import { flipTexture } from './flipTexture';
|
|
||||||
import { getSharedGeometry } 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,137 +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}>
|
||||||
|
{/* 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} />
|
<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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}: {
|
|
||||||
faceUrl?: string;
|
|
||||||
backUrl?: string;
|
|
||||||
numWidth?: number;
|
|
||||||
numHeight?: number;
|
|
||||||
uniqueBack: boolean;
|
|
||||||
cardId?: number;
|
|
||||||
}) {
|
|
||||||
// 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={faceMap ? '#ffffff' : '#52525b'}
|
|
||||||
map={faceMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={backGeo}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={backMap ? '#ffffff' : '#52525b'}
|
|
||||||
map={backMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={wallsGeo}>
|
|
||||||
<meshStandardMaterial color="#ffffff" 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,12 +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 type * as THREE from 'three';
|
|
||||||
import type { Object3D } from 'three';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { CustomModelMesh } from './CustomModelMesh';
|
||||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
@@ -14,74 +9,12 @@ import { FlexibleModelLoader } from './flexibleModelLoader';
|
|||||||
* (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;
|
|
||||||
if (!meshUrl) {
|
|
||||||
return (
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial color="#52525b" />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Model({
|
|
||||||
meshUrl,
|
|
||||||
diffuseUrl,
|
|
||||||
}: {
|
|
||||||
meshUrl: string;
|
|
||||||
diffuseUrl?: string;
|
|
||||||
}) {
|
|
||||||
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<primitive object={root} scale={0.5} />
|
|
||||||
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,30 +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 './assetUrl';
|
import { TileObjectMesh } from './TileMesh';
|
||||||
import { flipTexture } from './flipTexture';
|
import type { ViewerProps } from '../viewers';
|
||||||
import { getSharedGeometry, getSharedMaterial } 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`
|
||||||
@@ -34,119 +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}>
|
||||||
|
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<TileObjectMesh object={object} />
|
<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} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}: {
|
|
||||||
url?: string;
|
|
||||||
thickness: number;
|
|
||||||
type: number;
|
|
||||||
stretch: boolean;
|
|
||||||
}) {
|
|
||||||
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.
|
|
||||||
const faceKey = `tile-face:${url ?? 'none'}`;
|
|
||||||
const faceMat = getSharedMaterial(faceKey, {
|
|
||||||
color: texture ? '#ffffff' : '#52525b',
|
|
||||||
map: texture ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
const backMat = getSharedMaterial(faceKey + ':back', {
|
|
||||||
color: texture ? '#ffffff' : '#52525b',
|
|
||||||
map: backMap ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
const wallMat = getSharedMaterial('tile-wall', {
|
|
||||||
color: '#ffffff',
|
|
||||||
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,22 +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,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
type Shape,
|
|
||||||
type UVBounds,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import { traceImage } from '../../api';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { TokenObjectMesh } from './TokenMesh';
|
||||||
import { getSharedGeometry, getSharedMaterial } from './sharedResources';
|
import type { ViewerProps } from '../viewers';
|
||||||
|
|
||||||
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
|
||||||
@@ -24,186 +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}>
|
||||||
|
{/* Lay the token flat on the ground (see CardViewer). */}
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<TokenObjectMesh object={object} />
|
<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} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 }: { url?: string; thickness: number }) {
|
|
||||||
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 shape = trace ? toMeshShape(trace) : circleShape();
|
|
||||||
const uvBounds = trace ? toUvBounds(trace) : 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.
|
|
||||||
const material = getSharedMaterial(`token:${url ?? 'none'}`, {
|
|
||||||
color: texture ? '#ffffff' : '#52525b',
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A circle fallback when there's no image to trace. */
|
|
||||||
function circleShape(): Shape {
|
|
||||||
const pts: number[][] = [];
|
|
||||||
for (let i = 0; i < 48; i++) {
|
|
||||||
const a = (i / 48) * Math.PI * 2;
|
|
||||||
pts.push([Math.cos(a) * (TOKEN_SIZE / 2), Math.sin(a) * (TOKEN_SIZE / 2)]);
|
|
||||||
}
|
|
||||||
return { outline: pts };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a traced shape (image pixel coords, origin top-left, y-down) to a
|
|
||||||
* mesh `Shape` (y-up, centered at the origin). Flips the y-axis, scales to
|
|
||||||
* `TOKEN_SIZE`, centers the result, and normalizes winding so the outline is
|
|
||||||
* counter-clockwise and holes are clockwise (as `@tts/mesh` expects).
|
|
||||||
*/
|
|
||||||
function toMeshShape(trace: {
|
|
||||||
shape: { outline: number[][]; holes?: number[][][] };
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}): Shape {
|
|
||||||
const { shape, width, height } = trace;
|
|
||||||
const scale = TOKEN_SIZE / Math.max(width, height);
|
|
||||||
const ox = (width * scale) / 2;
|
|
||||||
const oy = (height * scale) / 2;
|
|
||||||
const transform = (pts: number[][]) =>
|
|
||||||
pts.map(([x, y]) => [x! * scale - ox, (height - y!) * scale - oy]);
|
|
||||||
return {
|
|
||||||
outline: normalizeWinding(transform(shape.outline), true),
|
|
||||||
holes: shape.holes?.map((h) => normalizeWinding(transform(h), false)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The full image rectangle, in mesh coordinates, used as the UV framing so the
|
|
||||||
* texture aligns with the traced silhouette (which may be smaller than the
|
|
||||||
* image when there is transparent padding).
|
|
||||||
*/
|
|
||||||
function toUvBounds(trace: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}): UVBounds {
|
|
||||||
const scale = TOKEN_SIZE / Math.max(trace.width, trace.height);
|
|
||||||
const ox = (trace.width * scale) / 2;
|
|
||||||
const oy = (trace.height * scale) / 2;
|
|
||||||
return { minX: -ox, minY: -oy, maxX: ox, maxY: oy };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a ring has the requested winding. `ccw` true yields a
|
|
||||||
* counter-clockwise ring (outline); false yields clockwise (hole).
|
|
||||||
*/
|
|
||||||
function normalizeWinding(pts: number[][], ccw: boolean): number[][] {
|
|
||||||
const isCcw = signedArea(pts) > 0;
|
|
||||||
return isCcw === ccw ? pts : [...pts].reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Signed area of a polygon; positive means counter-clockwise. */
|
|
||||||
function signedArea(points: number[][]): number {
|
|
||||||
let area = 0;
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const [x1, y1] = points[i]!;
|
|
||||||
const [x2, y2] = points[(i + 1) % points.length]!;
|
|
||||||
area += x1! * y2! - x2! * y1!;
|
|
||||||
}
|
|
||||||
return area / 2;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Route an external asset URL through the proxy so it can be loaded by
|
|
||||||
* three.js loaders (TextureLoader, GLTFLoader, etc.) despite the upstream host
|
|
||||||
* omitting CORS headers.
|
|
||||||
*/
|
|
||||||
export function assetUrl(url: string): string {
|
|
||||||
return `/asset?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
@@ -1,4 +1,25 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-object tint (`ColorDiffuse`, 0–1 per channel) as a three.js color,
|
||||||
|
* defaulting to white when absent. TTS multiplies this by the object's base
|
||||||
|
* color, so textured faces are tinted too.
|
||||||
|
*/
|
||||||
|
export function objectTint(object: TTSObject): THREE.Color {
|
||||||
|
const c = object.ColorDiffuse;
|
||||||
|
return c ? new THREE.Color(c.r, c.g, c.b) : new THREE.Color(1, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable cache key fragment for a tint, so tinted variants don't collide. */
|
||||||
|
export function tintKey(color: THREE.Color): string {
|
||||||
|
return `${color.r},${color.g},${color.b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiply a base color by the object's tint. */
|
||||||
|
export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
|
||||||
|
return base.clone().multiply(tint);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module-level caches so the full-setup view can share geometry and materials
|
* Module-level caches so the full-setup view can share geometry and materials
|
||||||
@@ -9,7 +30,7 @@ import * as THREE from 'three';
|
|||||||
* 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>();
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary view for a single bgm package: its metadata and counts of parts,
|
||||||
|
* surfaces, and setups, each linking to its collection page.
|
||||||
|
*/
|
||||||
|
export default function BgmPackagePage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const partCount = Object.keys(pkg.parts).length;
|
||||||
|
const surfaceCount = Object.keys(pkg.surfaces).length;
|
||||||
|
const setupCount = Object.keys(pkg.setups).length;
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
label: 'Parts',
|
||||||
|
count: partCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/parts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Surfaces',
|
||||||
|
count: surfaceCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/surfaces`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Setups',
|
||||||
|
count: setupCount,
|
||||||
|
to: `/bgm/${pkg.meta.id}/setups`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[{ label: 'Board games', to: '/bgm' }, { label: pkg.meta.title ?? pkg.meta.id }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
|
{pkg.meta.language}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<li key={section.label}>
|
||||||
|
<Link
|
||||||
|
to={section.to}
|
||||||
|
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{section.label}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-400">{section.count}</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
|
||||||
|
* default export is an array of all packages the loader found in the games
|
||||||
|
* root.
|
||||||
|
*/
|
||||||
|
export default function BgmPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs crumbs={[{ label: 'Board games' }]} />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Board games</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{packages.length} package{packages.length === 1 ? '' : 's'} discovered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<li key={pkg.meta.id}>
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}`}
|
||||||
|
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{pkg.meta.title ?? pkg.meta.id}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
|
||||||
|
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
|
||||||
|
{pkg.meta.language}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{Object.keys(pkg.parts).length} parts · {Object.keys(pkg.surfaces).length} surfaces ·{' '}
|
||||||
|
{Object.keys(pkg.setups).length} setups
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,9 +4,8 @@ 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 '../components/ErrorBoundary';
|
import { ErrorBoundary } from '@tts/tabletop';
|
||||||
import { resolveViewer } from '../components/viewers';
|
import { resolveViewer } from '../components/viewers';
|
||||||
import { iconsForObject } from '../components/objectIcons';
|
import { iconsForObject } from '../components/objectIcons';
|
||||||
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
|
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
|
||||||
@@ -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,31 +39,9 @@ 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">
|
|
||||||
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
|
|
||||||
asset refs
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<a
|
|
||||||
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]">
|
|
||||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
|
|
||||||
<ObjectTree
|
<ObjectTree
|
||||||
nodes={tree}
|
nodes={tree}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
@@ -72,47 +49,28 @@ export default function ModPage() {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<section className="h-full min-h-0 flex-1 p-4">
|
||||||
{selected ? (
|
{selected && Viewer ? (
|
||||||
<>
|
/* Key by selection path so the Canvas remounts and the camera
|
||||||
<header className="mb-4">
|
refits to the newly selected object. */
|
||||||
<span
|
<ErrorBoundary key={selectedPath}>
|
||||||
title={selected.object.Name}
|
|
||||||
className="inline-flex items-center gap-1 text-zinc-400"
|
|
||||||
>
|
|
||||||
{iconsForObject(selected.object.Name).map((icon) => (
|
|
||||||
<Icon key={icon} icon={icon} className="h-5 w-5" />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<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
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
Loading viewer…
|
Loading viewer…
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Key by selection path so the Canvas remounts and the
|
<Viewer object={selected.object} fill />
|
||||||
camera refits to the newly selected object. */}
|
|
||||||
<Viewer key={selectedPath} object={selected.object} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-zinc-500">
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
Select an object from the tree to inspect it.
|
Select an object from the tree to inspect it.
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { PartView, ErrorBoundary } from '@tts/tabletop';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import Scene from '../components/viewers/Scene';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single part within a package. */
|
||||||
|
export default function PartPage() {
|
||||||
|
const { id, type, part } = useParams<{ id: string; type: string; part: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.parts[`${type}#${part}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Parts', to: `/bgm/${pkg.meta.id}/parts` },
|
||||||
|
{ label: `${type}#${part}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No part “{type}#{part}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{found.size ? `size ${found.size.join('×')}mm` : ''}
|
||||||
|
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* The boundary wraps the Canvas (Scene), not PartView, so its HTML
|
||||||
|
fallback renders outside the r3f namespace. The library's proxy
|
||||||
|
calls default to the host's `/asset` and `/trace` paths. */}
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Scene>
|
||||||
|
<PartView part={found} baseUrl={found.baseUrl} />
|
||||||
|
</Scene>
|
||||||
|
</ErrorBoundary>
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(found, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All parts of a package, grouped by part type. Each part links to its
|
||||||
|
* `/bgm/:id/parts/:type/:part` detail page.
|
||||||
|
*/
|
||||||
|
export default function PartsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const parts = Object.values(pkg.parts);
|
||||||
|
const byType = new Map<string, typeof parts>();
|
||||||
|
for (const part of parts) {
|
||||||
|
const list = byType.get(part.type) ?? [];
|
||||||
|
list.push(part);
|
||||||
|
byType.set(part.type, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Parts' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Parts</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">{parts.length} part{parts.length === 1 ? '' : 's'}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[...byType.entries()].map(([type, list]) => (
|
||||||
|
<section key={type} className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">{type}</h2>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{list.map((part) => (
|
||||||
|
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/parts/${part.type}/${part.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{part.id}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{part.size ? `size ${part.size.join('×')}mm` : ''}
|
||||||
|
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import TabletopScene from '../components/tabletop/TabletopScene';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single setup within a package. */
|
||||||
|
export default function SetupPage() {
|
||||||
|
const { id, type, setup } = useParams<{ id: string; type: string; setup: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.setups[`${type}#${setup}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Setups', to: `/bgm/${pkg.meta.id}/setups` },
|
||||||
|
{ label: `${type}#${setup}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No setup “{type}#{setup}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</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>
|
||||||
|
<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">
|
||||||
|
{JSON.stringify(found.setup, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All setups of a package. Each setup links to its
|
||||||
|
* `/bgm/:id/setups/:type/:setup` detail page.
|
||||||
|
*/
|
||||||
|
export default function SetupsPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const setups = Object.values(pkg.setups);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Setups' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Setups</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{setups.length} setup{setups.length === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<li key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/setups/${setup.type}/${setup.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{setup.id}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/** Detail view for a single surface within a package. */
|
||||||
|
export default function SurfacePage() {
|
||||||
|
const { id, type, surface } = useParams<{ id: string; type: string; surface: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const found = pkg.surfaces[`${type}#${surface}`];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Surfaces', to: `/bgm/${pkg.meta.id}/surfaces` },
|
||||||
|
{ label: `${type}#${surface}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!found ? (
|
||||||
|
<p className="text-sm text-zinc-400">No surface “{type}#{surface}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{found.type}#{found.id}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{found.size ? `size ${found.size.join('×')}mm` : ''} · {found.layout.length} routes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
|
{JSON.stringify(found, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All surfaces of a package. Each surface links to its
|
||||||
|
* `/bgm/:id/surfaces/:type/:surface` detail page.
|
||||||
|
*/
|
||||||
|
export default function SurfacesPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pkg = findPackage(id);
|
||||||
|
if (!pkg) return <PackageMissing id={id ?? ''} />;
|
||||||
|
|
||||||
|
const surfaces = Object.values(pkg.surfaces);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
crumbs={[
|
||||||
|
{ label: 'Board games', to: '/bgm' },
|
||||||
|
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
|
||||||
|
{ label: 'Surfaces' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Surfaces</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{surfaces.length} surface{surfaces.length === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{surfaces.map((surface) => (
|
||||||
|
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||||
|
<Link
|
||||||
|
to={`/bgm/${pkg.meta.id}/surfaces/${surface.type}/${surface.id}`}
|
||||||
|
className="font-medium hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{surface.id}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import packages from 'virtual:bgm/packages';
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
|
||||||
|
/** Look up a bgm package by id from the `virtual:bgm/packages` module. */
|
||||||
|
export function findPackage(id: string | undefined): SerializedPackage | undefined {
|
||||||
|
return packages.find((p) => p.meta.id === id);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
Vendored
+18
@@ -1 +1,19 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient types for the virtual `bgm` modules served by the bgm vite plugin.
|
||||||
|
*
|
||||||
|
* - `virtual:bgm/packages` — every discovered package.
|
||||||
|
* - `virtual:bgm/package/<id>` — a single package's assembled JSON.
|
||||||
|
*/
|
||||||
|
declare module 'virtual:bgm/packages' {
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
const packages: SerializedPackage[];
|
||||||
|
export default packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'virtual:bgm/package/*' {
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
const pkg: SerializedPackage;
|
||||||
|
export default pkg;
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bgm } from '@tts/bgm';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
// Proxy API calls to the Hono backend during development.
|
// Proxy API calls to the Hono backend during development.
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -11,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`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
# Board Game Manifest — Technical Reference
|
||||||
|
|
||||||
|
> The concrete behavior of the board game manifest (bgm) format.
|
||||||
|
>
|
||||||
|
> Definitions can live in JSON/YAML/TOML files or in markdown code blocks. In
|
||||||
|
> codeblock mode, each code block is a virtual definition file, named relative
|
||||||
|
> to the current markdown file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. json features
|
||||||
|
|
||||||
|
### The `$variants` directive
|
||||||
|
|
||||||
|
For objects with a `$variants` key, the value is a CSV. Parse it into an object
|
||||||
|
array with `typed-csv`, extend the original object with each row, and return
|
||||||
|
the array.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
job: 'hero'
|
||||||
|
$variants: ./heroes.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name,parents
|
||||||
|
string,string[]
|
||||||
|
clark,[jonathan;martha]
|
||||||
|
bruce,[]
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
|
||||||
|
{ "job": "hero", "name": "bruce", "parents": [] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline vs file
|
||||||
|
|
||||||
|
`$variants` can be a single source or an array of sources. Each source is a
|
||||||
|
file/URL path if 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. In YAML a block scalar (`|`) is the natural
|
||||||
|
way to write inline CSV; in JSON you'd use `\n`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
$variants: |
|
||||||
|
id,name,faceCrop
|
||||||
|
string,string,[number;number;number;number]
|
||||||
|
fish,Fish,[0;0;5;2]
|
||||||
|
grain,Grain,[1;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 is parsed with `typed-csv`:
|
||||||
|
|
||||||
|
- The first row is the header, the second row is the type declaration
|
||||||
|
(`string`, `number`, `string[]`, ...), and the remaining rows are data.
|
||||||
|
- Rows are validated against a zod schema derived from the type row.
|
||||||
|
- **`crop` inside a CSV cell** uses `;` as the element separator
|
||||||
|
(`[0;0;5;2]`), because `,` is the CSV delimiter. `typed-csv` loads it into
|
||||||
|
an array with value `[0,0,5,2]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Definition discovery
|
||||||
|
|
||||||
|
Definitions are organized in **packages**. A loader loads a package
|
||||||
|
declaration, then uses its `include` paths to find the definitions.
|
||||||
|
|
||||||
|
### Code blocks as virtual files
|
||||||
|
|
||||||
|
A code block is a virtual definition file. Its name is derived from the
|
||||||
|
`role=` on its info string — `role.type.lang` — so it is discoverable by the
|
||||||
|
default `include: ./**/*.yaml` and addressable by that name:
|
||||||
|
|
||||||
|
````md
|
||||||
|
```yaml role=part.cargo
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=surface.game#main
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- `role=part.cargo` names the block `part.cargo.yaml`.
|
||||||
|
- `role=surface.game#main` names it `surface.game.yaml`.
|
||||||
|
- `role=package` names it `package.yaml`.
|
||||||
|
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
|
||||||
|
resolve against. When there is a real file in that path, the codeblock wins.
|
||||||
|
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
|
||||||
|
names the block `parts/cargo.yaml` regardless of its role.
|
||||||
|
- A block without `role=` is **not** a definition — it is ignored. Discovery
|
||||||
|
is explicit: a block is a definition only when its `role=` (or, for real
|
||||||
|
files, its filename) declares a known `role.type`.
|
||||||
|
|
||||||
|
### 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` is a list of git-style path patterns — the defs that make up the
|
||||||
|
package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
|
||||||
|
folders is discovered with no configuration. This also matches the package
|
||||||
|
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
|
||||||
|
|
||||||
|
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`
|
||||||
|
- `part`
|
||||||
|
- `surface`
|
||||||
|
- `setup`
|
||||||
|
- `dialog`
|
||||||
|
|
||||||
|
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.
|
||||||
|
`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
|
||||||
|
|
||||||
|
The package is the container for a game's definitions. It is declared with a
|
||||||
|
`role: package` object:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: package
|
||||||
|
id: harbor
|
||||||
|
title: Harbor
|
||||||
|
designer: Jane Doe
|
||||||
|
players: 2
|
||||||
|
language: en
|
||||||
|
```
|
||||||
|
|
||||||
|
- `role`: for block discovery.
|
||||||
|
- `id`: package identification.
|
||||||
|
- `title` — game name.
|
||||||
|
- `include` — the defs that make up the package (see §2).
|
||||||
|
- Optional metadata: `designer`, `development` (artist/developer), `publisher`,
|
||||||
|
`players` (player count), `language`.
|
||||||
|
|
||||||
|
### part
|
||||||
|
|
||||||
|
A part is a game component. It is identified by a `package:type#id` string,
|
||||||
|
placed on the board via `setup`, and visualized by routes.
|
||||||
|
|
||||||
|
#### part value types
|
||||||
|
|
||||||
|
- `image` — a url to an image.
|
||||||
|
- `crop` — a tuple `[col, row, cols, rows]`. Divides the image into a grid
|
||||||
|
and picks the cell at `[col, row]` with size `[width/cols, height/rows]`.
|
||||||
|
Negative `cols` flips the rendered image.
|
||||||
|
- `size` — a tuple `[width, height, depth]` in mm units.
|
||||||
|
|
||||||
|
#### part props
|
||||||
|
|
||||||
|
- `face` — `sprite`. Used for texture.
|
||||||
|
- `faceCrop` — `crop` for `face`.
|
||||||
|
- `back` — `sprite`. Used for texture. Defaults to `face`.
|
||||||
|
- `backCrop` — `crop` for `back`.
|
||||||
|
- `shape` — `sprite`. Traced for its profile to create the mesh for the part.
|
||||||
|
Defaults to the full rect of the back image.
|
||||||
|
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
|
||||||
|
ratio is kept, but not z (thickness).
|
||||||
|
- `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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: part
|
||||||
|
type: token
|
||||||
|
id: wood
|
||||||
|
face: ./assets/tokens.png
|
||||||
|
faceCrop: [1, 0, 5, 2]
|
||||||
|
back: ./assets/tokens.png
|
||||||
|
backCrop: [3, 0, 5, 2]
|
||||||
|
shape: ./assets/token-shape.png
|
||||||
|
size: [20, 20, 3]
|
||||||
|
fillet: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
A `wood` token: the `face` and `back` sprites come from the same sheet,
|
||||||
|
`faceCrop`/`backCrop` picking different cells of the `5×2` grid. The shape is
|
||||||
|
traced from `token-shape.png`, sized `20×20×3` mm with a `2` mm fillet.
|
||||||
|
|
||||||
|
### surface
|
||||||
|
|
||||||
|
A `surface` is a **view** over the state store, purely for **visual rendering**.
|
||||||
|
It has a reference `size` (`[width, height]` in mm) and a `layout` list of
|
||||||
|
routes. The size is a reference — it may be scaled to fit larger or smaller
|
||||||
|
tables. It does not affect part placement; placement lives in the state store
|
||||||
|
(see §4). A surface need not cover every part — parts with no matching route on
|
||||||
|
this surface are simply not shown.
|
||||||
|
|
||||||
|
A surface also declares how it is **mounted**: as the root table surface, on a
|
||||||
|
HUD area, or as a child of another surface. `mount` is always an object, with
|
||||||
|
`x`, `y`, and `rotation` (defaulting to `0`) anchoring it like a route. The
|
||||||
|
`kind` selects the mount type:
|
||||||
|
|
||||||
|
- `table` — the root table surface (default).
|
||||||
|
- `hud` — mounted to a HUD area, e.g. a player's hand.
|
||||||
|
- `child` — mounted relative to a parent surface. A surface lists its
|
||||||
|
`children` (`type#id` refs) so a surface can be repeated, like a player
|
||||||
|
board; each child is mounted relative to its parent's anchor.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: board
|
||||||
|
id: harbor
|
||||||
|
role: surface
|
||||||
|
size: [300, 200]
|
||||||
|
mount:
|
||||||
|
kind: table
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
children:
|
||||||
|
- board#player
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: hud
|
||||||
|
id: hand
|
||||||
|
role: surface
|
||||||
|
size: [200, 100]
|
||||||
|
mount:
|
||||||
|
kind: hud
|
||||||
|
area: bottom-left
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: board
|
||||||
|
id: player
|
||||||
|
role: surface
|
||||||
|
size: [200, 200]
|
||||||
|
mount:
|
||||||
|
kind: child
|
||||||
|
x: 100
|
||||||
|
y: 50
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### setup
|
||||||
|
|
||||||
|
`setup` seeds the state store: the enabled surfaces and the part placement.
|
||||||
|
Each valid game state is a valid setup.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: setup
|
||||||
|
type: game
|
||||||
|
id: main
|
||||||
|
surfaces:
|
||||||
|
- board#harbor
|
||||||
|
- hud#hand
|
||||||
|
setup:
|
||||||
|
- path: /dock/0
|
||||||
|
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
|
||||||
|
disabled and not rendered. When `surfaces` is omitted, all surfaces are
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
`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".
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
`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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Concepts
|
||||||
|
|
||||||
|
### Game state
|
||||||
|
|
||||||
|
The board's state is a **state store**: the set of **enabled surfaces** and a
|
||||||
|
map from path to a **stack** of parts. It is the authoritative record of which
|
||||||
|
surfaces are in play and where every part is placed.
|
||||||
|
|
||||||
|
A path is a URL path with named params, like `/dock/1`.
|
||||||
|
|
||||||
|
A part is identified by a `package:type#id` string.
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
A route is a **visualization route**: it maps a part to a location on a
|
||||||
|
surface. Routes match the keys of the state store, but they are defined by a
|
||||||
|
surface and need not cover every placed part — a part with no matching route on
|
||||||
|
a given surface is simply not shown there. Routes exist only for game parts; a
|
||||||
|
surface is not a part and never appears on a route.
|
||||||
|
|
||||||
|
A route matches all parts on the path; the placement of each individual part on
|
||||||
|
the stack is a separate concern.
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
x: 40
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Candidates
|
||||||
|
|
||||||
|
To match a class of routes against a list of positions, keep a single route with its param and give it a
|
||||||
|
`candidates` array to match `:param` against, each candidate carrying its own `x`/`y`/`rotation`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv
|
||||||
|
seat,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,40,0,0
|
||||||
|
1,40,20,0
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
When multiple parts live on a path, only the top (last) one shows by default.
|
||||||
|
To override this, add stacking strategies:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
layout:
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
stacking:
|
||||||
|
curve: M 0 0 C 20 -20 40 -20 60 0
|
||||||
|
limit: 5
|
||||||
|
align: center
|
||||||
|
steps: 4
|
||||||
|
tilt: 0.1
|
||||||
|
zStart: 0
|
||||||
|
zEnd: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
- `curve` — an SVG path string to spread the content along, relative to the
|
||||||
|
anchor `x`, `y`, `rotation`.
|
||||||
|
- `limit` — how many parts to display. `0` shows all, `3` shows the first 3,
|
||||||
|
`-3` shows the last 3.
|
||||||
|
- `align` — `start`, `end`, or `center` of the curve.
|
||||||
|
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
||||||
|
`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
|
||||||
|
|
||||||
|
1. **Determine the step length.** It is `curve length / max(steps, # of
|
||||||
|
parts on path − 1)`.
|
||||||
|
2. **Determine the alignment.** It places the span of
|
||||||
|
`step length × (# of parts − 1)` on the curve.
|
||||||
|
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
||||||
|
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
|
||||||
|
|
||||||
|
- Object with no matching route → **not placed on this surface**. The game
|
||||||
|
state is still valid — the part simply isn't visualized. A surface is a view
|
||||||
|
over the state store, not a mirror of it, and may show only a subset (e.g. a
|
||||||
|
player's hand on the HUD).
|
||||||
|
- Route with no matching object → empty, fine.
|
||||||
|
- Multiple routes match one path -> first route wins.
|
||||||
|
- Multiple parts on one path → **stack** (see §4 Stacking). One route wins
|
||||||
|
for all parts on a path, and the stacking strategy decides what's shown
|
||||||
|
(it may drop parts that are not dropped on other matching routes).
|
||||||
@@ -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.
|
||||||
+65
-1
@@ -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,
|
||||||
@@ -260,3 +260,67 @@ boundary.
|
|||||||
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 |
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# bgm Loader — Status
|
||||||
|
|
||||||
|
> Status: current. What's built, what works, what's missing, and the known
|
||||||
|
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
|
||||||
|
|
||||||
|
## What's built
|
||||||
|
|
||||||
|
### `packages/bgm` — the loader core (new)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, `SurfaceMount`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` |
|
||||||
|
| `src/schemas.ts` | zod schemas + `validate*` for each role |
|
||||||
|
| `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
|
||||||
|
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) |
|
||||||
|
| `src/variants.ts` | `$variants` expansion via **`typed-csv`** (`typed-csv/csv-loader`). Inline (newline) vs path; paths resolve against the virtual def map |
|
||||||
|
| `src/collect.ts` | `loadDefs` (real + virtual, virtual wins), `collectPackages` (package decl → `include` globs via **picomatch** → parts/surfaces/setups, `type#id` uniqueness, `$variants` on defs and route candidates). Sets each part's `baseUrl` to its source file's directory for resolving relative asset paths |
|
||||||
|
| `src/vite.ts` | The **vite plugin** (`bgm()`): resolves `virtual:bgm/packages` (all packages) and `virtual:bgm/package/<id>` (one package) imports to `export default <json>`, watches source files for reload. Serializes the package's `Map`s to objects (`SerializedPackage`); throws on unknown packages; re-collects per `load` (no stale cache). |
|
||||||
|
| `src/*.test.ts` | **23 tests, all passing** — markdown extractor, typed-csv parsing (incl. the spec's empty-array + crop tuple cases), full harbor collection, plugin unit tests (resolve/load/shape/watch), and a real `vite build` integration test covering two packages |
|
||||||
|
|
||||||
|
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
|
||||||
|
|
||||||
|
### 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`. 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)
|
||||||
|
|
||||||
|
A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards, each picking a cell from a `13×4` face sheet (`cards-13x4.jpg`) and a shared `4×1` back sheet (`back-4x1.png`). Assets are stored in Git LFS (`.gitattributes`).
|
||||||
|
|
||||||
|
### `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.md`](./bgm-tabletop.md).
|
||||||
|
|
||||||
|
### `packages/http` — shared proxy HTTP (new)
|
||||||
|
|
||||||
|
CORS-safe proxy HTTP helpers shared by the web app and `@tts/tabletop`: `assetUrl`/`resolveAssetUrl` (route external assets through `/asset`) and `traceImage` (image → vector shape via `/trace`, BSON-deserialized). The trace-to-shape geometry conversion lives in `@tts/mesh` (`traceToShape`/`traceToUvBounds`), and `ErrorBoundary` is shared via `@tts/tabletop`.
|
||||||
|
|
||||||
|
### `apps/proxy` — local game assets (new)
|
||||||
|
|
||||||
|
The proxy serves local game assets from the `games` root: `GAMES_ROOT` (env or default) is set at startup (`config.ts`) and read by the `/asset` and `/trace` routes to serve relative paths (e.g. `poker/parts/assets/cards.png`) from disk, alongside the existing http(s) proxy path.
|
||||||
|
|
||||||
|
### `apps/web` — consumer (new)
|
||||||
|
|
||||||
|
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
|
||||||
|
- `src/vite-env.d.ts` — ambient `declare module 'virtual:bgm/packages'` (all packages) and `'virtual:bgm/package/*'` (one package).
|
||||||
|
- `src/pages/BgmPage.tsx` — list page: imports `virtual:bgm/packages` and shows every discovered package. Routed at `/bgm`.
|
||||||
|
- `src/pages/BgmPackagePage.tsx` — detail page: looks up a package by id from `bgm` and renders its parts/surfaces/setups. Routed at `/bgm/:id`.
|
||||||
|
|
||||||
|
The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/bgm`), not in the web app — so the loader's plugin is tested in isolation from the web project.
|
||||||
|
|
||||||
|
## Works
|
||||||
|
|
||||||
|
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
|
||||||
|
- Root `pnpm test`: **191 pass**.
|
||||||
|
- `pnpm --filter @tts/web build` succeeds; config warnings fixed.
|
||||||
|
- `src/vite.test.ts` runs a **real `vite build`** against a self-contained fixture (`src/__fixtures__/vite-build/`) and asserts the bundled output contains the package data — the plugin is proven end-to-end without touching the web app.
|
||||||
|
- `pnpm --filter @tts/tabletop build` / `test` pass; `pnpm --filter @tts/proxy typecheck` passes.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
1. ~~Plugin emits empty maps~~ — fixed: `load` serializes `Map`s via `Object.fromEntries`.
|
||||||
|
2. ~~Plugin `load` uses `this.error`~~ — fixed: throws instead.
|
||||||
|
3. ~~HMR cache not invalidated~~ — fixed: dropped the closure cache; re-collects per `load`.
|
||||||
|
4. ~~`tinyglobby` leftover dep~~ — removed.
|
||||||
|
5. ~~Package-level test script finds no files~~ — added `packages/bgm/vitest.config.ts`.
|
||||||
|
|
||||||
|
## Not yet done
|
||||||
|
|
||||||
|
- ~~No consumer yet~~ — `apps/web/src/pages/BgmPage.tsx` lists packages from `virtual:bgm/packages`; `BgmPackagePage.tsx` shows one at `/bgm/:id`.
|
||||||
|
- **`$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`.
|
||||||
|
- **`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 `../bgm/tabletop.md`).
|
||||||
|
- Docs for the loader itself (this file is the start).
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# bgm-tabletop — Implementation Plan / Status
|
||||||
|
|
||||||
|
> **Scope:** A standalone r3f component library that renders
|
||||||
|
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
|
||||||
|
> placement with stacking, and per-part meshes. Design:
|
||||||
|
> [`../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
|
||||||
|
|
||||||
|
A library (new `packages/tabletop`) that takes a bgm package and renders it as
|
||||||
|
an interactive 3D table: enabled surfaces mounted in world/HUD space, parts
|
||||||
|
placed on their routes, stacked per the format's stacking strategy. The web
|
||||||
|
app's bgm inspector routes are one consumer; the library must not depend on the
|
||||||
|
web app.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
`react`, `react-router` (types only), `tailwind` (styles only), `r3f`
|
||||||
|
(`@react-three/fiber`), `drei`, `postprocessing`, `zustand`, `three`,
|
||||||
|
`@tts/bgm` (types), `@tts/mesh` (geometry).
|
||||||
|
|
||||||
|
## Package layout
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/tabletop/
|
||||||
|
package.json # @tts/tabletop
|
||||||
|
tsconfig.json
|
||||||
|
vitest.config.ts
|
||||||
|
src/
|
||||||
|
index.ts # public exports
|
||||||
|
state.ts # zustand store + derived render state
|
||||||
|
setup.ts # SetupLoader: seed state from a setup
|
||||||
|
mount.ts # resolve surface mount tree (table/hud/child)
|
||||||
|
stacking.ts # useStacking hook
|
||||||
|
placement.ts # PartPlacement
|
||||||
|
partView.tsx # PartView: mesh from a part definition
|
||||||
|
surfaces/
|
||||||
|
WorldSurfaceView.tsx
|
||||||
|
HudSurfaceView.tsx
|
||||||
|
*.test.ts # colocated unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Work items
|
||||||
|
|
||||||
|
### 1. Package scaffold ✅
|
||||||
|
|
||||||
|
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
|
||||||
|
globs `packages/*`).
|
||||||
|
- Deps: `@tts/bgm`, `@tts/mesh`, `three`, `@react-three/fiber`, `@react-three/drei`,
|
||||||
|
`@react-three/postprocessing`, `zustand`. Dev: `vitest`, `typescript`,
|
||||||
|
`@types/three`.
|
||||||
|
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
|
||||||
|
|
||||||
|
### 2. Part meshes + export + web integration ✅
|
||||||
|
|
||||||
|
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
|
||||||
|
useful slice and unblocks the web app's part inspection route immediately.
|
||||||
|
|
||||||
|
- `PartView` (`partView.tsx`): creates a mesh from a `Part` definition:
|
||||||
|
- `size` → world dimensions; `fillet` → corner radius.
|
||||||
|
- `face`/`faceCrop`/`back`/`backCrop` → textures (drei `useTexture`), sprite
|
||||||
|
UVs from `faceCrop`/`backCrop` (a `[col,row,cols,rows]` grid cell).
|
||||||
|
- `shape` → traced silhouette (via the proxy `/trace`, like the web token
|
||||||
|
viewer) or a fallback rect/rounded-rect.
|
||||||
|
- `extrudeShapeParts` from `@tts/mesh` for the mesh.
|
||||||
|
- Shared geometry/material caching (module-level `Map`s) so repeated parts
|
||||||
|
reuse buffers, mirroring the web viewers' `sharedResources`.
|
||||||
|
- Export `PartView` from `index.ts`.
|
||||||
|
- **Web integration**: replace the web app's part inspection route
|
||||||
|
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
|
||||||
|
it end-to-end.
|
||||||
|
|
||||||
|
### 3. State store (`state.ts`) ✅
|
||||||
|
|
||||||
|
Source-of-truth game state per `../bgm/tabletop.md` §2:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GameState {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- A zustand store holding `GameState`.
|
||||||
|
- **Derived render state**: `game state + surface routes => map of piece id to
|
||||||
|
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
|
||||||
|
Computed with a selector/memo so the render list is stable. A path's ordered
|
||||||
|
children (for stacking) are derived from the parts map by sorting on `index`.
|
||||||
|
- **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`) ✅
|
||||||
|
|
||||||
|
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||||
|
`Setup` — enables its `surfaces` (or all when omitted) and applies its
|
||||||
|
ordered `setup` placements (each moves its `parts` to a `path`).
|
||||||
|
- `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
|
||||||
|
game-state init concern, so it lives here).
|
||||||
|
|
||||||
|
### 5. Surface mounting (`mount.ts`) ✅
|
||||||
|
|
||||||
|
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
|
||||||
|
- `kind: table` — root, world space.
|
||||||
|
- `kind: hud` — HUD area (`mount.area`).
|
||||||
|
- `kind: child` — mounted relative to a parent that lists it in `children`.
|
||||||
|
- `WorldSurfaceView` / `HudSurfaceView` mount an enabled surface; a disabled
|
||||||
|
surface isn't rendered. Child surfaces mount relative to their parent's
|
||||||
|
anchor (`x`/`y`/`rotation`).
|
||||||
|
|
||||||
|
### 6. Part placement (`placement.ts`) ✅
|
||||||
|
|
||||||
|
- `PartPlacement`: stable per-part component that positions a part on a surface
|
||||||
|
location from the derived render state (route anchor + candidate anchor).
|
||||||
|
- Applies the route's stacking strategy via `useStacking`.
|
||||||
|
|
||||||
|
### 7. Stacking (`stacking.ts`) ✅
|
||||||
|
|
||||||
|
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
|
||||||
|
- Implements the format's positioning process (`../bgm/format.md` §4): step
|
||||||
|
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||||
|
`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).
|
||||||
|
|
||||||
|
### 8. Public API (`index.ts`) ✅
|
||||||
|
|
||||||
|
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
|
||||||
|
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
|
||||||
|
library never imports from `apps/*`.
|
||||||
|
|
||||||
|
## Reuse from `@tts/mesh`
|
||||||
|
|
||||||
|
- `extrudeShapeParts` / `extrudeShape` — front/back/walls geometry.
|
||||||
|
- `rectShape`, `roundedRectShape`, `circleShape`, `polygonShape`, `hexShape`,
|
||||||
|
`frameShape`, `scaleShape` — shape generators for parts without a `shape`
|
||||||
|
sprite.
|
||||||
|
- `shapeFromThree` — author shapes with the three.js path API.
|
||||||
|
- `ExtrudedGeometry` / `UVBounds` — raw typed arrays + UV framing.
|
||||||
|
|
||||||
|
The web viewers (`TokenViewer`/`CardViewer`) contain logic we'll mirror rather
|
||||||
|
than import: trace-to-shape conversion, sprite UV math, texture flipping. These
|
||||||
|
are candidates to lift into `@tts/mesh` or `@tts/tabletop` later so both
|
||||||
|
consumers share them (see Open decisions).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `state.ts` — derived render state: enabled surfaces, route matching,
|
||||||
|
candidate selection, stacking index/stackSize.
|
||||||
|
- `stacking.ts` — positioning process: step length, alignment, limit, z ramp,
|
||||||
|
tilt.
|
||||||
|
- `setup.ts` — seeding + bare-type expansion.
|
||||||
|
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
||||||
|
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
||||||
|
- A real `vite build` integration test (mirroring `packages/bgm/src/vite.test.ts`)
|
||||||
|
proving the library bundles against a fixture package.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `pnpm --filter @tts/tabletop build` / `typecheck` / `test`.
|
||||||
|
- Root `pnpm test` stays green.
|
||||||
|
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
|
||||||
|
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)
|
||||||
|
|
||||||
|
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
|
||||||
|
by web viewers + tabletop) vs duplicate in `@tts/tabletop`. Lifting is
|
||||||
|
cleaner but touches the web viewers; decide when `PartView` (work item 2)
|
||||||
|
needs them.
|
||||||
|
- **HUD rendering** — **drei `Html`/orthographic overlay** vs a second
|
||||||
|
`Canvas`. Default to an overlay so world + HUD share one scene.
|
||||||
|
- **Curve length** — **small internal SVG-path length helper** vs a dependency
|
||||||
|
(e.g. `svg-path-properties`). Prefer the helper to avoid a dep.
|
||||||
@@ -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).
|
||||||
|
|
||||||
@@ -110,7 +111,9 @@ view and the full-setup view.
|
|||||||
caches
|
caches
|
||||||
- `apps/web/src/components/viewers/transform.ts` — new TTS→three.js placement
|
- `apps/web/src/components/viewers/transform.ts` — new TTS→three.js placement
|
||||||
conversion
|
conversion
|
||||||
- `packages/shared/src/types.ts` — add `TTSObjectTransform`
|
- `apps/web/src/components/viewers/sharedResources.ts` — `objectTint`/
|
||||||
|
`tintedColor` helpers for the per-object `ColorDiffuse` tint
|
||||||
|
- `packages/shared/src/types.ts` — add `TTSObjectTransform` and `ColorDiffuse`
|
||||||
- `apps/web/src/pages/FullSetupPage.tsx` — new
|
- `apps/web/src/pages/FullSetupPage.tsx` — new
|
||||||
- `apps/web/src/App.tsx` — route
|
- `apps/web/src/App.tsx` — route
|
||||||
- `apps/web/src/pages/ModPage.tsx` — nav link
|
- `apps/web/src/pages/ModPage.tsx` — nav link
|
||||||
@@ -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,6 +55,15 @@ tts-workshop/
|
|||||||
├── .npmrc
|
├── .npmrc
|
||||||
├── .env.example # STEAM_API_KEY, PORT
|
├── .env.example # STEAM_API_KEY, PORT
|
||||||
├── docs/
|
├── docs/
|
||||||
|
│ ├── overview.md
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── decisions.md
|
||||||
|
│ ├── bgm/
|
||||||
|
│ │ ├── format.md
|
||||||
|
│ │ ├── engine.md
|
||||||
|
│ │ ├── commands.md
|
||||||
|
│ │ └── tabletop.md
|
||||||
|
│ └── status/
|
||||||
│ └── implementation-plan.md # this file
|
│ └── implementation-plan.md # this file
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── proxy/
|
│ ├── proxy/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,162 @@
|
|||||||
|
# Poker
|
||||||
|
|
||||||
|
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
|
||||||
|
generate a full deck from a single part definition.
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
id: poker
|
||||||
|
title: Poker
|
||||||
|
designer: Public Domain
|
||||||
|
players: 9
|
||||||
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parts
|
||||||
|
|
||||||
|
A single `card` part expanded into 52 cards by the `$variants` CSV. Each row
|
||||||
|
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
|
||||||
|
(`back-4x1.png`).
|
||||||
|
|
||||||
|
```yaml role=part.card
|
||||||
|
face: ./cards-13x4.jpg
|
||||||
|
back: ./back-4x1.png
|
||||||
|
size: [63, 88, 0.3]
|
||||||
|
fillet: 2
|
||||||
|
$variants: ./cards.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=cards.csv
|
||||||
|
id,rank,suit,faceCrop,backCrop
|
||||||
|
string,string,string,[number;number;number;number],[number;number;number;number]
|
||||||
|
2s,2,spades,[0;0;13;4],[0;0;4;1]
|
||||||
|
3s,3,spades,[1;0;13;4],[0;0;4;1]
|
||||||
|
4s,4,spades,[2;0;13;4],[0;0;4;1]
|
||||||
|
5s,5,spades,[3;0;13;4],[0;0;4;1]
|
||||||
|
6s,6,spades,[4;0;13;4],[0;0;4;1]
|
||||||
|
7s,7,spades,[5;0;13;4],[0;0;4;1]
|
||||||
|
8s,8,spades,[6;0;13;4],[0;0;4;1]
|
||||||
|
9s,9,spades,[7;0;13;4],[0;0;4;1]
|
||||||
|
10s,10,spades,[8;0;13;4],[0;0;4;1]
|
||||||
|
js,J,spades,[9;0;13;4],[0;0;4;1]
|
||||||
|
qs,Q,spades,[10;0;13;4],[0;0;4;1]
|
||||||
|
ks,K,spades,[11;0;13;4],[0;0;4;1]
|
||||||
|
as,A,spades,[12;0;13;4],[0;0;4;1]
|
||||||
|
2h,2,hearts,[0;1;13;4],[0;0;4;1]
|
||||||
|
3h,3,hearts,[1;1;13;4],[0;0;4;1]
|
||||||
|
4h,4,hearts,[2;1;13;4],[0;0;4;1]
|
||||||
|
5h,5,hearts,[3;1;13;4],[0;0;4;1]
|
||||||
|
6h,6,hearts,[4;1;13;4],[0;0;4;1]
|
||||||
|
7h,7,hearts,[5;1;13;4],[0;0;4;1]
|
||||||
|
8h,8,hearts,[6;1;13;4],[0;0;4;1]
|
||||||
|
9h,9,hearts,[7;1;13;4],[0;0;4;1]
|
||||||
|
10h,10,hearts,[8;1;13;4],[0;0;4;1]
|
||||||
|
jh,J,hearts,[9;1;13;4],[0;0;4;1]
|
||||||
|
qh,Q,hearts,[10;1;13;4],[0;0;4;1]
|
||||||
|
kh,K,hearts,[11;1;13;4],[0;0;4;1]
|
||||||
|
ah,A,hearts,[12;1;13;4],[0;0;4;1]
|
||||||
|
2d,2,diamonds,[0;2;13;4],[0;0;4;1]
|
||||||
|
3d,3,diamonds,[1;2;13;4],[0;0;4;1]
|
||||||
|
4d,4,diamonds,[2;2;13;4],[0;0;4;1]
|
||||||
|
5d,5,diamonds,[3;2;13;4],[0;0;4;1]
|
||||||
|
6d,6,diamonds,[4;2;13;4],[0;0;4;1]
|
||||||
|
7d,7,diamonds,[5;2;13;4],[0;0;4;1]
|
||||||
|
8d,8,diamonds,[6;2;13;4],[0;0;4;1]
|
||||||
|
9d,9,diamonds,[7;2;13;4],[0;0;4;1]
|
||||||
|
10d,10,diamonds,[8;2;13;4],[0;0;4;1]
|
||||||
|
jd,J,diamonds,[9;2;13;4],[0;0;4;1]
|
||||||
|
qd,Q,diamonds,[10;2;13;4],[0;0;4;1]
|
||||||
|
kd,K,diamonds,[11;2;13;4],[0;0;4;1]
|
||||||
|
ad,A,diamonds,[12;2;13;4],[0;0;4;1]
|
||||||
|
2c,2,clubs,[0;3;13;4],[0;0;4;1]
|
||||||
|
3c,3,clubs,[1;3;13;4],[0;0;4;1]
|
||||||
|
4c,4,clubs,[2;3;13;4],[0;0;4;1]
|
||||||
|
5c,5,clubs,[3;3;13;4],[0;0;4;1]
|
||||||
|
6c,6,clubs,[4;3;13;4],[0;0;4;1]
|
||||||
|
7c,7,clubs,[5;3;13;4],[0;0;4;1]
|
||||||
|
8c,8,clubs,[6;3;13;4],[0;0;4;1]
|
||||||
|
9c,9,clubs,[7;3;13;4],[0;0;4;1]
|
||||||
|
10c,10,clubs,[8;3;13;4],[0;0;4;1]
|
||||||
|
jc,J,clubs,[9;3;13;4],[0;0;4;1]
|
||||||
|
qc,Q,clubs,[10;3;13;4],[0;0;4;1]
|
||||||
|
kc,K,clubs,[11;3;13;4],[0;0;4;1]
|
||||||
|
ac,A,clubs,[12;3;13;4],[0;0;4;1]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Board
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```yaml role=surface.board
|
||||||
|
id: poker
|
||||||
|
size: [600, 400]
|
||||||
|
layout:
|
||||||
|
- route: /deck
|
||||||
|
x: -250
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
stacking:
|
||||||
|
align: center
|
||||||
|
zStart: 0
|
||||||
|
#zEnd: 100
|
||||||
|
curve: M -50 -200 C 50 -150 450 -150 550 -200
|
||||||
|
- route: /community/:slot
|
||||||
|
candidates:
|
||||||
|
$variants: ./community.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=community.csv
|
||||||
|
slot,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,-100,0,0
|
||||||
|
1,-50,0,0
|
||||||
|
2,0,0,0
|
||||||
|
3,50,0,0
|
||||||
|
4,100,0,0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
|
||||||
|
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 role=setup.game
|
||||||
|
id: main
|
||||||
|
setup:
|
||||||
|
- path: /deck
|
||||||
|
parts: poker:card
|
||||||
|
facing: back
|
||||||
|
- 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@tts/bgm",
|
||||||
|
"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\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vite": "^8.2.1",
|
||||||
|
"marked": "^16.0.0",
|
||||||
|
"picomatch": "^4.0.5",
|
||||||
|
"smol-toml": "^1.4.0",
|
||||||
|
"typed-csv": "^2.0.0",
|
||||||
|
"yaml": "^2.4.2",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
|
"@types/picomatch": "^4.0.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Harbor
|
||||||
|
|
||||||
|
A tiny example game used to exercise the bgm loader.
|
||||||
|
|
||||||
|
```yaml role=package
|
||||||
|
id: harbor
|
||||||
|
title: Harbor
|
||||||
|
designer: Jane Doe
|
||||||
|
players: 2
|
||||||
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
```yaml role=part.token
|
||||||
|
id: wood
|
||||||
|
face: ./assets/tokens.png
|
||||||
|
faceCrop: [1, 0, 5, 2]
|
||||||
|
back: ./assets/tokens.png
|
||||||
|
backCrop: [3, 0, 5, 2]
|
||||||
|
shape: ./assets/token-shape.png
|
||||||
|
size: [20, 20, 3]
|
||||||
|
fillet: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=part.token
|
||||||
|
id: grain
|
||||||
|
face: ./assets/tokens.png
|
||||||
|
faceCrop: [0, 0, 5, 2]
|
||||||
|
back: ./assets/tokens.png
|
||||||
|
backCrop: [2, 0, 5, 2]
|
||||||
|
shape: ./assets/token-shape.png
|
||||||
|
size: [20, 20, 3]
|
||||||
|
fillet: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Board
|
||||||
|
|
||||||
|
```yaml role=surface.board
|
||||||
|
id: harbor
|
||||||
|
size: [300, 200]
|
||||||
|
mount:
|
||||||
|
kind: table
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
children:
|
||||||
|
- board#player
|
||||||
|
layout:
|
||||||
|
- route: /dock/:seat
|
||||||
|
candidates:
|
||||||
|
$variants: ./seats.csv
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml role=surface.board
|
||||||
|
id: player
|
||||||
|
size: [200, 200]
|
||||||
|
mount:
|
||||||
|
kind: child
|
||||||
|
x: 100
|
||||||
|
y: 50
|
||||||
|
rotation: 0
|
||||||
|
layout:
|
||||||
|
- route: /hand/:slot
|
||||||
|
candidates:
|
||||||
|
$variants: ./hand.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=hand.csv
|
||||||
|
slot,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,0,0,0
|
||||||
|
1,0,20,0
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=seats.csv
|
||||||
|
seat,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,40,0,0
|
||||||
|
1,40,20,0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```yaml role=setup.game
|
||||||
|
id: main
|
||||||
|
surfaces:
|
||||||
|
- board#harbor
|
||||||
|
- board#player
|
||||||
|
setup:
|
||||||
|
- path: /dock/0
|
||||||
|
parts: harbor:token#wood
|
||||||
|
- path: /deck
|
||||||
|
parts: harbor:token#grain
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user