Compare commits

..
92 Commits
Author SHA1 Message Date
hyper ddf6785f9e test(tabletop): cover store actions, interactions, and path sampling
Add tests for the tabletop and interaction stores (seed, surface toggles,
setPart, movePart reindexing/clamping), the interaction store's hold/drop
and dialog stack, and the SVG path sampler (Q/T/S/A commands, unsupported
commands, pointAt edge cases, and zero-length curves). stacking.ts goes
from 61% to 99.5% line coverage.
2026-08-18 10:30:37 +08:00
hyper d0e4269ad6 chore: add vitest coverage provider and ignore coverage output
Add @vitest/coverage-v8 as a dev dependency so coverage reports can run,
and ignore generated coverage/ directories.
2026-08-18 10:30:34 +08:00
hyper 999b7a0771 feat(bgm): add dialog role and interaction affordances
Add the dialog role and the interaction affordances the free-interaction
layer depends on: Setup.interactions, Package.dialogs, and the Interaction,
DialogAction, and Dialog types. Wire the dialog role through the zod
schemas, package collection (with duplicate checking), and vite
serialization, and update the tabletop serializedToPackage and test
fixtures to carry dialogs.
2026-08-18 10:24:21 +08:00
hyper 8eff44712f feat(tabletop): add free-interaction layer (layer 3)
Add the interaction half of the sandbox: a held part and dialog stack as
UI state outside the game store, with pure helpers (interactionsFor,
dropPaths, pickPath, partFacings, nextFacing). PartPlacement is now
interactive (pick up, drag to move, click to cycle facing), and a drop on
a path with a stack-dialog interaction opens the deck dialog instead of
moving directly. DialogLayer renders the stack-inspector dialog whose
insert button issues a move. Add the dialog role, Setup.interactions, and
Package.dialogs to @tts/bgm, and declare the deck insert dialog in
games/poker.
2026-08-18 10:20:22 +08:00
hyper 3813287489 fix(web): pass TTSMod to flattenObjects in full setup 2026-08-17 17:59:01 +08:00
hypercross 71ae91960f docs: mark stale bgm engine/loader docs as in progress
Call out that the tabletop and engine package split is not yet landed, add a status note to tabletop.md, and drop the retired WIP banner in bgm-loader.md.
2026-08-16 13:04:14 +08:00
hypercross addfc03ab6 docs: add free interaction design to bgm specs
Add the command/dialog split to the state-model and interactions docs, declare role dialog and zone facing in the format spec, and let setups declare which dialogs power open interactions.
2026-08-16 12:59:03 +08:00
hypercross 211c151971 docs: add bgm state model design
Describe the format's state model: components as constants, setup as the shape of states, path-to-stack by facing (part affordance x zone restriction), and the stacks-on-paths anchoring scope cut.
2026-08-16 12:38:58 +08:00
hypercross 345832e389 docs: reorganize docs into bgm and status folders
Group the bgm spec cluster under docs/bgm and move dev logs and plans under docs/status, add an overview index, and update cross-references in the docs, README, and source comments.
2026-08-16 11:57:19 +08:00
hypercross cf8ca07850 fix: allow recovering from part viewer error
Add a Try again button to the ErrorBoundary fallback and key the ModPage boundary by selection so picking another object clears a stuck error.
2026-08-15 14:17:41 +08:00
hypercross 498cf1b633 fix(web): balance scene lighting so laid-flat cards don't overexpose
Replace the single dominant top light with evenly-spaced key lightformers
so a laid-flat card's top face isn't blown out and its underside is lifted
by a warm bounce.
2026-08-15 13:40:34 +08:00
hypercross a9297d8b2b fix(web): size contact shadows to content bounds
Lay flat card/tile/token meshes so the scene-level contact shadow has a
surface to project onto, and size the shadow plane to the measured content
bounds instead of a fixed 22-unit default that left small objects shadowless.
Give deck carousel cards their own soft radial shadow so the fanned arc reads
as floating over a floor.
2026-08-15 13:27:46 +08:00
hypercross 7d9b5e49ad feat(web): light 3D scene with image-based lighting
Replace the hand-placed light rig with an Environment built from Lightformers, giving MeshStandardMaterial objects consistent PBR lighting and richer color. Keep ContactShadows, which needs no light.
2026-08-15 12:42:05 +08:00
hypercross 3dd3db5643 fix(web): decode textures as sRGB in 3D viewers
TextureLoader leaves colorSpace as NoColorSpace, so card, tile, token, model, and part art is uploaded as linear and then double-decoded in the shader, washing out contrast. Mark each texture sRGB so the GPU decodes it once, correctly.
2026-08-15 12:25:21 +08:00
hypercross fc9f756d52 feat(web): add tone mapping and brightness to 3D scene
The EffectComposer disables three's tone mapping, leaving colors flat and washed out. Add ACES filmic tone mapping plus a small brightness lift so cards match their source images.
2026-08-15 12:25:18 +08:00
hypercross 7ff8e3c51d fix(web): thin out card mesh thickness 2026-08-15 12:10:41 +08:00
hypercross 82a4f9b645 fix(web): stop pdf viewer overflowing 2026-08-14 13:55:02 +08:00
hypercross 82d5a36497 feat(web): add search link to mod header 2026-08-14 13:53:05 +08:00
hypercross 699b89e916 feat(web): show child count in object tree nodes 2026-08-14 12:05:46 +08:00
hypercross 0d35ad56f2 feat(web): rework deck carousel navigation
Replace the counter with a sliding-dot pagination: dots slide and fade
as the active card changes, a counter above the bar shows the current
index, and round first/last and prev/next buttons flank the strip.
Scrolling over the dots steps through the deck. Also fix the camera fit
so it frames the active card front-on instead of the carousel center.
2026-08-14 12:05:37 +08:00
hypercross f01bb5f99b feat(web): rework mod inspector layout
Fill the viewport with a fixed sidebar of highlightable type-filter
icons over an independently scrolling tree, and let the viewer expand
into a full-height scene.
2026-08-14 11:27:46 +08:00
hypercross 8ebb9211c5 fix(web): frame deck carousel on active card
Fit the camera in a frame callback so the active card has settled into
its arc slot first, and aim it straight at the card's front face instead
of keeping the initial side angle. Measure the card's width and widen the
arc radius so wide cards no longer clip their neighbors.
2026-08-14 11:15:59 +08:00
hypercross a3095ead38 feat(web): move mod header into site header
Replace the global TTS Workshop header on mod routes with a ModHeader showing the thumbnail, game name, and download/setup links styled like the Search/BGM nav.
2026-08-14 10:59:20 +08:00
hypercross 30ef76632f feat: return workshop metadata with mod details
Include the Workshop title and preview image in the items response so the mod header survives a page refresh, not just navigation from search. Resolve both from the same Steam API call.
2026-08-14 10:59:14 +08:00
hypercross 7163451d1f fix(web): declare card shader UV uniforms
three.js does not auto-declare uniforms added via onBeforeCompile, so the
injected uMapRepeat/uMapOffset were undeclared and the card shader failed
to compile, rendering cards transparent. Prepend the declarations to the
vertex shader.
2026-08-14 10:52:54 +08:00
hypercross 2430be9661 feat(web): rework mod page header layout
Show the game name and thumbnail in the header, float the action buttons and object GUID to the right, and keep the viewer icon, label, and GUID on one line.
2026-08-14 10:46:27 +08:00
hypercross 002bcb324b perf(web): share card texture via shader UV transform
Move the card sprite repeat/offset out of per-card texture clones and
into a uniform injected into the material shader. Cards now share the
deck sheet (one GPU upload), the shader, and the geometry, with only
per-card material uniforms differing, so navigating a deck no longer
re-uploads the sheet on every step.
2026-08-14 10:46:09 +08:00
hypercross c665212209 feat(web): add deck viewer
Add a DeckViewer that frames the active card in a deck carousel. Scene gains a fit prop so the shared bounds behavior can be disabled and refit manually.
2026-08-14 10:29:04 +08:00
hypercross 2403abc07b feat(web): add type filter bar to object tree
Chips highlight object types to show only those in the tree; with none highlighted, everything is shown. Filtered nodes keep their original index path so selection stays stable.
2026-08-14 10:29:01 +08:00
hypercross ee7e73c798 style(web): use 3:4 aspect ratio for viewers 2026-08-14 09:54:52 +08:00
hypercross dd08f901ae feat: add inline PDF viewer for Custom_PDF objects
Add a /pdf proxy route that forces Content-Disposition inline so PDFs render instead of downloading, and a web viewer that embeds the document in an iframe with a standalone open-in-new-tab link.
2026-08-14 09:51:11 +08:00
hypercross 8bd186df54 refactor(engine): unify commands and orchestrators
Replace the CommandHost with a handler registry keyed by message type.
Message is a permissive base type the host extends; CommandResult is a
discriminated union on the type suffix. Commands and orchestrators are
the same async shape taking a RunContext with an AbortSignal; runCommand
emits :done/:cancel/:error.
2026-08-10 18:40:35 +08:00
hypercross 81c115cb4d docs: unify commands and orchestrators in bgm-engine
Messages are a discriminated union with generic command/result types;
the command host is dropped for a handler registry; commands and
orchestrators are the same async shape taking a cancellable RunContext.
2026-08-10 18:40:30 +08:00
hypercross 3167d26bd6 feat(engine): add message queue, triggers, and orchestrators
New @tts/engine package: the pure message layer that drives bgm games.
MessageQueue snapshots and drains on tick; TriggerRegistry matches by
type/id with runtime enable/disable; runOrchestrator suspends on wait
until a matching message is processed. Engine ties them together behind
the CommandHost contract that @tts/tabletop will implement.
2026-08-10 18:26:23 +08:00
hypercross 91cd3a16d7 docs: add bgm-engine message layer design
Define the message/queue/tick model, the three handler kinds (trigger,
orchestrator, command host), and the @tts/engine package split. Cross-link
from bgm-commands and bgm-tabletop, and add the engine to the architecture
package table.
2026-08-10 18:26:19 +08:00
hypercross 13b3eaed78 feat(bgm): derive definition names from role.type
A definition is identified by role.type and named role.type.lang
(package.lang for packages). Code blocks declare it via role= on the
info string; real files by their filename. Blocks and files without a
matching role.type are ignored. file= still overrides the auto-name.

Relative asset and $variants paths now resolve against the markdown
file's directory (baseDir) rather than the virtual name. Same role.type
with different ids groups under one name; duplicate type#id still
errors. Migrate fixtures and example games to role=.
2026-08-10 16:36:57 +08:00
hypercross 23332ab786 docs(bgm): derive definition names from role.type 2026-08-10 16:27:45 +08:00
hypercross aab0b66ee8 feat(bgm): support role= on code block info strings
Parse role.type#id from the fence info string and merge it into each
parsed object, erroring on conflicts with the content. id on the info
string can't combine with $variants. Auto-named blocks now hash their
content instead of the info string, so identical blocks dedupe.
2026-08-10 16:09:12 +08:00
hypercross c41c266ac1 docs(bgm): document role= on code block info strings 2026-08-10 16:07:17 +08:00
hypercross 634a99dd25 docs: design command execution for tabletop scripts
Add bgm-commands.md covering async commands with ok/cancel/error
results, per-invocation run contexts, fire-and-forget vs self-managed
waiting, and tap interaction with part-local trigger points. Link it
from the tabletop design and plan docs, and record the decisions in
decisions.md.
2026-08-10 15:41:43 +08:00
hypercross 9b5223686e feat(bgm): allow $variants to take multiple csv sources
Expand $variants to accept an array of csv paths, concatenating their
rows. Detect a path by a .csv suffix on the first line instead of a
newline, so inline csv and paths are self-documenting and the rule
applies uniformly to single values and array elements.
2026-08-10 14:06:36 +08:00
hypercross 3aa48058f7 fix(tabletop): render surface outline without depth test 2026-08-10 12:24:46 +08:00
hypercross 812b4640e2 feat(web): clamp tabletop camera to the horizon
Add a maxPolarAngle prop to Scene and use it in the tabletop view so the
camera can't dip below the table and peek under face-down cards.
2026-08-10 12:24:43 +08:00
hypercross 9d776771b1 feat: showcase facing states in poker setup
Deal the deck facedown and stand two drawn cards on their bottom edge,
exercising the back and standing facings alongside the face-up flop.
2026-08-10 12:24:40 +08:00
hypercross d663f4afea feat: add facing orientation to parts
Replace the face/back boolean with a three-state facing (face, back,
standing) seeded from setup placements. Parts now orient about the
anchor at the resting face/edge, so back-down parts sit on the table
instead of below it and standing parts rest on their bottom edge.
2026-08-10 11:47:58 +08:00
hypercross aa23e93b3f docs: add commit conventions skill 2026-08-10 11:47:35 +08:00
hypercross 77e0751554 docs: describe ordered setup placements
Document that setup is an ordered list of placements, each moving its parts to a path, in the format reference and the tabletop plan.
2026-08-10 11:27:56 +08:00
hypercross dc721d5c2d refactor(games): use ordered setup placements
Convert poker and carcassonne setups to the ordered placement list. Deal the deck/draw first, then move the flop cards and opening tiles out onto their paths.
2026-08-10 11:27:53 +08:00
hypercross 96c299e498 feat(tabletop): seed state from ordered setup placements
Apply each setup placement in order, moving its parts to its path, then index each path's parts contiguously so stacking stays valid.
2026-08-10 11:27:50 +08:00
hypercross c2693277ac feat(bgm): model setup as ordered placements
Replace the path->parts setup map with an ordered list of placements, each moving its parts to a path. Entries apply in order, so a part listed in a later placement ends up on that placement's path.
2026-08-10 11:27:46 +08:00
hypercross bc418b2c73 Model part face in tabletop state
Replace the path->part-list store with a per-part map keyed by part id, so each placed part carries its path, stack index, and face. Derive each path's ordered children for stacking, and flip face-down parts in PartPlacement.
2026-08-10 11:10:16 +08:00
hypercross 6502fdae4f refactor(web): code-split the three.js stack out of the main bundle 2026-08-10 09:12:38 +08:00
hypercross 45bf362fbc refactor(tts): derive save filename from URL path only 2026-08-10 09:12:34 +08:00
hypercross 82caa9bb9c fix(shared): accept local asset paths in trace schema 2026-08-10 09:12:31 +08:00
hypercross dfe49bad0c refactor(bgm): use doc-relative include in games and fixtures
The default include is now resolved relative to the package
declaration's directory, so the explicit root-relative patterns are
redundant. Drop them in favor of the default.
2026-08-10 08:52:54 +08:00
hypercross 2001616d9e fix(bgm): resolve include patterns relative to package dir
The default include matched every yaml across the games root, so a
package could absorb a sibling game's defs (e.g. two game#main setups).
Resolve patterns against the package declaration's directory instead;
a leading slash marks a pattern as root-relative.
2026-08-10 08:52:51 +08:00
hypercross 77ffd8ff00 feat(carcassonne): add base game manifest
A 24-tile base game: one part per tile image (A-X), a board with a
draw pile and an 11x11 grid, and a setup seeding the tile supply.
2026-08-10 08:52:48 +08:00
hypercross 5a3a9c1fcc feat(web): lighten tabletop scene and tune contact shadows
Replace the dark surface plane with a grey background and a spotlight,
size the contact shadow to the table, and scale its blur and fade with
the shadow plane. Disable depth test on the stacking curve overlay.
2026-08-10 00:57:32 +08:00
hypercross 50c6df48b5 fix(tabletop): align cards to the stacking curve
Fix the tangent angle at the curve's start and rotate cards to follow
the curve direction. Add a stacking curve visualization shown with
surface debug.
2026-08-10 00:40:56 +08:00
hypercross f12b40e82b fix(tabletop): default tilt to 1 degree for all parts
Apply the default 1 degree tilt even when a route has no stacking
strategy, so every placed part is tilted consistently.
2026-08-10 00:29:23 +08:00
hypercross 8d0e393100 feat(tabletop): let candidates inherit and override route stacking
Candidates now carry their own stacking strategy, inheriting the route's
when absent, so the default tilt applies consistently. Drop the now
redundant explicit tilt from the poker deck.
2026-08-10 00:27:01 +08:00
hypercross 43c6334413 fix(tabletop): tilt cards uniformly about the y axis
Apply the same tilt to every stacked part and rotate it about its local
Y axis instead of Z. Default tilt to 1 degree when a stacking strategy
is present, and arc the poker deck along the surface's bottom edge.
2026-08-10 00:23:08 +08:00
hypercross b312bf4f1f feat(tabletop): author rotation and tilt angles in degrees
Convert bgm rotation and tilt values to radians only at the three.js
boundary, so the format's angles are authored in degrees.
2026-08-10 00:11:13 +08:00
hypercross 90baa35c7c fix(bgm): hot-reload markdown definition edits
Normalize the games root to POSIX separators so the HMR path guard
matches on Windows, and watch the real on-disk markdown files (which
defMap.files omits in favor of their virtual code blocks) so edits
trigger a re-collect instead of a manual restart.
2026-08-10 00:06:43 +08:00
hypercross c5d6dff12d feat(tabletop): add tilt and zStart/zEnd stacking options
Replace the per-part lift with a local Y-axis tilt that fans the stack,
and add zStart/zEnd to ramp the stack's height across the curve so it
arches in 3D. Update the poker deck and docs accordingly.
2026-08-09 23:36:42 +08:00
hypercross 2da04940c2 fix(poker): use a realistic card thickness
A standard playing card is about 0.3mm thick, not 3mm.
2026-08-09 23:13:08 +08:00
hypercross 4727a00e26 fix(bgm): hot-reload game definition edits
Invalidate the virtual bgm module on changes under the games root so
editing a definition reloads the page instead of requiring a manual
refresh.
2026-08-09 23:13:04 +08:00
hypercross 5808e15c45 feat(tabletop): show surface bounds and lay parts flat
Add a toggleable surface plane, bounds outline, and constant-size HTML
label. Scale placement and mount anchors to world units so parts land on
the surface, and rotate parts to face up.
2026-08-09 23:04:51 +08:00
hypercross abf901418b feat(web): add tabletop scene controls
Disable auto-rotate and enable panning on the setup view, add a
fullscreen toggle, and convert the controls to iconify icon buttons.
2026-08-09 23:04:47 +08:00
hypercross d9d38c2bee fix(tabletop): key placements by surface, path, and piece
A piece can appear on more than one path of the same surface (e.g. the
poker deck expands to every card while the flop also places the ace), so
the render key must include the path to stay unique.
2026-08-09 22:46:06 +08:00
hypercross f494a6f9be feat(web): render bgm setups as a 3D table
Wire the tabletop library into the setup detail route: seed the store
from the setup, resolve the surface mount tree, and render world and HUD
surfaces. Add serializedToPackage to convert the vite-emitted package.
2026-08-09 22:46:03 +08:00
hypercross cd08e6af04 feat(tabletop): add state, setup, mounting, and stacking
Implement the tabletop library's game state store, setup seeding with
bare-type expansion, surface mount tree resolution, part placement, and
the stacking positioning process with a dependency-free SVG path helper.
Wire the public API and add unit plus vite integration tests.
2026-08-09 22:34:18 +08:00
hypercross ef0695ed04 refactor: update file paths in poker documentation
Remove the `parts/` and `setup/` prefixes from file references in
`games/poker/poker.md` to match the updated file structure.
2026-08-09 22:22:43 +08:00
hypercross 15c45e7106 refactor: share trace, error boundary, and proxy http helpers
Lift the trace-to-shape geometry into @tts/mesh (traceToShape/
traceToUvBounds), parameterized by scale so the web token viewer and
@tts/tabletop share one implementation. Move the CORS proxy HTTP helpers
(assetUrl, resolveAssetUrl, traceImage) into a new @tts/http package, and
make @tts/tabletop's ErrorBoundary the single source used by the web app.

This removes the duplicated ErrorBoundary, assetUrl, tabletopHttp, and
trace-to-shape code from apps/web and packages/tabletop.
2026-08-09 22:17:24 +08:00
hypercross b4cdcdeb42 fix(bgm): pass options arg to buildStart in unit test
Vite 8 (rolldown) changed buildStart to take (this, options), so the
test's one-arg call failed typecheck. Add an ambient declaration for the
fixture's virtual bgm modules so its entry typechecks in the editor.
2026-08-09 22:17:19 +08:00
hypercross 87e6eee9fe docs: update bgm loader status 2026-08-09 21:48:46 +08:00
hypercross 521519ce3d feat(tabletop): inject proxy handlers from web app 2026-08-09 21:46:36 +08:00
hypercross eefce5487d feat(poker): add card assets and wire them up 2026-08-09 21:46:30 +08:00
hypercross 4d14120d54 fix(proxy): wire games root into asset routes 2026-08-09 21:44:07 +08:00
hypercross 91d4b6c999 feat(proxy): serve local game assets from games root 2026-08-09 21:31:23 +08:00
hypercross 0503ac84a7 feat(bgm): resolve relative part assets against source dir 2026-08-09 21:19:37 +08:00
hypercross 43b69133cc docs: add bgm-tabletop implementation plan 2026-08-09 21:08:01 +08:00
hypercross 599fb6a76d feat(bgm): add surface mounting and setup surfaces 2026-08-09 21:03:20 +08:00
hypercross 4e18ab5bc8 feat(web): add bgm viewer navigation 2026-08-09 20:02:03 +08:00
hypercross 64c8bb60b3 feat(web): add bgm package browser 2026-08-09 19:44:27 +08:00
hypercross c86d7444b8 docs: add poker game document 2026-08-09 19:44:05 +08:00
hypercross 7f3f758c52 test(bgm): cover emitted shape and multi-package collection
Add a SerializedPackage type for the JSON the plugin emits, and tests
that assert the maps serialize to plain objects, that the packages module
returns every package, and that buildStart watches def files. Add a
second fixture package to exercise multi-package collection.
2026-08-09 19:23:50 +08:00
hypercross 845d510948 refactor(bgm): use virtual: module specifiers
Rename the plugin's virtual modules to virtual:bgm/packages and
virtual:bgm/package/<id> so they read as plugin-provided modules rather
than real packages. Update the unit and build integration tests.
2026-08-09 19:16:17 +08:00
hypercross ba16f17d8a feat(bgm): add vite plugin and build integration test
Move the bgm vite plugin into the package so it can be tested in
isolation from the web app. Serve each package as a JSON module,
serializing its maps, and verify resolution through a real vite build
against a self-contained fixture.
2026-08-09 19:07:14 +08:00
hypercross c0967ab71f feat(bgm): add board game manifest loader
Parse yaml/json/toml and markdown code blocks into packages, expanding
$variants via typed-csv and collecting parts, surfaces, and setups by
include patterns. Ships zod validation, a vitest config, and 16 tests.
2026-08-09 18:59:14 +08:00
hypercross 1cfec40c99 docs: simplify BGM format description 2026-08-09 18:17:47 +08:00
hypercross 664079528c docs: add board game manifest specification 2026-08-09 18:14:43 +08:00
180 changed files with 11510 additions and 926 deletions
@@ -0,0 +1,55 @@
---
name: commit-conventions
description: How to write git commits for this repository. Use when committing changes, writing commit messages, or splitting work into commits. Covers conventional commit format and separating commits by concern.
---
# Commit Conventions
Follow these rules whenever you create a commit in this repository.
## Conventional Commits
Write every commit message using the [Conventional Commits](https://www.conventionalcommits.org/) format:
```
<type>(<scope>): <subject>
```
- **type**: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `style`.
- **scope** (optional): the area of the codebase the change touches. Use the workspace package or directory name when it's clear (e.g. `proxy`, `web`, `packages`). Omit when the change spans multiple areas.
- **subject**: imperative mood, capitalized, no trailing period, ≤ 50 characters.
Examples:
```
feat(proxy): add TTS streaming endpoint
fix(web): handle empty transcript in player
docs: document workspace layout
```
## Separate Commits by Concern
Do not bundle unrelated changes into a single commit. Split work so each commit is a focused, self-contained unit:
- One logical change per commit (a feature, a fix, a refactor, a doc update).
- Keep each commit buildable and independently reviewable.
- Separate concerns that have different types, scopes, or reasons to be reverted independently.
- If a change is large, split it into a series of smaller commits that each stand on their own.
## Message Body
Include a body only when it adds useful context beyond the subject. If the subject fully captures the change, omit it.
- Separate the subject from the body with a blank line.
- Wrap the body at 72 characters.
- Explain the *why* and *what* rather than restating the code.
- Do not repeat information already in the subject line.
- Use `BREAKING CHANGE:` in the body (or `!` after the type/scope) for breaking changes.
## When to Use This Skill
Activate this skill when:
- Creating a new commit.
- Writing or revising a commit message.
- Deciding how to split staged or unstaged changes into commits.
+7
View File
@@ -0,0 +1,7 @@
# Game assets are large binaries; store them in Git LFS.
games/**/*.png filter=lfs diff=lfs merge=lfs -text
games/**/*.jpg filter=lfs diff=lfs merge=lfs -text
games/**/*.jpeg filter=lfs diff=lfs merge=lfs -text
games/**/*.webp filter=lfs diff=lfs merge=lfs -text
games/**/*.glb filter=lfs diff=lfs merge=lfs -text
games/**/*.gltf filter=lfs diff=lfs merge=lfs -text
+2 -1
View File
@@ -1,8 +1,9 @@
node_modules/
dist/
coverage/
*.log
.env
.env.local
# Generated TTS save dumps (scripts/dump-save.mjs)
scripts/dumps/
scripts/dumps/
+4 -3
View File
@@ -14,8 +14,9 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
| `packages/shared` | Shared types + zod schemas | Isomorphic |
See [`docs/architecture.md`](docs/architecture.md) for the architecture and
[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan.
See [`docs/overview.md`](docs/overview.md) for the docs index,
[`docs/architecture.md`](docs/architecture.md) for the architecture, and
[`docs/status/implementation-plan.md`](docs/status/implementation-plan.md) for the plan.
## Setup
@@ -38,7 +39,7 @@ To run the frontend alongside the proxy, open a second terminal and run
| GET | `/health` | Liveness |
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
| 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 | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
+12
View File
@@ -0,0 +1,12 @@
/**
* Runtime config for the proxy. `GAMES_ROOT` is set at startup (from env or a
* default) and read by the asset/trace routes to serve local game assets. It's
* a module-level value because the node-server adapter injects its own
* `HttpBindings` as the Hono env, not custom bindings.
*/
export let GAMES_ROOT: string | undefined;
/** Set the games root at startup. */
export function setGamesRoot(root: string | undefined): void {
GAMES_ROOT = root;
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { loadEnv } from './env.js';
describe('loadEnv', () => {
it('parses a valid environment', () => {
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000' })).toEqual({
expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000', GAMES_ROOT: '/games' })).toEqual({
STEAM_API_KEY: 'key',
PORT: 4000,
GAMES_ROOT: '/games',
});
});
+3
View File
@@ -3,6 +3,8 @@ import { z } from 'zod';
const envSchema = z.object({
STEAM_API_KEY: z.string().min(1).optional(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
/** Absolute path to the games root, for serving local game assets. */
GAMES_ROOT: z.string().min(1).optional(),
});
export type Env = z.infer<typeof envSchema>;
@@ -11,6 +13,7 @@ export type Env = z.infer<typeof envSchema>;
export interface Bindings {
STEAM_API_KEY?: string;
PORT: number;
GAMES_ROOT?: string;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
+12
View File
@@ -1,15 +1,26 @@
import { serve } from '@hono/node-server';
import { cors } from 'hono/cors';
import { Hono } from 'hono';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import { loadEnv, type Bindings } from './env.js';
import { setGamesRoot } from './config.js';
import asset from './routes/asset.js';
import health from './routes/health.js';
import items from './routes/items.js';
import pdf from './routes/pdf.js';
import search from './routes/search.js';
import trace from './routes/trace.js';
const env = loadEnv();
// Default GAMES_ROOT to the repo's `games` folder so local game assets work
// without configuration; override via env.
setGamesRoot(
env.GAMES_ROOT ??
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'games'),
);
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', cors());
@@ -17,6 +28,7 @@ app.route('/health', health);
app.route('/search', search);
app.route('/items', items);
app.route('/asset', asset);
app.route('/pdf', pdf);
app.route('/trace', trace);
serve(
+31 -1
View File
@@ -1,8 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import asset from './asset.js';
import { setGamesRoot } from '../config.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
setGamesRoot(dir);
});
afterEach(() => {
vi.unstubAllGlobals();
setGamesRoot(undefined);
});
describe('asset route', () => {
@@ -40,4 +52,22 @@ describe('asset route', () => {
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
expect(res.status).toBe(502);
});
it('serves a local game asset from GAMES_ROOT', async () => {
writeFileSync(path.join(dir, 'cards.png'), new Uint8Array([1, 2, 3]));
const res = await asset.request('/?url=cards.png');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
it('returns 404 for a missing local asset', async () => {
const res = await asset.request('/?url=nope.png');
expect(res.status).toBe(404);
});
it('rejects path traversal outside GAMES_ROOT', async () => {
const res = await asset.request('/?url=..%2F..%2Fetc%2Fpasswd');
expect(res.status).toBe(400);
});
});
+23 -9
View File
@@ -1,4 +1,6 @@
import { Hono } from 'hono';
import { GAMES_ROOT } from '../config.js';
import { resolveAsset } from './resolveAsset.js';
const app = new Hono();
@@ -7,6 +9,10 @@ const app = new Hono();
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
* Routing through the proxy makes those assets loadable.
*
* A relative URL (no scheme) is treated as a game asset path relative to the
* `GAMES_ROOT` directory and served from disk, so bgm parts can reference
* local files (e.g. `poker/parts/assets/cards.png`).
*/
app.get('/', async (c) => {
const raw = c.req.query('url');
@@ -14,20 +20,28 @@ app.get('/', async (c) => {
return c.json({ error: 'Missing url query param' }, 400);
}
let url: URL;
try {
url = new URL(raw);
} catch {
return c.json({ error: 'Invalid url query param' }, 400);
const result = await resolveAsset(raw, GAMES_ROOT);
if (!result.ok) {
// A missing local file vs an invalid reference.
return c.json(
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
result.reason === 'not-found' ? 404 : 400,
);
}
// Only allow http(s) to avoid SSRF via file://, etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
const asset = result.asset;
if (asset.stream) {
return new Response(asset.stream as unknown as BodyInit, {
headers: {
'Content-Type': asset.contentType,
'Cache-Control': 'public, max-age=86400',
},
});
}
let res: Response;
try {
res = await fetch(url);
res = await fetch(asset.url!);
} catch (err) {
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
}
+20 -17
View File
@@ -1,10 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
import type { TTSMod } from '@tts/shared';
import type { ModDetails } from '@tts/shared';
import items from './items.js';
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
const mod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const details: ModDetails = { mod };
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -17,7 +25,7 @@ describe('items route', () => {
});
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new TtsError('STEAM_API_KEY is not configured', 500),
);
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
@@ -26,11 +34,6 @@ describe('items route', () => {
});
it('loads from a fileUrl without an API key', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
};
const fetchModFromUrl = vi
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
.mockResolvedValue(mod);
@@ -40,26 +43,26 @@ describe('items route', () => {
{ STEAM_API_KEY: '', PORT: 3000 },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(mod);
expect(await res.json()).toEqual(details);
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
});
it('returns the parsed mod on success', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [],
it('returns the parsed mod with metadata on success', async () => {
const withMeta: ModDetails = {
mod,
title: 'My Mod',
previewImageUrl: 'https://example.com/preview.png',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod);
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
const res = await items.request('/123', {}, env);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(mod);
expect(await res.json()).toEqual(withMeta);
});
it('maps TtsError subclasses to their status', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new ItemNotFoundError('123'),
);
const res = await items.request('/123', {}, env);
@@ -70,7 +73,7 @@ describe('items route', () => {
});
it('returns 500 for unexpected errors', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
new Error('boom'),
);
const res = await items.request('/123', {}, env);
+8 -7
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { itemIdSchema, type TTSMod } from '@tts/shared';
import { itemIdSchema, type ModDetails } from '@tts/shared';
import {
fetchMod,
fetchModDetails,
fetchModFile,
fetchModFileFromUrl,
fetchModFromUrl,
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
try {
const fileUrl = c.req.query('fileUrl');
// With no Steam API key, the caller must supply the save URL (e.g. from a
// search result). Otherwise resolve it via the Steam API.
const mod: TTSMod = fileUrl
? await fetchModFromUrl(fileUrl)
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
return c.json<TTSMod>(mod);
// search result). Otherwise resolve it via the Steam API, which also
// yields the Workshop title and preview image.
const details: ModDetails = fileUrl
? { mod: await fetchModFromUrl(fileUrl) }
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
return c.json<ModDetails>(details);
} catch (err) {
if (err instanceof TtsError) {
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import pdf from './pdf.js';
import { setGamesRoot } from '../config.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'games-'));
setGamesRoot(dir);
});
afterEach(() => {
vi.unstubAllGlobals();
setGamesRoot(undefined);
});
describe('pdf route', () => {
it('rejects a missing url', async () => {
const res = await pdf.request('/');
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'Missing url query param' });
});
it('rejects a non-http url', async () => {
const res = await pdf.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
expect(res.status).toBe(400);
});
it('streams the fetched pdf inline with a pdf content-type', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'application/pdf' },
}),
),
);
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/pdf');
expect(res.headers.get('content-disposition')).toBe('inline');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
it('returns 502 when the upstream fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
);
const res = await pdf.request('/?url=https%3A%2F%2Fexample.com%2Fa.pdf');
expect(res.status).toBe(502);
});
it('serves a local game asset from GAMES_ROOT', async () => {
writeFileSync(path.join(dir, 'rules.pdf'), new Uint8Array([1, 2, 3]));
const res = await pdf.request('/?url=rules.pdf');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/pdf');
expect(res.headers.get('content-disposition')).toBe('inline');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { Hono } from 'hono';
import { GAMES_ROOT } from '../config.js';
import { resolveAsset } from './resolveAsset.js';
const app = new Hono();
/**
* Fetch a PDF and stream it back inline so the browser renders it instead of
* downloading. PDF hosts often send `Content-Disposition: attachment`, which
* forces a download even when the URL is opened in a tab or iframe. This route
* strips that header and forces `inline` so the PDF can be embedded in the web
* app's viewer. Like `/asset`, a relative URL (no scheme) is treated as a game
* asset path relative to `GAMES_ROOT`.
*/
app.get('/', async (c) => {
const raw = c.req.query('url');
if (!raw) {
return c.json({ error: 'Missing url query param' }, 400);
}
const result = await resolveAsset(raw, GAMES_ROOT);
if (!result.ok) {
return c.json(
{ error: result.reason === 'not-found' ? 'Asset not found' : 'Invalid url query param' },
result.reason === 'not-found' ? 404 : 400,
);
}
const asset = result.asset;
// A local game asset is streamed from disk; a remote one is fetched.
let body: BodyInit;
if (asset.stream) {
body = asset.stream as unknown as BodyInit;
} else {
let res: Response;
try {
res = await fetch(asset.url!);
} catch (err) {
return c.json({ error: `Failed to fetch pdf: ${String(err)}` }, 502);
}
if (!res.ok) {
return c.json({ error: `Pdf responded ${res.status}` }, 502);
}
body = res.body!;
}
return new Response(body, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline',
'Cache-Control': 'public, max-age=86400',
},
});
});
export default app;
+76
View File
@@ -0,0 +1,76 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import * as path from 'node:path';
/** Content-type by extension for local game assets. */
const CONTENT_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.gltf': 'model/gltf+json',
'.glb': 'model/gltf-binary',
'.obj': 'text/plain',
'.fbx': 'application/octet-stream',
};
export interface ResolvedAsset {
/** The http(s) URL to fetch, when the asset is remote. */
url?: string;
/** A readable stream of a local file, when the asset is on disk. */
stream?: NodeJS.ReadableStream;
contentType: string;
}
export type ResolveResult =
| { ok: true; asset: ResolvedAsset }
| { ok: false; reason: 'invalid' | 'not-found' };
/**
* Resolve an asset reference to either a remote http(s) URL or a local file
* under `gamesRoot`. A reference with a scheme is remote; otherwise it's a
* game asset path relative to `gamesRoot`. Returns `{ ok: false }` with a
* reason when the reference is invalid (non-http scheme, path traversal) or
* the file is missing.
*/
export async function resolveAsset(
raw: string,
gamesRoot: string | undefined,
): Promise<ResolveResult> {
// A relative path (no scheme) is a local game asset.
if (!/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
if (!gamesRoot) return { ok: false, reason: 'invalid' };
const rel = raw.replace(/^\/+/, '');
const abs = path.resolve(gamesRoot, rel);
if (!abs.startsWith(path.resolve(gamesRoot) + path.sep)) {
return { ok: false, reason: 'invalid' };
}
try {
await stat(abs);
} catch {
return { ok: false, reason: 'not-found' };
}
const ext = path.extname(abs).toLowerCase();
return {
ok: true,
asset: {
stream: createReadStream(abs),
contentType: CONTENT_TYPES[ext] ?? 'application/octet-stream',
},
};
}
let url: URL;
try {
url = new URL(raw);
} catch {
return { ok: false, reason: 'invalid' };
}
// Only allow http(s) to avoid SSRF via file://, etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { ok: false, reason: 'invalid' };
}
return { ok: true, asset: { url: raw, contentType: 'application/octet-stream' } };
}
+20 -8
View File
@@ -4,6 +4,8 @@ import sharp from 'sharp';
import { createRequire } from 'module';
import { traceRequestSchema, type TraceResult } from '@tts/shared';
import { offsetShape, parseSvgShape } from './svgShape.js';
import { resolveAsset } from './resolveAsset.js';
import { GAMES_ROOT } from '../config.js';
// vtracer is a CommonJS package; load it via require so the wasm initializes
// with the correct `__dirname`.
@@ -39,19 +41,29 @@ app.get('/', async (c) => {
const { url, mode, threshold, format, simplify, maxColors, offset } =
parsed.data;
// Only allow http(s) to avoid SSRF via file://, etc.
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
// Resolve the image: a relative path is a local game asset under GAMES_ROOT;
// otherwise it must be an http(s) URL.
const resolved = await resolveAsset(url, GAMES_ROOT);
if (!resolved.ok) {
return c.json({ error: 'Invalid or missing image url' }, 400);
}
const asset = resolved.asset;
let image: Buffer;
try {
const res = await fetch(url);
if (!res.ok) {
return c.json({ error: `Image responded ${res.status}` }, 502);
if (asset.stream) {
const chunks: Buffer[] = [];
for await (const chunk of asset.stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
image = Buffer.concat(chunks);
} else {
const res = await fetch(asset.url!);
if (!res.ok) {
return c.json({ error: `Image responded ${res.status}` }, 502);
}
image = Buffer.from(await res.arrayBuffer());
}
image = Buffer.from(await res.arrayBuffer());
} catch (err) {
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
}
+4
View File
@@ -21,7 +21,11 @@
"@tts/extract": "workspace:*",
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*",
"@tts/bgm": "workspace:*",
"@tts/http": "workspace:*",
"@tts/tabletop": "workspace:*",
"bson": "^7.3.1",
"postprocessing": "^6.36.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
+60 -14
View File
@@ -1,30 +1,76 @@
import { Link, Route, Routes } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { Link, Route, Routes, useLocation } from 'react-router-dom';
import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage';
import FullSetupPage from './pages/FullSetupPage';
import ModHeader from './components/ModHeader';
// The full-setup view and the bgm/tabletop pages pull in the whole three.js
// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
// stack stays code-split out of the main bundle and is only fetched when one
// of those routes is actually visited.
const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
const BgmPage = lazy(() => import('./pages/BgmPage'));
const BgmPackagePage = lazy(() => import('./pages/BgmPackagePage'));
const PartsPage = lazy(() => import('./pages/PartsPage'));
const PartPage = lazy(() => import('./pages/PartPage'));
const SurfacesPage = lazy(() => import('./pages/SurfacesPage'));
const SurfacePage = lazy(() => import('./pages/SurfacePage'));
const SetupsPage = lazy(() => import('./pages/SetupsPage'));
const SetupPage = lazy(() => import('./pages/SetupPage'));
export default function App() {
const pathname = useLocation().pathname;
const isModRoute = pathname.startsWith('/mod/');
// The inspector view is full-bleed/viewport-height; the setup sub-route keeps the standard centered layout.
const isModView = /^\/mod\/[^/]+$/.test(pathname);
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<Link to="/" className="text-lg font-semibold tracking-tight">
TTS Workshop
</Link>
<nav className="flex gap-4 text-sm text-zinc-400">
<Link to="/" className="hover:text-zinc-100">
Search
{/* For the mod route the layout is full-bleed and fills the viewport so
the sidebar can scroll independently and the viewer gets all the space. */}
<div className={"flex min-h-screen flex-col " + (isModView ? "h-screen" : "")}>
<header className={"border-b border-zinc-800 " + (isModRoute ? "shrink-0" : "")}>
{isModRoute ? (
<ModHeader />
) : (
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<Link to="/" className="text-lg font-semibold tracking-tight">
TTS Workshop
</Link>
</nav>
</div>
<nav className="flex gap-4 text-sm text-zinc-400">
<Link to="/" className="hover:text-zinc-100">
Search
</Link>
<Link to="/bgm" className="hover:text-zinc-100">
BGM
</Link>
</nav>
</div>
)}
</header>
<main className="mx-auto max-w-5xl px-4 py-8">
<main className={(isModView ? "min-h-0 flex-1" : "mx-auto max-w-5xl px-4 py-8")}>
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/mod/:id" element={<ModPage />} />
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
<Route
path="/mod/:id/setup"
element={
<Suspense fallback={<p className="text-sm text-zinc-400">Loading full setup</p>}>
<FullSetupPage />
</Suspense>
}
/>
<Route path="/bgm" element={<Suspense fallback={null}><BgmPage /></Suspense>} />
<Route path="/bgm/:id" element={<Suspense fallback={null}><BgmPackagePage /></Suspense>} />
<Route path="/bgm/:id/parts" element={<Suspense fallback={null}><PartsPage /></Suspense>} />
<Route path="/bgm/:id/parts/:type/:part" element={<Suspense fallback={null}><PartPage /></Suspense>} />
<Route path="/bgm/:id/surfaces" element={<Suspense fallback={null}><SurfacesPage /></Suspense>} />
<Route path="/bgm/:id/surfaces/:type/:surface" element={<Suspense fallback={null}><SurfacePage /></Suspense>} />
<Route path="/bgm/:id/setups" element={<Suspense fallback={null}><SetupsPage /></Suspense>} />
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
</Routes>
</main>
</div>
</div>
);
}
+7 -24
View File
@@ -1,5 +1,7 @@
import { deserialize } from 'bson';
import type { SearchResult, TraceResult, TTSMod } from '@tts/shared';
import type { ModDetails, SearchResult } from '@tts/shared';
import { traceImage } from '@tts/http';
export { traceImage };
const BASE = '';
@@ -19,32 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
}
/**
* Trace an image into a vector shape, BSON-deserializing the proxy response.
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
* non-zero `offset` insets (negative) or outsets (positive) the shape in
* pixels.
* Fetch a full parsed TTS save plus its Workshop metadata.
*/
export async function traceImage(
url: string,
mode: 'alpha' | 'bw' | 'color' = 'alpha',
offset?: number,
): Promise<TraceResult> {
const params = new URLSearchParams({ url, mode });
if (offset !== undefined) params.set('offset', String(offset));
const res = await fetch(`${BASE}/trace?${params.toString()}`);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Trace failed (${res.status})`);
}
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
}
/** Fetch a full parsed TTS save. */
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
const params = new URLSearchParams();
if (fileUrl) params.set('fileUrl', fileUrl);
const qs = params.toString();
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
}
/** Build a URL for the raw save file download. */
+33
View File
@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom';
export interface Crumb {
label: string;
/** Where the crumb links; omit for the current (last) crumb. */
to?: string;
}
/**
* A breadcrumb navigation trail. The last crumb is the current page and is
* rendered as plain text; earlier crumbs link to their routes.
*/
export default function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
return (
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-zinc-400">
{crumbs.map((crumb, i) => {
const last = i === crumbs.length - 1;
return (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-zinc-600">/</span>}
{crumb.to && !last ? (
<Link to={crumb.to} className="hover:text-zinc-100">
{crumb.label}
</Link>
) : (
<span className={last ? 'text-zinc-100' : ''}>{crumb.label}</span>
)}
</span>
);
})}
</nav>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
/**
* Replaces the global site header on the mod page: thumbnail, game name, and
* a subheader with the mod id and stats, plus download/setup links styled like
* the Search/BGM nav.
*/
export default function ModHeader() {
const { id } = useParams<{ id: string }>();
const { mod } = useModStore();
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
// Prefer the metadata fetched with the save (survives a refresh); fall back
// to the search result for the brief moment before the save loads.
const title = mod?.title ?? item?.title;
const previewImageUrl = mod?.previewImageUrl ?? item?.previewImageUrl;
return (
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4">
<div className="flex min-w-0 items-center gap-3">
{previewImageUrl && (
<img
src={previewImageUrl}
alt={title}
className="h-12 w-12 shrink-0 rounded-lg border border-zinc-800 object-cover"
/>
)}
<div className="min-w-0">
<h1 className="truncate text-lg font-semibold tracking-tight">
{title ?? `Mod ${id}`}
</h1>
{mod && (
<p className="truncate text-sm text-zinc-400">
<span className="font-mono">{id}</span> · {mod.mod.GameMode} ·{' '}
{mod.mod.Date} · {tree.length} objects · {refs.length} asset refs
</p>
)}
</div>
</div>
<nav className="flex shrink-0 gap-4 text-sm text-zinc-400">
<Link to="/" className="hover:text-zinc-100">
Search
</Link>
<a
href={modFileUrl(id!, item?.fileUrl)}
className="hover:text-zinc-100"
>
Download
</a>
<Link to={`/mod/${id}/setup`} className="hover:text-zinc-100">
Setup
</Link>
</nav>
</div>
);
}
+136 -13
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import type { ObjectTreeNode } from '@tts/extract';
import { iconsForObject } from './objectIcons';
@@ -10,19 +10,85 @@ interface Props {
}
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
const [highlighted, setHighlighted] = useState<Set<string>>(() => new Set());
const types = useMemo(() => collectTypes(nodes), [nodes]);
// When nothing is highlighted, every type is shown. Otherwise only the
// highlighted types are visible.
const visibleNodes = useMemo(
() => filterTree(nodes, highlighted),
[nodes, highlighted],
);
const toggleType = (name: string) =>
setHighlighted((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
const clearAll = () => setHighlighted(new Set());
return (
<ul className="space-y-0.5">
{nodes.map((node, index) => (
<TreeNode
key={index}
node={node}
depth={0}
path={`${index}`}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</ul>
<div className="flex h-full min-h-0 flex-col">
{types.length > 0 && (
<div className="shrink-0 border-b border-zinc-800 p-3 pb-2">
<div className="grid grid-cols-6 gap-1">
{types.map(({ name, count }) => {
const active = highlighted.has(name);
return (
<button
key={name}
onClick={() => toggleType(name)}
aria-pressed={active}
title={`${name} (${count}) — ${active ? 'unhighlight' : 'highlight'}`}
className={`relative flex h-8 items-center justify-center rounded-md border transition-colors ${
active
? 'border-zinc-300 bg-zinc-700 text-zinc-100'
: 'border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
}`}
>
{iconsForObject(name).map((icon) => (
<Icon key={icon} icon={icon} className="h-4 w-4" />
))}
<span
className={`absolute -bottom-1 -right-1 rounded bg-zinc-950 px-0.5 font-mono text-[9px] leading-tight ${
active ? 'text-zinc-200' : 'text-zinc-500'
}`}
>
{count}
</span>
</button>
);
})}
</div>
{highlighted.size > 0 && (
<div className="mt-1.5 flex justify-end">
<button
onClick={clearAll}
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs text-zinc-500 hover:text-zinc-200"
>
Clear
</button>
</div>
)}
</div>
)}
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-3 pt-2">
{visibleNodes.map(({ node, path }) => (
<TreeNode
key={path}
node={node}
depth={0}
path={path}
selectedPath={selectedPath}
onSelect={onSelect}
/>
))}
</ul>
</div>
);
}
@@ -84,6 +150,16 @@ function TreeNode({
))}
</span>
<span className="truncate">{node.label}</span>
{hasChildren && (
<span
title={`${node.children.length} ${
node.children.length === 1 ? 'child' : 'children'
}`}
className="ml-1.5 shrink-0 rounded bg-zinc-800 px-1 font-mono text-[10px] leading-4 text-zinc-500"
>
{node.children.length}
</span>
)}
</button>
</div>
{hasChildren && expanded && (
@@ -102,4 +178,51 @@ function TreeNode({
)}
</li>
);
}
/** Distinct object types present in the tree, with a count of each. */
function collectTypes(nodes: ObjectTreeNode[]): { name: string; count: number }[] {
const counts = new Map<string, number>();
const visit = (node: ObjectTreeNode) => {
counts.set(node.object.Name, (counts.get(node.object.Name) ?? 0) + 1);
node.children.forEach(visit);
};
nodes.forEach(visit);
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Keep a node when its type is highlighted or any descendant survives, so the
* containment hierarchy is preserved and highlighted parents still lead to
* highlighted children. With nothing highlighted, every node is kept.
*
* Each result carries the node's original index path into the full (unfiltered)
* tree, so selection stays stable regardless of filtering.
*/
function filterTree(
nodes: ObjectTreeNode[],
highlighted: Set<string>,
prefix = '',
): { node: ObjectTreeNode; path: string }[] {
const result: { node: ObjectTreeNode; path: string }[] = [];
nodes.forEach((node, index) => {
const path = prefix ? `${prefix}-${index}` : `${index}`;
const children = filterTree(node.children, highlighted, path);
const visible =
highlighted.size === 0 ||
highlighted.has(node.object.Name) ||
children.length > 0;
if (visible) {
result.push({
node:
children.length > 0
? { ...node, children: children.map((c) => c.node) }
: node,
path,
});
}
});
return result;
}
@@ -0,0 +1,15 @@
import { Link } from 'react-router-dom';
import Breadcrumbs from './Breadcrumbs';
/** Shown when a bgm package id doesn't resolve to a known package. */
export default function PackageMissing({ id }: { id: string }) {
return (
<div className="space-y-4">
<Breadcrumbs crumbs={[{ label: 'Board games', to: '/bgm' }, { label: id }]} />
<p className="text-sm text-zinc-400">No package {id}.</p>
<Link to="/bgm" className="text-sm text-zinc-300 underline">
Back to all packages
</Link>
</div>
);
}
@@ -0,0 +1,87 @@
import { Suspense, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import type { SerializedPackage, Setup } from '@tts/bgm';
import {
SetupLoader,
WorldSurfaceView,
HudSurfaceView,
resolveMountTree,
serializedToPackage,
useTabletopStore,
MM_TO_WORLD,
} from '@tts/tabletop';
import Scene from '../viewers/Scene';
/**
* Render a bgm setup as an interactive 3D table.
*
* Seeds the tabletop store from the setup, resolves the surface mount tree,
* and renders each enabled surface — world surfaces in world space, HUD
* surfaces as an overlay — with their parts placed on routes. The store is
* seeded on every render so a setup change re-seeds it.
*/
export default function TabletopScene({
pkg,
setup,
}: {
pkg: SerializedPackage;
setup: Setup;
}) {
const packageData = useMemo(() => serializedToPackage(pkg), [pkg]);
const surfaces = useTabletopStore((s) => s.surfaces);
const [showSurface, setShowSurface] = useState(false);
const tree = useMemo(
() => resolveMountTree(packageData.surfaces, new Set(Object.keys(surfaces))),
[packageData, surfaces],
);
// Size the contact shadow to cover 2x the largest enabled surface, so the
// shadow plane always extends past the table. Surface sizes are in mm;
// `MM_TO_WORLD` converts to world units.
const shadowScale = useMemo(() => {
let maxMm = 0;
for (const [id, enabled] of Object.entries(surfaces)) {
if (!enabled) continue;
const surface = packageData.surfaces.get(id);
if (!surface?.size) continue;
maxMm = Math.max(maxMm, ...surface.size);
}
return maxMm > 0 ? maxMm * 2 * MM_TO_WORLD : 22;
}, [packageData, surfaces]);
return (
<Scene
autoRotate={false}
enablePan
fullscreen
shadowScale={shadowScale}
// Keep the camera above the table so face-down cards can't be peeked
// from below. π/2 clamps the polar angle at the horizon.
maxPolarAngle={Math.PI / 2}
overlay={
<button
onClick={() => setShowSurface((v) => !v)}
title={showSurface ? 'Hide surfaces' : 'Show surfaces'}
aria-label={showSurface ? 'Hide surfaces' : 'Show surfaces'}
className="absolute right-2 top-10 z-10 flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
<Icon
icon={showSurface ? 'mdi:view-grid' : 'mdi:view-grid-outline'}
className="h-5 w-5"
/>
</button>
}
>
<SetupLoader pkg={packageData} setup={setup} />
<Suspense fallback={null}>
{tree.world.map((node) => (
<WorldSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
))}
{tree.hud.map((node) => (
<HudSurfaceView key={node.id} pkg={packageData} node={node} showSurface={showSurface} />
))}
</Suspense>
</Scene>
);
}
+9 -2
View File
@@ -8,7 +8,14 @@ import type { TTSObject } from '@tts/shared';
export interface ObjectViewer {
/** The object class this viewer handles, e.g. `Card`, `Bag`. */
name: string;
component: (props: { object: TTSObject }) => ReactNode;
component: (props: ViewerProps) => ReactNode;
}
/** Props passed to every object viewer. */
export interface ViewerProps {
object: TTSObject;
/** Expand the 3D scene to fill its container instead of the default frame. */
fill?: boolean;
}
const registry = new Map<string, ObjectViewer['component']>();
@@ -24,7 +31,7 @@ export function resolveViewer(object: TTSObject): ObjectViewer['component'] {
}
/** The default viewer: a plain inspection of the object's fields. */
export function DefaultViewer({ object }: { object: TTSObject }) {
export function DefaultViewer({ object }: ViewerProps) {
return (
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
{Object.entries(object).map(([key, value]) => {
@@ -0,0 +1,180 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
extrudeShapeParts,
roundedRectShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import { assetUrl } from '@tts/http';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { applyMapTransform } from './cardMaterial';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
/** Corner radius as a fraction of the shorter card edge. */
const CORNER_RADIUS = 0.05;
/** Thickness of the card, as a fraction of its length (real cards are ~0.3%). */
const CARD_THICKNESS = 0.01;
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
// Without it, the face/back hooks would be called conditionally, which breaks
// React's rules of hooks when switching between objects with different URL
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
const FALLBACK_URL =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/**
* The card mesh for an object, exported so the full-setup view can compose it
* into a shared scene.
*/
export function CardObjectMesh({ object }: { object: TTSObject }) {
const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
resolveCardConfig(object);
return (
<CardMesh
faceUrl={faceUrl}
backUrl={backUrl}
numWidth={numWidth}
numHeight={numHeight}
uniqueBack={uniqueBack}
cardId={cardId}
tint={objectTint(object)}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function CardMesh({
faceUrl,
backUrl,
numWidth,
numHeight,
uniqueBack,
cardId,
tint,
}: {
faceUrl?: string;
backUrl?: string;
numWidth?: number;
numHeight?: number;
uniqueBack: boolean;
cardId?: number;
tint: THREE.Color;
}) {
// Always call both hooks so the hook count is stable across renders. The
// placeholder is used only when a URL is absent; presence is checked via the
// URL strings below, not the texture objects.
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
// Card art is sRGB-encoded. `TextureLoader` leaves `colorSpace` as
// `NoColorSpace`, which uploads the texture as linear and then double-decodes
// it in the shader, washing out contrast. Mark it sRGB so the GPU decodes it
// once, correctly. Clones (e.g. `flipTexture`) inherit this from the source.
face.colorSpace = THREE.SRGBColorSpace;
back.colorSpace = THREE.SRGBColorSpace;
// The face/back textures are shared (drei caches them by URL); each card's
// sprite cell is selected via a per-material UV transform injected into the
// shader, so no per-card texture clone (and no re-upload) is needed. The
// transform is baked into the material's shader, so it must be keyed into the
// shared-material cache to avoid mutating a material used by another card.
const faceMap = faceUrl ? face : null;
const backMap = backUrl ? back : null;
const tintK = tintKey(tint);
const faceUv = faceUrl ? spriteUv(cardId, numWidth, numHeight) : null;
const backUv = backUrl
? uniqueBack
? spriteUv(cardId, numWidth, numHeight)
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
: null;
// Key by URL + card id + tint: the URL disambiguates different sheets (and
// `CardCustom` objects, which have no `CardID`), the card id selects the
// sprite cell, and the tint bakes the per-object color in.
const faceMat = getSharedMaterial(`card-face:${faceUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
color: tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: faceMap ?? undefined,
roughness: 0.6,
});
if (faceUv) {
applyMapTransform(faceMat, new THREE.Vector2(faceUv.repeatX, faceUv.repeatY), new THREE.Vector2(faceUv.offsetX, faceUv.offsetY));
}
const backMat = getSharedMaterial(`card-back:${backUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
color: tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: backMap ?? undefined,
roughness: 0.6,
});
if (backUv) {
// The back cap maps with the same planar UVs as the front, so mirror the
// sprite cell left/right to read correctly instead of appearing mirrored.
// Negating repeat.x and shifting offset.x by one repeat keeps the visible
// region in place while mirrored (see `flipTexture`).
applyMapTransform(
backMat,
new THREE.Vector2(-backUv.repeatX, backUv.repeatY),
new THREE.Vector2(backUv.offsetX + backUv.repeatX, backUv.offsetY),
);
}
const wallMat = getSharedMaterial(`card-wall:${tintK}`, {
color: tintedColor(new THREE.Color('#ffffff'), tint),
roughness: 0.6,
});
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
// front and back faces each get their own material; the walls are a solid
// white, matching TTS card tinting. Geometry is shared across cards of the
// same size so the full-setup view reuses it; the face/back materials are
// shared per card (keyed by card id + tint) and carry the sprite UV transform.
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement
| undefined;
const aspect = cardAspect(img, numWidth, numHeight);
const width = CARD_LENGTH * aspect;
const height = CARD_LENGTH;
// Radius scales with the shorter edge so corners look proportional and
// stay circular (no scaling distortion).
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
return {
frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
return (
<group>
<mesh geometry={frontGeo} material={faceMat} />
<mesh geometry={backGeo} material={backMat} />
<mesh geometry={wallsGeo} material={wallMat} />
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
+11 -158
View File
@@ -1,31 +1,7 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
extrudeShapeParts,
roundedRectShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
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';
import { CardObjectMesh } from './CardMesh';
import type { ViewerProps } from '../viewers';
/**
* A playing card: a thin rounded rect with the face texture on the front and
@@ -49,140 +25,17 @@ const FALLBACK_URL =
* It is flipped left/right so it isn't mirrored when viewed from the back of
* the card.
*/
export default function CardViewer({ object }: { object: TTSObject }) {
export default function CardViewer({ object, fill }: ViewerProps) {
return (
<Scene>
<CardObjectMesh object={object} />
<Scene fill={fill}>
{/* Cards are authored standing (face in XY, thickness along Z); lay them
flat on the ground so the face points up like a card on a table, which
is also what the contact shadow needs to project. */}
<group rotation={[-Math.PI / 2, 0, 0]}>
<CardObjectMesh object={object} />
</group>
</Scene>
);
}
/**
* 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;
}
export { CardObjectMesh, CardMesh } from './CardMesh';
@@ -0,0 +1,108 @@
import { Suspense, useLayoutEffect } from 'react';
import { useLoader } from '@react-three/fiber';
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared';
import * as THREE from 'three';
import type { Object3D } from 'three';
import { assetUrl } from '@tts/http';
import { FlexibleModelLoader } from './flexibleModelLoader';
import { objectTint, tintedColor } from './sharedResources';
/**
* The mesh content for a custom model, exported so the full-setup view can
* compose it into a shared scene. Renders the model from `CustomMesh.MeshURL`
* (or a neutral box placeholder when absent).
*/
export function CustomModelMesh({ object }: { object: TTSObject }) {
const meshUrl = object.CustomMesh?.MeshURL;
const tint = objectTint(object);
if (!meshUrl) {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
</mesh>
);
}
return (
<Suspense fallback={null}>
<Model
meshUrl={meshUrl}
diffuseUrl={object.CustomMesh?.DiffuseURL}
tint={tint}
/>
</Suspense>
);
}
function Model({
meshUrl,
diffuseUrl,
tint,
}: {
meshUrl: string;
diffuseUrl?: string;
tint: THREE.Color;
}) {
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
return (
<>
<primitive object={root} scale={0.5} />
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
<Tint root={root} tint={tint} />
</>
);
}
// Apply the object's tint to every material on the loaded model, multiplying
// the existing color. Rendered after the model so it runs once the materials
// exist.
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
useLayoutEffect(() => {
root.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
const material = Array.isArray(mesh.material)
? mesh.material[0]
: mesh.material;
if (material && 'color' in material) {
(material as THREE.MeshStandardMaterial).color.multiply(tint);
material.needsUpdate = true;
}
}
});
}, [root, tint]);
return null;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
// mounted when a diffuse URL exists, so the hook count stays consistent.
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
const texture = useTexture(assetUrl(url));
// Diffuse art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
// `NoColorSpace`, which double-decodes it in the shader and washes out
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
texture.colorSpace = THREE.SRGBColorSpace;
// Apply the diffuse texture to every mesh material on the loaded model.
useLayoutEffect(() => {
root.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
const material = Array.isArray(mesh.material)
? mesh.material[0]
: mesh.material;
if (material && 'map' in material) {
material.map = texture;
material.needsUpdate = true;
}
}
});
}, [root, texture]);
return null;
}
@@ -1,13 +1,7 @@
import { Suspense, useLayoutEffect } from 'react';
import { useLoader } from '@react-three/fiber';
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared';
import * as THREE from 'three';
import type { Object3D } from 'three';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
import { FlexibleModelLoader } from './flexibleModelLoader';
import { objectTint, tintedColor } from './sharedResources';
import { CustomModelMesh } from './CustomModelMesh';
import type { ViewerProps } from '../viewers';
/**
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
@@ -15,104 +9,12 @@ import { objectTint, tintedColor } from './sharedResources';
* (TTS model URLs are often extension-less). `DiffuseURL` is applied to the
* model's materials when present.
*/
export default function CustomModelViewer({ object }: { object: TTSObject }) {
export default function CustomModelViewer({ object, fill }: ViewerProps) {
return (
<Scene>
<Scene fill={fill}>
<CustomModelMesh object={object} />
</Scene>
);
}
/**
* 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;
}
export { CustomModelMesh } from './CustomModelMesh';
@@ -0,0 +1,400 @@
import { useMemo, useRef, useState } from 'react';
import type { RefObject, WheelEvent } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import { useBounds } from '@react-three/drei';
import Scene from './Scene';
import { CardObjectMesh } from './CardMesh';
import type { ViewerProps } from '../viewers';
/** How many cards to show to each side of the active card. */
const HALF_WINDOW = 3;
/** Angular spacing between adjacent cards in the arc, in radians. */
const ARC_STEP = 0.32;
/** Minimum radius of the arc, in world units. */
const ARC_RADIUS = 3.2;
/** Extra clearance between the active card's edge and its neighbors, in world units. */
const ARC_PADDING = 0.2;
/**
* A deck carousel: the deck's contained cards are fanned in a 3D arc with the
* active card front and center. Prev/next controls step through the deck, each
* card animating to its new slot. Side cards are turned 90° in y (album flow)
* so only the active card's face is framed; all neighbors edge-on around it.
*
* The camera is fitted to just the active card (not the whole carousel): the
* shared scene's auto-fit is disabled and `useBounds` refits whenever the
* selection changes.
*
* Only a window of cards around the active one is rendered (the rest stay
* hidden), so large decks stay lean. Falls back to a single card (the deck
* object itself) when there are no contained cards.
*/
export default function DeckViewer({ object, fill }: ViewerProps) {
const cards = (object.ContainedObjects ?? []).filter(
(o) => o.CardID != null || o.CustomImage != null,
);
const count = cards.length;
const [active, setActive] = useState(0);
const hasCards = count > 0;
const centerRef = useRef<THREE.Group>(null);
// The active card's world-space width, measured once it's laid out. Used to
// widen the arc so neighbors clear the card's edges (a fixed radius only fits
// square cards; wider cards clip their neighbors).
const [cardWidth, setCardWidth] = useState<number | null>(null);
const step = (dir: number) => setActive((a) => (a + dir + count) % count);
const radius = arcRadius(cardWidth);
const visible = useMemo(
() =>
cards
.map((card, i) => ({ card, i, k: i - active }))
.filter((v) => Math.abs(v.k) <= HALF_WINDOW),
[cards, active],
);
// The active card always settles to the arc center, so the camera only needs
// to frame it once on mount.
const didFit = useRef(false);
return (
<Scene
fit={false}
autoRotate={false}
fill={fill}
shadow={false}
overlay={
hasCards ? (
<CarouselControls active={active} count={count} onStep={step} />
) : undefined
}
>
{hasCards ? (
<>
{visible.map(({ card, i, k }) => (
// Key by the card's index (stable across renders) so the element
// persists and tweens as its slot changes; the index-path key is
// unique even though cards in a deck share the same GUID.
<CarouselCard
key={i}
card={card}
k={k}
radius={radius}
groupRef={k === 0 ? centerRef : undefined}
/>
))}
<FitActive
targetRef={centerRef}
didFit={didFit}
onMeasure={setCardWidth}
/>
</>
) : (
// No contained cards: show the deck object itself, laid flat so the
// shared scene's contact shadow has a surface to project onto.
<group rotation={[-Math.PI / 2, 0, 0]}>
<CardObjectMesh object={object} />
</group>
)}
</Scene>
);
}
/**
* Fits the camera once, on the first frame, to frame the active card from the
* front. Runs in a frame callback (not an effect) so the active card has been
* moved to its arc slot by its own `useFrame` first — otherwise the group is
* still at the origin and the camera would frame the carousel center.
*
* The card's front face points toward +Z, so the camera is placed directly in
* front of it and looks straight at it, rather than keeping its initial side
* angle (which drei's `fit()` would do). The active card always settles in the
* same slot, so no refit is needed while navigating — that would restart the
* camera tween on every step and feel laggy.
*/
function FitActive({
targetRef,
didFit,
onMeasure,
}: {
targetRef: RefObject<THREE.Group | null>;
didFit: RefObject<boolean>;
onMeasure: (width: number) => void;
}) {
const bounds = useBounds();
// Whether the active card's width has been measured once. The fit waits one
// frame after measuring so the arc radius (and thus the active card's slot)
// has settled before framing it.
const measured = useRef(false);
useFrame(() => {
const node = targetRef.current;
if (!node) return;
// Stop refreshing once fit: drei's `bounds.refresh()` resets the camera
// goal, so calling it every frame would wipe the fit we just set before
// the camera tween ever runs.
if (didFit.current) return;
bounds.refresh(node);
const { size, center, distance } = bounds.getSize();
// Report the active card's width so the arc radius can widen for wide
// cards (see `arcRadius`).
onMeasure(size.x);
if (!measured.current) {
measured.current = true;
return;
}
didFit.current = true;
// The card's front face points toward +Z, so put the camera in front of it
// and look straight at it.
bounds
.moveTo([center.x, center.y, center.z + distance])
.lookAt({ target: center });
});
return null;
}
/** A single card that tweens into its arc slot each frame. */
function CarouselCard({
card,
k,
radius,
groupRef,
}: {
card: TTSObject;
k: number;
radius: number;
groupRef?: RefObject<THREE.Group | null>;
}) {
const localRef = useRef<THREE.Group>(null);
const group = groupRef ?? localRef;
// Start at the target so the first render doesn't tween into place.
const state = useRef(slotTransform(k, radius));
const target = useMemo(() => slotTransform(k, radius), [k, radius]);
useFrame((_, dt) => {
const g = group.current;
if (!g) return;
// Smooth per-frame damping independent of frame rate.
const f = 1 - Math.pow(0.0001, dt);
const t = target;
const s = state.current;
s.x += (t.x - s.x) * f;
s.z += (t.z - s.z) * f;
s.rot += (t.rot - s.rot) * f;
s.scale += (t.scale - s.scale) * f;
g.position.set(s.x, 0, s.z);
g.rotation.y = s.rot;
g.scale.setScalar(s.scale);
});
return (
<group ref={group}>
<CardObjectMesh object={card} />
{/* A soft radial shadow under this card; it inherits the card group's
position/rotation/scale, so it tweens with the card. Card meshes are
laid flat (face up), so the plane sits just below the card's bottom. */}
<CardShadow />
</group>
);
}
// A soft elliptical drop shadow under a single carousel card. The card mesh is
// ~2 world-units tall (centered), so its bottom sits at local y = -1; we put a
// radial-gradient plane just below it. I can't use `ContactShadows` here
// because the card is a thin vertical panel — a top-down capture would only
// catch a line. Instead we fake a soft ambient contact shadow with a gradient.
const CARD_HALF_HEIGHT = 1;
const SHADOW_SCALE = 2.6;
function CardShadow() {
// A soft radial gradient, dark at the center fading to transparent — reads as
// a soft blob of shadow rather than a hard cast shadow.
const texture = useMemo(makeSoftShadowTexture, []);
return (
<mesh
position={[0, -CARD_HALF_HEIGHT, 0]}
rotation={[-Math.PI / 2, 0, 0]}
scale={[SHADOW_SCALE, SHADOW_SCALE, 1]}
>
<planeGeometry args={[1, 1]} />
<meshBasicMaterial map={texture} transparent depthWrite={false} />
</mesh>
);
}
/** Build a radial-gradient texture: dark center fading out to transparent. */
function makeSoftShadowTexture(): THREE.Texture {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return new THREE.Texture();
const g = ctx.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2,
);
const stops: [number, number][] = [
[0, 0.55],
[0.45, 0.35],
[0.75, 0.12],
[1, 0],
];
for (const [t, a] of stops) {
g.addColorStop(t, `rgba(0, 0, 0, ${a})`);
}
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
interface Slots {
x: number;
z: number;
rot: number;
scale: number;
}
/** World transform for a card at arc offset `k` (0 = front and center). */
function slotTransform(k: number, radius: number): Slots {
const ang = k * ARC_STEP;
// Side cards turn edge-on (album flow); the active card stays forward.
const turn = k === 0 ? 0 : Math.sign(k) * (Math.PI / 2);
return {
x: Math.sin(ang) * radius,
z: Math.cos(ang) * radius,
rot: turn,
scale: 1.15 - 0.15 * Math.abs(k),
};
}
/**
* Arc radius that keeps the nearest neighbors clear of the active card's
* edges. A neighbor at `k = 1` sits at `x = sin(ARC_STEP) * radius`, so the
* radius must exceed `cardWidth / 2 / sin(ARC_STEP)` for the neighbor to clear
* the card's half-width (plus padding). Falls back to the minimum when the
* card width isn't known yet.
*/
function arcRadius(cardWidth: number | null): number {
if (cardWidth == null) return ARC_RADIUS;
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
}
/**
* Prev/next controls and a sliding-dot pagination, rendered as the scene
* overlay. All dots live in a fixed-width track that slides so the active dot
* stays centered; dots fade out as they leave the window. A counter above the
* bar shows the current index (`1 / 51`). Round first/last and prev/next
* buttons flank the dots. Scrolling over the pagination steps through the
* deck.
*/
function CarouselControls({
active,
count,
onStep,
}: {
active: number;
count: number;
onStep: (dir: number) => void;
}) {
const prev = () => onStep(-1);
const next = () => onStep(1);
const first = () => onStep(-active);
const last = () => onStep(count - 1 - active);
// Dots visible on each side of the active dot.
const DOT_WINDOW = 4;
const DOT_SIZE = 8;
const ACTIVE_WIDTH = 26;
const GAP = 6;
const SLOTS = DOT_WINDOW * 2 + 1;
const slotW = DOT_SIZE + GAP;
// Extra width the active dot (plus its gap) pushes beyond a normal slot.
const extra = ACTIVE_WIDTH + GAP - slotW;
const trackW = SLOTS * slotW + extra;
// Left offset of a dot at slot `s` (0 = leftmost). Dots after the active
// slot shift right by `extra` to make room for the wider active dot.
const slotLeft = (s: number) => s * slotW + (s > DOT_WINDOW ? extra : 0);
const dots = Array.from({ length: count }, (_, i) => i);
const onWheel = (e: React.WheelEvent) => {
// Scroll up/left goes back, scroll down/right goes forward.
onStep(e.deltaY < 0 ? -1 : 1);
};
const roundBtn =
'flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-zinc-900/80 disabled:hover:text-zinc-300';
return (
<div className="absolute inset-x-0 bottom-2 z-10 flex flex-col items-center gap-2">
<span className="rounded-md bg-zinc-900/80 px-2 py-0.5 font-mono text-xs text-zinc-300 backdrop-blur">
{active + 1} / {count}
</span>
<div className="flex items-center gap-2">
<button
onClick={first}
disabled={active === 0}
aria-label="First card"
className={roundBtn}
>
«
</button>
<button onClick={prev} aria-label="Previous card" className={roundBtn}>
</button>
<div
onWheel={onWheel}
role="group"
aria-label="Browse cards"
className="flex items-center rounded-full bg-zinc-900/80 px-2 py-1.5 backdrop-blur"
>
<div
className="relative overflow-hidden"
style={{ width: trackW, height: DOT_SIZE }}
>
{dots.map((i) => {
const isActive = i === active;
const slot = DOT_WINDOW + (i - active);
const inWindow = slot >= 0 && slot < SLOTS;
return (
<button
key={i}
onClick={() => onStep(i - active)}
aria-label={`Go to card ${i + 1}`}
aria-current={isActive ? 'true' : undefined}
className="absolute top-0 flex items-center justify-center rounded-full transition-all duration-300"
style={{
left: slotLeft(slot),
width: isActive ? ACTIVE_WIDTH : DOT_SIZE,
height: DOT_SIZE,
opacity: inWindow ? 1 : 0,
pointerEvents: inWindow ? 'auto' : 'none',
backgroundColor: isActive ? '#f4f4f5' : '#71717a',
}}
/>
);
})}
</div>
</div>
<button onClick={next} aria-label="Next card" className={roundBtn}>
</button>
<button
onClick={last}
disabled={active === count - 1}
aria-label="Last card"
className={roundBtn}
>
»
</button>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import type { TTSObject } from '@tts/shared';
import { pdfUrl } from '@tts/http';
/**
* A PDF document (`Custom_PDF`). Renders the document inline in an iframe and
* offers a standalone link to open it in a new tab. The URL is routed through
* the proxy `/pdf` endpoint, which forces `Content-Disposition: inline` so the
* browser displays the PDF instead of downloading it.
*/
export default function PdfViewer({ object }: { object: TTSObject }) {
const url = object.CustomPDF?.PDFUrl;
if (!url) {
return (
<p className="text-sm text-zinc-500">
This object has no PDF URL.
</p>
);
}
const proxied = pdfUrl(url);
return (
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-mono text-xs text-zinc-500">{url}</p>
<a
href={proxied}
target="_blank"
rel="noreferrer"
className="shrink-0 rounded-lg bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
>
Open PDF in new tab
</a>
</div>
<iframe
src={proxied}
title="PDF preview"
className="min-h-0 w-full flex-1 rounded-lg border border-zinc-800 bg-zinc-950"
/>
</div>
);
}
+191 -24
View File
@@ -1,7 +1,10 @@
import { Suspense, type ReactNode } from 'react';
import { Canvas } from '@react-three/fiber';
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
import { Suspense, useEffect, useRef, useState, type RefObject, type ReactNode } from 'react';
import { Icon } from '@iconify/react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
import { BrightnessContrast, EffectComposer, ToneMapping, Vignette } from '@react-three/postprocessing';
import { ToneMappingMode } from 'postprocessing';
import * as THREE from 'three';
/**
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
@@ -12,43 +15,126 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
* The camera is fitted to the bounds of the content on mount. `Bounds` sits
* inside the Suspense boundary, so it only mounts once the (suspending) content
* has loaded and its geometry is present.
*
* `autoRotate` and `enablePan` tune the orbit controls (a tabletop view, for
* example, pans instead of rotating). `fullscreen` adds a toggle button that
* `maxPolarAngle` (radians) clamps how
* far the camera can tilt below the horizon, e.g. to stop a tabletop view from
* peeking under face-down cards.
*
* `fit` (default true) bounds, fits, and clips the camera to the scene's
* content on mount and resize. A viewer that needs to frame a specific part of
* its content (e.g. the active card in a deck carousel) can set it to false and
* call `useBounds()` itself to refit.
*
* By default the scene renders in an `aspect-[3/4]` frame; set `fill` to expand
* to the full height of its container (used by the inspector view).
*/
export default function Scene({ children }: { children: ReactNode }) {
export default function Scene({
children,
autoRotate = true,
enablePan = false,
fullscreen = false,
fit = true,
fill = false,
overlay,
shadowScale = 22,
maxPolarAngle = Math.PI,
shadowBlur = 0.012,
shadow = true,
}: {
children: ReactNode;
autoRotate?: boolean;
enablePan?: boolean;
fullscreen?: boolean;
/** Whether the shared scene fits + clips its children with `Bounds` (default true). */
fit?: boolean;
/** Expand to fill the container height instead of the default aspect-[3/4] frame. */
fill?: boolean;
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
overlay?: ReactNode;
/** Contact shadow plane size in world units; defaults to a generous 22. */
shadowScale?: number;
/** Contact shadow blur as a fraction of the plane size; a larger value is softer. */
shadowBlur?: number;
/** Whether to render a scene-level contact shadow under the content (default true). */
shadow?: boolean;
/** Max camera polar angle in radians; defaults to unrestricted (π). */
maxPolarAngle?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// Points at the wrapped children so the shadow can measure the real content
// bounding box (independent of drei's `Bounds` camera fit).
const contentRef = useRef<THREE.Group>(null);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else void containerRef.current?.requestFullscreen();
};
return (
<div className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950">
<div
ref={containerRef}
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
fill ? 'h-full min-h-0' : 'aspect-3/4'
}`}
>
<LoadingOverlay />
{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
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
dpr={[1, 2]}
gl={{ antialias: true }}
gl={{ antialias: true, alpha: true }}
>
<ambientLight intensity={0.5} />
<directionalLight position={[4, 6, 3]} intensity={1.4} />
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
<pointLight position={[0, 3, 0]} intensity={0.3} />
<ambientLight intensity={0.1} />
<Environment resolution={256}>
<Lightformer intensity={.5} position={[0, 5, 0]} scale={[10, 10, 1]} />
<Lightformer intensity={.5} position={[0, 0, 5]} scale={[10, 10, 1]} />
<Lightformer intensity={.5} position={[0, 0, -5]} scale={[10, 10, 1]} />
<Lightformer intensity={.5} position={[-5, 0, 0]} scale={[10, 10, 1]} />
<Lightformer intensity={.5} position={[5, 0, -0]} scale={[10, 10, 1]} />
<Lightformer intensity={0.6} color="#fff1d6" position={[0, -3, 0]} scale={[6, 6, 1]} />
</Environment>
{shadow && (
<AdaptiveContactShadow shadowScale={shadowScale} shadowBlur={shadowBlur} contentRef={contentRef} />
)}
<Suspense fallback={null}>
<Bounds fit observe clip>{children}</Bounds>
<Bounds fit={fit} observe={fit} clip={fit}>
<group ref={contentRef}>{children}</group>
</Bounds>
</Suspense>
<ContactShadows
position={[0, -0.5, 0]}
opacity={0.55}
scale={8}
blur={2.4}
far={3}
resolution={256}
/>
<OrbitControls
enablePan={false}
enablePan={enablePan}
minDistance={0.01}
maxDistance={8}
autoRotate
maxPolarAngle={maxPolarAngle}
autoRotate={autoRotate}
makeDefault
/>
<EffectComposer>
<Bloom intensity={0.25} luminanceThreshold={0.85} mipmapBlur />
<ToneMapping mode={ToneMappingMode.AGX} />
<BrightnessContrast brightness={0.12} contrast={.5} />
<Vignette eskil={false} offset={0.25} darkness={0.6} />
</EffectComposer>
</Canvas>
@@ -76,3 +162,84 @@ function LoadingOverlay() {
</div>
);
}
/**
* A contact shadow sized and placed to the scene's actual content bounds.
*
* A fixed-size shadow plane baked for a 2-unit token is invisible to a whole
* card or model ten times larger, so the plane is (re)sized to the bounding
* box of whatever is wrapped in the scene, measured each frame from a ref.
* Keeping it scene-level (instead of per mesh) keeps it world-horizontal at
* the table and costs one shadow render per frame even when there are many
* meshes, which per-mesh planes would multiply.
*
* The plane sits on the very bottom (y minimum) of the content, centered on
* its footprint, and spans a bit wider so the shadow edge doesn't fall inside
* the object. `far` covers the full content height so tall objects aren't
* clipped. Falls back to `shadowScale` while the bounds are still empty.
*/
function AdaptiveContactShadow({
shadowScale,
shadowBlur,
contentRef,
}: {
shadowScale: number;
shadowBlur: number;
contentRef: RefObject<THREE.Group | null>;
}) {
// Current computed shadow config, kept in a ref so it's stable between frames
// and only recomputed when the content bounds actually change.
const state = useRef({ scale: 0, posX: 0, posY: 0, posZ: 0, far: 0 });
// Scratch box, reused each frame to avoid allocations.
const box = useRef(new THREE.Box3()).current;
useFrame(() => {
const content = contentRef.current;
if (!content) return;
content.updateWorldMatrix(true, true);
box.setFromObject(content);
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
// The shadow plane rests on the very bottom of the content, centered on its
// footprint. `far` covers the full content height (plus margin) so the
// capture doesn't clip tall objects; `scale` covers the footprint.
const scale = Math.max(size.x, size.z) * SHADOW_PAD;
const posY = box.min.y;
const far = size.y + SHADOW_HEIGHT_MARGIN;
const s = state.current;
if (
scale !== s.scale ||
center.x !== s.posX ||
posY !== s.posY ||
center.z !== s.posZ ||
far !== s.far
) {
Object.assign(s, { scale, posX: center.x, posY, posZ: center.z, far });
}
});
const s = state.current;
const scale = s.scale || shadowScale;
// Sit just below the lowest point of the content so it rests on it without
// intersecting; the plane uses a (fixed) world-horizontal orientation.
const position = [s.posX, s.posY - 0.01, s.posZ] as [number, number, number];
return (
<ContactShadows
position={position}
opacity={0.2}
scale={scale}
blur={scale * shadowBlur}
far={s.far || scale * 0.01}
resolution={1024}
color="#000000"
/>
);
}
/** How far the shadow plane extends past the content footprint, as a multiple. */
const SHADOW_PAD = 1.1;
/** Extra capture depth beyond the content height, so the falloff edge isn't clipped. */
const SHADOW_HEIGHT_MARGIN = 0.5;
@@ -0,0 +1,159 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
hexShape,
rectShape,
roundedRectShape,
scaleShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import { assetUrl } from '@tts/http';
import { flipTexture } from './flipTexture';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
/** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = {
Box: 0,
Hex: 1,
Circle: 2,
Rounded: 3,
} as const;
const TILE_SIZE = 2;
/**
* The tile mesh for an object, exported so the full-setup view can compose it
* into a shared scene.
*/
export function TileObjectMesh({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
return (
<TileMesh
url={url}
thickness={thickness}
type={type}
stretch={stretch}
tint={objectTint(object)}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TileMesh({
url,
thickness,
type,
stretch,
tint,
}: {
url?: string;
thickness: number;
type: number;
stretch: boolean;
tint: THREE.Color;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// Tile art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
// `NoColorSpace`, which double-decodes it in the shader and washes out
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
// Build the extruded geometry from the tile shape. When `stretch` is false
// and a texture is available, scale the shape to the image's aspect ratio so
// the tile matches the source proportions instead of being square. Shared
// across tiles with the same shape so the full-setup view reuses geometry.
const { front, back, walls } = useMemo(() => {
const img = texture?.image as HTMLImageElement;
const aspect = stretch ? img.width / img.height : 1;
const shape = tileShape(type, aspect);
const parts = extrudeShapeParts(shape, { height: thickness });
const key = `tile:${type}:${aspect}:${thickness}`;
return {
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [type, thickness, stretch, texture]);
// The back face maps with the same planar UVs as the front, so flip it
// left/right to avoid a mirrored texture when viewed from behind.
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
// Shared materials: the front/back carry the tile texture (or a neutral
// color when absent); the walls are a solid white, matching TTS tinting.
// The tint is baked into the color and the cache key so tinted variants
// don't collide.
const tintK = tintKey(tint);
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
const faceMat = getSharedMaterial(faceKey, {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: texture ?? undefined,
roughness: 0.8,
});
const backMat = getSharedMaterial(faceKey + ':back', {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: backMap ?? undefined,
roughness: 0.8,
});
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
color: tintedColor(new THREE.Color('#ffffff'), tint),
roughness: 0.8,
});
return (
<group>
{/* Front face carries the tile texture. */}
<mesh geometry={front} material={faceMat} />
{/* Back face, flipped so it isn't mirrored. */}
<mesh geometry={back} material={backMat} />
{/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls} material={wallMat} />
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
function tileShape(type: number, aspect: number) {
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
const sx = aspect;
const sy = 1;
switch (type) {
case TileType.Hex:
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
case TileType.Circle:
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
case TileType.Rounded:
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
case TileType.Box:
default:
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
}
}
+9 -155
View File
@@ -1,36 +1,7 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
hexShape,
rectShape,
roundedRectShape,
scaleShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
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;
import { TileObjectMesh } from './TileMesh';
import type { ViewerProps } from '../viewers';
/**
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
@@ -40,132 +11,15 @@ const TILE_SIZE = 2;
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
* source image instead of being forced square.
*/
export default function TileViewer({ object }: { object: TTSObject }) {
export default function TileViewer({ object, fill }: ViewerProps) {
return (
<Scene>
<TileObjectMesh object={object} />
<Scene fill={fill}>
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
<group rotation={[-Math.PI / 2, 0, 0]}>
<TileObjectMesh object={object} />
</group>
</Scene>
);
}
/**
* 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);
}
}
export { TileObjectMesh, TileMesh } from './TileMesh';
@@ -0,0 +1,148 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
traceToShape,
traceToUvBounds,
type ExtrudedGeometry,
} from '@tts/mesh';
import { traceImage } from '@tts/http';
import { assetUrl } from '@tts/http';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
const TOKEN_SIZE = 1.8;
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
const TRACE_INSET = 2;
/**
* The token mesh for an object, exported so the full-setup view can compose it
* into a shared scene.
*/
export function TokenObjectMesh({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TokenMesh({
url,
thickness,
tint,
}: {
url?: string;
thickness: number;
tint: THREE.Color;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
// `NoColorSpace`, which double-decodes it in the shader and washes out
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
// Trace the image's alpha channel into a shape. Suspends until the trace
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
// once the token geometry is present. Falls back to a circle when there's no
// image or the trace fails.
const trace = useTrace(url);
const { front, back, walls } = useMemo(() => {
// The traced shape and its UV framing share the same transform, so the
// full image rectangle maps to the same bounds in mesh coordinates.
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
// Shared across tokens with the same source image (the trace is cached per
// URL, so the silhouette is deterministic) so the full-setup view reuses
// geometry.
const key = `token:${url ?? 'none'}:${thickness}`;
return {
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [trace, thickness, url]);
// A token is solid: front, back, and walls all carry the texture (projected
// UV), unlike tiles/cards where only the faces are textured. The tint is
// baked into the color and cache key so tinted variants don't collide.
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: texture ?? undefined,
roughness: 0.8,
});
return (
<group>
<mesh geometry={front} material={material} />
<mesh geometry={back} material={material} />
<mesh geometry={walls} material={material} />
</group>
);
}
interface TraceData {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}
// Cache traces by URL so Suspense doesn't re-issue the request on every render
// while the boundary is held open. A URL maps to either a pending promise (while
// loading) or the resolved value (once loaded).
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
/**
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
* when there's no URL / the trace fails). Throws the cached promise only while
* it's pending; once resolved, the value is returned directly so the retry
* render completes instead of suspending forever.
*/
function useTrace(url: string | undefined): TraceData | null {
if (!url) return null;
const cached = traceCache.get(url);
if (cached === undefined) {
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
const value: TraceData | null = result.shape
? {
shape: result.shape,
width: result.width,
height: result.height,
}
: null;
// Replace the pending promise with the resolved value so later renders
// return it instead of re-suspending on a settled promise.
traceCache.set(url, value);
return value;
});
traceCache.set(url, promise);
throw promise;
}
if (cached instanceof Promise) throw cached;
return cached;
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
+9 -210
View File
@@ -1,28 +1,7 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
extrudeShapeParts,
type ExtrudedGeometry,
type Shape,
type UVBounds,
} from '@tts/mesh';
import { traceImage } from '../../api';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
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;
import { TokenObjectMesh } from './TokenMesh';
import type { ViewerProps } from '../viewers';
/**
* A token: a short extruded shape with the texture on its top face. Uses
@@ -30,195 +9,15 @@ const TRACE_INSET = 2;
* color when absent. The footprint is traced from the image's alpha channel via
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
*/
export default function TokenViewer({ object }: { object: TTSObject }) {
export default function TokenViewer({ object, fill }: ViewerProps) {
return (
<Scene>
<TokenObjectMesh object={object} />
<Scene fill={fill}>
{/* Lay the token flat on the ground (see CardViewer). */}
<group rotation={[-Math.PI / 2, 0, 0]}>
<TokenObjectMesh object={object} />
</group>
</Scene>
);
}
/**
* 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 shape = trace ? toMeshShape(trace) : circleShape();
const uvBounds = trace ? toUvBounds(trace) : undefined;
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
// Shared across tokens with the same source image (the trace is cached per
// URL, so the silhouette is deterministic) so the full-setup view reuses
// geometry.
const key = `token:${url ?? 'none'}:${thickness}`;
return {
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [trace, thickness, url]);
// A token is solid: front, back, and walls all carry the texture (projected
// UV), unlike tiles/cards where only the faces are textured. 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;
}
/** A circle fallback when there's no image to trace. */
function circleShape(): Shape {
const pts: number[][] = [];
for (let i = 0; i < 48; i++) {
const a = (i / 48) * Math.PI * 2;
pts.push([Math.cos(a) * (TOKEN_SIZE / 2), Math.sin(a) * (TOKEN_SIZE / 2)]);
}
return { outline: pts };
}
/**
* Convert a traced shape (image pixel coords, origin top-left, y-down) to a
* mesh `Shape` (y-up, centered at the origin). Flips the y-axis, scales to
* `TOKEN_SIZE`, centers the result, and normalizes winding so the outline is
* counter-clockwise and holes are clockwise (as `@tts/mesh` expects).
*/
function toMeshShape(trace: {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}): Shape {
const { shape, width, height } = trace;
const scale = TOKEN_SIZE / Math.max(width, height);
const ox = (width * scale) / 2;
const oy = (height * scale) / 2;
const transform = (pts: number[][]) =>
pts.map(([x, y]) => [x! * scale - ox, (height - y!) * scale - oy]);
return {
outline: normalizeWinding(transform(shape.outline), true),
holes: shape.holes?.map((h) => normalizeWinding(transform(h), false)),
};
}
/**
* The full image rectangle, in mesh coordinates, used as the UV framing so the
* texture aligns with the traced silhouette (which may be smaller than the
* image when there is transparent padding).
*/
function toUvBounds(trace: {
width: number;
height: number;
}): UVBounds {
const scale = TOKEN_SIZE / Math.max(trace.width, trace.height);
const ox = (trace.width * scale) / 2;
const oy = (trace.height * scale) / 2;
return { minX: -ox, minY: -oy, maxX: ox, maxY: oy };
}
/**
* Ensure a ring has the requested winding. `ccw` true yields a
* counter-clockwise ring (outline); false yields clockwise (hole).
*/
function normalizeWinding(pts: number[][], ccw: boolean): number[][] {
const isCcw = signedArea(pts) > 0;
return isCcw === ccw ? pts : [...pts].reverse();
}
/** Signed area of a polygon; positive means counter-clockwise. */
function signedArea(points: number[][]): number {
let area = 0;
for (let i = 0; i < points.length; i++) {
const [x1, y1] = points[i]!;
const [x2, y2] = points[(i + 1) % points.length]!;
area += x1! * y2! - x2! * y1!;
}
return area / 2;
}
export { TokenObjectMesh, TokenMesh } from './TokenMesh';
@@ -1,8 +0,0 @@
/**
* Route an external asset URL through the proxy so it can be loaded by
* three.js loaders (TextureLoader, GLTFLoader, etc.) despite the upstream host
* omitting CORS headers.
*/
export function assetUrl(url: string): string {
return `/asset?url=${encodeURIComponent(url)}`;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { applyMapTransform } from './cardMaterial';
describe('applyMapTransform', () => {
it('injects the repeat/offset uniforms and UV transform into the shader', () => {
const mat = new THREE.MeshStandardMaterial();
applyMapTransform(mat, new THREE.Vector2(0.5, 0.25), new THREE.Vector2(0.1, 0.2));
expect(mat.onBeforeCompile).toBeTypeOf('function');
const shader = {
uniforms: {} as Record<string, { value: unknown }>,
vertexShader: '#include <uv_vertex>\nvoid main() {}',
};
mat.onBeforeCompile!(shader as never, {} as never);
// Uniforms are copied, so the caller's vectors stay reusable.
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(0.5, 0.25));
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0.1, 0.2));
// The uniforms are declared in the GLSL (three.js does not auto-declare
// uniforms added via `onBeforeCompile`), and the override is injected right
// after the chunk include, which stays in place (it declares `vMapUv`/`uv`).
expect(shader.vertexShader).toContain('uniform vec2 uMapRepeat;');
expect(shader.vertexShader).toContain('uniform vec2 uMapOffset;');
expect(shader.vertexShader).toContain('#include <uv_vertex>\n\tvMapUv = uv * uMapRepeat + uMapOffset;');
});
it('copies the vectors so later mutation of the inputs has no effect', () => {
const mat = new THREE.MeshStandardMaterial();
const repeat = new THREE.Vector2(1, 1);
const offset = new THREE.Vector2(0, 0);
applyMapTransform(mat, repeat, offset);
repeat.set(9, 9);
offset.set(9, 9);
const shader = { uniforms: {} as Record<string, { value: unknown }>, vertexShader: '' };
mat.onBeforeCompile!(shader as never, {} as never);
expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(1, 1));
expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0, 0));
});
});
@@ -0,0 +1,51 @@
import * as THREE from 'three';
/**
* Per-card UV transform injected into a `MeshStandardMaterial` shader.
*
* Cards share one texture (the deck's sprite sheet, cached by drei) and one
* geometry, but each card samples a different sprite cell. Rather than cloning
* the texture per card (which re-uploads the sheet on every GPU bind), the
* repeat/offset is pushed into the material as a uniform. The shader source is
* identical across cards, so three.js still compiles a single shared program.
*
* We inject our own uniform instead of setting `texture.repeat`/`offset`
* because three r185 derives the map UVs from a `mapTransform` matrix that is
* refreshed from `map.matrix` every frame, overwriting any per-material
* transform we set on the shared texture.
*/
// Uniforms added via `onBeforeCompile` are not auto-declared by three.js, so
// they must be declared in the GLSL explicitly (the built-in `mapTransform` is
// declared in `uv_pars_vertex.glsl.js`).
const UNIFORM_DECLS = /* glsl */ `
uniform vec2 uMapRepeat;
uniform vec2 uMapOffset;
`;
const VERTEX_INJECT = /* glsl */ `
#include <uv_vertex>
vMapUv = uv * uMapRepeat + uMapOffset;
`;
/**
* Apply a repeat/offset to a card material's map sampling. Call once per
* material (the transform is baked into the shader). `repeat`/`offset` are
* copied, so the caller may reuse the vectors.
*/
export function applyMapTransform(
material: THREE.MeshStandardMaterial,
repeat: THREE.Vector2,
offset: THREE.Vector2,
): void {
// Clone eagerly so later mutation of the caller's vectors can't leak into
// the uniform once the material is compiled.
const r = repeat.clone();
const o = offset.clone();
material.onBeforeCompile = (shader) => {
shader.uniforms.uMapRepeat = { value: r };
shader.uniforms.uMapOffset = { value: o };
shader.vertexShader =
UNIFORM_DECLS +
shader.vertexShader.replace('#include <uv_vertex>', VERTEX_INJECT);
};
}
+7 -4
View File
@@ -8,16 +8,19 @@ import { registerViewer } from '../viewers';
const TileViewer = lazy(() => import('./TileViewer'));
const TokenViewer = lazy(() => import('./TokenViewer'));
const CardViewer = lazy(() => import('./CardViewer'));
const DeckViewer = lazy(() => import('./DeckViewer'));
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
const PdfViewer = lazy(() => import('./PdfViewer'));
registerViewer('Tile', TileViewer);
registerViewer('Custom_Tile', TileViewer);
registerViewer('Custom_Token', TokenViewer);
registerViewer('Card', CardViewer);
registerViewer('CardCustom', CardViewer);
registerViewer('Deck', CardViewer);
registerViewer('DeckCustom', CardViewer);
registerViewer('Custom_Deck', CardViewer);
registerViewer('Deck', DeckViewer);
registerViewer('DeckCustom', DeckViewer);
registerViewer('Custom_Deck', DeckViewer);
registerViewer('Custom_Model', CustomModelViewer);
registerViewer('Custom_Model_Bag', CustomModelViewer);
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
registerViewer('Custom_PDF', PdfViewer);
@@ -30,7 +30,7 @@ export function tintedColor(base: THREE.Color, tint: THREE.Color): THREE.Color {
* the same image.
*
* These caches live for the session (like drei's global texture cache) and are
* not disposed on unmount; see `docs/full-setup-view.md`.
* not disposed on unmount; see `docs/status/full-setup-view.md`.
*/
const geometryCache = new Map<string, THREE.BufferGeometry>();
+67
View File
@@ -0,0 +1,67 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* Summary view for a single bgm package: its metadata and counts of parts,
* surfaces, and setups, each linking to its collection page.
*/
export default function BgmPackagePage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const partCount = Object.keys(pkg.parts).length;
const surfaceCount = Object.keys(pkg.surfaces).length;
const setupCount = Object.keys(pkg.setups).length;
const sections = [
{
label: 'Parts',
count: partCount,
to: `/bgm/${pkg.meta.id}/parts`,
},
{
label: 'Surfaces',
count: surfaceCount,
to: `/bgm/${pkg.meta.id}/surfaces`,
},
{
label: 'Setups',
count: setupCount,
to: `/bgm/${pkg.meta.id}/setups`,
},
];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[{ label: 'Board games', to: '/bgm' }, { label: pkg.meta.title ?? pkg.meta.id }]}
/>
<div>
<h1 className="text-2xl font-semibold">{pkg.meta.title ?? pkg.meta.id}</h1>
<p className="mt-1 text-sm text-zinc-400">
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
{pkg.meta.language}
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-3">
{sections.map((section) => (
<li key={section.label}>
<Link
to={section.to}
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
>
<div className="font-medium">{section.label}</div>
<div className="mt-1 text-sm text-zinc-400">{section.count}</div>
</Link>
</li>
))}
</ul>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Link } from 'react-router-dom';
import packages from 'virtual:bgm/packages';
import Breadcrumbs from '../components/Breadcrumbs';
/**
* Lists every discovered bgm package. The `virtual:bgm/packages` module's
* default export is an array of all packages the loader found in the games
* root.
*/
export default function BgmPage() {
return (
<div className="space-y-6">
<Breadcrumbs crumbs={[{ label: 'Board games' }]} />
<div>
<h1 className="text-2xl font-semibold">Board games</h1>
<p className="mt-1 text-sm text-zinc-400">
{packages.length} package{packages.length === 1 ? '' : 's'} discovered.
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{packages.map((pkg) => (
<li key={pkg.meta.id}>
<Link
to={`/bgm/${pkg.meta.id}`}
className="block rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-600"
>
<div className="font-medium">{pkg.meta.title ?? pkg.meta.id}</div>
<div className="mt-1 text-xs text-zinc-400">
{pkg.meta.designer ? `${pkg.meta.designer} · ` : ''}
{pkg.meta.players ? `${pkg.meta.players} players · ` : ''}
{pkg.meta.language}
</div>
<div className="mt-1 text-xs text-zinc-500">
{Object.keys(pkg.parts).length} parts · {Object.keys(pkg.surfaces).length} surfaces ·{' '}
{Object.keys(pkg.setups).length} setups
</div>
</Link>
</li>
))}
</ul>
</div>
);
}
+5 -5
View File
@@ -5,10 +5,10 @@ import type { TTSObject } from '@tts/shared';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import Scene from '../components/viewers/Scene';
import { TileObjectMesh } from '../components/viewers/TileViewer';
import { TokenObjectMesh } from '../components/viewers/TokenViewer';
import { CardObjectMesh } from '../components/viewers/CardViewer';
import { CustomModelMesh } from '../components/viewers/CustomModelViewer';
import { TileObjectMesh } from '../components/viewers/TileMesh';
import { TokenObjectMesh } from '../components/viewers/TokenMesh';
import { CardObjectMesh } from '../components/viewers/CardMesh';
import { CustomModelMesh } from '../components/viewers/CustomModelMesh';
import { objectPlacement } from '../components/viewers/transform';
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
@@ -42,7 +42,7 @@ export default function FullSetupPage() {
}, [id, item?.fileUrl, load]);
const objects = useMemo(
() => (mod ? flattenObjects(mod) : []),
() => (mod ? flattenObjects(mod.mod) : []),
[mod],
);
+33 -75
View File
@@ -4,9 +4,8 @@ import { Link, useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
import ObjectTree from '../components/ObjectTree';
import ErrorBoundary from '../components/ErrorBoundary';
import { ErrorBoundary } from '@tts/tabletop';
import { resolveViewer } from '../components/viewers';
import { iconsForObject } from '../components/objectIcons';
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
@@ -25,8 +24,8 @@ export default function ModPage() {
if (id) load(id, item?.fileUrl);
}, [id, item?.fileUrl, load]);
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
const selected = useMemo(
() => (mod ? findInTree(tree, selectedPath) : undefined),
@@ -40,78 +39,37 @@ export default function ModPage() {
const Viewer = selected ? resolveViewer(selected.object) : null;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Mod {id}</h1>
<p className="mt-1 text-sm text-zinc-400">
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
asset refs
</p>
<div className="mt-3 flex gap-2">
<a
href={modFileUrl(id!, item?.fileUrl)}
className="inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
>
Download save file
</a>
<Link
to={`/mod/${id}/setup`}
className="inline-block rounded-lg border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-800"
>
Full setup
</Link>
</div>
</div>
<div className="flex h-full min-h-0">
{/* Independently scrolling tree; the filter bar stays pinned above it. */}
<aside className="flex h-full min-h-0 w-72 shrink-0 flex-col border-r border-zinc-800 bg-zinc-900">
<ObjectTree
nodes={tree}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
</aside>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
<aside className="rounded-lg border border-zinc-800 bg-zinc-900 p-2">
<ObjectTree
nodes={tree}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
</aside>
<section className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
{selected ? (
<>
<header className="mb-4">
<span
title={selected.object.Name}
className="inline-flex items-center gap-1 text-zinc-400"
>
{iconsForObject(selected.object.Name).map((icon) => (
<Icon key={icon} icon={icon} className="h-5 w-5" />
))}
</span>
<h2 className="mt-1 text-lg font-semibold">{selected.label}</h2>
<p className="font-mono text-xs text-zinc-500">
{selected.object.GUID}
</p>
</header>
{Viewer && (
<ErrorBoundary>
<Suspense
fallback={
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
Loading viewer
</div>
}
>
{/* Key by selection path so the Canvas remounts and the
camera refits to the newly selected object. */}
<Viewer key={selectedPath} object={selected.object} />
</Suspense>
</ErrorBoundary>
)}
</>
) : (
<p className="text-sm text-zinc-500">
Select an object from the tree to inspect it.
</p>
)}
</section>
</div>
<section className="h-full min-h-0 flex-1 p-4">
{selected && Viewer ? (
/* Key by selection path so the Canvas remounts and the camera
refits to the newly selected object. */
<ErrorBoundary key={selectedPath}>
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
Loading viewer
</div>
}
>
<Viewer object={selected.object} fill />
</Suspense>
</ErrorBoundary>
) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
Select an object from the tree to inspect it.
</div>
)}
</section>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useParams } from 'react-router-dom';
import { PartView, ErrorBoundary } from '@tts/tabletop';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import Scene from '../components/viewers/Scene';
import { findPackage } from './bgm';
/** Detail view for a single part within a package. */
export default function PartPage() {
const { id, type, part } = useParams<{ id: string; type: string; part: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const found = pkg.parts[`${type}#${part}`];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Parts', to: `/bgm/${pkg.meta.id}/parts` },
{ label: `${type}#${part}` },
]}
/>
{!found ? (
<p className="text-sm text-zinc-400">No part {type}#{part}.</p>
) : (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">
{found.type}#{found.id}
</h1>
<p className="mt-1 text-sm text-zinc-400">
{found.size ? `size ${found.size.join('×')}mm` : ''}
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
</p>
</div>
{/* The boundary wraps the Canvas (Scene), not PartView, so its HTML
fallback renders outside the r3f namespace. The library's proxy
calls default to the host's `/asset` and `/trace` paths. */}
<ErrorBoundary>
<Scene>
<PartView part={found} baseUrl={found.baseUrl} />
</Scene>
</ErrorBoundary>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found, null, 2)}
</pre>
</div>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* All parts of a package, grouped by part type. Each part links to its
* `/bgm/:id/parts/:type/:part` detail page.
*/
export default function PartsPage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const parts = Object.values(pkg.parts);
const byType = new Map<string, typeof parts>();
for (const part of parts) {
const list = byType.get(part.type) ?? [];
list.push(part);
byType.set(part.type, list);
}
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Parts' },
]}
/>
<div>
<h1 className="text-2xl font-semibold">Parts</h1>
<p className="mt-1 text-sm text-zinc-400">{parts.length} part{parts.length === 1 ? '' : 's'}.</p>
</div>
{[...byType.entries()].map(([type, list]) => (
<section key={type} className="space-y-2">
<h2 className="text-lg font-semibold">{type}</h2>
<ul className="grid gap-2 sm:grid-cols-2">
{list.map((part) => (
<li key={`${part.type}#${part.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
<Link
to={`/bgm/${pkg.meta.id}/parts/${part.type}/${part.id}`}
className="font-medium hover:text-zinc-100"
>
{part.id}
</Link>
<div className="mt-1 text-xs text-zinc-400">
{part.size ? `size ${part.size.join('×')}mm` : ''}
{part.fillet ? ` · fillet ${part.fillet}mm` : ''}
</div>
</li>
))}
</ul>
</section>
))}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import TabletopScene from '../components/tabletop/TabletopScene';
import { findPackage } from './bgm';
/** Detail view for a single setup within a package. */
export default function SetupPage() {
const { id, type, setup } = useParams<{ id: string; type: string; setup: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const found = pkg.setups[`${type}#${setup}`];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Setups', to: `/bgm/${pkg.meta.id}/setups` },
{ label: `${type}#${setup}` },
]}
/>
{!found ? (
<p className="text-sm text-zinc-400">No setup {type}#{setup}.</p>
) : (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">
{found.type}#{found.id}
</h1>
<p className="mt-1 text-sm text-zinc-400">
{Object.keys(found.setup).length} path
{Object.keys(found.setup).length === 1 ? '' : 's'}
</p>
</div>
<TabletopScene pkg={pkg} setup={found} />
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found.setup, null, 2)}
</pre>
</div>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* All setups of a package. Each setup links to its
* `/bgm/:id/setups/:type/:setup` detail page.
*/
export default function SetupsPage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const setups = Object.values(pkg.setups);
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Setups' },
]}
/>
<div>
<h1 className="text-2xl font-semibold">Setups</h1>
<p className="mt-1 text-sm text-zinc-400">
{setups.length} setup{setups.length === 1 ? '' : 's'}.
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{setups.map((setup) => (
<li key={`${setup.type}#${setup.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
<Link
to={`/bgm/${pkg.meta.id}/setups/${setup.type}/${setup.id}`}
className="font-medium hover:text-zinc-100"
>
{setup.id}
</Link>
</li>
))}
</ul>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/** Detail view for a single surface within a package. */
export default function SurfacePage() {
const { id, type, surface } = useParams<{ id: string; type: string; surface: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const found = pkg.surfaces[`${type}#${surface}`];
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Surfaces', to: `/bgm/${pkg.meta.id}/surfaces` },
{ label: `${type}#${surface}` },
]}
/>
{!found ? (
<p className="text-sm text-zinc-400">No surface {type}#{surface}.</p>
) : (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">
{found.type}#{found.id}
</h1>
<p className="mt-1 text-sm text-zinc-400">
{found.size ? `size ${found.size.join('×')}mm` : ''} · {found.layout.length} routes
</p>
</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found, null, 2)}
</pre>
</div>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Link, useParams } from 'react-router-dom';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import { findPackage } from './bgm';
/**
* All surfaces of a package. Each surface links to its
* `/bgm/:id/surfaces/:type/:surface` detail page.
*/
export default function SurfacesPage() {
const { id } = useParams<{ id: string }>();
const pkg = findPackage(id);
if (!pkg) return <PackageMissing id={id ?? ''} />;
const surfaces = Object.values(pkg.surfaces);
return (
<div className="space-y-6">
<Breadcrumbs
crumbs={[
{ label: 'Board games', to: '/bgm' },
{ label: pkg.meta.title ?? pkg.meta.id, to: `/bgm/${pkg.meta.id}` },
{ label: 'Surfaces' },
]}
/>
<div>
<h1 className="text-2xl font-semibold">Surfaces</h1>
<p className="mt-1 text-sm text-zinc-400">
{surfaces.length} surface{surfaces.length === 1 ? '' : 's'}.
</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{surfaces.map((surface) => (
<li key={`${surface.type}#${surface.id}`} className="rounded-lg border border-zinc-800 bg-zinc-900 p-3">
<Link
to={`/bgm/${pkg.meta.id}/surfaces/${surface.type}/${surface.id}`}
className="font-medium hover:text-zinc-100"
>
{surface.id}
</Link>
<div className="mt-1 text-xs text-zinc-400">
{surface.size ? `size ${surface.size.join('×')}mm` : ''} · {surface.layout.length} routes
</div>
</li>
))}
</ul>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import packages from 'virtual:bgm/packages';
import type { SerializedPackage } from '@tts/bgm';
/** Look up a bgm package by id from the `virtual:bgm/packages` module. */
export function findPackage(id: string | undefined): SerializedPackage | undefined {
return packages.find((p) => p.meta.id === id);
}
+5 -5
View File
@@ -1,11 +1,11 @@
import { create } from 'zustand';
import type { TTSMod } from '@tts/shared';
import type { ModDetails } from '@tts/shared';
import { fetchMod } from '../api';
interface ModState {
id: string | null;
fileUrl?: string;
mod: TTSMod | null;
mod: ModDetails | null;
loading: boolean;
error: string | null;
load: (id: string, fileUrl?: string) => Promise<void>;
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
load: async (id, fileUrl) => {
set({ loading: true, error: null, id, fileUrl });
try {
const mod = await fetchMod(id, fileUrl);
set({ mod, loading: false });
const details = await fetchMod(id, fileUrl);
set({ mod: details, loading: false });
} catch (err) {
set({ loading: false, error: String(err) });
}
},
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
}));
+19 -1
View File
@@ -1 +1,19 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />
/**
* Ambient types for the virtual `bgm` modules served by the bgm vite plugin.
*
* - `virtual:bgm/packages` — every discovered package.
* - `virtual:bgm/package/<id>` — a single package's assembled JSON.
*/
declare module 'virtual:bgm/packages' {
import type { SerializedPackage } from '@tts/bgm';
const packages: SerializedPackage[];
export default packages;
}
declare module 'virtual:bgm/package/*' {
import type { SerializedPackage } from '@tts/bgm';
const pkg: SerializedPackage;
export default pkg;
}
+8 -1
View File
@@ -1,9 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath } from 'node:url';
import { bgm } from '@tts/bgm';
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
bgm({ root: fileURLToPath(new URL('../../games', import.meta.url)) }),
],
server: {
// Proxy API calls to the Hono backend during development.
proxy: {
@@ -11,6 +17,7 @@ export default defineConfig({
'/items': 'http://localhost:3000',
'/health': 'http://localhost:3000',
'/asset': 'http://localhost:3000',
'/pdf': 'http://localhost:3000',
'/trace': 'http://localhost:3000',
},
},
+4 -3
View File
@@ -2,8 +2,8 @@
> **Scope:** The system's architecture and dependency graph. For implementation
> details (files, endpoints, build order), see
> [`implementation-plan.md`](./implementation-plan.md). For the rationale behind
> key decisions, see [`decisions.md`](./decisions.md).
> [`status/implementation-plan.md`](./status/implementation-plan.md). For the
> rationale behind key decisions, see [`decisions.md`](./decisions.md).
## Overview
@@ -32,6 +32,7 @@ shared types/validation package.
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
| `packages/extract` | Analyze a `TTSMod`: objects, refs, assets | Isomorphic |
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
| `packages/engine` | Message bus, queue/tick, triggers, orchestrators | Isomorphic |
| `packages/shared` | Shared types + zod schemas | Isomorphic |
## Dependency graph
@@ -55,7 +56,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
- **`apps/web``packages/mesh`** — extrudes 2D shapes into 3D geometry
(`{ front, back, walls }`) for the tile, token, and card viewers.
- **`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
request/response validation.
- **`packages/tts``packages/shared`** — consumes `TTSMod` / `TTSObject`
+156
View File
@@ -0,0 +1,156 @@
# bgm-commands
Command execution for [bgm](./format.md) board games, built into
[`@tts/tabletop`](./tabletop.md). A command is a unit of scripted
interaction — focus the camera, wait for a tap, move a part, show a caption —
that runs against the tabletop state store and render layer.
This doc covers **command execution**: the async lifecycle, run contexts, and
tap interaction. The message layer above this — how commands are *declared*
and *fired* (triggers, orchestrators, the message queue) — is specified in
[`engine.md`](./engine.md). Commands are async functions registered
with the engine's handler registry; `@tts/tabletop` provides the concrete
commands that mutate the tabletop store and render layer.
## 1. async commands
A command is an async function that returns a result. Every command ends in
one of three states, emitted as a message discriminated on the type suffix
(see `engine.md` §3):
- `:done` — completed normally.
- `:cancel` — interrupted (a newer command superseded it, the user skipped,
the surface was disabled). **Not a failure.**
- `:error` — genuinely failed (asset missing, bad path, a thrown exception).
`cancel` is distinct from `error`: a superseded or skipped command stops
cleanly, while a broken command surfaces loudly. The runtime treats them
differently — a script that is superseded unwinds without alarming the player,
but an `error` is reported.
```ts
type CommandResult<Name extends string, R = void> =
| { type: `${Name}:done`; data: R }
| { type: `${Name}:cancel` }
| { type: `${Name}:error`; error: Error };
```
## 2. run contexts
Each command invocation creates its own **run context**: the unit of
cancellation and the carrier of command-specific state.
```ts
interface CommandRun {
id: string;
command: Command;
status: 'running' | 'ok' | 'cancel' | 'error';
data: unknown; // command-specific state, e.g. a pending tap target
cancel(): void;
done: Promise<CommandResult>;
}
```
A command **owns its own state and its own waiting**; the runtime only
orchestrates. Its job is to start a run, track its status, cancel it when
superseded, and react to its terminal state. This keeps commands
self-contained and testable in isolation.
## 3. fire-and-forget vs self-managed waiting
Commands fall into two categories:
- **Fire-and-forget** (`focus`, `highlight`, `caption`) — start and return
`ok` immediately (or when their tween settles). The runtime does not block
on them.
- **Self-managed waiting** (`wait: tap`, a dialog) — the command resolves its
own promise when its condition is met. The runtime just awaits it.
Fire-and-forget commands still get a run context and a cancel path. A `focus`
tween superseded by a newer `focus` must be cancellable, or two cameras fight.
"Fire-and-forget" means the runtime doesn't await it, not that it has no
lifecycle.
**Supersede groups** cancel a running command when another in the same group
starts. A `focus` command belongs to a `camera` group, so a second `focus`
cancels the first. A superseded command's `signal` is aborted, and it emits
`:cancel`.
A command is an async function taking the `RunContext` (with its `args`):
```ts
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
```
The engine wraps it: it builds the context from the message, runs the function,
and emits `:done` on resolve, `:cancel` on abort, `:error` on throw.
## 4. tap interaction
Only tap interaction is supported. A tap on a part is detected and reported to
the command layer as a `TapEvent`. Parts may declare **trigger points**
named, circular regions the author wants to be tappable.
```ts
interface TriggerPoint {
id: string;
position: [number, number]; // part-local frame, mm
radius: number; // mm
}
interface TapEvent {
part: string; // package:type#id
position: [number, number]; // part-local frame, mm
trigger: TriggerPoint | null; // nearest within radius, or null
}
```
Rules:
- Trigger points are authored in the **part's local frame** (mm, relative to
the part's origin), not world space. A part moves, rotates, and flips
(facing), so a world-space point would break the moment it moves. The tap
point is transformed into the part's local frame at tap time.
- Distance is measured in the part's plane. The reported trigger point is the
nearest one within its `radius`; ties go to the first declared.
- **Every tap on the part is reported**, with the nearest trigger point (or
`null` when none is in range). The command decides how to react — resolve,
reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the
command owns the UX.
Commands subscribe to the tap stream via the context and unsubscribe on
cancel, so a cancelled `wait: tap` never leaks a handler.
## 5. run context
The context a command receives is the handle to everything it can affect. The
engine defines the base `RunContext` (see `engine.md` §5): `signal`
(cancellation), `emit`, `wait`, and `enableTrigger`/`disableTrigger`. Tabletop
extends it with the handles commands need to mutate the board:
```ts
interface TabletopRunContext extends RunContext {
pkg: Package;
store: TabletopStore; // movePart, setPart, enableSurface, ...
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
// camera, highlight, and overlay handles are added as those subsystems land
}
```
## 6. where trigger points come from
The tap detector reads trigger points from a runtime map keyed by part id; it
does not care where they are declared. Declaration (on the part definition, in
a setup, or in a script) is the deferred "how to declare" half and lives in
`format.md`.
## Open decisions
- **Where commands are declared** — the `script` role and its schema
(`format.md`), deferred.
- **Animation** — a general "ease toward target placement" layer (preferred)
vs explicit per-move tweens.
- **Camera** — `CameraControls` (drei) vs hand-rolled.
- **Triggering** — does a setup reference a script to auto-run, or is a script
a separate page the player picks?
- **Narration** — pre-recorded audio assets per script, or TTS at runtime?
+263
View File
@@ -0,0 +1,263 @@
# bgm-engine
The message layer that drives [bgm](./format.md) board games, built into
[`@tts/engine`](../architecture.md). It unifies the two halves of scripted
interaction — *declaring* what should happen and *executing* it — into a single
reactive loop: **messages** flow through a **queue**, and **handlers** react to
them.
This doc covers the message model (what flows), the queue and its tick (how it
flows), and the handlers (who reacts). Command *execution* — the async
lifecycle, run contexts, and tap interaction — is specified in
[`commands.md`](./commands.md); this doc is the layer above it.
## package split
The engine is a **pure** package: the message bus, queue, tick, trigger
registry, and the handler runner. It has no r3f, no React, and no store, so it
is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep
style). It defines the contract — `Message`, the handler registry, `Trigger`,
`Orchestrator`, and `RunContext`.
[`@tts/tabletop`](./tabletop.md) is the intended consumer of that contract:
it will register the built-in commands (`move`, `focus`, `caption`,
`enableSurface`, ...) that mutate the tabletop store and drive the render layer.
The engine never imports tabletop, and tabletop is expected to depend on the
engine for the message types and the handler registry. **This wiring is designed
but not yet landed** — today `@tts/tabletop` has no dependency on the engine;
it ships its state store and render layer standalone (see
[`../status/bgm-tabletop.md`](../status/bgm-tabletop.md)). A headless sim or bot
harness can consume the engine without the render layer.
## 1. messages
A **message** is the unit of communication. It is both an *event* (something
happened) and an *intent* (something should happen) — the two are the same
thing. A message is dispatched to the handlers registered for its `type`; a
handler may emit new messages in response.
Messages are a **discriminated union** on `type`. The engine defines the
generic shapes; the host's concrete union extends them with its own command
types.
```ts
interface TapMessage {
type: 'tap';
data: TapEvent; // part, position, trigger
}
interface CommandMessage<Name extends string, Args> {
type: Name;
data: Args;
}
```
A message is identified by `type`, matching the format's `type#id`
convention. A `move` message both *runs* the move command and is *observable*
as an event; the command's completion is itself a message, which is what
triggers match and orchestrators await.
The loop is just: **message → handler → message**. Handlers consume messages
and emit new ones; the queue serializes them.
## 2. the queue and ticking
Messages are not processed inline. They are **enqueued** and handled on the
next **tick**. This kills reentrancy (a handler cannot cause unbounded
recursion), gives a natural debounce, and makes the whole system a
deterministic frame.
### tick contract
The engine is pure — it has no render loop and must stay node-testable. It
exposes `tick()`, and the host calls it:
- In `@tts/tabletop`, a `useFrame` drives `tick()`.
- In tests, `tick()` is called manually.
The engine never assumes a render loop.
### drain semantics
- **Snapshot-and-drain.** At `tick()`, snapshot the queue and process it.
Messages emitted *during* the drain go to the *next* tick. This guarantees
no reentrancy within a drain and makes ordering deterministic.
- **FIFO within a tick.** Simple and predictable.
- **One tick drains the whole snapshot** (not one message per tick), so a
burst of messages all resolve in one frame.
### awaiting
A handler suspends on `await ctx.wait(pred)` and resumes when a matching
message is processed during a drain. Its own emissions go to the next tick, so
it cannot re-enter itself.
## 3. message types
### interaction messages
Interaction is the player's input, reported to the engine as messages. Only
tap interaction is supported (see `commands.md` §4).
```ts
interface TapMessage {
type: 'tap';
data: TapEvent;
}
```
A tap on a part is reported with the nearest trigger point (or `null` on a
miss). The handler decides how to react — resolve, reject with a "wrong spot"
shake, or ignore. The runtime stays dumb; the handler owns the UX.
### command messages
A command message names a command to run. Its handler is the command
implementation; its completion is emitted as a result message. A command's
result is a **discriminated union on the type suffix**, carrying the terminal
state:
```ts
type CommandResult<Name extends string, R = void> =
| { type: `${Name}:done`; data: R }
| { type: `${Name}:cancel` }
| { type: `${Name}:error`; error: Error };
// e.g. move:done { data: MoveResult } | move:cancel | move:error
```
The command-id-as-key convention means a message both *is* the intent and
*observes* the result. `move:done`, `focus:done`, etc. are the messages that
triggers match and orchestrators await. A cancelled command emits `:cancel`, an
errored one `:error` — a trigger matching `move:done` does not fire on a
cancel.
## 4. handlers
There are three kinds of handler. All three consume messages and emit
messages; they differ in how they're declared and how they run.
| Handler | Declared | Runs | Purpose |
| --- | --- | --- | --- |
| **Trigger** | data (yaml) | synchronously on match | declarative reactive glue |
| **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow |
| **Command** | code (built-in) | async, on its message | atomic execution |
### triggers — declarative reactive glue
A trigger matches a message by `type` and named params, and emits messages in
response. It is declared as data, keyed by `role+type+id` like other defs, and
collision-checked the same way.
```yaml
role: trigger
type: tap
id: draw
match:
part: carcassonne:tile#a
trigger: draw
emit:
- move: { part: carcassonne:tile#a, to: /grid/5/5 }
- focus: { path: /grid/5/5 }
```
- `type` selects the message kind; `match` binds named params from the
payload (like a route's candidates).
- `emit` uses the command-id-as-key convention.
- Multiple triggers can match the same message — both fire, which is usually
what you want.
- A trigger is a **pre-registered handler**: it's a message consumer that
emits commands. An orchestrator can do the same thing imperatively with
`ctx.on(...)`.
### orchestrators — imperative async flow
An orchestrator is the code counterpart to a trigger: an async function that
emits messages and awaits matching ones. It is a proper TS module, declared
per folder as `main.ts` — unique per folder like `package.yaml`.
```ts
// main.ts
export default async function main(ctx: RunContext): Promise<void> {
await ctx.focus({ path: '/deck' });
await ctx.caption({ text: 'Draw a tile' });
const tap = await ctx.wait((m) => m.type === 'tap');
await ctx.move({ part: tap.data.part, to: '/grid/5/5' });
}
```
- **`main.ts` is executable code, loaded by the host, not the engine.** The
engine defines the contract (the orchestrator type and runner); the host
dynamically imports `main.ts` and hands the exported orchestrator to the
engine. The engine never imports user code.
- **A default export async function.** `main.ts` exports a single async
function as its default export, taking the `RunContext`. It is the folder's
orchestrator.
- **Trigger control lives here.** The orchestrator toggles triggers at runtime
by their `type#id`:
```ts
ctx.enableTrigger('tap', 'draw');
ctx.disableTrigger('tap', 'draw');
```
Declaration is data; activation is code. The orchestrator owns game-flow
logic ("no more placements this turn" → disable the trigger), while the
trigger stays a dumb declarative mapping.
### commands — atomic execution
A command is an async function, the same shape as an orchestrator. It takes a
`RunContext` (with its `args`), returns its result, and throws on error. The
engine wraps it: it builds the context from the message, runs the function, and
emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on
throw.
```ts
type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
```
Commands are the **single mutation path** — the only way state changes.
Triggers and orchestrators never mutate state directly; they emit command
messages, and the command handlers execute them.
## 5. run context
Every handler runs against a `RunContext`, the handle to everything it can
affect and the unit of cancellation.
```ts
interface RunContext {
signal: AbortSignal; // cancellation: superseded, skipped, surface disabled
emit(msg: Message): void;
wait(pred: (m: Message) => boolean): Promise<Message>; // rejects on abort
enableTrigger(type: string, id?: string): void;
disableTrigger(type: string, id?: string): void;
}
```
- **Cancellation** is an `AbortSignal`. A superseded command or a disabled
surface aborts the signal; a `wait` rejects on abort, and the command's
`:cancel` result is emitted.
- **Errors** are thrown. A command that throws emits `:error`; an orchestrator
that throws surfaces loudly.
- Commands and orchestrators are the same shape: an async function taking the
context. An orchestrator is a command that returns `void` and is never
awaited by a parent.
## 6. solo-only
This design is **solo-only** — no multiplayer. Other players either don't
exist or are automated with an automata. An automata is just another message
consumer that emits commands: a stateful trigger or orchestrator. The engine
doesn't care whether a `tap` message came from a human or a bot decision —
same queue, same handlers. Solo-only simplifies the design: no network, no
sync, no authoritative-server concerns. "Other players" are just more message
producers.
## Open decisions
- **`main.ts` loading.** The host dynamically imports `main.ts`; the exact
loading boundary (Vite dynamic import, error handling, HMR) is deferred to
implementation. The engine defines the orchestrator type; the host loads the
module and hands the exported orchestrator to the engine.
+557
View File
@@ -0,0 +1,557 @@
# Board Game Manifest — Technical Reference
> The concrete behavior of the board game manifest (bgm) format.
>
> Definitions can live in JSON/YAML/TOML files or in markdown code blocks. In
> codeblock mode, each code block is a virtual definition file, named relative
> to the current markdown file.
---
## 1. json features
### The `$variants` directive
For objects with a `$variants` key, the value is a CSV. Parse it into an object
array with `typed-csv`, extend the original object with each row, and return
the array.
```yaml
job: 'hero'
$variants: ./heroes.csv
```
```csv
name,parents
string,string[]
clark,[jonathan;martha]
bruce,[]
```
```json
[
{ "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
{ "job": "hero", "name": "bruce", "parents": [] }
]
```
### Inline vs file
`$variants` can be a single source or an array of sources. Each source is a
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
This keeps the two forms self-documenting and applies the same rule to single
values and array elements alike. In YAML a block scalar (`|`) is the natural
way to write inline CSV; in JSON you'd use `\n`.
```yaml
$variants: |
id,name,faceCrop
string,string,[number;number;number;number]
fish,Fish,[0;0;5;2]
grain,Grain,[1;0;5;2]
```
An array of sources concatenates their rows. This lets one part definition
pull from several CSVs with different schemas — e.g. a deck where the regular
cards share a face sheet but the jokers have their own:
```yaml
$variants:
- ./cards.csv
- ./jokers.csv
```
Each source is parsed with its own schema, and its rows extend the original
object independently.
### CSV conventions
CSV is parsed with `typed-csv`:
- The first row is the header, the second row is the type declaration
(`string`, `number`, `string[]`, ...), and the remaining rows are data.
- Rows are validated against a zod schema derived from the type row.
- **`crop` inside a CSV cell** uses `;` as the element separator
(`[0;0;5;2]`), because `,` is the CSV delimiter. `typed-csv` loads it into
an array with value `[0,0,5,2]`.
---
## 2. Definition discovery
Definitions are organized in **packages**. A loader loads a package
declaration, then uses its `include` paths to find the definitions.
### Code blocks as virtual files
A code block is a virtual definition file. Its name is derived from the
`role=` on its info string — `role.type.lang` — so it is discoverable by the
default `include: ./**/*.yaml` and addressable by that name:
````md
```yaml role=part.cargo
...
```
```yaml role=surface.game#main
...
```
```yaml role=package
...
```
````
- `role=part.cargo` names the block `part.cargo.yaml`.
- `role=surface.game#main` names it `surface.game.yaml`.
- `role=package` names it `package.yaml`.
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
resolve against. When there is a real file in that path, the codeblock wins.
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
names the block `parts/cargo.yaml` regardless of its role.
- A block without `role=` is **not** a definition — it is ignored. Discovery
is explicit: a block is a definition only when its `role=` (or, for real
files, its filename) declares a known `role.type`.
### role= on the info string
A block's role is declared on the info string, using the same `role.type#id`
shape as the block's identity. `type` and `id` are optional — anything not
given comes from the content (or from `$variants` rows):
````md
```yaml role=part.cargo
...
```
```yaml role=surface.game#main
...
```
```yaml role=package
...
```
````
- `role=part.cargo` declares a part of type `cargo`; its `id` comes from the
content or from `$variants`.
- `role=surface.game#main` declares a surface of type `game` with id `main`.
- `role=package` declares a package; it has no type.
- A `role`/`type`/`id` given on the info string **conflicts** with the same
key in the content and errors. `id` on the info string cannot combine with
`$variants`, since every row supplies its own `id`.
- A block without `role=` is not a definition — discovery is explicit (see
above).
### Real files
A real `role.type.lang` file (e.g. `part.cargo.yaml`) is a definition by its
filename, with no `role=` needed. `role` and `type` are parsed from the name;
`id` comes from the content or `$variants`. A real file and a code block with
the same name are the same definition; the code block wins.
### Duplicates
Two definitions with the same `role.type` are grouped under the same name.
They must not define the same `id` — a duplicate `type#id` errors. Blocks with
the same `role.type` but different ids are fine.
### include
`include` is a list of git-style path patterns — the defs that make up the
package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
folders is discovered with no configuration. This also matches the package
declaration itself, which is fine — it's the package, not a part.
Patterns are resolved **relative to the package declaration's own directory**,
not the games root. So a package declared in `carcassonne/carcassonne.md`
with the default `./**/*.yaml` only picks up yaml under `carcassonne/` — it
never absorbs defs from a sibling game. To reach outside its folder, a
package can use a `../`-relative pattern or an absolute-from-root pattern
(e.g. `**/shared/*.yaml`).
---
## 3. Roles
json objects in yaml blocks are handled if they are declared as a definition
by their `role=` (or, for real files, their filename) for either
- `package`
- `part`
- `surface`
- `setup`
- `dialog`
a valid object can either be the root or in the list of the yaml block.
for all roles except package, `type` and `id` are needed.
`type#id` is used for identification so that combo must be unique in the package.
A block declares its role on the info string — `role=part.cargo` is equivalent
to `role: part` + `type: cargo` in the content (see §2). A real file declares
it in its filename. The info string/filename and content must not both set the
same key.
### package
The package is the container for a game's definitions. It is declared with a
`role: package` object:
```yaml
role: package
id: harbor
title: Harbor
designer: Jane Doe
players: 2
language: en
```
- `role`: for block discovery.
- `id`: package identification.
- `title` — game name.
- `include` — the defs that make up the package (see §2).
- Optional metadata: `designer`, `development` (artist/developer), `publisher`,
`players` (player count), `language`.
### part
A part is a game component. It is identified by a `package:type#id` string,
placed on the board via `setup`, and visualized by routes.
#### part value types
- `image` — a url to an image.
- `crop` — a tuple `[col, row, cols, rows]`. Divides the image into a grid
and picks the cell at `[col, row]` with size `[width/cols, height/rows]`.
Negative `cols` flips the rendered image.
- `size` — a tuple `[width, height, depth]` in mm units.
#### part props
- `face` — `sprite`. Used for texture.
- `faceCrop` — `crop` for `face`.
- `back` — `sprite`. Used for texture. Defaults to `face`.
- `backCrop` — `crop` for `back`.
- `shape` — `sprite`. Traced for its profile to create the mesh for the part.
Defaults to the full rect of the back image.
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
ratio is kept, but not z (thickness).
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
- `facing` — the **physical affordance**: the facings the piece supports.
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
Declares what's *possible*, not what's legal on a given zone (see `surface`
`layout`). Defaults to `[face]`.
#### example
```yaml
role: part
type: token
id: wood
face: ./assets/tokens.png
faceCrop: [1, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [3, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
A `wood` token: the `face` and `back` sprites come from the same sheet,
`faceCrop`/`backCrop` picking different cells of the `5×2` grid. The shape is
traced from `token-shape.png`, sized `20×20×3` mm with a `2` mm fillet.
### surface
A `surface` is a **view** over the state store, purely for **visual rendering**.
It has a reference `size` (`[width, height]` in mm) and a `layout` list of
routes. The size is a reference — it may be scaled to fit larger or smaller
tables. It does not affect part placement; placement lives in the state store
(see §4). A surface need not cover every part — parts with no matching route on
this surface are simply not shown.
A surface also declares how it is **mounted**: as the root table surface, on a
HUD area, or as a child of another surface. `mount` is always an object, with
`x`, `y`, and `rotation` (defaulting to `0`) anchoring it like a route. The
`kind` selects the mount type:
- `table` — the root table surface (default).
- `hud` — mounted to a HUD area, e.g. a player's hand.
- `child` — mounted relative to a parent surface. A surface lists its
`children` (`type#id` refs) so a surface can be repeated, like a player
board; each child is mounted relative to its parent's anchor.
```yaml
type: board
id: harbor
role: surface
size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
- route: /deck
x: -100
y: 0
rotation: 0
```
```yaml
type: hud
id: hand
role: surface
size: [200, 100]
mount:
kind: hud
area: bottom-left
```
```yaml
type: board
id: player
role: surface
size: [200, 200]
mount:
kind: child
x: 100
y: 50
rotation: 0
```
### setup
`setup` seeds the state store: the enabled surfaces and the part placement.
Each valid game state is a valid setup.
```yaml
role: setup
type: game
id: main
surfaces:
- board#harbor
- hud#hand
setup:
- path: /dock/0
parts: harbor:boat#fleet
- path: /deck
parts: harbor:card
facing: back
- path: /table
parts: harbor:token#wood
facing: standing
```
`surfaces` lists the surfaces enabled at the start. A surface not listed is
disabled and not rendered. When `surfaces` is omitted, all surfaces are
enabled.
`setup` is an **ordered list of placements**. Each placement moves its `parts`
to its `path`, and entries are applied in order — so a part listed in a later
placement ends up on that placement's path. This makes a setup read like "deal
the deck, then move these cards to the flop".
`parts` can be a single part id, a bare type without an id, or a list of
either. A bare type expands to all parts of that type during game state
initialization.
`facing` sets how the placed parts are oriented on the board, defaulting to
`face`:
- `face` — lay flat, front up, resting on the bottom face.
- `back` — lay flat, front down (flipped over), resting on the top face.
- `standing` — stand upright on the bottom edge, front texture still showing.
A part's `facing` is seeded into the game state and can change at runtime; it
only affects orientation, never the part's texture.
A setup also declares the **interaction affordances** — which dialogs are the
tool for which open interactions on which paths:
```yaml
interactions:
- dialog: insert
on: [draw, grid] # insertion uses the `insert` dialog on these paths
- dialog: shuffle # open the shuffle dialog on any deck
```
`interactions` is a list of declarations: each names a `dialog` (a `role:
dialog` definition, see below) and the paths it applies to (`on`, matching by
path or by stack). It declares the *interaction surface*, not the legality of
the resulting command — rule scripts (later) gate legality. A dialog can be
opened by a player gesture or pushed by a rule script; only the trigger differs.
### dialog
A `dialog` is declarative content shown in the layer-3 shell. Opening and
closing it never issues a command or mutates state; it is pure UI. Its **action
buttons** issue commands — the bridge between the dialog and the rule seam.
```yaml
role: dialog
type: prompt
id: discard
title: Choose a card to discard
body: |
Select a card from your hand.
actions:
- label: Confirm
command: { move: { part: "#chosen", to: /discard } }
widget: stack # a stack-of-parts content type
```
A dialog's content is a **title**, **body**, **action buttons**, and an optional
`widget`. Supported content types include a **stack of parts** — an ordered,
scrolled view of a path's stack with an insertion cursor — which is what powers
insertion and shuffling dialogs against the state store.
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
---
## 4. Concepts
### Game state
The board's state is a **state store**: the set of **enabled surfaces** and a
map from path to a **stack** of parts. It is the authoritative record of which
surfaces are in play and where every part is placed.
A path is a URL path with named params, like `/dock/1`.
A part is identified by a `package:type#id` string.
A surface is enabled or disabled; a disabled surface is not rendered. Setup
seeds the enabled set (see §3), and it changes at runtime as the game
progresses (e.g. enabling the main board after an expansion-chooser scene).
Dialogs are **not** part of the state store. The dialog stack is UI state
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
action buttons issue commands (see `role: dialog` §3), which is the one-way
bridge onto the state.
### Routing
A route is a **visualization route**: it maps a part to a location on a
surface. Routes match the keys of the state store, but they are defined by a
surface and need not cover every placed part — a part with no matching route on
a given surface is simply not shown there. Routes exist only for game parts; a
surface is not a part and never appears on a route.
A route matches all parts on the path; the placement of each individual part on
the stack is a separate concern.
A route is an express-style URL path with named params, plus the `x`, `y`, and
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
same route path may appear more than once:
A route may also declare a zone `facing` — the **legal facing** on that zone.
This constrains what the placed parts may be, independent of each part's
physical affordance (see `part` `facing`). A state is legal only when the part's
facing is in both the part's affordance *and* the zone's legal set. When omitted,
the zone is unrestricted beyond the part's affordance. An MTG discard pile
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
`[back]`. The same card is face-up in the discard but tapped in play — the
piece's affordance is unchanged, only the zone's rule differs.
```yaml
layout:
- route: /dock/:seat
x: 40
y: 0
rotation: 0
- route: /deck
x: -100
y: 0
rotation: 0
```
### Candidates
To match a class of routes against a list of positions, keep a single route with its param and give it a
`candidates` array to match `:param` against, each candidate carrying its own `x`/`y`/`rotation`:
```yaml
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
```
```csv
seat,x,y,rotation
string,number,number,number
0,40,0,0
1,40,20,0
```
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
A candidate inherits the route's `x`, `y`, `rotation`, and `stacking`, and may override any of them with its own values. When no candidates match, the whole route fails to match.
### Stacking
When multiple parts live on a path, only the top (last) one shows by default.
To override this, add stacking strategies:
```yaml
layout:
- route: /deck
x: -100
y: 0
rotation: 0
stacking:
curve: M 0 0 C 20 -20 40 -20 60 0
limit: 5
align: center
steps: 4
tilt: 0.1
zStart: 0
zEnd: 30
```
- `curve` — an SVG path string to spread the content along, relative to the
anchor `x`, `y`, `rotation`.
- `limit` — how many parts to display. `0` shows all, `3` shows the first 3,
`-3` shows the last 3.
- `align` — `start`, `end`, or `center` of the curve.
- `steps` — the maximum number of parts per curve length unit. Defaults to
`1`. See the positioning process below.
- `tilt` — rotation in degrees applied to every shown part about the card's
local Y (long) axis. It applies even without a `curve`, so a bare `tilt`
rotates a straight pile. Defaults to `1` when not specified.
- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end
of the `curve`. The stack ramps linearly between them across its span,
lifting it in 3D. Requires a `curve`.
#### positioning process
1. **Determine the step length.** It is `curve length / max(steps, # of
parts on path 1)`.
2. **Determine the alignment.** It places the span of
`step length × (# of parts 1)` on the curve.
3. **Place each part.** Part `#0` is at the start, the last part at the end,
each `step length` apart.
4. **Lift each part.** The part's height is `zStart + (zEnd zStart) × u`,
where `u` is its normalized position along the `curve`.
5. **Tilt each part.** Every part is rotated `tilt` about its local Y (long)
axis.
### Edge cases
- Object with no matching route → **not placed on this surface**. The game
state is still valid — the part simply isn't visualized. A surface is a view
over the state store, not a mirror of it, and may show only a subset (e.g. a
player's hand on the HUD).
- Route with no matching object → empty, fine.
- Multiple routes match one path -> first route wins.
- Multiple parts on one path → **stack** (see §4 Stacking). One route wins
for all parts on a path, and the stacking strategy decides what's shown
(it may drop parts that are not dropped on other matching routes).
+134
View File
@@ -0,0 +1,134 @@
# bgm Interactions
The free-interaction layer (layer 3 of the layering) — how a player interacts
with a bgm game that has no rules yet: a sandbox. It builds on the state model:
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
facing, and the anchoring scope.
> **Status:** Implemented (layer 3, sandbox). The operation set, the
> command/dialog split, and the deck-pick-up dialog are built in
> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer
> 4) is not yet wired — the rule seam is a no-op filter over free interaction.
## 1. The operation set is closed and tiny
Two assumptions from the state model do most of the work:
1. **Parts are never created or destroyed** — the set of parts is fixed (the
setup's parts).
2. **State is only path, stack, and facing** — there is nowhere to store a free
position in space.
Together they collapse the *entire* space of free interaction into three
operations:
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
position (default: top of stack).
2. **`setFacing(id, facing)`** — change facing, within the part's physical
affordance.
3. **reorder** — a `move` with an explicit `index`.
That's it. There is no arbitrary positioning and no free placement in 3D because
the state model has nowhere to store one. "Free" means *unconstrained over these
three operations*, not "free in space."
## 2. Free interaction and rule play are the same operations
This is the payoff. Rule-enforced play (layer 4) is **the same three
operations, gated by a legality check**:
- The interaction layer produces an **intent** — "player wants
`move(card, /discard)`."
- **Sandbox mode**: apply it directly.
- **Rule mode**: validate the intent against the setup's shape + the rule script
first; reject if illegal.
So the rule engine needs no interaction vocabulary of its own — it is a **filter
over free interaction**. `move`/`setFacing` are the shared primitives; rules
decide which are legal in the current state. Drag-and-drop and a scripted move
both funnel through the same store mutation. That is the seam between layer 3
and layer 4.
## 3. Commands vs the dialog stack
There are two channels, and they do not mix:
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
the rule seam and mutate the store.
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
discard," a hint prompt). Opening/closing a dialog **never issues a command**
and never touches state; it is pure UI.
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
How they connect — one direction only:
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
insert button issues a `move` command.
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
discard) and awaits the player's `move` through it. The script's open/close
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
game state.
So the deck dialog is **one implementation, driven either way** — by a player
click or by a rule script. Only the *trigger* differs.
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
definition declares the title, body, action buttons, and an optional widget
(a stack of parts). Setups declare which dialogs are the tool for which
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
rule seam stays clean because a dialog's buttons issue commands while its
open/close is stack-only.
## 4. Compound interactions: the deck pick-up dialog
A dialog is where compound, multi-step manipulation lives, because it owns the
transient sub-state that the store must not. The prime example — inserting a
card into the middle of a deck:
1. pick up the deck
2. scroll through it to find the place
3. insert the card at the cursor
4. put the deck back
(even 0: put down your held hand of cards first).
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
table into the dialog (it stays on its path; the rest of the table becomes
backdrop). Its contents are shown **in order**, with an **insertion cursor**
between cards that you scroll. The cursor *is* the index: `move(id, path,
index)`'s index is discovered by scrolling the visible deck, not typed.
This is a real interaction *mode*, owned by the dialog:
- The scroll position is state.
- Everything else pauses while it's open (or the held part is kept visible).
- It only exists for **stacks**; single parts just snap onto a path.
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
same lifted-deck view.
## 5. The primitives map to concrete gestures
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
resolve the nearest path anchor within a threshold; drop → `move` (drop on
nothing returns to origin).
- **Facing** — click cycles the part through its physical affordance
(`face → back → standing`, or the declared list).
- **Reorder / insert** — the deck dialog above.
The "in hand" state is transient and illegal, so it lives **outside the store**
(a UI-level held part); only the committed outcome (drop) mutates the store.
## Open questions
- **Held source for insertion** — single held card (fits the physical model; the
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
fight the hand step.
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
single-part primitives are the foundation.
- **Scroll window** — a window over a subset of the deck can return with the
cursor's scroll position; exact widget is a render concern, not a state one.
+137
View File
@@ -0,0 +1,137 @@
# bgm State Model
The format's model of what a board game *is*, and the shape of the states it can
be in. This is the organizing model behind the concrete specs:
[`format.md`](./format.md) (the manifest), [`tabletop.md`](./tabletop.md) (the
render library), and [`engine.md`](./engine.md) / [`commands.md`](./commands.md)
(scripted interaction).
> **Status:** Design. The loader and render layer implement components and a
> static setup today; the state-shape framing, zone-facing, and the anchoring
> scope cut below are proposals to guide the next phase.
## 1. Components are constants; setup is the shape of states
A bgm game is authored two ways, and they answer different questions:
- **Components** (`part`, `surface`) describe the **constants** — the physical
pieces and the board geometry that do not change during play. A tile is a
45×45 mm square with a meadow/road/city face; the play board has a draw pile
and an 11×11 grid. These are timeless facts about the game, not state.
- **setup** describes the **shape of the game's states** — which parts can be
where, how many, and with what facing. It is a *schema* over the state space,
not a concrete snapshot of one run.
The runtime store is then an **instance** of the setup's shape: the current
state of play, a point in the state space the setup describes. This buys three
things:
- **Validation** — a state is legal iff it matches the setup's shape (on the
right paths, right counts, right facing).
- **A contract for the rule engine** — legal play is a *transition between
shapes*; the rule layer (layer 4 in the layering vision) reasons over them.
- **A clean boundary** — components are timeless; setup is the state space; the
store is the current point.
### Open: does setup carry the initial state?
A game needs a concrete starting position, not just a schema. The default is
that a `setup` is **shape + initial instance** — one role that both describes
the legal state space and seeds the store. The alternative (schema-only, with
the initial state derived from the shape) is explored but not preferred.
## 2. The state: path → stack × facing
The state space is made of two axes.
### Path → stack
Every part lives on a **path** (a URL-style key like `/grid/5/5` or `/draw`),
and multiple parts on a path form an ordered **stack** (a deck, a pile of meeples,
a tile with a meeple on it). Placement is one axis. This is already in use.
### facing — part affordance × zone restriction
Facing is the second axis, and it has two distinct sources of constraint:
- **The part** declares the **physical affordance** — the facings the piece
physically supports. A card supports face/back/standing; a tile supports
face/back but not "tapped".
- **The path (zone)** declares the **legal facing** — what's allowed on that
zone. An MTG discard pile requires face-up; a play area allows tapped; a
facedown deck requires face-down. The same card is face-up in the discard,
tapped in play, face-up in exile — the piece's affordance doesn't change, only
the zone's rule does.
A state's facing is legal iff it is **both physically possible (part) and
zone-legal (path)** — the effective set is the intersection.
```yaml
role: part
type: card
id: basic
facing: [face, back, standing] # physical affordance
role: surface
type: board
id: main
layout:
- route: /discard
facing: [face] # zone restriction
- route: /play
facing: [face, tap] # zone restriction
```
Deck / the current facing lives in the store and must be in the intersection.
### Open questions
- **Default when a path declares no `facing`** — unrestricted (only the part's
affordance bounds it), or a sensible default like `face`? Lean unrestricted.
- **Is "tapped" a facing or a rotation?** In MTG it's a 90° in-plane rotation.
Default: fold common rotations into the facing enum (`face` / `back` /
`standing` / `tap`) for schema simplicity; arbitrary rotation is a later
extension.
- **Naming** — the part-side and path-side are different constraints wearing the
same word. Worth distinct terms (capability/typ) so they don't collide.
## 3. Anchoring scope: stacks-on-paths, not part-to-part networks
Real TTS mods anchor components to each other — a meeple on a tile, a fanned
hand, tokens scattered on a board. We are **not** modeling part-aligned-to-part
relative placement networks. Instead:
- **Stacks absorb part-on-part.** "Meeple on tile" is a stack `[tile, meeple]`
on a path. Much of TTS's anchoring collapses into the path→stack model.
- **Free relative placement is out of scope** — a meeple at an offset on a tile,
a fanned hand, arbitrary token scatter are not modeled.
This is a deliberate scope cut. It keeps the state model closed and simple, but
it is a **fidelity loss for converted games** and part of the "with some fixing"
cost of the TTS→bgm conversion. Components (layer 1), not the state model, are
the place to extend later if needed.
### The grid compromise
Because we don't model parts-aligned-to-parts, common layouts are expressed
explicitly — and grid layouts become verbose (the Carcassonne board is 121
hand-written row coordinates in `grid.csv`). The mitigations:
- **Grid shorthand** — a declarative `grid` (cols/rows, cell size, origin) that
expands to routes, instead of authored coordinates.
- **Free-placement shorthand** — a `free`/scatter mode for loose collections,
when the exact positions don't matter to play.
But there is **no general part-to-part network** on the roadmap. If a converted
game needs it, that is a format extension to design deliberately, not an
implicit assumption.
## Summary
| Concept | Role | Where |
| --- | --- | --- |
| Components | Constants — the physical pieces & board geometry | `part`, `surface` |
| Setup | The shape of the state space (+ initial instance) | `setup` |
| Store | The current state, an instance of the setup | `@tts/tabletop` |
| Facing | Part affordance × zone restriction | part + path/zone restriction |
| Anchoring | Stacks-on-paths only; no part networks | — |
+69
View File
@@ -0,0 +1,69 @@
# bgm-tabletop
> **Status:** Implemented (items 18 of the plan). A few features below are
> designed but not yet wired (commands, HUD rendering); see §5 and
> [`../status/bgm-tabletop.md`](../status/bgm-tabletop.md).
An r3f-based interactive component library for working with [bgm](./format.md)
board games. It is used in the `web` app's bgm inspector routes.
## 1. stack
`react` - react, react router, tailwind v4
`r3f` - r3f, drei, postprocessing
`zustand` - for state management
## 2. states
source-of-truth game state:
```ts
{
surfaces: Record<string, boolean>, // enabled per surface id
parts: Record<string, PartState>, // part id -> placement state
}
interface PartState {
path: string, // the path key this part is on
index: number, // the part's position in its path's stack
facing: 'face' | 'back' | 'standing', // how the part is oriented on the board
}
```
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, facing }` for rendering on a surface. keys of this map makes a stable render list.
- `route` - the matched route.
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
- `index` - the piece's position in its path's stack.
- `stackSize` - the number of pieces on the path.
- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`).
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
## 3. components
- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement).
- `WorldSurfaceView` mounts a surface to world space.
- `HudSurfaceView` mounts a surface to hud space.
- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy.
- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers.
## 4. stacking
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
## 5. commands
Scripted interaction — focus, tap-to-advance, move, caption — is built on an
async command layer. See [`commands.md`](./commands.md) for command
execution (lifecycle, run contexts, tap interaction), and
[`engine.md`](./engine.md) for the message layer above it (the queue,
triggers, and orchestrators that declare and fire commands).
## 6. usage
- we will inspect individual parts with `PartView` in the web app's part inspection route.
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
- a surface is mounted only when enabled; a disabled surface is not rendered.
+66 -2
View File
@@ -2,7 +2,7 @@
> **Scope:** The rationale behind key design decisions. For the system's
> architecture, see [`architecture.md`](./architecture.md). For the concrete
> build plan, see [`implementation-plan.md`](./implementation-plan.md).
> build plan, see [`status/implementation-plan.md`](./status/implementation-plan.md).
>
> Each entry records the decision, the context, and the alternatives considered.
> New entries are appended; existing entries are updated only to correct facts,
@@ -259,4 +259,68 @@ boundary.
**Alternatives considered:** A polling `CameraFit` that waited for non-empty
bounds each frame; hand-rolled camera math. Rejected — Suspense already
signals content readiness, so `Bounds` inside the boundary fits the loaded
geometry directly.
geometry directly.
## D19 — Commands are async with ok/cancel/error results
**Decision:** Scripted interaction is built on async commands. Each command
returns `ok`, `cancel` (interrupted — superseded, skipped, surface disabled),
or `error` (genuinely failed). Each invocation gets its own run context — the
unit of cancellation and the carrier of command state. Commands are either
fire-and-forget (the runtime doesn't await them) or self-managed waiting (they
resolve their own promise when a condition is met); both get a run context and
cancel path. Supersede groups cancel a running command when another in the
group starts (e.g. a `camera` group so a second focus cancels the first).
**Context:** The user wants to script interaction sequences — focus, caption,
title, highlight, tap-to-advance, move, camera away. The state store and
render layer already exist; what's missing is a way to drive them over time
and react to input. Design: [`bgm/commands.md`](./bgm/commands.md).
**Alternatives considered:** A single monolithic script interpreter. Rejected
— commands as self-contained async units are testable in isolation and let
the runtime stay a thin orchestrator.
## D20 — Tap interaction reports every tap with the nearest trigger point
**Decision:** Only tap interaction is supported. A tap on a part is reported
to the command layer as a `TapEvent` carrying the part, the tap position in
the part's local frame, and the nearest trigger point within its `radius` (or
`null` on a miss). Trigger points are authored in the part's local frame with
mm radius; distance is measured in the part's plane; ties go to the first
declared. The command decides how to react to a miss — resolve, reject, or
ignore.
**Context:** Commands need to wait on player input (`wait: tap`). Reporting
every tap with the nearest trigger point keeps the runtime dumb and lets the
command own the UX (e.g. a "wrong spot" shake). Authoring trigger points in
the part's local frame keeps them valid as the part moves, rotates, and
flips.
**Alternatives considered:** Reporting only a hit and silently dropping
misses. Rejected — a command that needs to react to a wrong tap has no way to
do so. World-space trigger points. Rejected — they break when the part moves.
## D21 — Card sprite UVs live in the material shader, not the texture
**Decision:** A card's sprite cell is selected by a repeat/offset injected into
the material's shader (`cardMaterial.ts` extends `MeshStandardMaterial` via
`onBeforeCompile`) rather than by cloning the texture and setting its
`repeat`/`offset`.
**Context:** Cards in a deck share one sprite sheet (drei caches the texture by
URL), but each card samples a different cell. The previous approach cloned the
texture per card to set its UVs; each clone gets its own WebGL texture binding,
so navigating a deck re-uploaded the whole sheet on every step. Moving the
transform into a per-material uniform lets cards share the texture (one GPU
upload), the shader (identical injected source → one program), and the geometry,
with only the material uniforms differing.
We inject our own uniform rather than setting `texture.repeat`/`offset` because
three r185 derives map UVs from a `mapTransform` matrix refreshed from
`map.matrix` every frame, which would overwrite a per-material transform set on
the shared texture.
**Alternatives considered:** Cloning the texture per card (previous approach).
Rejected — re-uploads the sheet per card. A module-level cache of per-card
clones. Rejected — still one upload per unique card instead of one per sheet.
+27
View File
@@ -0,0 +1,27 @@
# Docs
The documentation is split into the living specs, which describe how the
system works today, and the status/plans, which track development iterations
and go stale as work lands.
## Specs — how the system works
| Doc | Covers |
| --- | --- |
| [`architecture.md`](./architecture.md) | System architecture and the package dependency graph |
| [`decisions.md`](./decisions.md) | Key design decisions and the rationale behind them |
| [`bgm/format.md`](./bgm/format.md) | The board game manifest (bgm) format spec |
| [`bgm/engine.md`](./bgm/engine.md) | The bgm message layer: queue, triggers, orchestrators |
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
## Status & plans (dev logs)
| Doc | Covers |
| --- | --- |
| [`status/implementation-plan.md`](./status/implementation-plan.md) | Original build plan |
| [`status/bgm-loader.md`](./status/bgm-loader.md) | bgm loader — what's built, works, missing |
| [`status/bgm-tabletop.md`](./status/bgm-tabletop.md) | bgm tabletop — implementation plan / status |
| [`status/full-setup-view.md`](./status/full-setup-view.md) | Full-setup view plan |
+75
View File
@@ -0,0 +1,75 @@
# bgm Loader — Status
> Status: current. What's built, what works, what's missing, and the known
> issues. Spec: [`../bgm/format.md`](../bgm/format.md).
## What's built
### `packages/bgm` — the loader core (new)
| File | Purpose |
| --- | --- |
| `src/types.ts` | Roles (`Package`, `Part`, `Surface`, `Setup`, `Route`, `Stacking`, `SurfaceMount`, …), `SerializedPackage` (the JSON the plugin emits), `BgmError`, `DefFile`, `ParsedDef` |
| `src/schemas.ts` | zod schemas + `validate*` for each role |
| `src/markdown.ts` | Virtual def files from markdown code blocks, via **`marked`**. `file=` naming + content-hash auto-naming (`./<hash>.yaml`); multiple blocks may share a `file=` name |
| `src/parse.ts` | yaml/json/toml → def objects (`yaml`, `smol-toml`); real-file walker (incl. `.csv`) |
| `src/variants.ts` | `$variants` expansion via **`typed-csv`** (`typed-csv/csv-loader`). Inline (newline) vs path; paths resolve against the virtual def map |
| `src/collect.ts` | `loadDefs` (real + virtual, virtual wins), `collectPackages` (package decl → `include` globs via **picomatch** → parts/surfaces/setups, `type#id` uniqueness, `$variants` on defs and route candidates). Sets each part's `baseUrl` to its source file's directory for resolving relative asset paths |
| `src/vite.ts` | The **vite plugin** (`bgm()`): resolves `virtual:bgm/packages` (all packages) and `virtual:bgm/package/<id>` (one package) imports to `export default <json>`, watches source files for reload. Serializes the package's `Map`s to objects (`SerializedPackage`); throws on unknown packages; re-collects per `load` (no stale cache). |
| `src/*.test.ts` | **23 tests, all passing** — markdown extractor, typed-csv parsing (incl. the spec's empty-array + crop tuple cases), full harbor collection, plugin unit tests (resolve/load/shape/watch), and a real `vite build` integration test covering two packages |
Deps: `marked`, `typed-csv`, `yaml`, `smol-toml`, `picomatch`, `zod`, `vite`, `@types/picomatch`.
### Example game: harbor (fixture)
Exercises the format end-to-end: package decl, two `file=parts/tokens.yaml` blocks, a table surface with `mount`/`children` and `candidates: $variants` against a virtual csv block, a child player surface, and a setup declaring its enabled `surfaces`. Lives as the vitest fixture under `packages/bgm/src/__fixtures__/harbor/` (and a copy under `packages/tabletop/src/__fixtures__/vite-build/games/harbor/`).
### `games/poker/poker.md` — example game (new)
A real 52-card deck: a single `card` part expanded by `$variants` into 52 cards, each picking a cell from a `13×4` face sheet (`cards-13x4.jpg`) and a shared `4×1` back sheet (`back-4x1.png`). Assets are stored in Git LFS (`.gitattributes`).
### `packages/tabletop` — rendering library (new)
A standalone r3f library that renders bgm parts. `PartView`/`PartMesh` build a mesh from a `Part` definition via `@tts/mesh` (size/fillet, face/back sprite UVs, traced `shape` or rect fallback). Proxy calls (`/asset`, `/trace`) default to `@tts/http` handlers and are overridable via `TabletopProvider`. Plan: [`bgm-tabletop.md`](./bgm-tabletop.md).
### `packages/http` — shared proxy HTTP (new)
CORS-safe proxy HTTP helpers shared by the web app and `@tts/tabletop`: `assetUrl`/`resolveAssetUrl` (route external assets through `/asset`) and `traceImage` (image → vector shape via `/trace`, BSON-deserialized). The trace-to-shape geometry conversion lives in `@tts/mesh` (`traceToShape`/`traceToUvBounds`), and `ErrorBoundary` is shared via `@tts/tabletop`.
### `apps/proxy` — local game assets (new)
The proxy serves local game assets from the `games` root: `GAMES_ROOT` (env or default) is set at startup (`config.ts`) and read by the `/asset` and `/trace` routes to serve relative paths (e.g. `poker/parts/assets/cards.png`) from disk, alongside the existing http(s) proxy path.
### `apps/web` — consumer (new)
- `vite.config.ts` — wired with `bgm({ root: <repo>/games })`, importing the plugin from `@tts/bgm`.
- `src/vite-env.d.ts` — ambient `declare module 'virtual:bgm/packages'` (all packages) and `'virtual:bgm/package/*'` (one package).
- `src/pages/BgmPage.tsx` — list page: imports `virtual:bgm/packages` and shows every discovered package. Routed at `/bgm`.
- `src/pages/BgmPackagePage.tsx` — detail page: looks up a package by id from `bgm` and renders its parts/surfaces/setups. Routed at `/bgm/:id`.
The vite plugin itself lives in `packages/bgm/src/vite.ts` (exported from `@tts/bgm`), not in the web app — so the loader's plugin is tested in isolation from the web project.
## Works
- `pnpm --filter @tts/bgm build` and `typecheck` pass.
- Root `pnpm test`: **191 pass**.
- `pnpm --filter @tts/web build` succeeds; config warnings fixed.
- `src/vite.test.ts` runs a **real `vite build`** against a self-contained fixture (`src/__fixtures__/vite-build/`) and asserts the bundled output contains the package data — the plugin is proven end-to-end without touching the web app.
- `pnpm --filter @tts/tabletop build` / `test` pass; `pnpm --filter @tts/proxy typecheck` passes.
## Known issues
1. ~~Plugin emits empty maps~~ — fixed: `load` serializes `Map`s via `Object.fromEntries`.
2. ~~Plugin `load` uses `this.error`~~ — fixed: throws instead.
3. ~~HMR cache not invalidated~~ — fixed: dropped the closure cache; re-collects per `load`.
4. ~~`tinyglobby` leftover dep~~ — removed.
5. ~~Package-level test script finds no files~~ — added `packages/bgm/vitest.config.ts`.
## Not yet done
- ~~No consumer yet~~ — `apps/web/src/pages/BgmPage.tsx` lists packages from `virtual:bgm/packages`; `BgmPackagePage.tsx` shows one at `/bgm/:id`.
- **`$variants` URL paths** — spec mentions file/URL; URLs deferred.
- **zod `SerializedPackage` shape for the emitted JSON** — the plugin emits `SerializedPackage` objects; a zod schema for the emitted module would give runtime validation beyond the ambient `declare module`.
- **`setup` value expansion** — `type` without `id` → all parts of that type is documented but not implemented in the loader (it's a game-state init concern; noted as future).
- **Surface mounting is validated but not resolved** — `mount`/`children`/`surfaces` are parsed and validated, but the loader doesn't resolve child→parent relationships or enforce that a setup's `surfaces`/a surface's `children` reference existing surfaces. That's a game-state/rendering concern (see `../bgm/tabletop.md`).
- Docs for the loader itself (this file is the start).
+215
View File
@@ -0,0 +1,215 @@
# bgm-tabletop — Implementation Plan / Status
> **Scope:** A standalone r3f component library that renders
> [bgm](../bgm/format.md) board games: a state store, surface mounting, part
> placement with stacking, and per-part meshes. Design:
> [`../bgm/tabletop.md`](../bgm/tabletop.md).
> **Status:** items 18 implemented and the full tabletop scene is wired into
> the web app's setup detail route (`/bgm/:id/setups/:type/:setup`). The
> part-inspection route renders `PartView` from the library.
## Goal
A library (new `packages/tabletop`) that takes a bgm package and renders it as
an interactive 3D table: enabled surfaces mounted in world/HUD space, parts
placed on their routes, stacked per the format's stacking strategy. The web
app's bgm inspector routes are one consumer; the library must not depend on the
web app.
## Stack
`react`, `react-router` (types only), `tailwind` (styles only), `r3f`
(`@react-three/fiber`), `drei`, `postprocessing`, `zustand`, `three`,
`@tts/bgm` (types), `@tts/mesh` (geometry).
## Package layout
```
packages/tabletop/
package.json # @tts/tabletop
tsconfig.json
vitest.config.ts
src/
index.ts # public exports
state.ts # zustand store + derived render state
setup.ts # SetupLoader: seed state from a setup
mount.ts # resolve surface mount tree (table/hud/child)
stacking.ts # useStacking hook
placement.ts # PartPlacement
partView.tsx # PartView: mesh from a part definition
surfaces/
WorldSurfaceView.tsx
HudSurfaceView.tsx
*.test.ts # colocated unit tests
```
## Work items
### 1. Package scaffold ✅
- New `packages/tabletop` workspace package (`pnpm-workspace.yaml` already
globs `packages/*`).
- Deps: `@tts/bgm`, `@tts/mesh`, `three`, `@react-three/fiber`, `@react-three/drei`,
`@react-three/postprocessing`, `zustand`. Dev: `vitest`, `typescript`,
`@types/three`.
- `tsconfig.json` mirroring `packages/bgm`'s (strict, ESM, `dist` output).
### 2. Part meshes + export + web integration ✅
First deliverable: `PartView` renders a single part's mesh from its definition,
reusing `@tts/mesh` geometry (not the web app's viewers). This is the smallest
useful slice and unblocks the web app's part inspection route immediately.
- `PartView` (`partView.tsx`): creates a mesh from a `Part` definition:
- `size` → world dimensions; `fillet` → corner radius.
- `face`/`faceCrop`/`back`/`backCrop` → textures (drei `useTexture`), sprite
UVs from `faceCrop`/`backCrop` (a `[col,row,cols,rows]` grid cell).
- `shape` → traced silhouette (via the proxy `/trace`, like the web token
viewer) or a fallback rect/rounded-rect.
- `extrudeShapeParts` from `@tts/mesh` for the mesh.
- Shared geometry/material caching (module-level `Map`s) so repeated parts
reuse buffers, mirroring the web viewers' `sharedResources`.
- Export `PartView` from `index.ts`.
- **Web integration**: replace the web app's part inspection route
(`/bgm/:id/parts/:type/:part`) to render `PartView` from the library, proving
it end-to-end.
### 3. State store (`state.ts`) ✅
Source-of-truth game state per `../bgm/tabletop.md` §2:
```ts
interface GameState {
surfaces: Record<string, boolean>; // enabled per surface id
parts: Record<string, PartState>; // part id -> placement state
}
interface PartState {
path: string; // the path key this part is on
index: number; // the part's position in its path's stack
facing: 'face' | 'back' | 'standing'; // how the part is oriented on the board
}
```
- A zustand store holding `GameState`.
- **Derived render state**: `game state + surface routes => map of piece id to
`{ surface, route, candidate, index, stackSize, face }``, per enabled surface.
Computed with a selector/memo so the render list is stable. A path's ordered
children (for stacking) are derived from the parts map by sorting on `index`.
- **Assumption**: each piece id is unique on the board (documented in
`../bgm/tabletop.md`); the render map is keyed by piece id.
### 4. Setup seeding (`setup.ts`) ✅
- `SetupLoader`: side-effect-only component that seeds the store from a
`Setup` — enables its `surfaces` (or all when omitted) and applies its
ordered `setup` placements (each moves its `parts` to a `path`).
- `setup` value expansion: a bare `type` (no id) expands to all parts of that
type (documented in `../bgm/format.md` §3; the loader doesn't do this — it's a
game-state init concern, so it lives here).
### 5. Surface mounting (`mount.ts`) ✅
- Resolve the surface mount tree from `Surface.mount` + `Surface.children`:
- `kind: table` — root, world space.
- `kind: hud` — HUD area (`mount.area`).
- `kind: child` — mounted relative to a parent that lists it in `children`.
- `WorldSurfaceView` / `HudSurfaceView` mount an enabled surface; a disabled
surface isn't rendered. Child surfaces mount relative to their parent's
anchor (`x`/`y`/`rotation`).
### 6. Part placement (`placement.ts`) ✅
- `PartPlacement`: stable per-part component that positions a part on a surface
location from the derived render state (route anchor + candidate anchor).
- Applies the route's stacking strategy via `useStacking`.
### 7. Stacking (`stacking.ts`) ✅
- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`.
- Implements the format's positioning process (`../bgm/format.md` §4): step
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
rotates each shown part about its local Y (long) axis.
- Curve length from an SVG path string (small helper; no new dep).
### 8. Public API (`index.ts`) ✅
Export `SetupLoader`, `WorldSurfaceView`, `HudSurfaceView`, `PartPlacement`,
`PartView`, `useStacking`, and the store hooks. The web app consumes these; the
library never imports from `apps/*`.
## Reuse from `@tts/mesh`
- `extrudeShapeParts` / `extrudeShape` — front/back/walls geometry.
- `rectShape`, `roundedRectShape`, `circleShape`, `polygonShape`, `hexShape`,
`frameShape`, `scaleShape` — shape generators for parts without a `shape`
sprite.
- `shapeFromThree` — author shapes with the three.js path API.
- `ExtrudedGeometry` / `UVBounds` — raw typed arrays + UV framing.
The web viewers (`TokenViewer`/`CardViewer`) contain logic we'll mirror rather
than import: trace-to-shape conversion, sprite UV math, texture flipping. These
are candidates to lift into `@tts/mesh` or `@tts/tabletop` later so both
consumers share them (see Open decisions).
## Testing
- `state.ts` — derived render state: enabled surfaces, route matching,
candidate selection, stacking index/stackSize.
- `stacking.ts` — positioning process: step length, alignment, limit, z ramp,
tilt.
- `setup.ts` — seeding + bare-type expansion.
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
- A real `vite build` integration test (mirroring `packages/bgm/src/vite.test.ts`)
proving the library bundles against a fixture package.
## Validation
- `pnpm --filter @tts/tabletop build` / `typecheck` / `test`.
- Root `pnpm test` stays green.
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
from the library (work item 2), proving it end-to-end.
## Free interaction (layer 3) ✅
Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md):
- `interactions.ts` — the held part + dialog stack (UI state, outside the game
store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`,
`partFacings`, `nextFacing`).
- `state.ts` — `setFacing` alongside `movePart` (the `move(id, path, index?)`
primitive, defaulting to top of stack).
- `placement.tsx` — `PartPlacement` is now interactive: pick up a part (held,
lifted above the board), drag to a path anchor to `move`, click to cycle
facing. A drop on a path with a stack-dialog interaction opens the deck
dialog instead of moving directly.
- `dialog.tsx` — `DialogLayer`, the stack-inspector dialog: an alternate view
of a stack with an insertion cursor; its insert button issues a `move`.
- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs`
(schema + collection + serialization).
- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack
insert) on the deck.
The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
## Commands (not yet implemented)
Scripted interaction is designed in [`../bgm/commands.md`](../bgm/commands.md):
async commands with `ok`/`cancel`/`error` results, per-invocation run
contexts, fire-and-forget vs self-managed waiting, and tap interaction with
part-local trigger points. Implementation order: types + run-context manager,
tap detection, then the first commands (`wait: tap`, `focus`).
## Open decisions (defaults in bold)
- **Where the trace/sprite helpers live** — **lift into `@tts/mesh`** (shared
by web viewers + tabletop) vs duplicate in `@tts/tabletop`. Lifting is
cleaner but touches the web viewers; decide when `PartView` (work item 2)
needs them.
- **HUD rendering** — **drei `Html`/orthographic overlay** vs a second
`Canvas`. Default to an overlay so world + HUD share one scene.
- **Curve length** — **small internal SVG-path length helper** vs a dependency
(e.g. `svg-path-properties`). Prefer the helper to avoid a dep.
@@ -81,9 +81,10 @@ view and the full-setup view.
`textureUrl + color + roughness`. drei already caches textures by URL
globally, so sharing the material on top avoids per-object material
allocation for tiles/tokens with the same image.
- **Cards are the exception:** each card clones its texture for sprite UVs, so
its face material cannot be shared — but its geometry still can (same card
size).
- **Cards:** the face/back textures are shared (drei caches them by URL) and
the sprite cell is selected via a per-material UV transform injected into the
shader (`cardMaterial.ts`), so cards share texture, shader, and geometry —
only the material uniforms differ. Materials are cached per card id + tint.
- Dispose shared resources on page unmount, or accept a module-level cache for
the session (see Open decisions).
@@ -2,8 +2,8 @@
> **Scope:** The concrete build plan — files, endpoints, dependencies, build
> order. For the system's architecture and dependency graph, see
> [`architecture.md`](./architecture.md). For the rationale behind key decisions,
> see [`decisions.md`](./decisions.md).
> [`../architecture.md`](../architecture.md). For the rationale behind key decisions,
> see [`../decisions.md`](../decisions.md).
A lightweight, client-only pnpm monorepo for searching the Tabletop Simulator
Steam Workshop, fetching full TTS save files, and analyzing their contents.
@@ -55,7 +55,16 @@ tts-workshop/
├── .npmrc
├── .env.example # STEAM_API_KEY, PORT
├── docs/
── implementation-plan.md # this file
── overview.md
│ ├── architecture.md
│ ├── decisions.md
│ ├── bgm/
│ │ ├── format.md
│ │ ├── engine.md
│ │ ├── commands.md
│ │ └── tabletop.md
│ └── status/
│ └── implementation-plan.md # this file
├── apps/
│ ├── proxy/
│ │ ├── package.json
@@ -155,8 +164,8 @@ Low-level fetcher, extracted from the existing scraper.
(no Steam API call).
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
bytes + derived filename, with and without the Steam API.
- `getFileName(url: string): Promise<string>` — derive filename from the
`content-disposition` header.
- `getFileName(url: string): string` — derive a filename from the save URL
path (the upstream `content-disposition` header is ignored).
- `errors.ts`
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
- Notes
@@ -237,8 +246,8 @@ Hono server exposing search + fetch.
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
query param to download the save directly (no Steam API key needed);
otherwise resolves via the Steam API.
- `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also
accepts `fileUrl`.
- `GET /items/:id/file` — raw save bytes, filename from `getFileName` (the
URL path). Also accepts `fileUrl`.
- `routes/asset.ts`
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
it back with a `Content-Type` header. Workshop hosts often omit CORS
@@ -325,7 +334,7 @@ proxy API and `packages/extract` directly for analysis.
| GET | `/health` | Liveness | — |
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
| 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 | `/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.
+226
View File
@@ -0,0 +1,226 @@
# Carcassonne
The base game's 24 landscape tiles, laid out on a table with a draw pile and a
grid of placed tiles. Each tile is a single `110×110` image (AX), sized to a
standard `45×45` mm square.
```yaml role=package
id: carcassonne
title: Carcassonne
designer: Klaus-Jürgen Wrede
publisher: Hans im Glück
players: 5
language: en
include: ['./**/*.yaml']
```
## Parts
One `tile` part per distinct tile type (AX). All tiles share the same square
face image and a uniform size; the `$variants` CSV expands them into the 24
tile parts.
```yaml role=part.tile
face: ./20AE_Base_Game_C2_Tile_A.png
size: [45, 45, 3]
fillet: 1
$variants: ./tiles.csv
```
```csv file=tiles.csv
id,face
string,string
a,./20AE_Base_Game_C2_Tile_A.png
b,./20AE_Base_Game_C2_Tile_B.png
c,./20AE_Base_Game_C2_Tile_C.png
d,./20AE_Base_Game_C2_Tile_D.png
e,./20AE_Base_Game_C2_Tile_E.png
f,./20AE_Base_Game_C2_Tile_F.png
g,./20AE_Base_Game_C2_Tile_G.png
h,./20AE_Base_Game_C2_Tile_H.png
i,./20AE_Base_Game_C2_Tile_I.png
j,./20AE_Base_Game_C2_Tile_J.png
k,./20AE_Base_Game_C2_Tile_K.png
l,./20AE_Base_Game_C2_Tile_L.png
m,./20AE_Base_Game_C2_Tile_M.png
n,./20AE_Base_Game_C2_Tile_N.png
o,./20AE_Base_Game_C2_Tile_O.png
p,./20AE_Base_Game_C2_Tile_P.png
q,./20AE_Base_Game_C2_Tile_Q.png
r,./20AE_Base_Game_C2_Tile_R.png
s,./20AE_Base_Game_C2_Tile_S.png
t,./20AE_Base_Game_C2_Tile_T.png
u,./20AE_Base_Game_C2_Tile_U.png
v,./20AE_Base_Game_C2_Tile_V.png
w,./20AE_Base_Game_C2_Tile_W.png
x,./20AE_Base_Game_C2_Tile_X.png
```
## Board
A table with a draw pile on the left and an `11×11` grid of placed tiles in the
middle. The grid routes each tile to its `col,row` cell, spaced `45` mm apart
so tiles sit edge to edge.
```yaml role=surface.board
id: board
size: [600, 600]
layout:
- route: /draw
x: -280
y: 0
rotation: 0
stacking:
align: center
zStart: 0
curve: M -50 -200 C 50 -150 450 -150 550 -200
- route: /grid/:col/:row
candidates:
$variants: ./grid.csv
```
```csv file=grid.csv
col,row,x,y,rotation
string,string,number,number,number
0,0,-225,-225,0
0,1,-225,-180,0
0,2,-225,-135,0
0,3,-225,-90,0
0,4,-225,-45,0
0,5,-225,0,0
0,6,-225,45,0
0,7,-225,90,0
0,8,-225,135,0
0,9,-225,180,0
0,10,-225,225,0
1,0,-180,-225,0
1,1,-180,-180,0
1,2,-180,-135,0
1,3,-180,-90,0
1,4,-180,-45,0
1,5,-180,0,0
1,6,-180,45,0
1,7,-180,90,0
1,8,-180,135,0
1,9,-180,180,0
1,10,-180,225,0
2,0,-135,-225,0
2,1,-135,-180,0
2,2,-135,-135,0
2,3,-135,-90,0
2,4,-135,-45,0
2,5,-135,0,0
2,6,-135,45,0
2,7,-135,90,0
2,8,-135,135,0
2,9,-135,180,0
2,10,-135,225,0
3,0,-90,-225,0
3,1,-90,-180,0
3,2,-90,-135,0
3,3,-90,-90,0
3,4,-90,-45,0
3,5,-90,0,0
3,6,-90,45,0
3,7,-90,90,0
3,8,-90,135,0
3,9,-90,180,0
3,10,-90,225,0
4,0,-45,-225,0
4,1,-45,-180,0
4,2,-45,-135,0
4,3,-45,-90,0
4,4,-45,-45,0
4,5,-45,0,0
4,6,-45,45,0
4,7,-45,90,0
4,8,-45,135,0
4,9,-45,180,0
4,10,-45,225,0
5,0,0,-225,0
5,1,0,-180,0
5,2,0,-135,0
5,3,0,-90,0
5,4,0,-45,0
5,5,0,0,0
5,6,0,45,0
5,7,0,90,0
5,8,0,135,0
5,9,0,180,0
5,10,0,225,0
6,0,45,-225,0
6,1,45,-180,0
6,2,45,-135,0
6,3,45,-90,0
6,4,45,-45,0
6,5,45,0,0
6,6,45,45,0
6,7,45,90,0
6,8,45,135,0
6,9,45,180,0
6,10,45,225,0
7,0,90,-225,0
7,1,90,-180,0
7,2,90,-135,0
7,3,90,-90,0
7,4,90,-45,0
7,5,90,0,0
7,6,90,45,0
7,7,90,90,0
7,8,90,135,0
7,9,90,180,0
7,10,90,225,0
8,0,135,-225,0
8,1,135,-180,0
8,2,135,-135,0
8,3,135,-90,0
8,4,135,-45,0
8,5,135,0,0
8,6,135,45,0
8,7,135,90,0
8,8,135,135,0
8,9,135,180,0
8,10,135,225,0
9,0,180,-225,0
9,1,180,-180,0
9,2,180,-135,0
9,3,180,-90,0
9,4,180,-45,0
9,5,180,0,0
9,6,180,45,0
9,7,180,90,0
9,8,180,135,0
9,9,180,180,0
9,10,180,225,0
10,0,225,-225,0
10,1,225,-180,0
10,2,225,-135,0
10,3,225,-90,0
10,4,225,-45,0
10,5,225,0,0
10,6,225,45,0
10,7,225,90,0
10,8,225,135,0
10,9,225,180,0
10,10,225,225,0
```
## Setup
Start with the full tile supply on the draw pile (`carcassonne:tile` expands to
every tile of that type), then seed the grid with a few opening tiles.
```yaml role=setup.game
id: main
surfaces:
- board#board
setup:
- path: /draw
parts: carcassonne:tile
- path: /grid/5/5
parts: carcassonne:tile#a
- path: /grid/5/6
parts: carcassonne:tile#b
- path: /grid/6/5
parts: carcassonne:tile#c
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
+162
View File
@@ -0,0 +1,162 @@
# Poker
A standard 52-card poker deck, laid out on a table with a draw pile and
community-card slots. Exercises the bgm loader's `$variants` expansion to
generate a full deck from a single part definition.
```yaml role=package
id: poker
title: Poker
designer: Public Domain
players: 9
language: en
include: ['./**/*.yaml']
```
## Parts
A single `card` part expanded into 52 cards by the `$variants` CSV. Each row
picks a cell from the `13×4` face sheet (`cards-13x4.jpg`) — 13 ranks across,
4 suits down. All cards share the same back from the `4×1` back sheet
(`back-4x1.png`).
```yaml role=part.card
face: ./cards-13x4.jpg
back: ./back-4x1.png
size: [63, 88, 0.3]
fillet: 2
$variants: ./cards.csv
```
```csv file=cards.csv
id,rank,suit,faceCrop,backCrop
string,string,string,[number;number;number;number],[number;number;number;number]
2s,2,spades,[0;0;13;4],[0;0;4;1]
3s,3,spades,[1;0;13;4],[0;0;4;1]
4s,4,spades,[2;0;13;4],[0;0;4;1]
5s,5,spades,[3;0;13;4],[0;0;4;1]
6s,6,spades,[4;0;13;4],[0;0;4;1]
7s,7,spades,[5;0;13;4],[0;0;4;1]
8s,8,spades,[6;0;13;4],[0;0;4;1]
9s,9,spades,[7;0;13;4],[0;0;4;1]
10s,10,spades,[8;0;13;4],[0;0;4;1]
js,J,spades,[9;0;13;4],[0;0;4;1]
qs,Q,spades,[10;0;13;4],[0;0;4;1]
ks,K,spades,[11;0;13;4],[0;0;4;1]
as,A,spades,[12;0;13;4],[0;0;4;1]
2h,2,hearts,[0;1;13;4],[0;0;4;1]
3h,3,hearts,[1;1;13;4],[0;0;4;1]
4h,4,hearts,[2;1;13;4],[0;0;4;1]
5h,5,hearts,[3;1;13;4],[0;0;4;1]
6h,6,hearts,[4;1;13;4],[0;0;4;1]
7h,7,hearts,[5;1;13;4],[0;0;4;1]
8h,8,hearts,[6;1;13;4],[0;0;4;1]
9h,9,hearts,[7;1;13;4],[0;0;4;1]
10h,10,hearts,[8;1;13;4],[0;0;4;1]
jh,J,hearts,[9;1;13;4],[0;0;4;1]
qh,Q,hearts,[10;1;13;4],[0;0;4;1]
kh,K,hearts,[11;1;13;4],[0;0;4;1]
ah,A,hearts,[12;1;13;4],[0;0;4;1]
2d,2,diamonds,[0;2;13;4],[0;0;4;1]
3d,3,diamonds,[1;2;13;4],[0;0;4;1]
4d,4,diamonds,[2;2;13;4],[0;0;4;1]
5d,5,diamonds,[3;2;13;4],[0;0;4;1]
6d,6,diamonds,[4;2;13;4],[0;0;4;1]
7d,7,diamonds,[5;2;13;4],[0;0;4;1]
8d,8,diamonds,[6;2;13;4],[0;0;4;1]
9d,9,diamonds,[7;2;13;4],[0;0;4;1]
10d,10,diamonds,[8;2;13;4],[0;0;4;1]
jd,J,diamonds,[9;2;13;4],[0;0;4;1]
qd,Q,diamonds,[10;2;13;4],[0;0;4;1]
kd,K,diamonds,[11;2;13;4],[0;0;4;1]
ad,A,diamonds,[12;2;13;4],[0;0;4;1]
2c,2,clubs,[0;3;13;4],[0;0;4;1]
3c,3,clubs,[1;3;13;4],[0;0;4;1]
4c,4,clubs,[2;3;13;4],[0;0;4;1]
5c,5,clubs,[3;3;13;4],[0;0;4;1]
6c,6,clubs,[4;3;13;4],[0;0;4;1]
7c,7,clubs,[5;3;13;4],[0;0;4;1]
8c,8,clubs,[6;3;13;4],[0;0;4;1]
9c,9,clubs,[7;3;13;4],[0;0;4;1]
10c,10,clubs,[8;3;13;4],[0;0;4;1]
jc,J,clubs,[9;3;13;4],[0;0;4;1]
qc,Q,clubs,[10;3;13;4],[0;0;4;1]
kc,K,clubs,[11;3;13;4],[0;0;4;1]
ac,A,clubs,[12;3;13;4],[0;0;4;1]
```
## Board
A table with a draw pile on the left and five community-card slots across the
middle. The deck pile fans its stacked cards along a curve.
```yaml role=surface.board
id: poker
size: [600, 400]
layout:
- route: /deck
x: -250
y: 0
rotation: 0
stacking:
align: center
zStart: 0
#zEnd: 100
curve: M -50 -200 C 50 -150 450 -150 550 -200
- route: /community/:slot
candidates:
$variants: ./community.csv
```
```csv file=community.csv
slot,x,y,rotation
string,number,number,number
0,-100,0,0
1,-50,0,0
2,0,0,0
3,50,0,0
4,100,0,0
```
## Setup
Deal the whole deck facedown onto the draw pile (`poker:card` expands to every
card of that type, `facing: back`), then flip a flop onto the community slots
and stand a couple of drawn cards on their bottom edge.
```yaml role=setup.game
id: main
setup:
- path: /deck
parts: poker:card
facing: back
- path: /community/0
parts: poker:card#as
- path: /community/1
parts: poker:card#kh
- path: /community/2
parts: poker:card#7d
- path: /community/3
parts: poker:card#jc
facing: standing
- path: /community/4
parts: poker:card#2h
facing: standing
interactions:
- dialog: prompt#insert
on: [/deck]
```
## Dialog
Inserting a card into the middle of the deck is a compound interaction: the
deck is lifted into a stack-inspector dialog with an insertion cursor. The
cursor *is* the index — `move(id, /deck, index)` is discovered by scrolling,
not typed.
```yaml role=dialog.prompt
id: insert
title: Insert into deck
body: Scroll to the insertion point, then insert the held card.
widget: stack
```
+1
View File
@@ -13,6 +13,7 @@
},
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@vitest/coverage-v8": "^4.1.10",
"typescript": "^5.7.2",
"vitest": "^4.1.10"
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@tts/bgm",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo \"no lint configured\""
},
"dependencies": {
"vite": "^8.2.1",
"marked": "^16.0.0",
"picomatch": "^4.0.5",
"smol-toml": "^1.4.0",
"typed-csv": "^2.0.0",
"yaml": "^2.4.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.12.0",
"@types/picomatch": "^4.0.0",
"typescript": "^5.7.2",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,100 @@
# Harbor
A tiny example game used to exercise the bgm loader.
```yaml role=package
id: harbor
title: Harbor
designer: Jane Doe
players: 2
language: en
include: ['./**/*.yaml']
```
## Tokens
```yaml role=part.token
id: wood
face: ./assets/tokens.png
faceCrop: [1, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [3, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
```yaml role=part.token
id: grain
face: ./assets/tokens.png
faceCrop: [0, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [2, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
## Board
```yaml role=surface.board
id: harbor
size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
- route: /deck
x: -100
y: 0
rotation: 0
```
```yaml role=surface.board
id: player
size: [200, 200]
mount:
kind: child
x: 100
y: 50
rotation: 0
layout:
- route: /hand/:slot
candidates:
$variants: ./hand.csv
```
```csv file=hand.csv
slot,x,y,rotation
string,number,number,number
0,0,0,0
1,0,20,0
```
```csv file=seats.csv
seat,x,y,rotation
string,number,number,number
0,40,0,0
1,40,20,0
```
## Setup
```yaml role=setup.game
id: main
surfaces:
- board#harbor
- board#player
setup:
- path: /dock/0
parts: harbor:token#wood
- path: /deck
parts: harbor:token#grain
```

Some files were not shown because too many files have changed in this diff Show More