From ed8b4e1028247caeb9e838b2539d09a9a075d61f Mon Sep 17 00:00:00 2001 From: hearthplaygamesllc Date: Sun, 19 Apr 2026 23:05:36 -0400 Subject: [PATCH 1/2] added origin from which to throw and clamp max throw strength. Co-Authored-By: Claude Opus 4.7 --- drag-demo/index.html | 40 ++++++++++ drag-demo/main.js | 125 +++++++++++++++++++++++++++++++ drag-demo/vite.config.js | 18 +++++ package.json | 1 + src/WorldFacade.js | 39 ++++++++-- src/components/physics.worker.js | 122 +++++++++++++++++++----------- src/components/world.onscreen.js | 3 + 7 files changed, 298 insertions(+), 50 deletions(-) create mode 100644 drag-demo/index.html create mode 100644 drag-demo/main.js create mode 100644 drag-demo/vite.config.js 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, } From 98676f3f1da7912a07c37dee410005344f04d3c0 Mon Sep 17 00:00:00 2001 From: hearthplaygamesllc Date: Sun, 19 Apr 2026 23:06:47 -0400 Subject: [PATCH 2/2] chore: ignore .claude directory Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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