Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6502fdae4f | ||
|
|
45bf362fbc | ||
|
|
82caa9bb9c | ||
|
|
dfe49bad0c | ||
|
|
2001616d9e | ||
|
|
77ffd8ff00 | ||
|
|
5a3a9c1fcc | ||
|
|
50c6df48b5 | ||
|
|
f12b40e82b | ||
|
|
8d0e393100 | ||
|
|
43c6334413 | ||
|
|
b312bf4f1f | ||
|
|
90baa35c7c | ||
|
|
c5d6dff12d | ||
|
|
2da04940c2 | ||
|
|
4727a00e26 | ||
|
|
5808e15c45 | ||
|
|
abf901418b | ||
|
|
d9d38c2bee | ||
|
|
f494a6f9be | ||
|
|
cd08e6af04 |
@@ -38,7 +38,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 |
|
||||||
|
|
||||||
|
|||||||
+31
-18
@@ -1,15 +1,21 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { Link, Route, Routes } from 'react-router-dom';
|
import { Link, Route, Routes } 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 BgmPage from './pages/BgmPage';
|
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
|
||||||
import BgmPackagePage from './pages/BgmPackagePage';
|
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
|
||||||
import PartsPage from './pages/PartsPage';
|
// stack stays code-split out of the main bundle and is only fetched when one
|
||||||
import PartPage from './pages/PartPage';
|
// of those routes is actually visited.
|
||||||
import SurfacesPage from './pages/SurfacesPage';
|
const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
|
||||||
import SurfacePage from './pages/SurfacePage';
|
const BgmPage = lazy(() => import('./pages/BgmPage'));
|
||||||
import SetupsPage from './pages/SetupsPage';
|
const BgmPackagePage = lazy(() => import('./pages/BgmPackagePage'));
|
||||||
import SetupPage from './pages/SetupPage';
|
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() {
|
||||||
return (
|
return (
|
||||||
@@ -33,15 +39,22 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SearchPage />} />
|
<Route path="/" element={<SearchPage />} />
|
||||||
<Route path="/mod/:id" element={<ModPage />} />
|
<Route path="/mod/:id" element={<ModPage />} />
|
||||||
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
<Route
|
||||||
<Route path="/bgm" element={<BgmPage />} />
|
path="/mod/:id/setup"
|
||||||
<Route path="/bgm/:id" element={<BgmPackagePage />} />
|
element={
|
||||||
<Route path="/bgm/:id/parts" element={<PartsPage />} />
|
<Suspense fallback={<p className="text-sm text-zinc-400">Loading full setup…</p>}>
|
||||||
<Route path="/bgm/:id/parts/:type/:part" element={<PartPage />} />
|
<FullSetupPage />
|
||||||
<Route path="/bgm/:id/surfaces" element={<SurfacesPage />} />
|
</Suspense>
|
||||||
<Route path="/bgm/:id/surfaces/:type/:surface" element={<SurfacePage />} />
|
}
|
||||||
<Route path="/bgm/:id/setups" element={<SetupsPage />} />
|
/>
|
||||||
<Route path="/bgm/:id/setups/:type/:setup" element={<SetupPage />} />
|
<Route path="/bgm" element={<Suspense fallback={null}><BgmPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id" element={<Suspense fallback={null}><BgmPackagePage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/parts" element={<Suspense fallback={null}><PartsPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/parts/:type/:part" element={<Suspense fallback={null}><PartPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/surfaces" element={<Suspense fallback={null}><SurfacesPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/surfaces/:type/:surface" element={<Suspense fallback={null}><SurfacePage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/setups" element={<Suspense fallback={null}><SetupsPage /></Suspense>} />
|
||||||
|
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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 { flipTexture } from './flipTexture';
|
||||||
|
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
||||||
|
|
||||||
|
/** Longer card dimension, in world units. */
|
||||||
|
const CARD_LENGTH = 2;
|
||||||
|
/** Corner radius as a fraction of the shorter card edge. */
|
||||||
|
const CORNER_RADIUS = 0.05;
|
||||||
|
/** Thickness of the card. */
|
||||||
|
const CARD_THICKNESS = 0.06;
|
||||||
|
|
||||||
|
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
||||||
|
// Without it, the face/back hooks would be called conditionally, which breaks
|
||||||
|
// React's rules of hooks when switching between objects with different URL
|
||||||
|
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
|
||||||
|
const FALLBACK_URL =
|
||||||
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card mesh for an object, exported so the full-setup view can compose it
|
||||||
|
* into a shared scene.
|
||||||
|
*/
|
||||||
|
export function CardObjectMesh({ object }: { object: TTSObject }) {
|
||||||
|
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
|
||||||
|
resolveCardConfig(object);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardMesh
|
||||||
|
faceUrl={faceUrl}
|
||||||
|
backUrl={backUrl}
|
||||||
|
numWidth={numWidth}
|
||||||
|
numHeight={numHeight}
|
||||||
|
uniqueBack={uniqueBack}
|
||||||
|
cardId={cardId}
|
||||||
|
tint={objectTint(object)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
|
// Exported so the full-setup view can compose it into a shared scene.
|
||||||
|
export function CardMesh({
|
||||||
|
faceUrl,
|
||||||
|
backUrl,
|
||||||
|
numWidth,
|
||||||
|
numHeight,
|
||||||
|
uniqueBack,
|
||||||
|
cardId,
|
||||||
|
tint,
|
||||||
|
}: {
|
||||||
|
faceUrl?: string;
|
||||||
|
backUrl?: string;
|
||||||
|
numWidth?: number;
|
||||||
|
numHeight?: number;
|
||||||
|
uniqueBack: boolean;
|
||||||
|
cardId?: number;
|
||||||
|
tint: THREE.Color;
|
||||||
|
}) {
|
||||||
|
// Always call both hooks so the hook count is stable across renders. The
|
||||||
|
// placeholder is used only when a URL is absent; presence is checked via the
|
||||||
|
// URL strings below, not the texture objects.
|
||||||
|
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
|
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||||
|
|
||||||
|
// Front texture: the sprite cell from the sheet (or the full image when there
|
||||||
|
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
|
||||||
|
// that share the same sheet URL (drei caches textures globally by URL).
|
||||||
|
const faceMap = useMemo(() => {
|
||||||
|
if (!faceUrl) return null;
|
||||||
|
const tex = face.clone();
|
||||||
|
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
|
||||||
|
tex.repeat.set(repeatX, repeatY);
|
||||||
|
tex.offset.set(offsetX, offsetY);
|
||||||
|
return tex;
|
||||||
|
}, [faceUrl, face, cardId, numWidth, numHeight]);
|
||||||
|
|
||||||
|
// Back texture: a single full image (tile) unless the deck has unique backs,
|
||||||
|
// in which case it's a sheet too. Flipped left/right so it reads correctly
|
||||||
|
// instead of being mirrored on the back face.
|
||||||
|
const backMap = useMemo(() => {
|
||||||
|
if (!backUrl) return null;
|
||||||
|
const tex = back.clone();
|
||||||
|
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
|
||||||
|
? spriteUv(cardId, numWidth, numHeight)
|
||||||
|
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
tex.repeat.set(repeatX, repeatY);
|
||||||
|
tex.offset.set(offsetX, offsetY);
|
||||||
|
return flipTexture(tex);
|
||||||
|
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
|
||||||
|
|
||||||
|
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
||||||
|
// front and back faces each get their own material; the walls are a solid
|
||||||
|
// white, matching TTS card tinting. Geometry is shared across cards of the
|
||||||
|
// same size so the full-setup view reuses it; the face/back materials stay
|
||||||
|
// per-card because each card clones its texture for sprite UVs.
|
||||||
|
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||||
|
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
||||||
|
| HTMLImageElement
|
||||||
|
| undefined;
|
||||||
|
const aspect = cardAspect(img, numWidth, numHeight);
|
||||||
|
const width = CARD_LENGTH * aspect;
|
||||||
|
const height = CARD_LENGTH;
|
||||||
|
// Radius scales with the shorter edge so corners look proportional and
|
||||||
|
// stay circular (no scaling distortion).
|
||||||
|
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
|
||||||
|
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
|
||||||
|
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
|
||||||
|
return {
|
||||||
|
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={frontGeo}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||||
|
map={faceMap ?? undefined}
|
||||||
|
roughness={0.6}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh geometry={backGeo}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
||||||
|
map={backMap ?? undefined}
|
||||||
|
roughness={0.6}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh geometry={wallsGeo}>
|
||||||
|
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
@@ -1,31 +1,6 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
|
||||||
extrudeShapeParts,
|
|
||||||
roundedRectShape,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { CardObjectMesh } from './CardMesh';
|
||||||
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
|
|
||||||
import { flipTexture } from './flipTexture';
|
|
||||||
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
|
|
||||||
|
|
||||||
/** Longer card dimension, in world units. */
|
|
||||||
const CARD_LENGTH = 2;
|
|
||||||
/** Corner radius as a fraction of the shorter card edge. */
|
|
||||||
const CORNER_RADIUS = 0.05;
|
|
||||||
/** Thickness of the card. */
|
|
||||||
const CARD_THICKNESS = 0.06;
|
|
||||||
|
|
||||||
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
|
||||||
// Without it, the face/back hooks would be called conditionally, which breaks
|
|
||||||
// React's rules of hooks when switching between objects with different URL
|
|
||||||
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
|
|
||||||
const FALLBACK_URL =
|
|
||||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A playing card: a thin rounded rect with the face texture on the front and
|
* A playing card: a thin rounded rect with the face texture on the front and
|
||||||
@@ -57,132 +32,4 @@ export default function CardViewer({ object }: { object: TTSObject }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { CardObjectMesh, CardMesh } from './CardMesh';
|
||||||
* The card mesh for an object, exported so the full-setup view can compose it
|
|
||||||
* into a shared scene.
|
|
||||||
*/
|
|
||||||
export function CardObjectMesh({ object }: { object: TTSObject }) {
|
|
||||||
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
|
|
||||||
resolveCardConfig(object);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardMesh
|
|
||||||
faceUrl={faceUrl}
|
|
||||||
backUrl={backUrl}
|
|
||||||
numWidth={numWidth}
|
|
||||||
numHeight={numHeight}
|
|
||||||
uniqueBack={uniqueBack}
|
|
||||||
cardId={cardId}
|
|
||||||
tint={objectTint(object)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
||||||
// Exported so the full-setup view can compose it into a shared scene.
|
|
||||||
export function CardMesh({
|
|
||||||
faceUrl,
|
|
||||||
backUrl,
|
|
||||||
numWidth,
|
|
||||||
numHeight,
|
|
||||||
uniqueBack,
|
|
||||||
cardId,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
faceUrl?: string;
|
|
||||||
backUrl?: string;
|
|
||||||
numWidth?: number;
|
|
||||||
numHeight?: number;
|
|
||||||
uniqueBack: boolean;
|
|
||||||
cardId?: number;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
// Always call both hooks so the hook count is stable across renders. The
|
|
||||||
// placeholder is used only when a URL is absent; presence is checked via the
|
|
||||||
// URL strings below, not the texture objects.
|
|
||||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
|
||||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
|
||||||
|
|
||||||
// Front texture: the sprite cell from the sheet (or the full image when there
|
|
||||||
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
|
|
||||||
// that share the same sheet URL (drei caches textures globally by URL).
|
|
||||||
const faceMap = useMemo(() => {
|
|
||||||
if (!faceUrl) return null;
|
|
||||||
const tex = face.clone();
|
|
||||||
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
|
|
||||||
tex.repeat.set(repeatX, repeatY);
|
|
||||||
tex.offset.set(offsetX, offsetY);
|
|
||||||
return tex;
|
|
||||||
}, [faceUrl, face, cardId, numWidth, numHeight]);
|
|
||||||
|
|
||||||
// Back texture: a single full image (tile) unless the deck has unique backs,
|
|
||||||
// in which case it's a sheet too. Flipped left/right so it reads correctly
|
|
||||||
// instead of being mirrored on the back face.
|
|
||||||
const backMap = useMemo(() => {
|
|
||||||
if (!backUrl) return null;
|
|
||||||
const tex = back.clone();
|
|
||||||
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
|
|
||||||
? spriteUv(cardId, numWidth, numHeight)
|
|
||||||
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
|
|
||||||
tex.repeat.set(repeatX, repeatY);
|
|
||||||
tex.offset.set(offsetX, offsetY);
|
|
||||||
return flipTexture(tex);
|
|
||||||
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
|
|
||||||
|
|
||||||
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
|
|
||||||
// front and back faces each get their own material; the walls are a solid
|
|
||||||
// white, matching TTS card tinting. Geometry is shared across cards of the
|
|
||||||
// same size so the full-setup view reuses it; the face/back materials stay
|
|
||||||
// per-card because each card clones its texture for sprite UVs.
|
|
||||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
|
||||||
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
|
|
||||||
| HTMLImageElement
|
|
||||||
| undefined;
|
|
||||||
const aspect = cardAspect(img, numWidth, numHeight);
|
|
||||||
const width = CARD_LENGTH * aspect;
|
|
||||||
const height = CARD_LENGTH;
|
|
||||||
// Radius scales with the shorter edge so corners look proportional and
|
|
||||||
// stay circular (no scaling distortion).
|
|
||||||
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
|
|
||||||
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
|
|
||||||
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
|
|
||||||
return {
|
|
||||||
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
||||||
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
||||||
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
||||||
};
|
|
||||||
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh geometry={frontGeo}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
|
||||||
map={faceMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={backGeo}>
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
|
|
||||||
map={backMap ?? undefined}
|
|
||||||
roughness={0.6}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
<mesh geometry={wallsGeo}>
|
|
||||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
||||||
function toGeometry(extruded: ExtrudedGeometry) {
|
|
||||||
const { positions, normals, uvs, indices } = extruded;
|
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
||||||
return geo;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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));
|
||||||
|
|
||||||
|
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
root.traverse((child) => {
|
||||||
|
const mesh = child as THREE.Mesh;
|
||||||
|
if (mesh.isMesh) {
|
||||||
|
const material = Array.isArray(mesh.material)
|
||||||
|
? mesh.material[0]
|
||||||
|
: mesh.material;
|
||||||
|
if (material && 'map' in material) {
|
||||||
|
material.map = texture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [root, texture]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import { Suspense, useLayoutEffect } from 'react';
|
|
||||||
import { useLoader } from '@react-three/fiber';
|
|
||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { Object3D } from 'three';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { CustomModelMesh } from './CustomModelMesh';
|
||||||
import { FlexibleModelLoader } from './flexibleModelLoader';
|
|
||||||
import { objectTint, tintedColor } from './sharedResources';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
|
||||||
@@ -23,96 +16,4 @@ export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { CustomModelMesh } from './CustomModelMesh';
|
||||||
* The mesh content for a custom model, exported so the full-setup view can
|
|
||||||
* compose it into a shared scene. Renders the model from `CustomMesh.MeshURL`
|
|
||||||
* (or a neutral box placeholder when absent).
|
|
||||||
*/
|
|
||||||
export function CustomModelMesh({ object }: { object: TTSObject }) {
|
|
||||||
const meshUrl = object.CustomMesh?.MeshURL;
|
|
||||||
const tint = objectTint(object);
|
|
||||||
if (!meshUrl) {
|
|
||||||
return (
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Model
|
|
||||||
meshUrl={meshUrl}
|
|
||||||
diffuseUrl={object.CustomMesh?.DiffuseURL}
|
|
||||||
tint={tint}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Model({
|
|
||||||
meshUrl,
|
|
||||||
diffuseUrl,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
meshUrl: string;
|
|
||||||
diffuseUrl?: string;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<primitive object={root} scale={0.5} />
|
|
||||||
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
|
|
||||||
<Tint root={root} tint={tint} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the object's tint to every material on the loaded model, multiplying
|
|
||||||
// the existing color. Rendered after the model so it runs once the materials
|
|
||||||
// exist.
|
|
||||||
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
root.traverse((child) => {
|
|
||||||
const mesh = child as THREE.Mesh;
|
|
||||||
if (mesh.isMesh) {
|
|
||||||
const material = Array.isArray(mesh.material)
|
|
||||||
? mesh.material[0]
|
|
||||||
: mesh.material;
|
|
||||||
if (material && 'color' in material) {
|
|
||||||
(material as THREE.MeshStandardMaterial).color.multiply(tint);
|
|
||||||
material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [root, tint]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
|
|
||||||
// mounted when a diffuse URL exists, so the hook count stays consistent.
|
|
||||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
|
||||||
const texture = useTexture(assetUrl(url));
|
|
||||||
|
|
||||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
root.traverse((child) => {
|
|
||||||
const mesh = child as THREE.Mesh;
|
|
||||||
if (mesh.isMesh) {
|
|
||||||
const material = Array.isArray(mesh.material)
|
|
||||||
? mesh.material[0]
|
|
||||||
: mesh.material;
|
|
||||||
if (material && 'map' in material) {
|
|
||||||
material.map = texture;
|
|
||||||
material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [root, texture]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Suspense, type ReactNode } from 'react';
|
import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
|
import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
||||||
@@ -12,15 +13,63 @@ 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
|
||||||
|
* expands the scene to the full screen.
|
||||||
*/
|
*/
|
||||||
export default function Scene({ children }: { children: ReactNode }) {
|
export default function Scene({
|
||||||
|
children,
|
||||||
|
autoRotate = true,
|
||||||
|
enablePan = false,
|
||||||
|
fullscreen = false,
|
||||||
|
overlay,
|
||||||
|
shadowScale = 22,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
enablePan?: boolean;
|
||||||
|
fullscreen?: 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;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
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 h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400"
|
||||||
|
>
|
||||||
<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.5} />
|
||||||
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
||||||
@@ -32,23 +81,23 @@ export default function Scene({ children }: { children: ReactNode }) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<ContactShadows
|
<ContactShadows
|
||||||
position={[0, -0.5, 0]}
|
position={[0, -0.01, 0]}
|
||||||
opacity={0.55}
|
opacity={0.2}
|
||||||
scale={8}
|
scale={shadowScale}
|
||||||
blur={2.4}
|
blur={shadowScale * 0.005}
|
||||||
far={3}
|
far={shadowScale * 0.01}
|
||||||
resolution={256}
|
resolution={1024}
|
||||||
|
color="#000000"
|
||||||
/>
|
/>
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enablePan={false}
|
enablePan={enablePan}
|
||||||
minDistance={0.01}
|
minDistance={0.01}
|
||||||
maxDistance={8}
|
maxDistance={8}
|
||||||
autoRotate
|
autoRotate={autoRotate}
|
||||||
makeDefault
|
makeDefault
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EffectComposer>
|
<EffectComposer>
|
||||||
<Bloom intensity={0.25} luminanceThreshold={0.85} mipmapBlur />
|
|
||||||
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Build the extruded geometry from the tile shape. When `stretch` is false
|
||||||
|
// and a texture is available, scale the shape to the image's aspect ratio so
|
||||||
|
// the tile matches the source proportions instead of being square. Shared
|
||||||
|
// across tiles with the same shape so the full-setup view reuses geometry.
|
||||||
|
const { front, back, walls } = useMemo(() => {
|
||||||
|
const img = texture?.image as HTMLImageElement;
|
||||||
|
const aspect = stretch ? img.width / img.height : 1;
|
||||||
|
const shape = tileShape(type, aspect);
|
||||||
|
const parts = extrudeShapeParts(shape, { height: thickness });
|
||||||
|
const key = `tile:${type}:${aspect}:${thickness}`;
|
||||||
|
return {
|
||||||
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [type, thickness, stretch, texture]);
|
||||||
|
|
||||||
|
// The back face maps with the same planar UVs as the front, so flip it
|
||||||
|
// left/right to avoid a mirrored texture when viewed from behind.
|
||||||
|
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
|
||||||
|
|
||||||
|
// Shared materials: the front/back carry the tile texture (or a neutral
|
||||||
|
// color when absent); the walls are a solid white, matching TTS tinting.
|
||||||
|
// The tint is baked into the color and the cache key so tinted variants
|
||||||
|
// don't collide.
|
||||||
|
const tintK = tintKey(tint);
|
||||||
|
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
|
||||||
|
const faceMat = getSharedMaterial(faceKey, {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: texture ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const backMat = getSharedMaterial(faceKey + ':back', {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: backMap ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
|
||||||
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Front face carries the tile texture. */}
|
||||||
|
<mesh geometry={front} material={faceMat} />
|
||||||
|
{/* Back face, flipped so it isn't mirrored. */}
|
||||||
|
<mesh geometry={back} material={backMat} />
|
||||||
|
{/* Sides are a solid white, matching TTS tile tinting. */}
|
||||||
|
<mesh geometry={walls} material={wallMat} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
||||||
|
function tileShape(type: number, aspect: number) {
|
||||||
|
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
||||||
|
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
||||||
|
const sx = aspect;
|
||||||
|
const sy = 1;
|
||||||
|
switch (type) {
|
||||||
|
case TileType.Hex:
|
||||||
|
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Circle:
|
||||||
|
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Rounded:
|
||||||
|
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
||||||
|
case TileType.Box:
|
||||||
|
default:
|
||||||
|
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,6 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
|
||||||
circleShape,
|
|
||||||
extrudeShapeParts,
|
|
||||||
hexShape,
|
|
||||||
rectShape,
|
|
||||||
roundedRectShape,
|
|
||||||
scaleShape,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { TileObjectMesh } from './TileMesh';
|
||||||
import { flipTexture } from './flipTexture';
|
|
||||||
import {
|
|
||||||
getSharedGeometry,
|
|
||||||
getSharedMaterial,
|
|
||||||
objectTint,
|
|
||||||
tintKey,
|
|
||||||
tintedColor,
|
|
||||||
} from './sharedResources';
|
|
||||||
|
|
||||||
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
|
||||||
const TileType = {
|
|
||||||
Box: 0,
|
|
||||||
Hex: 1,
|
|
||||||
Circle: 2,
|
|
||||||
Rounded: 3,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TILE_SIZE = 2;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||||
@@ -48,124 +18,4 @@ export default function TileViewer({ object }: { object: TTSObject }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { TileObjectMesh, TileMesh } from './TileMesh';
|
||||||
* The tile mesh for an object, exported so the full-setup view can compose it
|
|
||||||
* into a shared scene.
|
|
||||||
*/
|
|
||||||
export function TileObjectMesh({ object }: { object: TTSObject }) {
|
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
||||||
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
|
||||||
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
|
||||||
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TileMesh
|
|
||||||
url={url}
|
|
||||||
thickness={thickness}
|
|
||||||
type={type}
|
|
||||||
stretch={stretch}
|
|
||||||
tint={objectTint(object)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
||||||
// Exported so the full-setup view can compose it into a shared scene.
|
|
||||||
export function TileMesh({
|
|
||||||
url,
|
|
||||||
thickness,
|
|
||||||
type,
|
|
||||||
stretch,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
url?: string;
|
|
||||||
thickness: number;
|
|
||||||
type: number;
|
|
||||||
stretch: boolean;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
|
||||||
|
|
||||||
// Build the extruded geometry from the tile shape. When `stretch` is false
|
|
||||||
// and a texture is available, scale the shape to the image's aspect ratio so
|
|
||||||
// the tile matches the source proportions instead of being square. Shared
|
|
||||||
// across tiles with the same shape so the full-setup view reuses geometry.
|
|
||||||
const { front, back, walls } = useMemo(() => {
|
|
||||||
const img = texture?.image as HTMLImageElement;
|
|
||||||
const aspect = stretch ? img.width / img.height : 1;
|
|
||||||
const shape = tileShape(type, aspect);
|
|
||||||
const parts = extrudeShapeParts(shape, { height: thickness });
|
|
||||||
const key = `tile:${type}:${aspect}:${thickness}`;
|
|
||||||
return {
|
|
||||||
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
||||||
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
||||||
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
||||||
};
|
|
||||||
}, [type, thickness, stretch, texture]);
|
|
||||||
|
|
||||||
// The back face maps with the same planar UVs as the front, so flip it
|
|
||||||
// left/right to avoid a mirrored texture when viewed from behind.
|
|
||||||
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
|
|
||||||
|
|
||||||
// Shared materials: the front/back carry the tile texture (or a neutral
|
|
||||||
// color when absent); the walls are a solid white, matching TTS tinting.
|
|
||||||
// The tint is baked into the color and the cache key so tinted variants
|
|
||||||
// don't collide.
|
|
||||||
const tintK = tintKey(tint);
|
|
||||||
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
|
|
||||||
const faceMat = getSharedMaterial(faceKey, {
|
|
||||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
||||||
map: texture ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
const backMat = getSharedMaterial(faceKey + ':back', {
|
|
||||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
||||||
map: backMap ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
|
|
||||||
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Front face carries the tile texture. */}
|
|
||||||
<mesh geometry={front} material={faceMat} />
|
|
||||||
{/* Back face, flipped so it isn't mirrored. */}
|
|
||||||
<mesh geometry={back} material={backMat} />
|
|
||||||
{/* Sides are a solid white, matching TTS tile tinting. */}
|
|
||||||
<mesh geometry={walls} material={wallMat} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
||||||
function toGeometry(extruded: ExtrudedGeometry) {
|
|
||||||
const { positions, normals, uvs, indices } = extruded;
|
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
||||||
return geo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
|
||||||
function tileShape(type: number, aspect: number) {
|
|
||||||
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
|
||||||
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
|
||||||
const sx = aspect;
|
|
||||||
const sy = 1;
|
|
||||||
switch (type) {
|
|
||||||
case TileType.Hex:
|
|
||||||
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
|
||||||
case TileType.Circle:
|
|
||||||
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
|
||||||
case TileType.Rounded:
|
|
||||||
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
|
||||||
case TileType.Box:
|
|
||||||
default:
|
|
||||||
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||||
|
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||||
|
// once the token geometry is present. Falls back to a circle when there's no
|
||||||
|
// image or the trace fails.
|
||||||
|
const trace = useTrace(url);
|
||||||
|
|
||||||
|
const { front, back, walls } = useMemo(() => {
|
||||||
|
// The traced shape and its UV framing share the same transform, so the
|
||||||
|
// full image rectangle maps to the same bounds in mesh coordinates.
|
||||||
|
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
||||||
|
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
||||||
|
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
||||||
|
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
||||||
|
// Shared across tokens with the same source image (the trace is cached per
|
||||||
|
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
||||||
|
// geometry.
|
||||||
|
const key = `token:${url ?? 'none'}:${thickness}`;
|
||||||
|
return {
|
||||||
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
||||||
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
||||||
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
||||||
|
};
|
||||||
|
}, [trace, thickness, url]);
|
||||||
|
|
||||||
|
// A token is solid: front, back, and walls all carry the texture (projected
|
||||||
|
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
||||||
|
// baked into the color and cache key so tinted variants don't collide.
|
||||||
|
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
||||||
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
||||||
|
map: texture ?? undefined,
|
||||||
|
roughness: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh geometry={front} material={material} />
|
||||||
|
<mesh geometry={back} material={material} />
|
||||||
|
<mesh geometry={walls} material={material} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceData {
|
||||||
|
shape: { outline: number[][]; holes?: number[][][] };
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache traces by URL so Suspense doesn't re-issue the request on every render
|
||||||
|
// while the boundary is held open. A URL maps to either a pending promise (while
|
||||||
|
// loading) or the resolved value (once loaded).
|
||||||
|
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
|
||||||
|
* when there's no URL / the trace fails). Throws the cached promise only while
|
||||||
|
* it's pending; once resolved, the value is returned directly so the retry
|
||||||
|
* render completes instead of suspending forever.
|
||||||
|
*/
|
||||||
|
function useTrace(url: string | undefined): TraceData | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const cached = traceCache.get(url);
|
||||||
|
if (cached === undefined) {
|
||||||
|
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
||||||
|
const value: TraceData | null = result.shape
|
||||||
|
? {
|
||||||
|
shape: result.shape,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
// Replace the pending promise with the resolved value so later renders
|
||||||
|
// return it instead of re-suspending on a settled promise.
|
||||||
|
traceCache.set(url, value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
traceCache.set(url, promise);
|
||||||
|
throw promise;
|
||||||
|
}
|
||||||
|
if (cached instanceof Promise) throw cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
@@ -1,29 +1,6 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
import {
|
|
||||||
circleShape,
|
|
||||||
extrudeShapeParts,
|
|
||||||
traceToShape,
|
|
||||||
traceToUvBounds,
|
|
||||||
type ExtrudedGeometry,
|
|
||||||
} from '@tts/mesh';
|
|
||||||
import { traceImage } from '@tts/http';
|
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from '@tts/http';
|
import { TokenObjectMesh } from './TokenMesh';
|
||||||
import {
|
|
||||||
getSharedGeometry,
|
|
||||||
getSharedMaterial,
|
|
||||||
objectTint,
|
|
||||||
tintKey,
|
|
||||||
tintedColor,
|
|
||||||
} from './sharedResources';
|
|
||||||
|
|
||||||
const TOKEN_SIZE = 1.8;
|
|
||||||
|
|
||||||
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
|
|
||||||
const TRACE_INSET = 2;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A token: a short extruded shape with the texture on its top face. Uses
|
* A token: a short extruded shape with the texture on its top face. Uses
|
||||||
@@ -39,120 +16,4 @@ export default function TokenViewer({ object }: { object: TTSObject }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { TokenObjectMesh, TokenMesh } from './TokenMesh';
|
||||||
* The token mesh for an object, exported so the full-setup view can compose it
|
|
||||||
* into a shared scene.
|
|
||||||
*/
|
|
||||||
export function TokenObjectMesh({ object }: { object: TTSObject }) {
|
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
||||||
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
|
||||||
|
|
||||||
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
||||||
// Exported so the full-setup view can compose it into a shared scene.
|
|
||||||
export function TokenMesh({
|
|
||||||
url,
|
|
||||||
thickness,
|
|
||||||
tint,
|
|
||||||
}: {
|
|
||||||
url?: string;
|
|
||||||
thickness: number;
|
|
||||||
tint: THREE.Color;
|
|
||||||
}) {
|
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
|
||||||
|
|
||||||
// Trace the image's alpha channel into a shape. Suspends until the trace
|
|
||||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
|
||||||
// once the token geometry is present. Falls back to a circle when there's no
|
|
||||||
// image or the trace fails.
|
|
||||||
const trace = useTrace(url);
|
|
||||||
|
|
||||||
const { front, back, walls } = useMemo(() => {
|
|
||||||
// The traced shape and its UV framing share the same transform, so the
|
|
||||||
// full image rectangle maps to the same bounds in mesh coordinates.
|
|
||||||
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
|
||||||
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
|
||||||
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
|
||||||
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
|
||||||
// Shared across tokens with the same source image (the trace is cached per
|
|
||||||
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
|
||||||
// geometry.
|
|
||||||
const key = `token:${url ?? 'none'}:${thickness}`;
|
|
||||||
return {
|
|
||||||
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
||||||
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
||||||
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
||||||
};
|
|
||||||
}, [trace, thickness, url]);
|
|
||||||
|
|
||||||
// A token is solid: front, back, and walls all carry the texture (projected
|
|
||||||
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
|
||||||
// baked into the color and cache key so tinted variants don't collide.
|
|
||||||
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
|
||||||
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
||||||
map: texture ?? undefined,
|
|
||||||
roughness: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh geometry={front} material={material} />
|
|
||||||
<mesh geometry={back} material={material} />
|
|
||||||
<mesh geometry={walls} material={material} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TraceData {
|
|
||||||
shape: { outline: number[][]; holes?: number[][][] };
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache traces by URL so Suspense doesn't re-issue the request on every render
|
|
||||||
// while the boundary is held open. A URL maps to either a pending promise (while
|
|
||||||
// loading) or the resolved value (once loaded).
|
|
||||||
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
|
|
||||||
* when there's no URL / the trace fails). Throws the cached promise only while
|
|
||||||
* it's pending; once resolved, the value is returned directly so the retry
|
|
||||||
* render completes instead of suspending forever.
|
|
||||||
*/
|
|
||||||
function useTrace(url: string | undefined): TraceData | null {
|
|
||||||
if (!url) return null;
|
|
||||||
const cached = traceCache.get(url);
|
|
||||||
if (cached === undefined) {
|
|
||||||
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
|
||||||
const value: TraceData | null = result.shape
|
|
||||||
? {
|
|
||||||
shape: result.shape,
|
|
||||||
width: result.width,
|
|
||||||
height: result.height,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
// Replace the pending promise with the resolved value so later renders
|
|
||||||
// return it instead of re-suspending on a settled promise.
|
|
||||||
traceCache.set(url, value);
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
traceCache.set(url, promise);
|
|
||||||
throw promise;
|
|
||||||
}
|
|
||||||
if (cached instanceof Promise) throw cached;
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
||||||
function toGeometry(extruded: ExtrudedGeometry) {
|
|
||||||
const { positions, normals, uvs, indices } = extruded;
|
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
||||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
||||||
return geo;
|
|
||||||
}
|
|
||||||
@@ -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`). */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import Breadcrumbs from '../components/Breadcrumbs';
|
import Breadcrumbs from '../components/Breadcrumbs';
|
||||||
import PackageMissing from '../components/PackageMissing';
|
import PackageMissing from '../components/PackageMissing';
|
||||||
|
import TabletopScene from '../components/tabletop/TabletopScene';
|
||||||
import { findPackage } from './bgm';
|
import { findPackage } from './bgm';
|
||||||
|
|
||||||
/** Detail view for a single setup within a package. */
|
/** Detail view for a single setup within a package. */
|
||||||
@@ -30,7 +31,12 @@ export default function SetupPage() {
|
|||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
{found.type}#{found.id}
|
{found.type}#{found.id}
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
{Object.keys(found.setup).length} path
|
||||||
|
{Object.keys(found.setup).length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<TabletopScene pkg={pkg} setup={found} />
|
||||||
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
|
||||||
{JSON.stringify(found.setup, null, 2)}
|
{JSON.stringify(found.setup, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -55,7 +55,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`
|
||||||
|
|||||||
+21
-1
@@ -104,6 +104,13 @@ package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
|
|||||||
folders is discovered with no configuration. This also matches the package
|
folders is discovered with no configuration. This also matches the package
|
||||||
declaration itself, which is fine — it's the package, not a part.
|
declaration itself, which is fine — it's the package, not a part.
|
||||||
|
|
||||||
|
Patterns are resolved **relative to the package declaration's own directory**,
|
||||||
|
not the games root. So a package declared in `carcassonne/carcassonne.md`
|
||||||
|
with the default `./**/*.yaml` only picks up yaml under `carcassonne/` — it
|
||||||
|
never absorbs defs from a sibling game. To reach outside its folder, a
|
||||||
|
package can use a `../`-relative pattern or an absolute-from-root pattern
|
||||||
|
(e.g. `**/shared/*.yaml`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Roles
|
## 3. Roles
|
||||||
@@ -341,7 +348,7 @@ string,number,number,number
|
|||||||
|
|
||||||
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
|
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
|
||||||
|
|
||||||
When no candidates match, the whole route fails to match.
|
A candidate inherits the route's `x`, `y`, `rotation`, and `stacking`, and may override any of them with its own values. When no candidates match, the whole route fails to match.
|
||||||
|
|
||||||
### Stacking
|
### Stacking
|
||||||
|
|
||||||
@@ -359,6 +366,9 @@ layout:
|
|||||||
limit: 5
|
limit: 5
|
||||||
align: center
|
align: center
|
||||||
steps: 4
|
steps: 4
|
||||||
|
tilt: 0.1
|
||||||
|
zStart: 0
|
||||||
|
zEnd: 30
|
||||||
```
|
```
|
||||||
|
|
||||||
- `curve` — an SVG path string to spread the content along, relative to the
|
- `curve` — an SVG path string to spread the content along, relative to the
|
||||||
@@ -368,6 +378,12 @@ layout:
|
|||||||
- `align` — `start`, `end`, or `center` of the curve.
|
- `align` — `start`, `end`, or `center` of the curve.
|
||||||
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
||||||
`1`. See the positioning process below.
|
`1`. See the positioning process below.
|
||||||
|
- `tilt` — rotation in degrees applied to every shown part about the card's
|
||||||
|
local Y (long) axis. It applies even without a `curve`, so a bare `tilt`
|
||||||
|
rotates a straight pile. Defaults to `1` when not specified.
|
||||||
|
- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end
|
||||||
|
of the `curve`. The stack ramps linearly between them across its span,
|
||||||
|
lifting it in 3D. Requires a `curve`.
|
||||||
|
|
||||||
#### positioning process
|
#### positioning process
|
||||||
|
|
||||||
@@ -377,6 +393,10 @@ layout:
|
|||||||
`step length × (# of parts − 1)` on the curve.
|
`step length × (# of parts − 1)` on the curve.
|
||||||
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
||||||
each `step length` apart.
|
each `step length` apart.
|
||||||
|
4. **Lift each part.** The part's height is `zStart + (zEnd − zStart) × u`,
|
||||||
|
where `u` is its normalized position along the `curve`.
|
||||||
|
5. **Tilt each part.** Every part is rotated `tilt` about its local Y (long)
|
||||||
|
axis.
|
||||||
|
|
||||||
### Edge cases
|
### Edge cases
|
||||||
|
|
||||||
|
|||||||
+16
-11
@@ -3,7 +3,9 @@
|
|||||||
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
> **Scope:** A standalone r3f component library that renders [bgm](./bgm-format.md)
|
||||||
> board games: a state store, surface mounting, part placement with stacking,
|
> board games: a state store, surface mounting, part placement with stacking,
|
||||||
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
> and per-part meshes. Design: [`bgm-tabletop.md`](./bgm-tabletop.md).
|
||||||
> **Status:** planning — no code yet.
|
> **Status:** items 1–8 implemented and the full tabletop scene is wired into
|
||||||
|
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
|
||||||
|
> part-inspection route renders `PartView` from the library.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ packages/tabletop/
|
|||||||
|
|
||||||
## Work items
|
## Work items
|
||||||
|
|
||||||
### 1. Package scaffold
|
### 1. Package scaffold ✅
|
||||||
|
|
||||||
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
|
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
|
||||||
globs `packages/*`).
|
globs `packages/*`).
|
||||||
@@ -51,7 +53,7 @@ packages/tabletop/
|
|||||||
`@types/three`.
|
`@types/three`.
|
||||||
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
|
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
|
||||||
|
|
||||||
### 2. Part meshes + export + web integration
|
### 2. Part meshes + export + web integration ✅
|
||||||
|
|
||||||
First deliverable: `PartView` renders a single part's mesh from its definition,
|
First deliverable: `PartView` renders a single part's mesh from its definition,
|
||||||
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
|
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
|
||||||
@@ -71,7 +73,7 @@ useful slice and unblocks the web app's part inspection route immediately.
|
|||||||
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
|
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
|
||||||
it end-to-end.
|
it end-to-end.
|
||||||
|
|
||||||
### 3. State store (`state.ts`)
|
### 3. State store (`state.ts`) ✅
|
||||||
|
|
||||||
Source-of-truth game state per `bgm-tabletop.md` §2:
|
Source-of-truth game state per `bgm-tabletop.md` §2:
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ interface GameState {
|
|||||||
- **Assumption**: each piece id is unique within a path (documented in
|
- **Assumption**: each piece id is unique within a path (documented in
|
||||||
`bgm-tabletop.md`); the render map is keyed by piece id.
|
`bgm-tabletop.md`); the render map is keyed by piece id.
|
||||||
|
|
||||||
### 4. Setup seeding (`setup.ts`)
|
### 4. Setup seeding (`setup.ts`) ✅
|
||||||
|
|
||||||
- `SetupLoader`: side-effect-only component that seeds the store from a
|
- `SetupLoader`: side-effect-only component that seeds the store from a
|
||||||
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
|
`Setup` — enables its `surfaces` (or all when omitted) and places parts on
|
||||||
@@ -98,7 +100,7 @@ interface GameState {
|
|||||||
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
|
type (documented in `bgm-format.md` §3; the loader doesn't do this — it's a
|
||||||
game-state init concern, so it lives here).
|
game-state init concern, so it lives here).
|
||||||
|
|
||||||
### 5. Surface mounting (`mount.ts`)
|
### 5. Surface mounting (`mount.ts`) ✅
|
||||||
|
|
||||||
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
|
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
|
||||||
- `kind: table` — root, world space.
|
- `kind: table` — root, world space.
|
||||||
@@ -108,21 +110,23 @@ interface GameState {
|
|||||||
surface isn't rendered. Child surfaces mount relative to their parent's
|
surface isn't rendered. Child surfaces mount relative to their parent's
|
||||||
anchor (`x`/`y`/`rotation`).
|
anchor (`x`/`y`/`rotation`).
|
||||||
|
|
||||||
### 6. Part placement (`placement.ts`)
|
### 6. Part placement (`placement.ts`) ✅
|
||||||
|
|
||||||
- `PartPlacement`: stable per-part component that positions a part on a surface
|
- `PartPlacement`: stable per-part component that positions a part on a surface
|
||||||
location from the derived render state (route anchor + candidate anchor).
|
location from the derived render state (route anchor + candidate anchor).
|
||||||
- Applies the route's stacking strategy via `useStacking`.
|
- Applies the route's stacking strategy via `useStacking`.
|
||||||
|
|
||||||
### 7. Stacking (`stacking.ts`)
|
### 7. Stacking (`stacking.ts`) ✅
|
||||||
|
|
||||||
- `useStacking(route.stacking, index, stackSize)` → `{ offset, rotation }`.
|
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
|
||||||
- Implements the format's positioning process (`bgm-format.md` §4): step
|
- Implements the format's positioning process (`bgm-format.md` §4): step
|
||||||
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||||
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
||||||
|
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
|
||||||
|
rotates each shown part about its local Y (long) axis.
|
||||||
- Curve length from an SVG path string (small helper; no new dep).
|
- Curve length from an SVG path string (small helper; no new dep).
|
||||||
|
|
||||||
### 8. Public API (`index.ts`)
|
### 8. Public API (`index.ts`) ✅
|
||||||
|
|
||||||
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
|
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
|
||||||
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
|
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
|
||||||
@@ -146,7 +150,8 @@ consumers share them (see Open decisions).
|
|||||||
|
|
||||||
- `state.ts` — derived render state: enabled surfaces, route matching,
|
- `state.ts` — derived render state: enabled surfaces, route matching,
|
||||||
candidate selection, stacking index/stackSize.
|
candidate selection, stacking index/stackSize.
|
||||||
- `stacking.ts` — positioning process: step length, alignment, limit.
|
- `stacking.ts` — positioning process: step length, alignment, limit, z ramp,
|
||||||
|
tilt.
|
||||||
- `setup.ts` — seeding + bare-type expansion.
|
- `setup.ts` — seeding + bare-type expansion.
|
||||||
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
||||||
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ the render map is per enabled surface: a piece may appear on more than one enabl
|
|||||||
|
|
||||||
## 4. stacking
|
## 4. stacking
|
||||||
|
|
||||||
the format's stacking strategy (curve / limit / align / steps, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece. `PartPlacement` consumes it.
|
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ 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. usage
|
## 5. usage
|
||||||
|
|
||||||
|
|||||||
@@ -155,8 +155,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 +237,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 +325,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,229 @@
|
|||||||
|
# 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 file=carcassonne.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 file=tiles.yaml
|
||||||
|
role: part
|
||||||
|
type: 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 file=board.yaml
|
||||||
|
type: board
|
||||||
|
id: board
|
||||||
|
role: surface
|
||||||
|
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 file=main.yaml
|
||||||
|
role: setup
|
||||||
|
type: game
|
||||||
|
id: main
|
||||||
|
surfaces:
|
||||||
|
- board#board
|
||||||
|
setup:
|
||||||
|
/draw: carcassonne:tile
|
||||||
|
/grid/5/5: carcassonne:tile#a
|
||||||
|
/grid/5/6: carcassonne:tile#b
|
||||||
|
/grid/6/5: carcassonne:tile#c
|
||||||
|
```
|
||||||
@@ -11,6 +11,7 @@ title: Poker
|
|||||||
designer: Public Domain
|
designer: Public Domain
|
||||||
players: 9
|
players: 9
|
||||||
language: en
|
language: en
|
||||||
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
## Parts
|
## Parts
|
||||||
@@ -25,7 +26,7 @@ role: part
|
|||||||
type: card
|
type: card
|
||||||
face: ./cards-13x4.jpg
|
face: ./cards-13x4.jpg
|
||||||
back: ./back-4x1.png
|
back: ./back-4x1.png
|
||||||
size: [63, 88, 3]
|
size: [63, 88, 0.3]
|
||||||
fillet: 2
|
fillet: 2
|
||||||
$variants: ./cards.csv
|
$variants: ./cards.csv
|
||||||
```
|
```
|
||||||
@@ -103,9 +104,10 @@ layout:
|
|||||||
y: 0
|
y: 0
|
||||||
rotation: 0
|
rotation: 0
|
||||||
stacking:
|
stacking:
|
||||||
curve: M 0 0 C 20 -20 40 -20 60 0
|
|
||||||
limit: 0
|
|
||||||
align: center
|
align: center
|
||||||
|
zStart: 0
|
||||||
|
#zEnd: 100
|
||||||
|
curve: M -50 -200 C 50 -150 450 -150 550 -200
|
||||||
- route: /community/:slot
|
- route: /community/:slot
|
||||||
candidates:
|
candidates:
|
||||||
$variants: ./community.csv
|
$variants: ./community.csv
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ title: Azul
|
|||||||
designer: Michael Kiesling
|
designer: Michael Kiesling
|
||||||
players: 4
|
players: 4
|
||||||
language: en
|
language: en
|
||||||
include: ['**/azul/**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tiles.yaml
|
```yaml file=parts/tiles.yaml
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ title: Harbor
|
|||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
include: ['**/harbor/**/*.yaml']
|
include: ['./**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml file=parts/tokens.yaml
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { loadDefs, collectPackages } from './collect.js';
|
import { loadDefs, collectPackages } from './collect.js';
|
||||||
|
|
||||||
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
|
const fixtureRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'harbor');
|
||||||
|
const multiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'vite-build', 'games');
|
||||||
|
|
||||||
describe('collectPackages', () => {
|
describe('collectPackages', () => {
|
||||||
it('collects the harbor package from markdown code blocks', () => {
|
it('collects the harbor package from markdown code blocks', () => {
|
||||||
@@ -60,6 +61,23 @@ describe('collectPackages', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scopes include patterns to the package declaration directory', () => {
|
||||||
|
// Two packages share a games root. Each uses the default `./**/*.yaml`
|
||||||
|
// include, which must resolve relative to its own folder so neither
|
||||||
|
// absorbs the other's defs (both define a `game#main` setup).
|
||||||
|
const defMap = loadDefs('', multiRoot);
|
||||||
|
const packages = collectPackages(defMap, multiRoot);
|
||||||
|
|
||||||
|
expect(packages).toHaveLength(2);
|
||||||
|
const azul = packages.find((p) => p.meta.id === 'azul')!;
|
||||||
|
const harbor = packages.find((p) => p.meta.id === 'harbor')!;
|
||||||
|
|
||||||
|
expect([...azul.parts.keys()]).toEqual(['tile#blue']);
|
||||||
|
expect([...azul.setups.keys()]).toEqual(['game#main']);
|
||||||
|
expect([...harbor.parts.keys()]).toEqual(['token#wood']);
|
||||||
|
expect([...harbor.setups.keys()]).toEqual(['game#main']);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws on a duplicate type#id', () => {
|
it('throws on a duplicate type#id', () => {
|
||||||
const defMap = loadDefs('', fixtureRoot);
|
const defMap = loadDefs('', fixtureRoot);
|
||||||
// Inject a duplicate part into the map under a new file name.
|
// Inject a duplicate part into the map under a new file name.
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ export function collectPackages(defMap: DefMap, rootDir: string): Package[] {
|
|||||||
const role = def.value['role'] as Role;
|
const role = def.value['role'] as Role;
|
||||||
if (role === 'package') {
|
if (role === 'package') {
|
||||||
const pkg = asPackage(def, file);
|
const pkg = asPackage(def, file);
|
||||||
accs.push(new PackageAcc(pkg, defMap, rootDir));
|
const baseDir = path.posix.dirname(file).replace(/^\/+/, '');
|
||||||
|
accs.push(new PackageAcc(pkg, defMap, rootDir, baseDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +126,8 @@ class PackageAcc {
|
|||||||
readonly pkg: PackageDef,
|
readonly pkg: PackageDef,
|
||||||
private readonly defs: DefMap,
|
private readonly defs: DefMap,
|
||||||
private readonly rootDir: string,
|
private readonly rootDir: string,
|
||||||
|
/** Path-style directory of the package declaration, e.g. `carcassonne`. */
|
||||||
|
private readonly baseDir: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
collect() {
|
collect() {
|
||||||
@@ -152,10 +155,17 @@ class PackageAcc {
|
|||||||
private expandIncludes(patterns: string[]): string[] {
|
private expandIncludes(patterns: string[]): string[] {
|
||||||
// Match include patterns against the parsed definitions' names, which
|
// Match include patterns against the parsed definitions' names, which
|
||||||
// cover both real files and markdown code blocks. Patterns are relative
|
// cover both real files and markdown code blocks. Patterns are relative
|
||||||
// to the games root (e.g. `./**/*.yaml`).
|
// to the package declaration's own directory (e.g. `./**/*.yaml` means
|
||||||
|
// this package's folder and below), so a package never absorbs defs from
|
||||||
|
// a sibling game. A leading `/` marks a pattern as root-relative.
|
||||||
|
// Def names carry a leading `/` (from the empty games root), so resolved
|
||||||
|
// patterns are prefixed to match.
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const matcher = picomatch(pattern, { dot: true });
|
const resolved = pattern.startsWith('/')
|
||||||
|
? pattern
|
||||||
|
: `/${path.posix.join(this.baseDir, pattern)}`;
|
||||||
|
const matcher = picomatch(resolved, { dot: true });
|
||||||
for (const name of this.defs.defs.keys()) {
|
for (const name of this.defs.defs.keys()) {
|
||||||
if (matcher(name)) names.add(name);
|
if (matcher(name)) names.add(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const stacking = z.object({
|
|||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
align: z.enum(['start', 'end', 'center']).optional(),
|
align: z.enum(['start', 'end', 'center']).optional(),
|
||||||
steps: z.number().optional(),
|
steps: z.number().optional(),
|
||||||
|
tilt: z.number().optional(),
|
||||||
|
zStart: z.number().optional(),
|
||||||
|
zEnd: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = z.object({
|
const route = z.object({
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export interface Candidate {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
rotation?: number;
|
rotation?: number;
|
||||||
|
/** Stacking strategy; overrides the route's when set, else inherits it. */
|
||||||
|
stacking?: Stacking;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
@@ -107,6 +109,20 @@ export interface Stacking {
|
|||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
/** Maximum parts per curve length unit; defaults to `1`. */
|
/** Maximum parts per curve length unit; defaults to `1`. */
|
||||||
steps?: number;
|
steps?: number;
|
||||||
|
/**
|
||||||
|
* Rotation in degrees per shown part about the card's local Y (long) axis.
|
||||||
|
* Each part tilts `tilt` more than the previous, fanning the stack so its
|
||||||
|
* edges stay visible. Works with or without a `curve`.
|
||||||
|
*/
|
||||||
|
tilt?: number;
|
||||||
|
/**
|
||||||
|
* Height (surface-normal) in mm at the start of the `curve`. The stack
|
||||||
|
* ramps linearly to `zEnd` across its span, lifting it in 3D. Requires a
|
||||||
|
* `curve`.
|
||||||
|
*/
|
||||||
|
zStart?: number;
|
||||||
|
/** Height (surface-normal) in mm at the end of the `curve`. */
|
||||||
|
zEnd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a surface is mounted. `kind` selects the mount type. */
|
/** How a surface is mounted. `kind` selects the mount type. */
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
* hot-reloads the app via `addWatchFile`.
|
* hot-reloads the app via `addWatchFile`.
|
||||||
*/
|
*/
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import type { Plugin } from 'vite';
|
import { normalizePath, type ModuleNode, type Plugin } from 'vite';
|
||||||
import { collectPackages, loadDefs } from './collect.js';
|
import { collectPackages, loadDefs } from './collect.js';
|
||||||
|
import { readDefFiles } from './parse.js';
|
||||||
import type { Package, SerializedPackage } from './types.js';
|
import type { Package, SerializedPackage } from './types.js';
|
||||||
|
|
||||||
const VIRTUAL_PREFIX = '\0bgm:';
|
const VIRTUAL_PREFIX = '\0bgm:';
|
||||||
@@ -32,7 +33,10 @@ export interface BgmOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bgm(options: BgmOptions): Plugin {
|
export function bgm(options: BgmOptions): Plugin {
|
||||||
const root = options.root;
|
// Vite normalizes `ctx.file` to POSIX separators before HMR hooks run, but
|
||||||
|
// `fileURLToPath` retains backslashes on Windows. Normalize `root` to the
|
||||||
|
// same form so `ctx.file.startsWith(root)` matches regardless of platform.
|
||||||
|
const root = normalizePath(options.root);
|
||||||
|
|
||||||
const collect = (): Package[] => {
|
const collect = (): Package[] => {
|
||||||
const defMap = loadDefs('', root);
|
const defMap = loadDefs('', root);
|
||||||
@@ -42,16 +46,35 @@ export function bgm(options: BgmOptions): Plugin {
|
|||||||
return {
|
return {
|
||||||
name: 'bgm',
|
name: 'bgm',
|
||||||
buildStart() {
|
buildStart() {
|
||||||
// Watch every source file so edits trigger a reload/re-collect.
|
// Watch every real definition source under the games root so edits
|
||||||
|
// trigger a re-collect. `defMap.files` only holds the virtual code-block
|
||||||
|
// files plus real non-markdown files; the markdown files themselves are
|
||||||
|
// consumed for their code blocks and never appear there, so watch the
|
||||||
|
// real files on disk too (markdown and anything else the loader reads).
|
||||||
const defMap = loadDefs('', root);
|
const defMap = loadDefs('', root);
|
||||||
for (const name of defMap.files.keys()) {
|
for (const name of defMap.files.keys()) {
|
||||||
this.addWatchFile(path.join(root, name));
|
this.addWatchFile(path.join(root, name));
|
||||||
}
|
}
|
||||||
|
for (const file of readDefFiles(root, '')) {
|
||||||
|
this.addWatchFile(file.source);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resolveId(id) {
|
resolveId(id) {
|
||||||
if (id === PACKAGES) return VIRTUAL_PREFIX + PACKAGES;
|
if (id === PACKAGES) return VIRTUAL_PREFIX + PACKAGES;
|
||||||
if (id.startsWith(PACKAGE)) return VIRTUAL_PREFIX + id;
|
if (id.startsWith(PACKAGE)) return VIRTUAL_PREFIX + id;
|
||||||
},
|
},
|
||||||
|
handleHotUpdate(ctx) {
|
||||||
|
// Re-collect and invalidate the virtual modules when a game definition
|
||||||
|
// changes, so edits hot-reload instead of requiring a manual refresh.
|
||||||
|
if (!ctx.file.startsWith(root)) return;
|
||||||
|
const invalidated: ModuleNode[] = [];
|
||||||
|
const mod = ctx.server.moduleGraph.getModuleById(VIRTUAL_PREFIX + PACKAGES);
|
||||||
|
if (mod) {
|
||||||
|
ctx.server.moduleGraph.invalidateModule(mod);
|
||||||
|
invalidated.push(mod);
|
||||||
|
}
|
||||||
|
return invalidated;
|
||||||
|
},
|
||||||
load(id) {
|
load(id) {
|
||||||
if (!id.startsWith(VIRTUAL_PREFIX)) return;
|
if (!id.startsWith(VIRTUAL_PREFIX)) return;
|
||||||
const virtual = id.slice(VIRTUAL_PREFIX.length);
|
const virtual = id.slice(VIRTUAL_PREFIX.length);
|
||||||
|
|||||||
@@ -85,10 +85,13 @@ describe('bgm vite plugin', () => {
|
|||||||
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never);
|
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context, {} as never);
|
||||||
|
|
||||||
// Every def file (real + virtual code blocks) is watched so edits
|
// Every def file (real + virtual code blocks) is watched so edits
|
||||||
// trigger a re-collect.
|
// trigger a re-collect. The real markdown source must be watched too:
|
||||||
|
// `defMap.files` only lists virtual code-block files, so without watching
|
||||||
|
// the on-disk `.md` file the dev server would never notice an edit.
|
||||||
expect(watched.length).toBeGreaterThan(0);
|
expect(watched.length).toBeGreaterThan(0);
|
||||||
expect(watched.some((f) => f.endsWith('.yaml'))).toBe(true);
|
expect(watched.some((f) => f.endsWith('.yaml'))).toBe(true);
|
||||||
expect(watched.some((f) => f.endsWith('.csv'))).toBe(true);
|
expect(watched.some((f) => f.endsWith('.csv'))).toBe(true);
|
||||||
|
expect(watched.some((f) => f.endsWith('.md'))).toBe(true);
|
||||||
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
|
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,10 +165,18 @@ describe('traceRequestSchema', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an invalid url', () => {
|
it('accepts a local game asset path (not just http(s))', () => {
|
||||||
expect(traceRequestSchema.safeParse({ url: 'not-a-url' }).success).toBe(
|
// The proxy resolves relative paths against the games root, so a bare
|
||||||
false,
|
// path is a valid trace source.
|
||||||
);
|
expect(
|
||||||
|
traceRequestSchema.safeParse({ url: 'poker/parts/assets/cards.png' })
|
||||||
|
.success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing or blank url', () => {
|
||||||
|
expect(traceRequestSchema.safeParse({}).success).toBe(false);
|
||||||
|
expect(traceRequestSchema.safeParse({ url: '' }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown mode or format', () => {
|
it('rejects an unknown mode or format', () => {
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ export const traceFormatSchema = z.enum(['svg', 'shape']);
|
|||||||
|
|
||||||
/** Query params for `GET /trace`. */
|
/** Query params for `GET /trace`. */
|
||||||
export const traceRequestSchema = z.object({
|
export const traceRequestSchema = z.object({
|
||||||
url: z.string().url('url must be a valid URL'),
|
// The url may be an http(s) URL or a local game asset path relative to the
|
||||||
|
// games root (e.g. `poker/parts/assets/cards.png`); the proxy resolves both
|
||||||
|
// via `resolveAsset`. Only a non-empty string is validated here.
|
||||||
|
url: z.string().min(1, 'url is required'),
|
||||||
mode: traceModeSchema.default('alpha'),
|
mode: traceModeSchema.default('alpha'),
|
||||||
threshold: z.coerce.number().int().min(0).max(255).default(128),
|
threshold: z.coerce.number().int().min(0).max(255).default(128),
|
||||||
format: traceFormatSchema.default('shape'),
|
format: traceFormatSchema.default('shape'),
|
||||||
|
|||||||
@@ -29,9 +29,11 @@
|
|||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/three": "^0.185.4",
|
"@types/three": "^0.185.4",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^8.2.1",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Harbor
|
||||||
|
|
||||||
|
A tiny example game used to exercise the tabletop library end-to-end through a
|
||||||
|
real vite build.
|
||||||
|
|
||||||
|
```yaml file=harbor.yaml
|
||||||
|
role: package
|
||||||
|
id: harbor
|
||||||
|
title: Harbor
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml file=parts/tokens.yaml
|
||||||
|
role: part
|
||||||
|
type: token
|
||||||
|
id: wood
|
||||||
|
size: [20, 20, 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml file=parts/board.yaml
|
||||||
|
type: board
|
||||||
|
id: harbor
|
||||||
|
role: surface
|
||||||
|
size: [300, 200]
|
||||||
|
layout:
|
||||||
|
- route: /deck
|
||||||
|
x: -100
|
||||||
|
y: 0
|
||||||
|
rotation: 0
|
||||||
|
stacking:
|
||||||
|
curve: M 0 0 L 100 0
|
||||||
|
align: center
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml file=setup/main.yaml
|
||||||
|
role: setup
|
||||||
|
type: game
|
||||||
|
id: main
|
||||||
|
setup:
|
||||||
|
/deck: harbor:token
|
||||||
|
```
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>tabletop build fixture</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import harbor from 'virtual:bgm/package/harbor';
|
||||||
|
import type { SerializedPackage } from '@tts/bgm';
|
||||||
|
import { seedFromSetup, expandSetupValue } from '@tts/tabletop';
|
||||||
|
import { computeRenderState, matchRoute } from '@tts/tabletop';
|
||||||
|
import { stackingOffset } from '@tts/tabletop';
|
||||||
|
import { resolveMountTree } from '@tts/tabletop';
|
||||||
|
|
||||||
|
// Re-export the results so the test can assert the bundled output.
|
||||||
|
const pkg = harbor as unknown as SerializedPackage;
|
||||||
|
|
||||||
|
// The setup seeds the store; a bare type expands to all parts of that type.
|
||||||
|
const setup = pkg.setups['game#main']!;
|
||||||
|
const seeded = seedFromSetup(pkg as never, setup);
|
||||||
|
|
||||||
|
// A part with a matching route is placed.
|
||||||
|
const placements = computeRenderState(pkg as never, seeded);
|
||||||
|
|
||||||
|
// Stacking spreads parts along a curve.
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 1, 3);
|
||||||
|
|
||||||
|
// Mount resolution separates world and hud surfaces.
|
||||||
|
const tree = resolveMountTree(
|
||||||
|
new Map(Object.entries(pkg.surfaces)),
|
||||||
|
new Set(Object.keys(seeded.surfaces)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const seededPaths = Object.keys(seeded.paths);
|
||||||
|
export const expanded = expandSetupValue(pkg as never, 'harbor:token');
|
||||||
|
export const placementCount = placements.length;
|
||||||
|
export const matched = matchRoute({ route: '/dock/:seat', x: 0, y: 0, rotation: 0 }, '/dock/0');
|
||||||
|
export const offsetX = offset.x;
|
||||||
|
export const worldCount = tree.world.length;
|
||||||
|
|
||||||
|
console.log(seededPaths, expanded, placementCount, matched, offsetX, worldCount);
|
||||||
@@ -8,5 +8,37 @@ export {
|
|||||||
fallbackShape,
|
fallbackShape,
|
||||||
traceToShape,
|
traceToShape,
|
||||||
traceToUvBounds,
|
traceToUvBounds,
|
||||||
|
MM_TO_WORLD,
|
||||||
} from './part.js';
|
} from './part.js';
|
||||||
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
|
||||||
|
|
||||||
|
// Serialized package -> Package conversion.
|
||||||
|
export { serializedToPackage } from './package.js';
|
||||||
|
|
||||||
|
// State store + derived render state.
|
||||||
|
export {
|
||||||
|
useTabletopStore,
|
||||||
|
useRenderState,
|
||||||
|
matchRoute,
|
||||||
|
computeRenderState,
|
||||||
|
computeSurfacePlacements,
|
||||||
|
placementKey,
|
||||||
|
} from './state.js';
|
||||||
|
export type { GameState, Placement } from './state.js';
|
||||||
|
|
||||||
|
// Setup seeding.
|
||||||
|
export { SetupLoader, seedFromSetup, expandSetupValue } from './setup.js';
|
||||||
|
|
||||||
|
// Surface mounting.
|
||||||
|
export { resolveMountTree } from './mount.js';
|
||||||
|
export type { MountNode, MountTree } from './mount.js';
|
||||||
|
export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js';
|
||||||
|
export { HudSurfaceView } from './surfaces/HudSurfaceView.js';
|
||||||
|
export { SurfaceBounds } from './surfaces/SurfaceBounds.js';
|
||||||
|
|
||||||
|
// Part placement.
|
||||||
|
export { PartPlacement } from './placement.js';
|
||||||
|
|
||||||
|
// Stacking.
|
||||||
|
export { useStacking, stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
|
||||||
|
export type { StackOffset } from './stacking.js';
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Surface } from '@tts/bgm';
|
||||||
|
import { resolveMountTree } from './mount.js';
|
||||||
|
|
||||||
|
function surface(type: string, id: string, overrides: Partial<Surface> = {}): [string, Surface] {
|
||||||
|
return [`${type}#${id}`, { type, id, layout: [], ...overrides }];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveMountTree', () => {
|
||||||
|
it('separates world and hud roots', () => {
|
||||||
|
const surfaces = new Map([
|
||||||
|
surface('board', 'harbor', { mount: { kind: 'table' } }),
|
||||||
|
surface('hud', 'hand', { mount: { kind: 'hud', area: 'bottom-left' } }),
|
||||||
|
]);
|
||||||
|
const tree = resolveMountTree(surfaces, new Set(['board#harbor', 'hud#hand']));
|
||||||
|
expect(tree.world).toHaveLength(1);
|
||||||
|
expect(tree.hud).toHaveLength(1);
|
||||||
|
expect(tree.hud[0]!.area).toBe('bottom-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches child surfaces to their parent', () => {
|
||||||
|
const surfaces = new Map([
|
||||||
|
surface('board', 'harbor', {
|
||||||
|
mount: { kind: 'table' },
|
||||||
|
children: ['board#player'],
|
||||||
|
}),
|
||||||
|
surface('board', 'player', { mount: { kind: 'child', x: 100, y: 50 } }),
|
||||||
|
]);
|
||||||
|
const tree = resolveMountTree(surfaces, new Set(['board#harbor', 'board#player']));
|
||||||
|
expect(tree.world).toHaveLength(1);
|
||||||
|
expect(tree.world[0]!.children).toHaveLength(1);
|
||||||
|
expect(tree.world[0]!.children[0]!.id).toBe('board#player');
|
||||||
|
expect(tree.world[0]!.children[0]!.x).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a child with no matching parent', () => {
|
||||||
|
const surfaces = new Map([
|
||||||
|
surface('board', 'player', { mount: { kind: 'child' } }),
|
||||||
|
]);
|
||||||
|
const tree = resolveMountTree(surfaces, new Set(['board#player']));
|
||||||
|
expect(tree.world).toHaveLength(0);
|
||||||
|
expect(tree.hud).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes disabled surfaces', () => {
|
||||||
|
const surfaces = new Map([
|
||||||
|
surface('board', 'harbor', { mount: { kind: 'table' } }),
|
||||||
|
surface('board', 'other', { mount: { kind: 'table' } }),
|
||||||
|
]);
|
||||||
|
const tree = resolveMountTree(surfaces, new Set(['board#harbor']));
|
||||||
|
expect(tree.world).toHaveLength(1);
|
||||||
|
expect(tree.world[0]!.id).toBe('board#harbor');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Surface mounting — resolve the surface mount tree from `Surface.mount` +
|
||||||
|
* `Surface.children` (`bgm-tabletop.md` §5).
|
||||||
|
*
|
||||||
|
* - `kind: table` — root, world space.
|
||||||
|
* - `kind: hud` — HUD area (`mount.area`).
|
||||||
|
* - `kind: child` — mounted relative to a parent surface that lists it in
|
||||||
|
* `children`.
|
||||||
|
*
|
||||||
|
* A child surface's own `mount.x`/`y`/`rotation` is relative to its parent's
|
||||||
|
* anchor. The tree is built from the enabled surfaces only.
|
||||||
|
*/
|
||||||
|
import type { Surface } from '@tts/bgm';
|
||||||
|
|
||||||
|
/** A node in the resolved mount tree. */
|
||||||
|
export interface MountNode {
|
||||||
|
/** The surface. */
|
||||||
|
surface: Surface;
|
||||||
|
/** The surface id (`type#id`). */
|
||||||
|
id: string;
|
||||||
|
/** The mount kind. */
|
||||||
|
kind: 'table' | 'hud' | 'child';
|
||||||
|
/** Anchor x/y/rotation (world for table, relative for child, HUD area for hud). */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotation: number;
|
||||||
|
/** HUD area, when `kind: hud`. */
|
||||||
|
area?: string;
|
||||||
|
/** Child surfaces mounted relative to this one. */
|
||||||
|
children: MountNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The resolved mount tree: world roots, HUD roots, and their children. */
|
||||||
|
export interface MountTree {
|
||||||
|
/** Surfaces mounted in world space (`kind: table`). */
|
||||||
|
world: MountNode[];
|
||||||
|
/** Surfaces mounted to a HUD area (`kind: hud`). */
|
||||||
|
hud: MountNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the mount tree for the given surfaces. A `child` surface is attached
|
||||||
|
* to the first parent that lists it in `children`; a child with no matching
|
||||||
|
* parent is dropped. Surfaces not in `enabled` are excluded.
|
||||||
|
*/
|
||||||
|
export function resolveMountTree(
|
||||||
|
surfaces: Map<string, Surface>,
|
||||||
|
enabled: Set<string>,
|
||||||
|
): MountTree {
|
||||||
|
const nodes = new Map<string, MountNode>();
|
||||||
|
for (const [id, surface] of surfaces) {
|
||||||
|
if (!enabled.has(id)) continue;
|
||||||
|
const mount = surface.mount ?? { kind: 'table' };
|
||||||
|
nodes.set(id, {
|
||||||
|
surface,
|
||||||
|
id,
|
||||||
|
kind: mount.kind,
|
||||||
|
x: mount.x ?? 0,
|
||||||
|
y: mount.y ?? 0,
|
||||||
|
rotation: mount.rotation ?? 0,
|
||||||
|
area: mount.area,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const world: MountNode[] = [];
|
||||||
|
const hud: MountNode[] = [];
|
||||||
|
const attached = new Set<string>();
|
||||||
|
|
||||||
|
// Attach children to their parents first.
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
if (node.kind !== 'child') continue;
|
||||||
|
const parent = findParent(nodes, node.id);
|
||||||
|
if (!parent) continue;
|
||||||
|
parent.children.push(node);
|
||||||
|
attached.add(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots are world/hud surfaces; unattached children are dropped.
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
if (node.kind === 'table') world.push(node);
|
||||||
|
else if (node.kind === 'hud') hud.push(node);
|
||||||
|
// A child that wasn't attached to a parent is not rendered.
|
||||||
|
}
|
||||||
|
return { world, hud };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the parent surface that lists `childId` in its `children`. */
|
||||||
|
function findParent(nodes: Map<string, MountNode>, childId: string): MountNode | undefined {
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
if (node.surface.children?.includes(childId)) return node;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { serializedToPackage } from './package.js';
|
||||||
|
|
||||||
|
describe('serializedToPackage', () => {
|
||||||
|
it('converts plain-object maps to Map instances', () => {
|
||||||
|
const serialized = {
|
||||||
|
meta: { id: 'harbor' },
|
||||||
|
parts: { 'token#wood': { type: 'token', id: 'wood' } },
|
||||||
|
surfaces: { 'board#harbor': { type: 'board', id: 'harbor', layout: [] } },
|
||||||
|
setups: { 'game#main': { type: 'game', id: 'main', setup: {} } },
|
||||||
|
};
|
||||||
|
const pkg = serializedToPackage(serialized);
|
||||||
|
expect(pkg.meta.id).toBe('harbor');
|
||||||
|
expect(pkg.parts).toBeInstanceOf(Map);
|
||||||
|
expect(pkg.parts.get('token#wood')).toEqual({ type: 'token', id: 'wood' });
|
||||||
|
expect(pkg.surfaces.get('board#harbor')).toMatchObject({ type: 'board' });
|
||||||
|
expect(pkg.setups.get('game#main')).toMatchObject({ type: 'game' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Convert a serialized package (plain objects, as emitted by the bgm vite
|
||||||
|
* plugin) into a `Package` (Maps). Consumers that load a package from
|
||||||
|
* `virtual:bgm/*` get the serialized form; the tabletop components take the
|
||||||
|
* `Package` form.
|
||||||
|
*/
|
||||||
|
import type { Package, SerializedPackage } from '@tts/bgm';
|
||||||
|
|
||||||
|
export function serializedToPackage(serialized: SerializedPackage): Package {
|
||||||
|
return {
|
||||||
|
meta: serialized.meta,
|
||||||
|
parts: new Map(Object.entries(serialized.parts)),
|
||||||
|
surfaces: new Map(Object.entries(serialized.surfaces)),
|
||||||
|
setups: new Map(Object.entries(serialized.setups)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
/** Scale from mm (the format's `size` unit) to world units. */
|
/** Scale from mm (the format's `size` unit) to world units. */
|
||||||
export const MM_TO_WORLD = 1 / 30;
|
export const MM_TO_WORLD = 1 / 30;
|
||||||
|
|
||||||
|
/** Degrees → radians. Angles in the bgm format are authored in degrees. */
|
||||||
|
export const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
/** Default part size `[w, h, d]` in mm when a part has no `size`. */
|
/** Default part size `[w, h, d]` in mm when a part has no `size`. */
|
||||||
const DEFAULT_SIZE: [number, number, number] = [60, 60, 3];
|
const DEFAULT_SIZE: [number, number, number] = [60, 60, 3];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* `PartPlacement` — position a part on a surface location.
|
||||||
|
*
|
||||||
|
* A stable per-part component that places a part at its route's anchor (plus
|
||||||
|
* the candidate's anchor when there is one) and applies the route's stacking
|
||||||
|
* strategy via `useStacking`. Renders the part's mesh with `PartView`.
|
||||||
|
*/
|
||||||
|
import type { Package } from '@tts/bgm';
|
||||||
|
import { useStacking } from './stacking.js';
|
||||||
|
import type { Placement } from './state.js';
|
||||||
|
import { PartView } from './partView.js';
|
||||||
|
import { MM_TO_WORLD, DEG_TO_RAD } from './part.js';
|
||||||
|
|
||||||
|
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
|
||||||
|
const { route, candidate, piece, index, stackSize } = placement;
|
||||||
|
// `piece` is `package:type#id`; the parts map is keyed by `type#id`.
|
||||||
|
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
||||||
|
if (!part) return null;
|
||||||
|
|
||||||
|
// A candidate inherits the route's stacking unless it overrides it.
|
||||||
|
const stacking = candidate?.stacking ?? route.stacking;
|
||||||
|
const { x, y, rotation, z, tilt } = useStacking(stacking, index, stackSize);
|
||||||
|
|
||||||
|
// Route anchors and stacking offsets are in mm; convert to world units so
|
||||||
|
// parts land on the (world-scaled) surface. `z` raises the part along the
|
||||||
|
// surface normal (world +Y); `tilt` rotates it about its local Y (long) axis.
|
||||||
|
// Angles are authored in degrees; three.js expects radians.
|
||||||
|
const anchorX = ((candidate?.x ?? route.x ?? 0) + x) * MM_TO_WORLD;
|
||||||
|
const anchorY = ((candidate?.y ?? route.y ?? 0) + y) * MM_TO_WORLD;
|
||||||
|
const anchorZ = z * MM_TO_WORLD;
|
||||||
|
// The curve tangent `rotation` is the direction of travel; the card's long
|
||||||
|
// edge ends up along (sinR, cosR) in the surface plane, so it aligns with
|
||||||
|
// the tangent (cosφ, sinφ) when R = 90° − φ.
|
||||||
|
const anchorRotation =
|
||||||
|
((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||||
|
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
||||||
|
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
||||||
|
<PartView part={part} baseUrl={part.baseUrl} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Package } from '@tts/bgm';
|
||||||
|
import { expandSetupValue, seedFromSetup } from './setup.js';
|
||||||
|
|
||||||
|
const pkg: Package = {
|
||||||
|
meta: { id: 'harbor' },
|
||||||
|
parts: new Map([
|
||||||
|
['token#wood', { type: 'token', id: 'wood' }],
|
||||||
|
['token#grain', { type: 'token', id: 'grain' }],
|
||||||
|
['card#fleet', { type: 'card', id: 'fleet' }],
|
||||||
|
]),
|
||||||
|
surfaces: new Map([
|
||||||
|
['board#harbor', { type: 'board', id: 'harbor', layout: [] }],
|
||||||
|
['hud#hand', { type: 'hud', id: 'hand', layout: [] }],
|
||||||
|
]),
|
||||||
|
setups: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('expandSetupValue', () => {
|
||||||
|
it('keeps a full part id', () => {
|
||||||
|
expect(expandSetupValue(pkg, 'harbor:card#fleet')).toEqual(['harbor:card#fleet']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a bare type to all parts of that type', () => {
|
||||||
|
expect(expandSetupValue(pkg, 'harbor:token')).toEqual(['harbor:token#wood', 'harbor:token#grain']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands each entry of a list', () => {
|
||||||
|
expect(expandSetupValue(pkg, ['harbor:card#fleet', 'harbor:token'])).toEqual([
|
||||||
|
'harbor:card#fleet',
|
||||||
|
'harbor:token#wood',
|
||||||
|
'harbor:token#grain',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedFromSetup', () => {
|
||||||
|
it('enables listed surfaces and places parts', () => {
|
||||||
|
const setup = {
|
||||||
|
type: 'game',
|
||||||
|
id: 'main',
|
||||||
|
surfaces: ['board#harbor'],
|
||||||
|
setup: { '/deck': 'harbor:card#fleet' },
|
||||||
|
};
|
||||||
|
const state = seedFromSetup(pkg, setup);
|
||||||
|
expect(state.surfaces).toEqual({ 'board#harbor': true });
|
||||||
|
expect(state.paths).toEqual({ '/deck': ['harbor:card#fleet'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables all surfaces when omitted', () => {
|
||||||
|
const setup = { type: 'game', id: 'main', setup: {} };
|
||||||
|
const state = seedFromSetup(pkg, setup);
|
||||||
|
expect(state.surfaces).toEqual({ 'board#harbor': true, 'hud#hand': true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* `SetupLoader` — seed the game state from a `Setup`.
|
||||||
|
*
|
||||||
|
* A side-effect-only component: it enables the setup's `surfaces` (or all when
|
||||||
|
* omitted) and places parts on the setup's paths. A setup value that is a bare
|
||||||
|
* `type` (no id) expands to all parts of that type during initialization.
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { Package, Setup } from '@tts/bgm';
|
||||||
|
import { useTabletopStore } from './state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a setup value into a list of part ids (`package:type#id`). A bare
|
||||||
|
* `type` (no `#id`) expands to every part of that type in the package.
|
||||||
|
*/
|
||||||
|
export function expandSetupValue(
|
||||||
|
pkg: Package,
|
||||||
|
value: string | string[],
|
||||||
|
): string[] {
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const v of values) {
|
||||||
|
// A full ref is `package:type#id`; a bare type is `package:type` (no id).
|
||||||
|
const [ref, id] = v.split('#');
|
||||||
|
if (id) {
|
||||||
|
out.push(v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Bare type: expand to all parts of that type, in package order.
|
||||||
|
const [pkgId, bareType] = ref?.split(':') ?? [];
|
||||||
|
if (pkgId !== pkg.meta.id) continue;
|
||||||
|
for (const [key, part] of pkg.parts) {
|
||||||
|
if (part.type === bareType) out.push(`${pkg.meta.id}:${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the store from a setup. Returns the resulting game state. */
|
||||||
|
export function seedFromSetup(pkg: Package, setup: Setup): {
|
||||||
|
surfaces: Record<string, boolean>;
|
||||||
|
paths: Record<string, string[]>;
|
||||||
|
} {
|
||||||
|
const surfaces: Record<string, boolean> = {};
|
||||||
|
if (setup.surfaces) {
|
||||||
|
for (const id of setup.surfaces) surfaces[id] = true;
|
||||||
|
} else {
|
||||||
|
for (const key of pkg.surfaces.keys()) surfaces[key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths: Record<string, string[]> = {};
|
||||||
|
for (const [path, value] of Object.entries(setup.setup)) {
|
||||||
|
paths[path] = expandSetupValue(pkg, value);
|
||||||
|
}
|
||||||
|
return { surfaces, paths };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-effect-only: seeds the store from `setup` on mount (and whenever the
|
||||||
|
* setup changes). Renders nothing.
|
||||||
|
*/
|
||||||
|
export function SetupLoader({ pkg, setup }: { pkg: Package; setup: Setup }) {
|
||||||
|
const seed = useTabletopStore((s) => s.seed);
|
||||||
|
useEffect(() => {
|
||||||
|
seed(seedFromSetup(pkg, setup));
|
||||||
|
}, [pkg, setup, seed]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
|
||||||
|
|
||||||
|
describe('parsePath', () => {
|
||||||
|
it('measures a straight line', () => {
|
||||||
|
const path = parsePath('M 0 0 L 10 0');
|
||||||
|
expect(path.length).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures a cubic curve', () => {
|
||||||
|
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
|
||||||
|
// Longer than the chord (60) but finite.
|
||||||
|
expect(path.length).toBeGreaterThan(60);
|
||||||
|
expect(path.length).toBeLessThan(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles relative commands', () => {
|
||||||
|
const path = parsePath('m 0 0 l 10 0 l 0 10');
|
||||||
|
expect(path.length).toBeCloseTo(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports h/v/z', () => {
|
||||||
|
const path = parsePath('M 0 0 H 10 V 10 Z');
|
||||||
|
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
|
||||||
|
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointAt', () => {
|
||||||
|
it('returns the start at distance 0 and end at full length', () => {
|
||||||
|
const path = parsePath('M 0 0 L 10 0');
|
||||||
|
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
||||||
|
const end = pointAt(path, path.length);
|
||||||
|
expect(end.x).toBeCloseTo(10);
|
||||||
|
expect(end.y).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates along the path', () => {
|
||||||
|
const path = parsePath('M 0 0 L 10 0');
|
||||||
|
const mid = pointAt(path, 5);
|
||||||
|
expect(mid.x).toBeCloseTo(5);
|
||||||
|
expect(mid.angle).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the tangent angle in degrees', () => {
|
||||||
|
const path = parsePath('M 0 0 L 10 10');
|
||||||
|
expect(pointAt(path, 5).angle).toBeCloseTo(45);
|
||||||
|
const down = parsePath('M 0 0 L 0 10');
|
||||||
|
expect(pointAt(down, 5).angle).toBeCloseTo(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the tangent angle at the start of the path', () => {
|
||||||
|
const path = parsePath('M 0 0 L 10 10');
|
||||||
|
expect(pointAt(path, 0).angle).toBeCloseTo(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stackingOffset', () => {
|
||||||
|
it('defaults to a 1° tilt without a curve', () => {
|
||||||
|
expect(stackingOffset(undefined, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||||
|
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spreads parts evenly along a straight curve', () => {
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
|
||||||
|
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
||||||
|
expect(offset.x).toBeCloseTo(50);
|
||||||
|
expect(offset.y).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns to center', () => {
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
|
||||||
|
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
||||||
|
expect(offset.x).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns to end', () => {
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
|
||||||
|
// start = 100 - 100 = 0; part 2 at 100.
|
||||||
|
expect(offset.x).toBeCloseTo(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a positive limit (first n)', () => {
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4);
|
||||||
|
// Part 2 is beyond the first 2 shown -> not placed.
|
||||||
|
expect(offset).toBe(NO_OFFSET);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a negative limit (last n)', () => {
|
||||||
|
// Last 2 of 4 are indices 2,3. Part 2 is the first shown.
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4);
|
||||||
|
expect(offset.x).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses steps to densify the curve', () => {
|
||||||
|
// steps=4, 3 parts -> step = 100 / max(4, 2) = 25.
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
|
||||||
|
expect(offset.x).toBeCloseTo(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tilts every part the same amount without a curve', () => {
|
||||||
|
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
||||||
|
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tilts every part the same amount along the curve', () => {
|
||||||
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
|
||||||
|
expect(offset.x).toBeCloseTo(50);
|
||||||
|
expect(offset.tilt).toBeCloseTo(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tilts only the shown parts', () => {
|
||||||
|
// limit 2 shows indices 0,1; index 2 is dropped.
|
||||||
|
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET);
|
||||||
|
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ramps z from zStart to zEnd across the curve', () => {
|
||||||
|
// 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40.
|
||||||
|
const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3);
|
||||||
|
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
|
||||||
|
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
|
||||||
|
expect(first.z).toBeCloseTo(0);
|
||||||
|
expect(mid.z).toBeCloseTo(20);
|
||||||
|
expect(last.z).toBeCloseTo(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no offset for an empty stack', () => {
|
||||||
|
expect(stackingOffset(undefined, 0, 0)).toBe(NO_OFFSET);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Stacking — the format's positioning process (`bgm-format.md` §4).
|
||||||
|
*
|
||||||
|
* Given a route's `stacking` strategy and a piece's position in its path's
|
||||||
|
* stack, compute the offset/rotation to apply. Parts are spread along an SVG
|
||||||
|
* `curve` relative to the route's anchor, `step length` apart, aligned per the
|
||||||
|
* strategy.
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { Stacking } from '@tts/bgm';
|
||||||
|
|
||||||
|
export interface StackOffset {
|
||||||
|
/** x offset from the anchor. */
|
||||||
|
x: number;
|
||||||
|
/** y offset from the anchor. */
|
||||||
|
y: number;
|
||||||
|
/** Rotation in degrees. */
|
||||||
|
rotation: number;
|
||||||
|
/** Vertical (surface-normal) offset from the anchor, in mm. */
|
||||||
|
z: number;
|
||||||
|
/** Rotation in degrees about the card's local Y (long) axis. */
|
||||||
|
tilt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The identity offset: no stacking applied. */
|
||||||
|
export const NO_OFFSET: StackOffset = { x: 0, y: 0, rotation: 0, z: 0, tilt: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the offset/rotation for the piece at `index` of a `stackSize`-piece
|
||||||
|
* stack, given the route's stacking strategy. Returns `NO_OFFSET` when the
|
||||||
|
* stack is empty. Every placed part gets a default 1° tilt unless overridden.
|
||||||
|
*/
|
||||||
|
export function stackingOffset(
|
||||||
|
stacking: Stacking | undefined,
|
||||||
|
index: number,
|
||||||
|
stackSize: number,
|
||||||
|
): StackOffset {
|
||||||
|
if (stackSize <= 0) return NO_OFFSET;
|
||||||
|
|
||||||
|
// `limit` selects which pieces are shown; the offset is computed over the
|
||||||
|
// shown span. `0` (or absent) shows all.
|
||||||
|
const shown = applyLimit(stacking?.limit, stackSize);
|
||||||
|
const shownIndex = shown.indexOf(index);
|
||||||
|
if (shownIndex < 0) return NO_OFFSET;
|
||||||
|
|
||||||
|
// `tilt` rotates each shown part about its local Y (long) axis by the same
|
||||||
|
// amount. It applies even without a curve. Defaults to 1° when a stacking
|
||||||
|
// strategy doesn't specify a tilt.
|
||||||
|
const tilt = stacking?.tilt ?? 1;
|
||||||
|
|
||||||
|
// The horizontal position along the curve (or a straight pile when there's
|
||||||
|
// no curve), plus the normalized progress used to ramp the z height.
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let rotation = 0;
|
||||||
|
let u = shown.length > 1 ? shownIndex / (shown.length - 1) : 0;
|
||||||
|
|
||||||
|
if (stacking?.curve) {
|
||||||
|
const curve = parsePath(stacking.curve);
|
||||||
|
const length = curve.length;
|
||||||
|
if (length > 0) {
|
||||||
|
// Step length: curve length / max(steps, # parts − 1). A single part
|
||||||
|
// sits at the start of the curve.
|
||||||
|
const steps = stacking.steps ?? 1;
|
||||||
|
const span = Math.max(steps, shown.length - 1);
|
||||||
|
const step = length / span;
|
||||||
|
|
||||||
|
// Alignment: how far the whole span is inset from the curve's start.
|
||||||
|
const spanLength = step * (shown.length - 1);
|
||||||
|
let start = 0;
|
||||||
|
if (stacking.align === 'end') start = length - spanLength;
|
||||||
|
else if (stacking.align === 'center') start = (length - spanLength) / 2;
|
||||||
|
|
||||||
|
const distance = start + shownIndex * step;
|
||||||
|
const point = pointAt(curve, distance);
|
||||||
|
x = point.x;
|
||||||
|
y = point.y;
|
||||||
|
rotation = point.angle;
|
||||||
|
u = distance / length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The z height ramps linearly from `zStart` to `zEnd` across the curve's
|
||||||
|
// span, lifting the stack in 3D.
|
||||||
|
const zStart = stacking?.zStart ?? 0;
|
||||||
|
const zEnd = stacking?.zEnd ?? 0;
|
||||||
|
const z = zStart + (zEnd - zStart) * u;
|
||||||
|
|
||||||
|
if (x === 0 && y === 0 && rotation === 0 && z === 0 && tilt === 0) return NO_OFFSET;
|
||||||
|
return { x, y, rotation, z, tilt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stacking hook: memoized `stackingOffset` for a piece. */
|
||||||
|
export function useStacking(
|
||||||
|
stacking: Stacking | undefined,
|
||||||
|
index: number,
|
||||||
|
stackSize: number,
|
||||||
|
): StackOffset {
|
||||||
|
return useMemo(() => stackingOffset(stacking, index, stackSize), [stacking, index, stackSize]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a stacking `limit` to a stack size, returning the shown indices. */
|
||||||
|
function applyLimit(limit: number | undefined, stackSize: number): number[] {
|
||||||
|
const indices = Array.from({ length: stackSize }, (_, i) => i);
|
||||||
|
if (!limit || limit === 0) return indices;
|
||||||
|
if (limit > 0) return indices.slice(0, limit);
|
||||||
|
return indices.slice(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SVG path sampling ---
|
||||||
|
|
||||||
|
/** A sampled point along a path. */
|
||||||
|
interface PathPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Cumulative arc length from the path start. */
|
||||||
|
t: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed path: a dense polyline approximation with cumulative lengths. */
|
||||||
|
interface SampledPath {
|
||||||
|
points: PathPoint[];
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an SVG path `d` string into a dense polyline approximation. Supports
|
||||||
|
* the common commands (M/L/H/V/C/S/Q/T/A/Z, absolute and relative). This is a
|
||||||
|
* small, dependency-free helper for curve length and point-at-distance.
|
||||||
|
*/
|
||||||
|
export function parsePath(d: string): SampledPath {
|
||||||
|
const tokens = tokenize(d);
|
||||||
|
const points: PathPoint[] = [];
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let i = 0;
|
||||||
|
let cmd = 'M';
|
||||||
|
|
||||||
|
const push = (x: number, y: number) => {
|
||||||
|
cx = x;
|
||||||
|
cy = y;
|
||||||
|
points.push({ x, y, t: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const rel = (v: number, base: number) => (cmd === cmd.toLowerCase() ? base + v : v);
|
||||||
|
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const tok = tokens[i]!;
|
||||||
|
if (/[a-zA-Z]/.test(tok)) {
|
||||||
|
cmd = tok;
|
||||||
|
i++;
|
||||||
|
// `Z` closes the path and takes no arguments; handle it immediately.
|
||||||
|
if (cmd.toUpperCase() === 'Z') {
|
||||||
|
push(startX, startY);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const num = () => {
|
||||||
|
const v = parseFloat(tokens[i]!);
|
||||||
|
i++;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (cmd.toUpperCase()) {
|
||||||
|
case 'M': {
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
push(x, y);
|
||||||
|
startX = x;
|
||||||
|
startY = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'L': {
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
push(x, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'H': {
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
push(x, cy);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'V': {
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
push(cx, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'C': {
|
||||||
|
const x1 = rel(num(), cx);
|
||||||
|
const y1 = rel(num(), cy);
|
||||||
|
const x2 = rel(num(), cx);
|
||||||
|
const y2 = rel(num(), cy);
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y);
|
||||||
|
push(x, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'S': {
|
||||||
|
// Reflect the previous control point; without one, use the current point.
|
||||||
|
const prev = points[points.length - 2];
|
||||||
|
const x1 = prev ? 2 * cx - prev.x : cx;
|
||||||
|
const y1 = prev ? 2 * cy - prev.y : cy;
|
||||||
|
const x2 = rel(num(), cx);
|
||||||
|
const y2 = rel(num(), cy);
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y);
|
||||||
|
push(x, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Q': {
|
||||||
|
const x1 = rel(num(), cx);
|
||||||
|
const y1 = rel(num(), cy);
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
sampleQuadratic(points, cx, cy, x1, y1, x, y);
|
||||||
|
push(x, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'T': {
|
||||||
|
const prev = points[points.length - 2];
|
||||||
|
const x1 = prev ? 2 * cx - prev.x : cx;
|
||||||
|
const y1 = prev ? 2 * cy - prev.y : cy;
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
sampleQuadratic(points, cx, cy, x1, y1, x, y);
|
||||||
|
push(x, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'A': {
|
||||||
|
const rx = Math.abs(num());
|
||||||
|
const ry = Math.abs(num());
|
||||||
|
const rot = (num() * Math.PI) / 180;
|
||||||
|
const largeArc = num() !== 0;
|
||||||
|
const sweep = num() !== 0;
|
||||||
|
const x = rel(num(), cx);
|
||||||
|
const y = rel(num(), cy);
|
||||||
|
sampleArc(points, cx, cy, rx, ry, rot, largeArc, sweep, x, y);
|
||||||
|
push(x, y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported SVG path command: ${cmd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute cumulative arc length.
|
||||||
|
let t = 0;
|
||||||
|
for (let k = 1; k < points.length; k++) {
|
||||||
|
const a = points[k - 1]!;
|
||||||
|
const b = points[k]!;
|
||||||
|
t += Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
b.t = t;
|
||||||
|
}
|
||||||
|
return { points, length: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a path `d` string into command letters and numbers. */
|
||||||
|
function tokenize(d: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const re = /([a-zA-Z])|(-?\d*\.?\d+(?:[eE][+-]?\d+)?)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(d)) !== null) {
|
||||||
|
out.push(m[1] ?? m[2]!);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample a cubic Bezier into the point list (excluding the endpoint). */
|
||||||
|
function sampleCubic(
|
||||||
|
points: PathPoint[],
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
x3: number,
|
||||||
|
y3: number,
|
||||||
|
) {
|
||||||
|
for (let s = 1; s < SEGMENTS; s++) {
|
||||||
|
const u = s / SEGMENTS;
|
||||||
|
const v = 1 - u;
|
||||||
|
const x =
|
||||||
|
v * v * v * x0 + 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u * x3;
|
||||||
|
const y =
|
||||||
|
v * v * v * y0 + 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u * y3;
|
||||||
|
points.push({ x, y, t: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample a quadratic Bezier into the point list (excluding the endpoint). */
|
||||||
|
function sampleQuadratic(
|
||||||
|
points: PathPoint[],
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
) {
|
||||||
|
for (let s = 1; s < SEGMENTS; s++) {
|
||||||
|
const u = s / SEGMENTS;
|
||||||
|
const v = 1 - u;
|
||||||
|
const x = v * v * x0 + 2 * v * u * x1 + u * u * x2;
|
||||||
|
const y = v * v * y0 + 2 * v * u * y1 + u * u * y2;
|
||||||
|
points.push({ x, y, t: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample an elliptical arc into the point list (excluding the endpoint). */
|
||||||
|
function sampleArc(
|
||||||
|
points: PathPoint[],
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
rot: number,
|
||||||
|
largeArc: boolean,
|
||||||
|
sweep: boolean,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
) {
|
||||||
|
// Convert endpoint parameterization to center parameterization.
|
||||||
|
const dx = (x0 - x1) / 2;
|
||||||
|
const dy = (y0 - y1) / 2;
|
||||||
|
const cos = Math.cos(rot);
|
||||||
|
const sin = Math.sin(rot);
|
||||||
|
const px = cos * dx + sin * dy;
|
||||||
|
const py = -sin * dx + cos * dy;
|
||||||
|
const rx2 = rx * rx;
|
||||||
|
const ry2 = ry * ry;
|
||||||
|
const px2 = px * px;
|
||||||
|
const py2 = py * py;
|
||||||
|
const radicand = Math.max(0, (rx2 * ry2 - rx2 * py2 - ry2 * px2) / (rx2 * py2 + ry2 * px2));
|
||||||
|
const sign = largeArc !== sweep ? 1 : -1;
|
||||||
|
const factor = sign * Math.sqrt(radicand);
|
||||||
|
const cx = (factor * (rx * py)) / ry;
|
||||||
|
const cy = (factor * (-ry * px)) / rx;
|
||||||
|
const cxp = cx * cos - cy * sin + (x0 + x1) / 2;
|
||||||
|
const cyp = cx * sin + cy * cos + (y0 + y1) / 2;
|
||||||
|
|
||||||
|
const angle = (ux: number, uy: number, vx: number, vy: number) => {
|
||||||
|
const dot = ux * vx + uy * vy;
|
||||||
|
const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
|
||||||
|
let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
|
||||||
|
if (ux * vy - uy * vx < 0) a = -a;
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ux = (px - cx) / rx;
|
||||||
|
const uy = (py - cy) / ry;
|
||||||
|
const vx = (-px - cx) / rx;
|
||||||
|
const vy = (-py - cy) / ry;
|
||||||
|
let theta1 = angle(1, 0, ux, uy);
|
||||||
|
let dtheta = angle(ux, uy, vx, vy);
|
||||||
|
if (!sweep && dtheta > 0) dtheta -= Math.PI * 2;
|
||||||
|
else if (sweep && dtheta < 0) dtheta += Math.PI * 2;
|
||||||
|
|
||||||
|
for (let s = 1; s < SEGMENTS; s++) {
|
||||||
|
const a = theta1 + (s / SEGMENTS) * dtheta;
|
||||||
|
const cosA = Math.cos(a);
|
||||||
|
const sinA = Math.sin(a);
|
||||||
|
const x = cxp + rx * cosA * cos - ry * sinA * sin;
|
||||||
|
const y = cyp + rx * cosA * sin + ry * sinA * cos;
|
||||||
|
points.push({ x, y, t: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample density per curve segment. */
|
||||||
|
const SEGMENTS = 32;
|
||||||
|
|
||||||
|
/** The point (and tangent angle in degrees) at a distance along a sampled path. */
|
||||||
|
export function pointAt(path: SampledPath, distance: number): { x: number; y: number; angle: number } {
|
||||||
|
const { points, length } = path;
|
||||||
|
if (points.length === 0) return { x: 0, y: 0, angle: 0 };
|
||||||
|
const d = Math.max(0, Math.min(distance, length));
|
||||||
|
if (points.length === 1) return { x: points[0]!.x, y: points[0]!.y, angle: 0 };
|
||||||
|
|
||||||
|
let lo = 0;
|
||||||
|
let hi = points.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (points[mid]!.t < d) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
const b = points[lo]!;
|
||||||
|
const a = points[lo - 1] ?? b;
|
||||||
|
const seg = b.t - a.t;
|
||||||
|
const u = seg > 0 ? (d - a.t) / seg : 0;
|
||||||
|
const x = a.x + (b.x - a.x) * u;
|
||||||
|
const y = a.y + (b.y - a.y) * u;
|
||||||
|
// The tangent direction in degrees, matching the format's angle units. At
|
||||||
|
// the very start (lo === 0) the segment is degenerate (a === b), so fall
|
||||||
|
// back to the first segment's direction instead of a 0° angle.
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const angle =
|
||||||
|
lo === 0 && points.length > 1
|
||||||
|
? (Math.atan2(points[1]!.y - points[0]!.y, points[1]!.x - points[0]!.x) * 180) / Math.PI
|
||||||
|
: (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||||
|
return { x, y, angle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Package, Surface } from '@tts/bgm';
|
||||||
|
import { matchRoute, computeSurfacePlacements, computeRenderState, placementKey } from './state.js';
|
||||||
|
|
||||||
|
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
||||||
|
return {
|
||||||
|
type: 'board',
|
||||||
|
id: 'harbor',
|
||||||
|
layout: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg: Package = {
|
||||||
|
meta: { id: 'harbor' },
|
||||||
|
parts: new Map(),
|
||||||
|
surfaces: new Map([
|
||||||
|
['board#harbor', makeSurface()],
|
||||||
|
['hud#hand', makeSurface({ type: 'hud', id: 'hand' })],
|
||||||
|
]),
|
||||||
|
setups: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('matchRoute', () => {
|
||||||
|
it('matches a literal path', () => {
|
||||||
|
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
|
||||||
|
expect(matchRoute(route, '/deck')).toEqual({ candidate: undefined });
|
||||||
|
expect(matchRoute(route, '/other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a :param against a candidate', () => {
|
||||||
|
const route = {
|
||||||
|
route: '/dock/:seat',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
candidates: [
|
||||||
|
{ seat: '0', x: 40, y: 0 },
|
||||||
|
{ seat: '1', x: 40, y: 20 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(matchRoute(route, '/dock/1')).toEqual({ candidate: { seat: '1', x: 40, y: 20 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when no candidate matches the param', () => {
|
||||||
|
const route = {
|
||||||
|
route: '/dock/:seat',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
candidates: [{ seat: '0', x: 40, y: 0 }],
|
||||||
|
};
|
||||||
|
expect(matchRoute(route, '/dock/9')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on length mismatch', () => {
|
||||||
|
const route = { route: '/deck', x: 0, y: 0, rotation: 0 };
|
||||||
|
expect(matchRoute(route, '/deck/extra')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeSurfacePlacements', () => {
|
||||||
|
it('places parts on a matching route with index and stackSize', () => {
|
||||||
|
const surface = makeSurface({
|
||||||
|
layout: [{ route: '/deck', x: -100, y: 0, rotation: 0 }],
|
||||||
|
});
|
||||||
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'/deck': ['harbor:card#a', 'harbor:card#b'],
|
||||||
|
});
|
||||||
|
expect(placements).toHaveLength(2);
|
||||||
|
expect(placements[0]).toMatchObject({ piece: 'harbor:card#a', index: 0, stackSize: 2 });
|
||||||
|
expect(placements[1]).toMatchObject({ piece: 'harbor:card#b', index: 1, stackSize: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops parts with no matching route', () => {
|
||||||
|
const surface = makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] });
|
||||||
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'/deck': ['harbor:card#a'],
|
||||||
|
'/elsewhere': ['harbor:card#b'],
|
||||||
|
});
|
||||||
|
expect(placements).toHaveLength(1);
|
||||||
|
expect(placements[0]!.piece).toBe('harbor:card#a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the candidate anchor for a :param route', () => {
|
||||||
|
const surface = makeSurface({
|
||||||
|
layout: [
|
||||||
|
{
|
||||||
|
route: '/dock/:seat',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
candidates: [{ seat: '0', x: 40, y: 5, rotation: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
||||||
|
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, rotation: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the candidate stacking alongside its anchor', () => {
|
||||||
|
const surface = makeSurface({
|
||||||
|
layout: [
|
||||||
|
{
|
||||||
|
route: '/dock/:seat',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
candidates: [{ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const placements = computeSurfacePlacements(surface, { '/dock/0': ['harbor:boat#fleet'] });
|
||||||
|
expect(placements[0]!.candidate).toEqual({ seat: '0', x: 40, y: 5, stacking: { tilt: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the same piece on two paths as distinct placements', () => {
|
||||||
|
const surface = makeSurface({
|
||||||
|
layout: [
|
||||||
|
{ route: '/deck', x: 0, y: 0, rotation: 0 },
|
||||||
|
{ route: '/community/:slot', x: 0, y: 0, rotation: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The deck expands to every card (including `as`); the flop also places `as`.
|
||||||
|
const placements = computeSurfacePlacements(surface, {
|
||||||
|
'/deck': ['poker:card#as', 'poker:card#kh'],
|
||||||
|
'/community/0': ['poker:card#as'],
|
||||||
|
});
|
||||||
|
expect(placements).toHaveLength(3);
|
||||||
|
const deck = placements.filter((p) => p.path === '/deck');
|
||||||
|
const flop = placements.filter((p) => p.path === '/community/0');
|
||||||
|
expect(deck).toHaveLength(2);
|
||||||
|
expect(flop).toHaveLength(1);
|
||||||
|
// The same piece on two paths yields distinct placement keys.
|
||||||
|
expect(placementKey(deck[0]!)).not.toBe(placementKey(flop[0]!));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeRenderState', () => {
|
||||||
|
it('only includes enabled surfaces', () => {
|
||||||
|
const state = {
|
||||||
|
surfaces: { 'board#harbor': true, 'hud#hand': false },
|
||||||
|
paths: { '/deck': ['harbor:card#a'] },
|
||||||
|
};
|
||||||
|
pkg.surfaces.set(
|
||||||
|
'board#harbor',
|
||||||
|
makeSurface({ layout: [{ route: '/deck', x: 0, y: 0, rotation: 0 }] }),
|
||||||
|
);
|
||||||
|
const placements = computeRenderState(pkg, state);
|
||||||
|
expect(placements).toHaveLength(1);
|
||||||
|
expect(placements[0]!.surface).toBe('board#harbor');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placementKey', () => {
|
||||||
|
it('is unique per surface, path, and piece', () => {
|
||||||
|
const a = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#a' } as never;
|
||||||
|
const b = { surface: 'board#harbor', path: '/deck', piece: 'harbor:card#b' } as never;
|
||||||
|
const c = { surface: 'hud#hand', path: '/deck', piece: 'harbor:card#a' } as never;
|
||||||
|
// The same piece on two paths of the same surface is a distinct placement.
|
||||||
|
const d = { surface: 'board#harbor', path: '/community/0', piece: 'harbor:card#a' } as never;
|
||||||
|
expect(placementKey(a)).not.toBe(placementKey(b));
|
||||||
|
expect(placementKey(a)).not.toBe(placementKey(c));
|
||||||
|
expect(placementKey(a)).not.toBe(placementKey(d));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Source-of-truth game state and the derived render state.
|
||||||
|
*
|
||||||
|
* The store holds the enabled surfaces and the path -> part placement map
|
||||||
|
* (`bgm-tabletop.md` §2). The derived render state is computed from the game
|
||||||
|
* state plus a package's surface routes: a stable list of placements, one per
|
||||||
|
* (surface, piece) pair, keyed for rendering.
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Candidate, Package, Route, Surface } from '@tts/bgm';
|
||||||
|
|
||||||
|
/** Source-of-truth game state. */
|
||||||
|
export interface GameState {
|
||||||
|
/** Enabled per surface id (`type#id`). */
|
||||||
|
surfaces: Record<string, boolean>;
|
||||||
|
/** Path -> part list (`package:type#id`). */
|
||||||
|
paths: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single placed piece on a surface, ready for rendering. */
|
||||||
|
export interface Placement {
|
||||||
|
/** Surface id (`type#id`). */
|
||||||
|
surface: string;
|
||||||
|
/** The path key this placement came from. */
|
||||||
|
path: string;
|
||||||
|
/** The matched route. */
|
||||||
|
route: Route;
|
||||||
|
/** The matched candidate, when the route has `:param`s. */
|
||||||
|
candidate?: Candidate;
|
||||||
|
/** The piece id (`package:type#id`). */
|
||||||
|
piece: string;
|
||||||
|
/** The piece's position in its path's stack. */
|
||||||
|
index: number;
|
||||||
|
/** The number of pieces on the path. */
|
||||||
|
stackSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabletopState extends GameState {
|
||||||
|
setSurfaces: (surfaces: Record<string, boolean>) => void;
|
||||||
|
setPaths: (paths: Record<string, string[]>) => void;
|
||||||
|
seed: (state: GameState) => void;
|
||||||
|
enableSurface: (id: string) => void;
|
||||||
|
disableSurface: (id: string) => void;
|
||||||
|
setPath: (path: string, parts: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTabletopStore = create<TabletopState>((set) => ({
|
||||||
|
surfaces: {},
|
||||||
|
paths: {},
|
||||||
|
setSurfaces: (surfaces) => set({ surfaces }),
|
||||||
|
setPaths: (paths) => set({ paths }),
|
||||||
|
seed: (state) => set(state),
|
||||||
|
enableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: true } })),
|
||||||
|
disableSurface: (id) => set((s) => ({ surfaces: { ...s.surfaces, [id]: false } })),
|
||||||
|
setPath: (path, parts) => set((s) => ({ paths: { ...s.paths, [path]: parts } })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Route matching ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a route pattern against a path key. Returns the matched candidate (or
|
||||||
|
* undefined when the route has no `:param`s), or null when the route doesn't
|
||||||
|
* match. A route with candidates matches only when a candidate's props match
|
||||||
|
* every `:param`; the first such candidate wins.
|
||||||
|
*/
|
||||||
|
export function matchRoute(route: Route, path: string): { candidate?: Candidate } | null {
|
||||||
|
const routeSegs = route.route.split('/').filter(Boolean);
|
||||||
|
const pathSegs = path.split('/').filter(Boolean);
|
||||||
|
if (routeSegs.length !== pathSegs.length) return null;
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < routeSegs.length; i++) {
|
||||||
|
const rs = routeSegs[i]!;
|
||||||
|
const ps = pathSegs[i]!;
|
||||||
|
if (rs.startsWith(':')) params[rs.slice(1)] = ps;
|
||||||
|
else if (rs !== ps) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.candidates) {
|
||||||
|
for (const cand of route.candidates) {
|
||||||
|
const allMatch = Object.entries(params).every(([k, v]) => cand[k] === v);
|
||||||
|
if (allMatch) return { candidate: cand };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { candidate: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the placements for a single surface from the game state's paths. */
|
||||||
|
export function computeSurfacePlacements(surface: Surface, paths: Record<string, string[]>): Placement[] {
|
||||||
|
const placements: Placement[] = [];
|
||||||
|
const surfaceId = `${surface.type}#${surface.id}`;
|
||||||
|
for (const [path, parts] of Object.entries(paths)) {
|
||||||
|
const route = surface.layout.find((r) => matchRoute(r, path));
|
||||||
|
if (!route) continue;
|
||||||
|
const match = matchRoute(route, path)!;
|
||||||
|
const stackSize = parts.length;
|
||||||
|
for (const piece of parts) {
|
||||||
|
placements.push({
|
||||||
|
surface: surfaceId,
|
||||||
|
path,
|
||||||
|
route,
|
||||||
|
candidate: match.candidate,
|
||||||
|
piece,
|
||||||
|
index: parts.indexOf(piece),
|
||||||
|
stackSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return placements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the full render state across all enabled surfaces. */
|
||||||
|
export function computeRenderState(pkg: Package, state: GameState): Placement[] {
|
||||||
|
const out: Placement[] = [];
|
||||||
|
for (const [surfaceId, enabled] of Object.entries(state.surfaces)) {
|
||||||
|
if (!enabled) continue;
|
||||||
|
const surface = pkg.surfaces.get(surfaceId);
|
||||||
|
if (!surface) continue;
|
||||||
|
out.push(...computeSurfacePlacements(surface, state.paths));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable key for a placement, unique across surfaces, paths, and pieces. */
|
||||||
|
export function placementKey(p: Placement): string {
|
||||||
|
return `${p.surface}:${p.path}:${p.piece}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The derived render state for a package, from the current game state. */
|
||||||
|
export function useRenderState(pkg: Package): Placement[] {
|
||||||
|
const surfaces = useTabletopStore((s) => s.surfaces);
|
||||||
|
const paths = useTabletopStore((s) => s.paths);
|
||||||
|
return useMemo(() => computeRenderState(pkg, { surfaces, paths }), [pkg, surfaces, paths]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* `HudSurfaceView` — mount a surface to a HUD area.
|
||||||
|
*
|
||||||
|
* Renders a `kind: hud` mount node (and its child surfaces) anchored to a HUD
|
||||||
|
* area. The default is a drei `Html` overlay so world and HUD share one scene
|
||||||
|
* (see `bgm-tabletop.md` Open decisions). Parts are placed via `PartPlacement`.
|
||||||
|
*/
|
||||||
|
import { Html } from '@react-three/drei';
|
||||||
|
import type { Package } from '@tts/bgm';
|
||||||
|
import { useRenderState, placementKey } from '../state.js';
|
||||||
|
import { PartPlacement } from '../placement.js';
|
||||||
|
import type { MountNode } from '../mount.js';
|
||||||
|
import { MM_TO_WORLD } from '../part.js';
|
||||||
|
import { SurfaceBounds } from './SurfaceBounds.js';
|
||||||
|
import { StackingCurve } from './StackingCurve.js';
|
||||||
|
import { SurfaceNode } from './WorldSurfaceView.js';
|
||||||
|
|
||||||
|
export function HudSurfaceView({
|
||||||
|
pkg,
|
||||||
|
node,
|
||||||
|
showSurface = false,
|
||||||
|
}: {
|
||||||
|
pkg: Package;
|
||||||
|
node: MountNode;
|
||||||
|
showSurface?: boolean;
|
||||||
|
}) {
|
||||||
|
const placements = useRenderState(pkg);
|
||||||
|
const own = placements.filter((p) => p.surface === node.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html
|
||||||
|
position={[node.x * MM_TO_WORLD, 0, node.y * MM_TO_WORLD]}
|
||||||
|
transform
|
||||||
|
distanceFactor={1}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{showSurface && (
|
||||||
|
<>
|
||||||
|
<SurfaceBounds surface={node.surface} />
|
||||||
|
{node.surface.layout.map((route, i) =>
|
||||||
|
route.stacking ? <StackingCurve key={i} route={route} /> : null,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{own.map((p) => (
|
||||||
|
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
|
||||||
|
))}
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<SurfaceNode key={child.id} pkg={pkg} node={child} showSurface={showSurface} />
|
||||||
|
))}
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* `StackingCurve` — a debug visualization of a route's stacking `curve`.
|
||||||
|
*
|
||||||
|
* Draws the SVG path (mm, converted to world units) as a line on the surface,
|
||||||
|
* plus a small marker at the curve's start. The curve is defined relative to
|
||||||
|
* the route's anchor, so the points are offset by the anchor's `x`/`y`. Used
|
||||||
|
* by the surface views when `showSurface` is enabled, so the curve's shape and
|
||||||
|
* direction are visible.
|
||||||
|
*/
|
||||||
|
import { Line } from '@react-three/drei';
|
||||||
|
import type { Route } from '@tts/bgm';
|
||||||
|
import { parsePath } from '../stacking.js';
|
||||||
|
import { MM_TO_WORLD } from '../part.js';
|
||||||
|
|
||||||
|
export function StackingCurve({ route }: { route: Route }) {
|
||||||
|
if (!route.stacking?.curve) return null;
|
||||||
|
const path = parsePath(route.stacking.curve);
|
||||||
|
if (path.points.length === 0) return null;
|
||||||
|
|
||||||
|
// Curve coords are in mm relative to the route anchor (x, y); map to world
|
||||||
|
// (x, z) and offset by the anchor, matching how parts are placed.
|
||||||
|
const points: [number, number, number][] = path.points.map((p) => [
|
||||||
|
(route.x + p.x) * MM_TO_WORLD,
|
||||||
|
0,
|
||||||
|
(route.y + p.y) * MM_TO_WORLD,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const start = points[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<Line points={points} color="#f59e0b" lineWidth={2} depthTest={false} />
|
||||||
|
{/* Marker at the curve's start, showing its direction. */}
|
||||||
|
<mesh position={[start[0], 0.01, start[2]]}>
|
||||||
|
<sphereGeometry args={[0.01, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#f59e0b" depthTest={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* `SurfaceBounds` — a debug visualization of a surface's area.
|
||||||
|
*
|
||||||
|
* Draws a rectangle outline at the surface's reference `size` (mm, converted
|
||||||
|
* to world units) centered on the mount anchor, plus an HTML label with the
|
||||||
|
* surface's name. Used by the surface views when `showSurface` is enabled.
|
||||||
|
*/
|
||||||
|
import { Html, Line } from '@react-three/drei';
|
||||||
|
import type { Surface } from '@tts/bgm';
|
||||||
|
import { MM_TO_WORLD } from '../part.js';
|
||||||
|
|
||||||
|
export function SurfaceBounds({ surface }: { surface: Surface }) {
|
||||||
|
const [w, h] = surface.size ?? [0, 0];
|
||||||
|
const hw = (w * MM_TO_WORLD) / 2;
|
||||||
|
const hh = (h * MM_TO_WORLD) / 2;
|
||||||
|
const points: [number, number, number][] = [
|
||||||
|
[-hw, 0, -hh],
|
||||||
|
[hw, 0, -hh],
|
||||||
|
[hw, 0, hh],
|
||||||
|
[-hw, 0, hh],
|
||||||
|
[-hw, 0, -hh],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{surface.size && <Line points={points} color="#22d3ee" lineWidth={1} />}
|
||||||
|
<Html position={[0, 0.05, 0]} center style={{ pointerEvents: 'none' }}>
|
||||||
|
<div className="rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
||||||
|
{surface.type}#{surface.id}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* `WorldSurfaceView` — mount a surface in world space.
|
||||||
|
*
|
||||||
|
* Renders a world mount node (a `kind: table` surface and its child surfaces)
|
||||||
|
* at its anchor. Parts on the surface are placed via `PartPlacement` from the
|
||||||
|
* derived render state. A disabled surface isn't part of the mount tree, so
|
||||||
|
* it's never rendered.
|
||||||
|
*/
|
||||||
|
import type { Package } from '@tts/bgm';
|
||||||
|
import { useRenderState, placementKey } from '../state.js';
|
||||||
|
import { PartPlacement } from '../placement.js';
|
||||||
|
import type { MountNode } from '../mount.js';
|
||||||
|
import { MM_TO_WORLD, DEG_TO_RAD } from '../part.js';
|
||||||
|
import { SurfaceBounds } from './SurfaceBounds.js';
|
||||||
|
import { StackingCurve } from './StackingCurve.js';
|
||||||
|
|
||||||
|
export function WorldSurfaceView({
|
||||||
|
pkg,
|
||||||
|
node,
|
||||||
|
showSurface = false,
|
||||||
|
}: {
|
||||||
|
pkg: Package;
|
||||||
|
node: MountNode;
|
||||||
|
showSurface?: boolean;
|
||||||
|
}) {
|
||||||
|
return <SurfaceNode pkg={pkg} node={node} showSurface={showSurface} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a mount node at its anchor, placing parts and recursing into children. */
|
||||||
|
export function SurfaceNode({
|
||||||
|
pkg,
|
||||||
|
node,
|
||||||
|
showSurface = false,
|
||||||
|
}: {
|
||||||
|
pkg: Package;
|
||||||
|
node: MountNode;
|
||||||
|
showSurface?: boolean;
|
||||||
|
}) {
|
||||||
|
const placements = useRenderState(pkg);
|
||||||
|
const own = placements.filter((p) => p.surface === node.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
position={[node.x * MM_TO_WORLD, 0, node.y * MM_TO_WORLD]}
|
||||||
|
rotation={[0, node.rotation * DEG_TO_RAD, 0]}
|
||||||
|
>
|
||||||
|
{showSurface && (
|
||||||
|
<>
|
||||||
|
<SurfaceBounds surface={node.surface} />
|
||||||
|
{node.surface.layout.map((route, i) =>
|
||||||
|
route.stacking ? <StackingCurve key={i} route={route} /> : null,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{own.map((p) => (
|
||||||
|
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
|
||||||
|
))}
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<SurfaceNode key={child.id} pkg={pkg} node={child} showSurface={showSurface} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { build } from 'vite';
|
||||||
|
import { bgm } from '@tts/bgm';
|
||||||
|
|
||||||
|
const fixtureRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'__fixtures__',
|
||||||
|
'vite-build',
|
||||||
|
);
|
||||||
|
const gamesRoot = path.join(fixtureRoot, 'games');
|
||||||
|
|
||||||
|
describe('tabletop vite build (integration)', () => {
|
||||||
|
it('bundles the library logic against a fixture package', async () => {
|
||||||
|
const outDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'tabletop-build-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await build({
|
||||||
|
root: fixtureRoot,
|
||||||
|
logLevel: 'silent',
|
||||||
|
build: {
|
||||||
|
outDir,
|
||||||
|
write: true,
|
||||||
|
emptyOutDir: true,
|
||||||
|
// Keep identifiers readable so the test can assert on them.
|
||||||
|
minify: false,
|
||||||
|
},
|
||||||
|
plugins: [bgm({ root: gamesRoot })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunk = walk(outDir).find((f) => f.endsWith('.js'))!;
|
||||||
|
const code = fs.readFileSync(path.join(outDir, chunk), 'utf8');
|
||||||
|
|
||||||
|
// The bgm plugin serialized the package's parts and setup into the
|
||||||
|
// emitted module, and the library's logic is bundled alongside.
|
||||||
|
expect(code).toContain('token#wood');
|
||||||
|
expect(code).toContain('game#main');
|
||||||
|
// The library's pure logic (setup seeding, render state, stacking,
|
||||||
|
// mount resolution) is reachable from the fixture entry.
|
||||||
|
expect(code).toContain('placementCount');
|
||||||
|
expect(code).toContain('offsetX');
|
||||||
|
expect(code).toContain('worldCount');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(outDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Recursively list files under a directory, as paths relative to it. */
|
||||||
|
function walk(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const visit = (current: string) => {
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const full = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) visit(full);
|
||||||
|
else out.push(path.relative(dir, full));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(dir);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/__fixtures__", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
@@ -43,26 +43,12 @@ describe('fetchModFileFromUrl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getFileName', () => {
|
describe('getFileName', () => {
|
||||||
it('parses a quoted content-disposition filename', () => {
|
it('derives the filename from the URL path', () => {
|
||||||
expect(
|
expect(getFileName('https://example.com/files/mod.json')).toBe('mod.json');
|
||||||
getFileName('https://example.com/save', 'attachment; filename="mod.json"'),
|
|
||||||
).toBe('mod.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses an unquoted filename', () => {
|
|
||||||
expect(
|
|
||||||
getFileName('https://example.com/save', 'attachment; filename=mod.json'),
|
|
||||||
).toBe('mod.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the URL path when there is no disposition', () => {
|
|
||||||
expect(getFileName('https://example.com/files/mod.json', null)).toBe(
|
|
||||||
'mod.json',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a default when the URL has no path', () => {
|
it('falls back to a default when the URL has no path', () => {
|
||||||
expect(getFileName('https://example.com', null)).toBe('save.json');
|
expect(getFileName('https://example.com')).toBe('save.json');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,12 @@ export async function fetchModFromUrl(fileUrl: string): Promise<TTSMod> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive a filename from a `content-disposition` header.
|
* Derive a filename for a downloaded save from its URL path, falling back to
|
||||||
* Parses `filename="..."`; falls back to the URL path, then a default.
|
* a default when the URL has no path segment. The upstream `content-disposition`
|
||||||
|
* header is intentionally ignored: Steam save URLs are extension-less and
|
||||||
|
* rarely carry a useful filename, so the URL path is the reliable source.
|
||||||
*/
|
*/
|
||||||
export function getFileName(url: string, disposition: string | null): string {
|
export function getFileName(url: string): string {
|
||||||
if (disposition) {
|
|
||||||
const match = disposition.match(/filename\*?=(?:"([^"]*)"|([^;\s]*))/i);
|
|
||||||
const name = match?.[1] ?? match?.[2];
|
|
||||||
if (name) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new URL(url).pathname.split('/').pop() || 'save.json';
|
return new URL(url).pathname.split('/').pop() || 'save.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +76,7 @@ export async function fetchModFileFromUrl(
|
|||||||
fileUrl: string,
|
fileUrl: string,
|
||||||
): Promise<{ data: ArrayBuffer; filename: string }> {
|
): Promise<{ data: ArrayBuffer; filename: string }> {
|
||||||
const data = await downloadSave(fileUrl);
|
const data = await downloadSave(fileUrl);
|
||||||
const filename = getFileName(fileUrl, null);
|
const filename = getFileName(fileUrl);
|
||||||
return { data, filename };
|
return { data, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+6
@@ -268,6 +268,9 @@ importers:
|
|||||||
specifier: ^5.0.14
|
specifier: ^5.0.14
|
||||||
version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
|
version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.20.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.18
|
specifier: ^19.2.18
|
||||||
version: 19.2.18
|
version: 19.2.18
|
||||||
@@ -277,6 +280,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^8.2.1
|
||||||
|
version: 8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.10
|
specifier: ^4.1.10
|
||||||
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
|
||||||
|
|||||||
Reference in New Issue
Block a user