diff --git a/.gitignore b/.gitignore
index db59637..48e76a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,5 @@ stash
public-stash
notes.txt
stats.html
-.history
\ No newline at end of file
+.history
+.claude
diff --git a/drag-demo/index.html b/drag-demo/index.html
new file mode 100644
index 0000000..ce5a943
--- /dev/null
+++ b/drag-demo/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Dice Drag Throw
+
+
+
+
+
+ drag anywhere on the canvas to fling the dice
+
+
+
+
+
+
diff --git a/drag-demo/main.js b/drag-demo/main.js
new file mode 100644
index 0000000..04ff8d2
--- /dev/null
+++ b/drag-demo/main.js
@@ -0,0 +1,125 @@
+import DiceBox from '../src/index.js'
+
+const Dice = new DiceBox({
+ id: 'dice-canvas',
+ assetPath: '/assets/dice-box/',
+ throwForce: 9,
+ scale: 6,
+})
+
+await Dice.init()
+
+const notationInput = document.getElementById('notation')
+const debugEl = document.getElementById('debug')
+const arrowLine = document.getElementById('arrow-line')
+const canvas = document.getElementById('dice-canvas')
+
+// Screen → world mapping. With a top-down camera at (0, h, 0) the X axis
+// ends up inverted versus screen space (empirically: screen-right == world -X,
+// screen-down == world +Z). Normalize to [-1, 1] then flip x.
+function screenToWorld(sx, sy) {
+ const rect = canvas.getBoundingClientRect()
+ const size = Dice.config.size ?? 9.5
+ const aspect = rect.width / rect.height
+ const u = (sx - rect.left) / rect.width * 2 - 1
+ const v = (sy - rect.top) / rect.height * 2 - 1
+ const xHalf = size * aspect / 2 - 0.5
+ const zHalf = size / 2 - 0.5
+ const clamp = (n, lim) => Math.max(-lim, Math.min(lim, n))
+ return {
+ x: clamp(-u * size * aspect / 2, xHalf),
+ z: clamp(v * size / 2, zHalf),
+ }
+}
+
+let dragging = null
+
+function setDebug(obj) {
+ debugEl.textContent = Object.entries(obj)
+ .map(([k, v]) => `${k}: ${typeof v === 'number' ? v.toFixed(2) : JSON.stringify(v)}`)
+ .join('\n')
+}
+
+function drawArrow(x1, y1, x2, y2) {
+ arrowLine.setAttribute('x1', x1)
+ arrowLine.setAttribute('y1', y1)
+ arrowLine.setAttribute('x2', x2)
+ arrowLine.setAttribute('y2', y2)
+}
+
+function clearArrow() {
+ drawArrow(0, 0, 0, 0)
+}
+
+canvas.addEventListener('pointerdown', (e) => {
+ // ignore drags originating on the HUD
+ if (e.target.closest('#hud')) return
+ canvas.setPointerCapture(e.pointerId)
+ dragging = { id: e.pointerId, startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY }
+ drawArrow(e.clientX, e.clientY, e.clientX, e.clientY)
+})
+
+canvas.addEventListener('pointermove', (e) => {
+ if (!dragging || e.pointerId !== dragging.id) return
+ dragging.x = e.clientX
+ dragging.y = e.clientY
+ drawArrow(dragging.startX, dragging.startY, dragging.x, dragging.y)
+})
+
+canvas.addEventListener('pointerup', (e) => {
+ if (!dragging || e.pointerId !== dragging.id) return
+ const { startX, startY } = dragging
+ const endX = e.clientX
+ const endY = e.clientY
+ dragging = null
+ clearArrow()
+
+ const dxScreen = endX - startX
+ const dyScreen = endY - startY
+ const dragPx = Math.hypot(dxScreen, dyScreen)
+
+ const notation = notationInput.value || '1d6'
+ const MIN_DRAG = 8 // treat small drags as taps → use default random throw
+
+ if (dragPx < MIN_DRAG) {
+ setDebug({ note: 'tap — default random throw', notation })
+ Dice.roll(notation)
+ return
+ }
+
+ // origin = drag start, projected into the world plane
+ const origin = screenToWorld(startX, startY)
+ // direction: drag delta in screen space → world x/z
+ // screen-right == world -X, so flip dx; screen-down == world +Z, leave dy.
+ const throwDirection = [-dxScreen, 0, dyScreen]
+
+ // strength scales with drag length, normalized against a reference pixel
+ // distance so the mapping is resolution-independent. Clamped in the worker.
+ const refPx = Math.min(window.innerWidth, window.innerHeight) * 0.4
+ const throwStrength = Math.min(dragPx / refPx, 2.5)
+
+ setDebug({
+ notation,
+ startPx: [startX, startY],
+ endPx: [endX, endY],
+ dragPx: dragPx,
+ throwOrigin: [origin.x, origin.z],
+ throwDirection,
+ throwStrength,
+ })
+
+ Dice.roll(notation, {
+ throwOrigin: [origin.x, undefined, origin.z],
+ throwDirection,
+ throwStrength,
+ })
+})
+
+canvas.addEventListener('pointercancel', () => {
+ dragging = null
+ clearArrow()
+})
+
+Dice.onRollComplete = (results) => {
+ console.log('results', results)
+}
diff --git a/drag-demo/vite.config.js b/drag-demo/vite.config.js
new file mode 100644
index 0000000..dadc1b1
--- /dev/null
+++ b/drag-demo/vite.config.js
@@ -0,0 +1,18 @@
+import path from 'path'
+import { fileURLToPath } from 'url'
+import { defineConfig } from 'vite'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+
+// Standalone dev config for the drag demo. Serves this folder and uses
+// ../public as the static asset root so /assets/dice-box/... resolves.
+export default defineConfig({
+ root: here,
+ publicDir: path.resolve(here, '../public'),
+ server: {
+ fs: {
+ // allow importing from ../src
+ allow: [path.resolve(here, '..')],
+ },
+ },
+})
diff --git a/package.json b/package.json
index f1f8080..1d7e6c5 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
+ "demo:drag": "vite --config drag-demo/vite.config.js",
"postinstall": "node copyAssets.js"
},
"dependencies": {
diff --git a/src/WorldFacade.js b/src/WorldFacade.js
index dad4067..e7f62a5 100644
--- a/src/WorldFacade.js
+++ b/src/WorldFacade.js
@@ -18,7 +18,18 @@ const defaultOptions = {
assetPath: '/assets/dice-box/', // path to 'ammo', 'themes' folders and web workers
// origin: location.origin,
origin: typeof window !== "undefined" ? window.location.origin : "",
- suspendSimulation: false
+ suspendSimulation: false,
+ // throwOrigin: where dice spawn from. Accepts a named zone
+ // ('random' | 'center' | 'top' | 'bottom' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight')
+ // or an explicit [x, y, z] world position. Defaults to 'random' (existing behavior).
+ throwOrigin: 'random',
+ // throwDirection: optional [x, y, z] vector indicating the general direction to throw.
+ // When omitted, dice are thrown toward the opposite side of the origin (existing behavior).
+ throwDirection: null,
+ // throwStrength: per-roll multiplier on throw velocity. 1 = default, clamped in the physics worker.
+ throwStrength: 1,
+ // maxThrowStrength: ceiling applied to throwStrength in the physics worker.
+ maxThrowStrength: 3
}
class WorldFacade {
@@ -460,18 +471,21 @@ class WorldFacade {
}
// TODO: pass data with roll - such as roll name. Passed back at the end in the results
- roll(notation, {theme = this.config.theme, themeColor = this.config.themeColor, newStartPoint = true} = {}) {
+ roll(notation, {theme = this.config.theme, themeColor = this.config.themeColor, newStartPoint = true, throwOrigin = this.config.throwOrigin, throwDirection = this.config.throwDirection, throwStrength = this.config.throwStrength} = {}) {
// note: to add to a roll on screen use .add method
// reset the offscreen worker and physics worker with each new roll
this.clear()
const collectionId = this.#collectionIndex++
-
+
this.rollCollectionData[collectionId] = new Collection({
id: collectionId,
notation,
theme,
themeColor,
- newStartPoint
+ newStartPoint,
+ throwOrigin,
+ throwDirection,
+ throwStrength
})
const parsedNotation = this.createNotationArray(notation, this.themesLoadedData[theme].diceAvailable)
@@ -481,7 +495,7 @@ class WorldFacade {
return this.rollCollectionData[collectionId].promise
}
- add(notation, {theme = this.config.theme, themeColor = this.config.themeColor, newStartPoint = true} = {}) {
+ add(notation, {theme = this.config.theme, themeColor = this.config.themeColor, newStartPoint = true, throwOrigin = this.config.throwOrigin, throwDirection = this.config.throwDirection, throwStrength = this.config.throwStrength} = {}) {
const collectionId = this.#collectionIndex++
@@ -490,7 +504,10 @@ class WorldFacade {
notation,
theme,
themeColor,
- newStartPoint
+ newStartPoint,
+ throwOrigin,
+ throwDirection,
+ throwStrength
})
const parsedNotation = this.createNotationArray(notation, this.themesLoadedData[theme].diceAvailable)
@@ -500,7 +517,7 @@ class WorldFacade {
return this.rollCollectionData[collectionId].promise
}
- reroll(notation, {remove = false, hide = false, newStartPoint = true} = {}) {
+ reroll(notation, {remove = false, hide = false, newStartPoint = true, throwOrigin, throwDirection, throwStrength} = {}) {
// TODO: add hide if you want to keep the die result for an external parser
// ensure notation is an array
@@ -514,7 +531,7 @@ class WorldFacade {
}
// .add will return a promise that will then be returned here
- return this.add(cleanNotation, {newStartPoint})
+ return this.add(cleanNotation, {newStartPoint, throwOrigin, throwDirection, throwStrength})
}
remove(notation, {hide = false} = {}) {
@@ -554,6 +571,9 @@ class WorldFacade {
const collection = this.rollCollectionData[collectionId]
let newStartPoint = collection.newStartPoint
+ const throwOrigin = collection.throwOrigin
+ const throwDirection = collection.throwDirection
+ const throwStrength = collection.throwStrength
// loop through the number of dice in the group and roll each one
parsedNotation.forEach(async notation => {
@@ -655,6 +675,9 @@ class WorldFacade {
this.#DiceWorld.add({
...roll,
newStartPoint,
+ throwOrigin,
+ throwDirection,
+ throwStrength,
theme: extendedTheme?.systemName || theme,
meshName: extendedTheme?.meshName || meshName,
colorSuffix
diff --git a/src/components/physics.worker.js b/src/components/physics.worker.js
index e4e9dd1..f42409c 100644
--- a/src/components/physics.worker.js
+++ b/src/components/physics.worker.js
@@ -80,10 +80,10 @@ self.onmessage = (e) => {
// toss from all edges
// setStartPosition()
if(e.data.options.newStartPoint){
- setStartPosition()
+ setStartPosition(e.data.options.throwOrigin)
}
const newDie = addDie(e.data.options)
- rollDie(newDie)
+ rollDie(newDie, e.data.options.throwDirection, e.data.options.throwStrength)
break;
case "rollDie":
// TODO: this won't work, need a die object
@@ -231,38 +231,54 @@ const setVector3 = (x,y,z) => {
return sharedVector3
}
-const setStartPosition = () => {
- let size = config.size
- // let envelopeSize = size * .6 / 2
- let edgeOffset = .5
- let xMin = size * aspect / 2 - edgeOffset
- let xMax = size * aspect / -2 + edgeOffset
- let yMin = size / 2 - edgeOffset
- let yMax = size / -2 + edgeOffset
- // let xEnvelope = lerp(envelopeSize * aspect - edgeOffset * aspect, -envelopeSize * aspect + edgeOffset * aspect, Math.random())
- let xEnvelope = lerp(xMin, xMax, Math.random())
- let yEnvelope = lerp(yMin, yMax, Math.random())
- let tossFromTop = Math.round(Math.random())
- let tossFromLeft = Math.round(Math.random())
- let tossX = Math.round(Math.random())
- // console.log(`throw coming from`, tossX ? tossFromTop ? "top" : "bottom" : tossFromLeft ? "left" : "right")
-
- // forces = {
- // xMinForce: tossX ? -config.throwForce * aspect : tossFromLeft ? config.throwForce * aspect * .3 : -config.throwForce * aspect * .3,
- // xMaxForce: tossX ? config.throwForce * aspect : tossFromLeft ? config.throwForce * aspect * 1 : -config.throwForce * aspect * 1,
- // zMinForce: tossX ? tossFromTop ? config.throwForce * .3 : -config.throwForce * .3 : -config.throwForce,
- // zMaxForce: tossX ? tossFromTop ? config.throwForce * 1 : -config.throwForce * 1 : config.throwForce,
- // }
-
- config.startPosition = [
- // tossing on x axis then z should be locked to top or bottom
- // not tossing on x axis then x should be locked to the left or right
- tossX ? xEnvelope : tossFromLeft ? xMax : xMin,
- config.startingHeight,
- tossX ? tossFromTop ? yMax : yMin : yEnvelope
- ]
-
- // console.log(`startPosition`, config.startPosition)
+const setStartPosition = (originOverride) => {
+ const size = config.size
+ const edgeOffset = .5
+ // axis extremes in the x/z plane. xRight/xLeft match prior xMin/xMax respectively;
+ // zNear/zFar match prior yMin/yMax (the variables were named as if looking down at the box).
+ const xRight = size * aspect / 2 - edgeOffset
+ const xLeft = size * aspect / -2 + edgeOffset
+ const zNear = size / 2 - edgeOffset // closer to camera (bottom of overhead view)
+ const zFar = size / -2 + edgeOffset // farther from camera (top of overhead view)
+ const y = config.startingHeight
+
+ const origin = originOverride !== undefined ? originOverride : config.throwOrigin
+
+ // explicit vector: [x, y?, z]
+ if (Array.isArray(origin)) {
+ config.startPosition = [
+ origin[0],
+ origin[1] == null ? y : origin[1], // null from structured clone of undefined
+ origin[2]
+ ]
+ return
+ }
+
+ const randX = () => lerp(xLeft, xRight, Math.random())
+ const randZ = () => lerp(zFar, zNear, Math.random())
+
+ let x, z
+ switch (origin) {
+ case 'center': x = 0; z = 0; break
+ case 'top': x = randX(); z = zFar; break
+ case 'bottom': x = randX(); z = zNear; break
+ case 'left': x = xLeft; z = randZ(); break
+ case 'right': x = xRight; z = randZ(); break
+ case 'topLeft': x = xLeft; z = zFar; break
+ case 'topRight': x = xRight; z = zFar; break
+ case 'bottomLeft': x = xLeft; z = zNear; break
+ case 'bottomRight': x = xRight; z = zNear; break
+ default: {
+ // 'random', undefined, or unknown string: preserve historical random-edge behavior
+ const tossFromTop = Math.round(Math.random())
+ const tossFromLeft = Math.round(Math.random())
+ const tossX = Math.round(Math.random())
+ x = tossX ? randX() : tossFromLeft ? xLeft : xRight
+ z = tossX ? (tossFromTop ? zFar : zNear) : randZ()
+ }
+ }
+
+ config.startPosition = [x, y, z]
}
const createConvexHull = (mesh) => {
@@ -459,14 +475,36 @@ const addDie = (options) => {
// rollDie(newDie)
}
-const rollDie = (die) => {
-
- // lerp picks a random number between two values
- die.setLinearVelocity(setVector3(
- lerp(-config.startPosition[0] * .5, -config.startPosition[0] * config.throwForce, Math.random()),
- lerp(-config.startPosition[1], -config.startPosition[1] * 2, Math.random()), // limit the y force to 2
- lerp(-config.startPosition[2] * .5, -config.startPosition[2] * config.throwForce, Math.random()),
- ))
+const rollDie = (die, directionOverride, strengthOverride) => {
+
+ const direction = directionOverride !== undefined ? directionOverride : config.throwDirection
+ const rawStrength = strengthOverride !== undefined ? strengthOverride : config.throwStrength
+ // clamp to a sane range: 0 would produce a dead drop, and runaway values
+ // would fling dice through the box walls.
+ const maxStrength = config.maxThrowStrength ?? 3
+ const strength = Math.max(0, Math.min(maxStrength, rawStrength == null ? 1 : rawStrength))
+ let vx, vy, vz
+ if (Array.isArray(direction)) {
+ // Use the provided direction as the throw vector. Normalize so magnitude is
+ // governed by throwForce and the origin's distance from center (matching the
+ // scale of the default-random behavior).
+ const [dx, dy = 0, dz] = direction
+ const mag = Math.hypot(dx, dy, dz) || 1
+ const nx = dx / mag, ny = dy / mag, nz = dz / mag
+ const baseMag = Math.hypot(config.startPosition[0], config.startPosition[2]) || config.size
+ const speed = baseMag * config.throwForce * strength
+ vx = lerp(nx * speed * .5, nx * speed, Math.random())
+ vz = lerp(nz * speed * .5, nz * speed, Math.random())
+ // preserve a downward drop plus any vertical component of the requested direction
+ vy = lerp(-config.startPosition[1], -config.startPosition[1] * 2, Math.random()) + ny * speed * 0.3
+ } else {
+ // default: throw toward the opposite side of the box
+ const force = config.throwForce * strength
+ vx = lerp(-config.startPosition[0] * .5, -config.startPosition[0] * force, Math.random())
+ vy = lerp(-config.startPosition[1], -config.startPosition[1] * 2, Math.random())
+ vz = lerp(-config.startPosition[2] * .5, -config.startPosition[2] * force, Math.random())
+ }
+ die.setLinearVelocity(setVector3(vx, vy, vz))
const flippy = Math.random() > .5 ? 1 : -1 // random positive or negative number
const spinny = lerp(config.spinForce * .5, config.spinForce, Math.random())
diff --git a/src/components/world.onscreen.js b/src/components/world.onscreen.js
index 6b00298..3377ab4 100644
--- a/src/components/world.onscreen.js
+++ b/src/components/world.onscreen.js
@@ -266,6 +266,9 @@ class WorldOnscreen {
scale: this.config.scale,
id: newDie.id,
newStartPoint: options.newStartPoint,
+ throwOrigin: options.throwOrigin,
+ throwDirection: options.throwDirection,
+ throwStrength: options.throwStrength,
theme: options.theme,
meshName: options.meshName,
}