Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.history
.history

# Serena MCP server local data
.serena/
25 changes: 0 additions & 25 deletions .npmignore

This file was deleted.

57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Changelog

All notable changes to this project are documented in this file.

## 0.1.1

### New API
- Added `addMulti(specs)`: the additive counterpart to `rollMulti()`. Adds several independently-parsed, independently-colorset-tagged dice notations to the table as a single combined physics throw, without clearing the dice already present (same non-destructive semantics as `add()`, generalized to multiple notations/colorsets). Resolves with the same flat per-die results array shape as `add()`; falls back to `rollMulti(specs)` when the table is empty, mirroring `add()`'s existing fallback to `roll()`.
- `add(notationSting)` is now implemented as a thin delegator to `addMulti([{ notation: notationSting }])` - behavior is unchanged.

## 0.1.0

### Audio
- Migrated sound playback to the Web Audio API (`AudioContext`/`AudioBufferSourceNode`), fixing stutter on iOS and desktop browsers when `sounds: true`.
- `AudioContext` is created lazily and resumed from user-gesture call stacks (click-to-roll, `add()`, `reroll()`, `rollMulti()`) so playback isn't silently blocked by browser autoplay/gesture restrictions.

### Physics performance
- Switched to `CANNON.SAPBroadphase` for collision broadphase.
- Capped the per-frame physics catch-up loop so a stalled/backgrounded tab can't spiral into running many `world.step()` calls back-to-back.
- The pre-throw settle simulation now yields to the event loop periodically instead of blocking the main thread in one long synchronous loop.

### Correctness fixes
- `updateConfig()` now actually applies merged options (via `Object.assign`) and correctly reloads sounds when `sounds` is turned on.
- `updateConfig()` no longer wipes `theme_customColorset` back to `null` on calls that don't mention it at all (e.g. `updateConfig({volume: 50})`); it now only touches theme-related fields the caller actually passed, using property-presence checks instead of truthiness.
- `rollMulti()` no longer has a race where a `roll()`/`rollMulti()`/`reroll()`/`add()` call started while a prior `rollMulti()` was still pre-warming its colorsets could be incorrectly superseded once that prior `rollMulti()`'s prewarm resolved; ownership is now claimed synchronously before the prewarm `await`, matching `roll()`/`add()`/`reroll()`.
- `destroy()` called while `add()` was mid-way through its chunked pre-simulation now correctly rejects `add()`'s returned Promise instead of silently resolving it with `undefined`.
- Per-set `+`/`-` operators in dice notation (e.g. `2d6-1d4`) are now respected.
- Signed modifier merging (e.g. combining a `+5` roll with a `-3` `add()`) now nets out correctly, including exact cancellation to zero.
- Forced/predetermined results for `d4` now produce the correct face.
- Fixed `disotope`'s explicit per-face values array being incorrectly collapsed into a min/max/step range.
- Malformed forced-result (`@`) suffixes (e.g. `2d6@foo`, or `2d6@1,garbage` where one token doesn't parse) are now rejected as malformed notation instead of silently forcing only the tokens that happened to look like numbers and dropping the rest.

### Behavior changes
- Malformed/unparseable dice notation now rejects the returned Promise (with a descriptive `Error`) instead of silently resolving a fake empty result.
- A roll superseded by a newer `roll()`/`rollMulti()`/`reroll()`/`add()` call now rejects with `Error('Roll superseded by a new roll')` instead of hanging forever.
- `reroll()` called while a previous roll is still animating (table not yet settled) now clears the still-falling dice and rejects the `reroll()` call itself, instead of silently rerolling whichever half-fallen dice happened to occupy the requested ids. See the [Error Handling](README.md#error-handling) section.
- Die type names are now case-sensitive.
- The reroll-function notation `{r,...}` (e.g. `{r,2}`) has been removed as unsupported; it was previously accepted by the parser but silently did nothing.

### Colorsets
- Colorsets were redesigned for WCAG AAA text legibility (numeral/outline contrast against each die's body color); several damage-type colorsets were also conceptually rethemed. The colorset names and selection API (`theme_colorset`) are unchanged.

### Memory / lifecycle
- Added `destroy()` to fully tear down a `DiceBox` instance: stops in-flight rolls/animation, removes the window resize listener, settles any pending roll Promise, disposes GPU resources (geometries, materials, cached textures, environment map), closes the `AudioContext`, and removes the canvas from the DOM.
- `destroy()` now also cancels `clearDice()`'s pending ~100ms delayed re-render and guards the debounced window-resize handler, so neither can fire after `destroy()` and throw against the torn-down renderer/scene.
- Added an LRU cache (with a capacity cap) for composite/bump material textures, and a deferred-disposal path for cache evictions so a texture still in use on the table isn't disposed out from under it.
- Per-(type, face) bump normal-map textures are now cached and disposed per-`DiceFactory` instance rather than leaking onto a shared static registry.
- `add()` no longer silently adopts an interrupted in-flight roll's still-falling dice into its own result; a superseded in-flight roll is cleared first, matching `startClickThrow()`'s behavior.
- `reroll()` no longer silently adopts an interrupted in-flight roll's still-falling dice either; see the Behavior changes entry above.

### Packaging
- Fixed the npm `"files"` allowlist so the published tarball actually contains what's needed.
- Marked the package `"type": "module"` and added a proper `"exports"` map. The package is ESM-only; there is no CommonJS build.
- Removed `.npmignore` — the `"files"` allowlist in `package.json` now governs the published tarball.

### Developer experience
- Added a new `src/demo.js` + demo page exercising roll/add/reroll/rollMulti/colorset switching, for local development and manual testing.
78 changes: 74 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

Based on [Major's 3D Dice](https://majorvictory.github.io/3DDiceRoller/)

The goal of this project is to decouple the UI of Major's 3D Dice and strip down the dice box to just the essentials. Just a module that accepts simple dice notation input and outputs a JSON object when the dice finish rolling.
The goal of this project is to decouple the UI of Major's 3D Dice and keep the dice box focused: dice notation goes in, a JSON results object comes out when the roll finishes, and the API around that gives you a full roll lifecycle to manage - add dice to a settled table (`add()`/`addMulti()`), reroll specific dice, remove dice, roll multiple colorsets/notations as one combined throw (`rollMulti()`), and theme the table (colors, texture, material) at runtime.

Why another dice roller when you have [@3d-dice/dice-box](https://github.com/3d-dice/dice-box)?
Teall dice had already solved predeterministic rolling, which is a feature some developers really need. Major's 3D dice are based on Teall Dice.
Expand All @@ -19,6 +19,7 @@ npm install @3d-dice/dice-box-threejs
## Config Options
```
const defaultConfig = {
assetPath: "./", // path (relative to your page, or absolute) prefixed onto texture/sound asset URLs; see "Notes" below
framerate: (1/60),
sounds: false,
volume: 100,
Expand All @@ -29,15 +30,33 @@ const defaultConfig = {
theme_customColorset: null,
theme_colorset: "white", // see available colorsets in https://github.com/3d-dice/dice-box-threejs/blob/main/src/const/colorsets.js
theme_texture: "", // see available textures in https://github.com/3d-dice/dice-box-threejs/blob/main/src/const/texturelist.js
theme_material: "glass", // "none" | "metal" | "wood" | "glass" | "plastic"
theme_material: "glass", // "none" | "metal" | "wood" | "glass" | "plastic" -- "glass" is a glossy/polished look (low roughness), not real transparent/refractive glass
gravity_multiplier: 400,
light_intensity: 0.7,
baseScale: 100,
strength: 1, // toss strength of dice
onRollComplete: () => {}
iterationLimit: 1000, // safety cap on the number of physics settle-check iterations before a roll is considered resolved
onRollComplete: () => {},
onRerollComplete: () => {}, // called with the results of a reroll(), see below
onAddDiceComplete: () => {}, // called with the results of an add(), see below
onRemoveDiceComplete: () => {}, // called when dice removed via remove() finish clearing
}
```

Note: dice colorsets were redesigned in this release for WCAG AAA text legibility (numeral/outline contrast against each die's body color). A few colorsets also had their body colors adjusted or textures swapped as part of this pass; the colorset names and selection API (`theme_colorset`) are unchanged.

## Changing The Theme At Runtime: `loadTheme()`
`Box.loadTheme({ colorset, texture, material })` reloads the dice colors/texture/material without recreating the `DiceBox` instance (`initialize()` also calls this internally at startup using `theme_colorset`/`theme_texture`/`theme_material` from the constructor config, so you don't need to call it yourself just to apply the initial theme). All three properties are optional:
- `colorset` (string) — a colorset name, same values as `theme_colorset` above
- `texture` (string) — a texture name, same values as `theme_texture` above
- `material` (string) — `"none" | "metal" | "wood" | "glass" | "plastic"`, same values as `theme_material` above

```js
await Box.loadTheme({ colorset: 'fire' })
```

Note: if `theme_customColorset` is set (in the constructor config, or via a later `updateConfig({ theme_customColorset: ... })`), `loadTheme()` ignores the `colorset`/`texture`/`material` arguments above entirely and rebuilds that custom colorset instead. Clear it first (`updateConfig({ theme_customColorset: null })`) if you want a `loadTheme()` call to switch back to a named colorset.

## Getting Results
### There are three ways to get results
1. You can define an `onRollComplete` callback function when creating the Dice Box
Expand Down Expand Up @@ -68,5 +87,56 @@ As mentioned previously, this project was forked for it's predeterministic rolli
Box.roll("6d6@4,4,4,4,4,4") // rolls six dice that will land on 4's
```

## Rolling Multiple Colorsets At Once: `rollMulti()`
`rollMulti()` lets you roll several independently-parsed dice notations as a single combined physics throw, with each notation using its own colorset. This is useful for things like "roll 2d6 as fire damage dice and 1d4 as poison damage dice" in one throw.

```js
const results = await Box.rollMulti([
{ notation: '2d6', colorset: 'fire', label: 'fire' },
{ notation: '1d4', colorset: 'poison', label: 'poison' },
])
```

Each entry in the `specs` array is an object:
- `notation` (string, required) — standard dice notation for that set, e.g. `"2d6+1"`
- `colorset` (string, optional) — a colorset name (see `theme_colorset` above) applied to just this set's dice
- `label` (string, optional) — an arbitrary label echoed back on the result's `sets[]` entry for that notation (defaults to `colorset` if omitted)

`rollMulti()` returns a Promise that resolves with the same shape as `roll()`'s result (a `sets[]` array, one entry per spec, each with its own `rolls`, `total`, `colorset`, and `label`, plus an overall `total`). Note that two specs of the same die type are never merged into a single set — each spec always produces its own `sets[]` entry, even if two specs use the same dice type.

## Adding Multiple Colorsets At Once: `addMulti()`
`addMulti()` is the additive counterpart to `rollMulti()`: it adds several independently-parsed dice notations to the table as a single combined physics throw, each notation using its own colorset, **without** clearing the dice already on the table (like `add()`, but for more than one notation/colorset at a time).

```js
const results = await Box.addMulti([
{ notation: '1d6', colorset: 'fire', label: 'fire' },
{ notation: '1d6', colorset: 'poison', label: 'poison' },
])
```

The `specs` array uses the exact same per-entry shape as `rollMulti()` (`notation` required, `colorset`/`label` optional). `addMulti()` returns a Promise that resolves with the same flat per-die results array shape as `add()` (one entry per newly-added die, each with its own `colorset`), not `rollMulti()`'s `sets[]` shape. If the table is empty when `addMulti()` is called, it falls back to `rollMulti(specs)` (mirroring `add()`'s existing fallback to `roll()`) — in that case the resolved shape is `rollMulti()`'s `sets[]` shape instead. `Box.add(notationSting)` is now a thin wrapper around `Box.addMulti([{ notation: notationSting }])`.

## Removing And Rerolling Dice
- `Box.reroll(diceIdArray)` — re-throws the specific dice (by `id`, from a previous result) and resolves with just their updated results. Fires `onRerollComplete` / the `rerollComplete` event.
- `Box.add(notationSting)` — adds more dice to the ones already on the table without re-rolling existing dice. Fires `onAddDiceComplete` / the `addDiceComplete` event.
- `Box.addMulti(specs)` — same as `add()`, but adds several independently-colorset-tagged notations as one combined throw. See [Adding Multiple Colorsets At Once](#adding-multiple-colorsets-at-once-addmulti) above. Also fires `onAddDiceComplete` / the `addDiceComplete` event.
- `Box.remove(diceIdArray)` — removes specific dice from the table. Fires `onRemoveDiceComplete` / the `removeDiceComplete` event.

## Error Handling
`roll()`, `rollMulti()`, `add()`, `addMulti()`, and `reroll()` all return Promises that can **reject**, so calls should be wrapped in `try`/`catch` (or given a `.catch()`):
- Malformed/unparseable dice notation (e.g. an unknown die type, or a malformed forced-result suffix like `2d6@1,garbage`) rejects with an `Error` describing the parse failure, and any dice already on the table are cleared.
- If a new roll-type call (`roll()`/`add()`/`addMulti()`/`reroll()`/`rollMulti()`) supersedes a still-in-flight previous call (for example a rapid double-click, or calling `reroll()` before a prior roll's animation has finished), the **earlier** call's Promise rejects with `new Error('Roll superseded by a new roll')`. This is expected and can usually just be ignored — it means a newer roll took over.
- Calling `Box.reroll(diceIdArray)` while a previous roll is still animating (the table hasn't settled yet) supersedes that previous call as above, but `reroll()` itself **also** rejects — with an `Error` explaining the table wasn't settled — instead of rerolling whatever dice happen to still be falling. The still-falling dice are cleared. Wait for the previous call's Promise (or its completion event) before calling `reroll()`.

## Cleaning Up: `destroy()`
Call `Box.destroy()` when a `DiceBox` instance is no longer needed (e.g. unmounting a component). It stops any in-flight roll/animation (rejecting its Promise), removes the window resize listener, disposes all GPU resources, and removes the canvas from the DOM. The instance cannot be used again after `destroy()` is called.

## Unsupported Notation
The reroll-function notation `{r,...}` (e.g. `{r,2}` for "reroll all 2s") is accepted by the notation parser but is **not implemented** — using it has no effect on the roll. Treat it as reserved syntax, not a working feature.

## Notes
In order to use textures or sounds, you will need to manually copy the assets out of the `./public` folder and into your static assets folder where you're building your app.
In order to use textures or sounds, you will need to manually copy the assets out of the `./public` folder and into your static assets folder where you're building your app, or set the `assetPath` config option to point at wherever you host them.

Sound playback uses the Web Audio API internally. On browsers that require a user gesture before audio can play (notably iOS Safari), sounds may be silent on the very first roll if it happens before any click/tap on the page; subsequent user-triggered rolls (click-to-roll, `add()`, `addMulti()`, `reroll()`, `rollMulti()`) will have unlocked audio automatically.

This package is ESM-only (`"type": "module"` in `package.json`) — there is no CommonJS build, so it must be `import`ed rather than `require`d.
Loading