diff --git a/src/components/DOM/BGM/index.jsx b/src/components/DOM/BGM/index.jsx index 2476bfc..510fac7 100644 --- a/src/components/DOM/BGM/index.jsx +++ b/src/components/DOM/BGM/index.jsx @@ -22,35 +22,45 @@ const BGM = () => { return `${minutes}:${seconds}`; }; + const safeDuration = trackDuration || 0; + return ( - <> - {bgmLoaded && ( -
- - - - - playFromPosition(parseFloat(e.target.value))} - /> - - {convertTime(playbackPosition)} / {convertTime(trackDuration)} - -
- )} - +
+ + + + + + playFromPosition(parseFloat(e.target.value))} + disabled={!bgmLoaded} + /> + + + {bgmLoaded + ? `${convertTime(playbackPosition)} / ${convertTime(trackDuration)}` + : "Loading..."} + +
); }; + export default BGM; diff --git a/src/components/DOM/DMenu/Options.jsx b/src/components/DOM/DMenu/Options.jsx index 32f88c8..752caf6 100644 --- a/src/components/DOM/DMenu/Options.jsx +++ b/src/components/DOM/DMenu/Options.jsx @@ -2,15 +2,15 @@ import { CompactPicker } from "react-color"; import { useState } from "react"; import Slider from "./Slider"; -import { useAudio, useDice } from "../../../contexts"; +import { useDice, useSFX } from "../../../contexts"; import styles from "./Options.module.scss"; import { validDice } from "../../../utils"; import Credits from "./Credits"; const Options = ({}) => { - const { volumes, updateVolume } = useAudio(); - const { diceAttributes, diceOptions, updateAttributes, updateOptions } = + const { volumes, updateVolume } = useSFX(); + const { diceAttributes, diceOptions, updateAttributes, updateOptions, gravity, setGravity } = useDice(); const [selectedForColor, setSelectedForColor] = useState("D4"); const [selectedForSize, setSelectedForSize] = useState("D4"); @@ -103,6 +103,15 @@ const Options = ({}) => { : diceAttributes.sizes[selectedForSize] ?? 0 } /> + setGravity([0, -value, 0])} + value={gravity ? -gravity[1] : 9.81} + /> {!diceOptions.globalSize && (
{validDice.map((d) => ( diff --git a/src/components/R3F/Dx/D10.jsx b/src/components/R3F/Dx/D10.jsx index d52fed1..9de9ed5 100644 --- a/src/components/R3F/Dx/D10.jsx +++ b/src/components/R3F/Dx/D10.jsx @@ -66,11 +66,7 @@ const D10 = (props) => { return retVal; }, [geometryArgs]); - return ( - - - - ); + return ; }; export default D10; diff --git a/src/components/R3F/Dx/D12.jsx b/src/components/R3F/Dx/D12.jsx index 6e271fb..df5c536 100644 --- a/src/components/R3F/Dx/D12.jsx +++ b/src/components/R3F/Dx/D12.jsx @@ -14,11 +14,7 @@ const D12 = (props) => { return retVal; }, []); - return ( - - - - ); + return ; }; export default D12; diff --git a/src/components/R3F/Dx/D20.jsx b/src/components/R3F/Dx/D20.jsx index 66cfb1c..9ba74e5 100644 --- a/src/components/R3F/Dx/D20.jsx +++ b/src/components/R3F/Dx/D20.jsx @@ -14,11 +14,7 @@ const D20 = (props) => { return retVal; }, []); - return ( - - - - ); + return ; }; export default D20; diff --git a/src/components/R3F/Dx/D4.jsx b/src/components/R3F/Dx/D4.jsx index b4466d1..54ab944 100644 --- a/src/components/R3F/Dx/D4.jsx +++ b/src/components/R3F/Dx/D4.jsx @@ -14,11 +14,7 @@ const D4 = (props) => { return retVal; }, []); - return ( - - - - ); + return ; }; export default D4; diff --git a/src/components/R3F/Dx/D6.jsx b/src/components/R3F/Dx/D6.jsx index 6e2b8bb..a96cf7f 100644 --- a/src/components/R3F/Dx/D6.jsx +++ b/src/components/R3F/Dx/D6.jsx @@ -56,11 +56,7 @@ const D6 = (props) => { return retVal; }, [geometryArgs]); - return ( - - - - ); + return ; }; export default D6; diff --git a/src/components/R3F/Dx/D8.jsx b/src/components/R3F/Dx/D8.jsx index b4c86ea..36e8060 100644 --- a/src/components/R3F/Dx/D8.jsx +++ b/src/components/R3F/Dx/D8.jsx @@ -14,11 +14,7 @@ const D8 = (props) => { return retVal; }, []); - return ( - - - - ); + return ; }; export default D8; diff --git a/src/components/R3F/Dx/Dx.jsx b/src/components/R3F/Dx/Dx.jsx index 776879c..d558a1f 100644 --- a/src/components/R3F/Dx/Dx.jsx +++ b/src/components/R3F/Dx/Dx.jsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useConvexPolyhedron } from "@react-three/cannon"; -import { Text } from "@react-three/drei"; import { Color } from "three"; -import { useAudio, useDice } from "../../../contexts"; +import { useSFX, useDiceActions } from "../../../contexts"; import { + applyAtlasUVs, CannonUtils, + getDiceTexture, randomAngularVelocity, randomRotation, randomVelocity, @@ -14,11 +15,14 @@ import { REST_INTERVAL, ZEROISH, } from "../../../utils"; -import font from "../../../../assets/fonts/TypeMachine.ttf"; + +const NO_TINT = new Color(1, 1, 1); +const REST_TINT = new Color(1.4, 1.4, 1.4); +const REST_HOVER_TINT = new Color(1.2, 1.2, 1.2); +const TEXT_SIZE = 0.4; const Dx = ({ id, - children, inertiaMod, geometry, position, @@ -30,17 +34,15 @@ const Dx = ({ shouldReroll, rerollTime, }) => { - const { playContactSFX } = useAudio(); - const { diceInPlay, diceOptions, onDieResolve, resetDie } = useDice(); + const { playContactSFX } = useSFX(); + const { diceOptions, onDieResolve, resetDie } = useDiceActions(); const [collidingPlane, setCollidingPlane] = useState(false); - const [lastContactId, setLastContactId] = useState(null); const [hovered, setHover] = useState(false); const [lowVelocity, setLowVelocity] = useState(false); const [atRest, setAtRest] = useState(false); const [roll, setRoll] = useState(null); const [shouldReset, setShouldReset] = useState(false); - let restInterval; - let rerollInterval; + const [texture, setTexture] = useState(null); const onCollideBegin = useCallback((e) => { if (e.body.geometry.type === "PlaneGeometry") { @@ -50,9 +52,6 @@ const Dx = ({ const onCollide = useCallback((e) => { playContactSFX(e.contact.impactVelocity); - if (lastContactId !== e.contact.id) { - setLastContactId(e.contact.id); - } }, []); const onCollideEnd = useCallback((e) => { @@ -78,10 +77,48 @@ const Dx = ({ () => CannonUtils.getCentroids(geometry), [geometry] ); - // const vertices = useMemo(() => CannonUtils.getVertices(geometry), [geometry]); const normals = useMemo(() => CannonUtils.getNormals(geometry), [geometry]); + const faceDirections = useMemo( + () => centroids.map((c) => c.clone().normalize()), + [centroids] + ); + + const cellWorldSize = useMemo( + () => applyAtlasUVs(geometry, centroids, normals), + [geometry, centroids, normals] + ); + const fontFraction = (TEXT_SIZE * radius) / cellWorldSize; + + useEffect(() => { + let active = true; + const highlightColor = + roll === null + ? null + : roll === 0 + ? "red" + : roll === centroids.length - 1 + ? "green" + : "blue"; + getDiceTexture({ + name: geometry.name, + faceCount: centroids.length, + color: `#${color.getHexString()}`, + textColor, + fontFraction, + highlightFace: roll, + highlightColor, + }).then((t) => { + if (active) { + setTexture(t); + } + }); + return () => { + active = false; + }; + }, [geometry.name, centroids.length, color, textColor, fontFraction, roll]); const resetRoll = useCallback(() => { + api.wakeUp(); setAtRest(false); setRoll(null); setHover(false); @@ -92,30 +129,36 @@ const Dx = ({ api.velocity.set(...randomVelocity()); api.angularVelocity.set(...randomAngularVelocity()); resetDie(id); - }, [api, resetDie]); + }, [api, id, resetDie]); useEffect(() => { - // onRest needs to be an effect, so the most up-to-date state and context are available - // the restInterval that triggers atRest captures a state from 500ms prior - if (atRest && diceInPlay[id] && !diceInPlay[id].resolved) { - api.velocity.set(0, 0, 0); + if (!atRest || roll !== null) { + return; + } + api.velocity.set(0, 0, 0); + api.sleep(); - const result = CannonUtils.getResult( - geometry.name, - ref.current.matrixWorld, - centroids - ); + const result = CannonUtils.getResult( + geometry.name, + ref.current.matrixWorld, + faceDirections + ); - const resultFudge = - result === 0 - ? "min" - : result === centroids.length - 1 - ? "max" - : "neutral"; - onDieResolve(id, result + 1, resultFudge); - setRoll(result); + // a cocked die has no readable face, so roll it again + if (result === null) { + resetRoll(); + return; } - }, [api, atRest, centroids, diceInPlay, onDieResolve]); + + const resultFudge = + result === 0 + ? "min" + : result === faceDirections.length - 1 + ? "max" + : "neutral"; + setRoll(result); + onDieResolve(id, result + 1, resultFudge); + }, [api, atRest, roll, faceDirections, geometry.name, id, onDieResolve, resetRoll]); useEffect(() => { // this effect checks the velocity of the die, and if any velocity values are low enough, @@ -127,10 +170,9 @@ const Dx = ({ Math.abs(velocity[1]) < inertiaFactor || Math.abs(velocity[2]) < inertiaFactor ) { - const x = Math.pow(Math.abs(velocity[0]), 2); - const y = Math.pow(Math.abs(velocity[1]), 2); - const z = Math.pow(Math.abs(velocity[2]), 2); - const magnitude = Math.sqrt(x + y + z); + const magnitude = Math.sqrt( + velocity[0] ** 2 + velocity[1] ** 2 + velocity[2] ** 2 + ); if (magnitude < inertiaFactor) { if (!lowVelocity) { setLowVelocity(true); @@ -144,34 +186,31 @@ const Dx = ({ }, [api, inertiaMod, lowVelocity]); useEffect(() => { - // this effect checks if the die is low velocity, - // then if it's resting on an acceptable surface, as set by the user - // if so, then it starts an restInterval/timer to see if that persists for half a second. - // if so, sets atRest to true + // once the die stays at low velocity on an acceptable surface + // for a full REST_INTERVAL, consider it at rest if ( - lowVelocity && - (!diceOptions.restOnTable || collidingPlane) && - !atRest + !lowVelocity || + atRest || + (diceOptions.restOnTable && !collidingPlane) ) { - restInterval = setInterval(() => { - setAtRest(true); - }, REST_INTERVAL); + return; } - return () => clearInterval(restInterval); - }, [atRest, collidingPlane, diceOptions, lowVelocity]); + const timer = setTimeout(() => setAtRest(true), REST_INTERVAL); + return () => clearTimeout(timer); + }, [atRest, collidingPlane, diceOptions.restOnTable, lowVelocity]); useEffect(() => { // this effect makes sure that any 'stuck' dice can still resolve, by rerolling // them after a timer - if (!atRest && shouldReroll) { - rerollInterval = setInterval(() => { - setShouldReset(true); - }, rerollTime * 1000); - } else { - clearInterval(rerollInterval); + if (atRest || !shouldReroll) { + return; } - return () => clearInterval(rerollInterval); - }, [atRest, shouldReroll]); + const interval = setInterval( + () => setShouldReset(true), + rerollTime * 1000 + ); + return () => clearInterval(interval); + }, [atRest, shouldReroll, rerollTime]); useEffect(() => { if (shouldReset) { @@ -182,74 +221,33 @@ const Dx = ({ useEffect(() => { // when the die first loads, spin it api.angularVelocity.set(...randomAngularVelocity()); - // reset the roll setRoll(null); }, []); - const assignColor = useCallback( - // this callback sets the text color based on roll state - // Nat 1 turns red - // Nat 20 turns green - (index) => { - if (index === roll) { - if (index === 0) return "red"; - if (index === centroids.length - 1) return "green"; - return "blue"; - } - }, - [roll] - ); - return ( - <> - { - if (atRest) { - resetRoll(); - } - }} - onPointerOver={(event) => setHover(true)} - onPointerOut={(event) => setHover(false)} - > - {children} + { + if (atRest) { + resetRoll(); + } + }} + onPointerOver={() => setHover(true)} + onPointerOut={() => setHover(false)} + > + {texture ? ( - {centroids.map((centroid, index) => { - // this quaternion represents a rotation - // equal to the orientation of the face normal - const quaternion = CannonUtils.calculateFaceQuaternion( - normals[index] - ); - - return ( - - {`${index + 1}` + `${index === 5 || index === 8 ? "." : ""}`} - - ); - })} - - + ) : ( + + )} + ); }; diff --git a/src/components/R3F/index.jsx b/src/components/R3F/index.jsx index 85f8c2e..eab486d 100644 --- a/src/components/R3F/index.jsx +++ b/src/components/R3F/index.jsx @@ -1,16 +1,15 @@ import { Suspense } from "react"; import { Physics } from "@react-three/cannon"; -import { OrbitControls, Text } from "@react-three/drei"; +import { OrbitControls } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; import { useDice } from "../../contexts"; import TablePlane from "./TablePlane"; -import font from "../../../assets/fonts/TypeMachine.ttf"; import styles from "./R3F.module.scss"; const R3F = () => { - const { diceInPlay } = useDice(); + const { diceInPlay, gravity } = useDice(); return (
{ > + - + {Object.keys(diceInPlay).map((d) => diceInPlay[d]?.component)} - {/* HACK(tb): for some stupid reason, Text needs to be prewarmed */} -
); diff --git a/src/contexts/AudioContext.jsx b/src/contexts/AudioContext.jsx index a6633d1..35a77fa 100644 --- a/src/contexts/AudioContext.jsx +++ b/src/contexts/AudioContext.jsx @@ -30,6 +30,15 @@ const AudioContext = createContext({ updateVolume: (key, value) => undefined, }); +// SFX callbacks are stable across renders, so consumers of this context +// (every die) don't re-render when BGM playback state ticks +const SFXContext = createContext({ + volumes: { current: defaultVolumes }, + playContactSFX: (impactVelocity) => undefined, + playRollResultSFX: (roll) => undefined, + updateVolume: (key, value) => undefined, +}); + const CONTACT_FACTOR = 20; const CONTACT_THRESHOLD = 0.0075; const CONTACT_DETUNE_RANGE = 300; @@ -42,6 +51,7 @@ export const AudioProvider = ({ children }) => { const volumes = useRef(defaultVolumes); const sfxListener = useMemo(() => new AudioListener(), []); + const bgmListener = useMemo(() => new AudioListener(), []); const allRollResultSFX = useMemo( () => ({ @@ -126,7 +136,7 @@ export const AudioProvider = ({ children }) => { // BEGIN BGM LOGIC const bgmBuffersRef = useRef([]); - const bgmAudioRef = useRef(new Audio(new AudioListener())); + const bgmAudioRef = useRef(new Audio(bgmListener)); const [bgmLoaded, setBGMLoaded] = useState(false); const [bgmPlaying, setBgmPlaying] = useState(false); const [currentTrack, setCurrentTrack] = useState(0); @@ -149,8 +159,8 @@ export const AudioProvider = ({ children }) => { bgmAudioRef.current.setBuffer(bgmBuffersRef.current[0]); bgmAudioRef.current.setVolume(0.5); bgmAudioRef.current.setLoop(true); - bgmAudioRef.current.play(); - setBgmPlaying(true); + // bgmAudioRef.current.pause(); + // setBgmPlaying(false); console.log("BGM loaded and playing"); }) .catch((err) => { @@ -221,24 +231,50 @@ export const AudioProvider = ({ children }) => { bgmAudioRef.current.setVolume(volumes.current.global * volumes.current.bgm); } + const sfxValue = useMemo( + () => ({ + volumes, + playContactSFX, + playRollResultSFX, + updateVolume, + }), + [playContactSFX, playRollResultSFX, updateVolume] + ); + + const audioValue = useMemo( + () => ({ + bgmLoaded, + bgmPlaying, + playbackPosition, + trackDuration, + volumes, + nextTrack, + playContactSFX, + playFromPosition, + playRollResultSFX, + togglePlayback, + updateVolume, + }), + [ + bgmLoaded, + bgmPlaying, + playbackPosition, + trackDuration, + nextTrack, + playContactSFX, + playFromPosition, + playRollResultSFX, + togglePlayback, + updateVolume, + ] + ); + return ( - - {children} - + + + {children} + + ); }; @@ -248,3 +284,7 @@ export function useAudio() { } return useContext(AudioContext); } + +export function useSFX() { + return useContext(SFXContext); +} diff --git a/src/contexts/DiceContext.jsx b/src/contexts/DiceContext.jsx index c561853..06c36de 100644 --- a/src/contexts/DiceContext.jsx +++ b/src/contexts/DiceContext.jsx @@ -7,7 +7,7 @@ import { } from "react"; import { Color } from "three"; -import { useAudio } from "./AudioContext"; +import { useSFX } from "./AudioContext"; import { diceComponents } from "../components/R3F/Dx"; import { @@ -27,12 +27,21 @@ export const DiceContext = createContext({ updateAttributes: (attribute, key, value) => undefined, }); +// stable subset consumed by every die, kept separate from DiceContext so +// dice don't re-render each time another die resolves +export const DiceActionsContext = createContext({ + diceOptions: defaultDiceOptions, + onDieResolve: (id, result, resultFudge) => undefined, + resetDie: (key) => undefined, +}); + export const DiceProvider = ({ children }) => { const [currentIndex, setCurrentIndex] = useState(0); const [diceAttributes, setDiceAttributes] = useState(defaultDiceAttributes); const [diceOptions, setDiceOptions] = useState(defaultDiceOptions); const [diceInPlay, setDiceInPlay] = useState({}); - const { playRollResultSFX } = useAudio(); + const { playRollResultSFX } = useSFX(); + const [gravity, setGravity] = useState([0, -9.8, 0]); const diceCounts = useMemo(() => { const total = Object.keys(diceInPlay).length; @@ -113,37 +122,31 @@ export const DiceProvider = ({ children }) => { const createDice = useCallback( (listToCreate, shouldClear) => { - let dice = diceInPlay; - if (shouldClear) { - dice = {}; - } - const addedDice = listToCreate.reduce( - (prev, cur, i) => ({ - ...prev, - [currentIndex + i]: createDx(cur, currentIndex + i), - }), - {} + const addedDice = {}; + listToCreate.forEach((dName, i) => { + addedDice[currentIndex + i] = createDx(dName, currentIndex + i); + }); + setDiceInPlay((prev) => + shouldClear ? addedDice : { ...prev, ...addedDice } ); - setDiceInPlay({ ...dice, ...addedDice }); setCurrentIndex(currentIndex + listToCreate.length); }, - [currentIndex, diceAttributes, diceInPlay, createDx] + [currentIndex, createDx] ); const onDieResolve = useCallback( (id, result, resultFudge) => { - try { - setDiceInPlay({ - ...diceInPlay, - [id]: { ...diceInPlay[id], resolved: true, resolveValue: result }, - }); - playRollResultSFX(resultFudge); - } catch (e) { - console.error(e); - return; - } + setDiceInPlay((prev) => + prev[id] + ? { + ...prev, + [id]: { ...prev[id], resolved: true, resolveValue: result }, + } + : prev + ); + playRollResultSFX(resultFudge); }, - [diceInPlay, playRollResultSFX] + [playRollResultSFX] ); const submitDiceFormula = useCallback( @@ -173,15 +176,16 @@ export const DiceProvider = ({ children }) => { submitDiceFormula(diceCounts.formula); }, [diceCounts.formula, submitDiceFormula]); - const resetDie = useCallback( - (key) => { - setDiceInPlay({ - ...diceInPlay, - [key]: { ...diceInPlay[key], resolved: false, resolveValue: -1 }, - }); - }, - [diceInPlay] - ); + const resetDie = useCallback((key) => { + setDiceInPlay((prev) => + prev[key] + ? { + ...prev, + [key]: { ...prev[key], resolved: false, resolveValue: -1 }, + } + : prev + ); + }, []); const clearBoard = useCallback(() => { setDiceInPlay({}); @@ -208,27 +212,49 @@ export const DiceProvider = ({ children }) => { [diceOptions] ); + const diceActions = useMemo( + () => ({ diceOptions, onDieResolve, resetDie }), + [diceOptions, onDieResolve, resetDie] + ); + + const diceValue = useMemo( + () => ({ + clearBoard, + createDice, + diceAttributes, + diceCounts, + diceInPlay, + diceOptions, + onDieResolve, + rerollBoard, + resetDie, + submitDiceFormula, + updateAttributes, + updateOptions, + gravity, + setGravity, + }), + [ + clearBoard, + createDice, + diceAttributes, + diceCounts, + diceInPlay, + diceOptions, + onDieResolve, + rerollBoard, + resetDie, + submitDiceFormula, + updateAttributes, + updateOptions, + gravity, + ] + ); + return ( - <> - - {children} - - + + {children} + ); }; @@ -238,3 +264,7 @@ export function useDice() { } return useContext(DiceContext); } + +export function useDiceActions() { + return useContext(DiceActionsContext); +} diff --git a/src/contexts/index.js b/src/contexts/index.js index 18a1bd4..e414eae 100644 --- a/src/contexts/index.js +++ b/src/contexts/index.js @@ -1,2 +1,2 @@ -export { AudioProvider, useAudio } from "./AudioContext"; -export { DiceProvider, useDice } from "./DiceContext"; \ No newline at end of file +export { AudioProvider, useAudio, useSFX } from "./AudioContext"; +export { DiceProvider, useDice, useDiceActions } from "./DiceContext"; diff --git a/src/utils/CannonUtils.jsx b/src/utils/CannonUtils.jsx index f93759b..8c7e672 100644 --- a/src/utils/CannonUtils.jsx +++ b/src/utils/CannonUtils.jsx @@ -4,6 +4,10 @@ import { Quaternion, Vector3 } from "three"; +const RESULT_THRESHOLD = 0.95; +const localUp = new Vector3(); +const inverseRotation = new Quaternion(); + class CannonUtils { // turns mesh information into physics compatible format static toConvexPolyhedronArgs(geometry) { @@ -188,28 +192,29 @@ class CannonUtils { return quaternion; } - // returns the roll result by calculating dot product (a • b) - // where a = (center - centroid) and b = up - static getResult(name, mat, centroids) { - const worldCenter = new Vector3(0, 0, 0).applyMatrix4(mat); - const trueVertical = - name === "D4" ? new Vector3(0, -1, 0) : new Vector3(0, 1, 0); - let largestDotProd = -Infinity; - let result; - - centroids.map((c, index) => { - const worldPosition = new Vector3(c.x, c.y, c.z).applyMatrix4(mat); - const direction = new Vector3() - .subVectors(worldPosition, worldCenter) - .normalize(); - const dotProd = direction.dot(trueVertical); - if (dotProd > largestDotProd) { - largestDotProd = dotProd; - result = index; + // transforms world up (down for D4, which reads its bottom face) into the + // die's local space, then finds the face direction most aligned with it. + // returns null when no face is aligned enough, i.e. the die is cocked. + // rotation comes from matrixWorld because @react-three/cannon writes the + // object's matrix directly and never updates position/quaternion + static getResult(name, matrixWorld, faceDirections) { + localUp + .set(0, name === "D4" ? -1 : 1, 0) + .applyQuaternion( + inverseRotation.setFromRotationMatrix(matrixWorld).invert() + ); + + let best = -Infinity; + let result = null; + for (let i = 0; i < faceDirections.length; i++) { + const dot = faceDirections[i].dot(localUp); + if (dot > best) { + best = dot; + result = i; } - }); + } - return result; + return best >= RESULT_THRESHOLD ? result : null; } } diff --git a/src/utils/DiceTextures.js b/src/utils/DiceTextures.js new file mode 100644 index 0000000..9da5132 --- /dev/null +++ b/src/utils/DiceTextures.js @@ -0,0 +1,152 @@ +import { BufferAttribute, CanvasTexture, Quaternion, SRGBColorSpace, Vector3 } from "three"; + +import CannonUtils from "./CannonUtils"; + +import fontUrl from "../../assets/fonts/TypeMachine.ttf"; + +const CELL_SIZE = 128; +// keeps face UVs just inside their atlas cell so linear filtering +// doesn't sample the neighboring cell +const FACE_INSET = 0.98; + +const textureCache = new Map(); +let fontPromise = null; + +const loadFont = () => { + if (!fontPromise) { + fontPromise = new FontFace("TypeMachine", `url("${fontUrl}")`) + .load() + .then((font) => { + document.fonts.add(font); + return font; + }) + .catch((err) => { + console.error("Could not load dice font, falling back: ", err); + return null; + }); + } + return fontPromise; +}; + +const atlasLayout = (faceCount) => { + const cols = Math.ceil(Math.sqrt(faceCount)); + const rows = Math.ceil(faceCount / cols); + return { cols, rows }; +}; + +const faceLabel = (index) => + `${index + 1}${index === 5 || index === 8 ? "." : ""}`; + +// draws one number-atlas texture per unique combination of inputs, +// shared across all dice that use the same combination +export const getDiceTexture = async ({ + name, + faceCount, + color, + textColor, + fontFraction, + highlightFace = null, + highlightColor = null, +}) => { + const key = [ + name, + faceCount, + color, + textColor, + fontFraction.toFixed(3), + highlightFace, + highlightColor, + ].join("|"); + if (textureCache.has(key)) { + return textureCache.get(key); + } + + await loadFont(); + if (textureCache.has(key)) { + return textureCache.get(key); + } + + const { cols, rows } = atlasLayout(faceCount); + const canvas = document.createElement("canvas"); + canvas.width = cols * CELL_SIZE; + canvas.height = rows * CELL_SIZE; + + const ctx = canvas.getContext("2d"); + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${Math.round(CELL_SIZE * fontFraction)}px TypeMachine, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + + for (let i = 0; i < faceCount; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const label = faceLabel(i); + // center on the visible glyph box rather than the em box + const metrics = ctx.measureText(label); + const centerOffset = + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; + ctx.fillStyle = i === highlightFace ? highlightColor : textColor; + ctx.fillText( + label, + (col + 0.5) * CELL_SIZE, + (row + 0.5) * CELL_SIZE + centerOffset + ); + } + + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + texture.anisotropy = 4; + textureCache.set(key, texture); + return texture; +}; + +// maps each logical face of the geometry into its atlas cell; all faces +// share one scale so numbers render the same size on every face. +// returns the world-space width one atlas cell spans, for sizing the +// font in world units +export const applyAtlasUVs = (geometry, centroids, normals) => { + const position = geometry.attributes.position; + const faceCount = centroids.length; + const groupSize = geometry.groupSize || 1; + const { cols, rows } = atlasLayout(faceCount); + const uvs = new Float32Array(position.count * 2); + const toFacePlane = new Quaternion(); + const point = new Vector3(); + const faceLocals = []; + let extent = 0; + + for (let f = 0; f < faceCount; f++) { + toFacePlane + .copy(CannonUtils.calculateFaceQuaternion(normals[f])) + .invert(); + + const start = f * groupSize * 3; + const count = groupSize * 3; + const locals = []; + + for (let v = 0; v < count; v++) { + point + .fromBufferAttribute(position, start + v) + .sub(centroids[f]) + .applyQuaternion(toFacePlane); + locals.push([point.x, point.y]); + extent = Math.max(extent, Math.abs(point.x), Math.abs(point.y)); + } + faceLocals.push(locals); + } + + for (let f = 0; f < faceCount; f++) { + const start = f * groupSize * 3; + const col = f % cols; + const row = Math.floor(f / cols); + faceLocals[f].forEach(([x, y], v) => { + uvs[(start + v) * 2] = (col + 0.5 + (x / extent) * 0.5 * FACE_INSET) / cols; + uvs[(start + v) * 2 + 1] = + (rows - 1 - row + 0.5 + (y / extent) * 0.5 * FACE_INSET) / rows; + }); + } + + geometry.setAttribute("uv", new BufferAttribute(uvs, 2)); + return (2 * extent) / FACE_INSET; +}; diff --git a/src/utils/Vec3Utils.jsx b/src/utils/Vec3Utils.jsx index 52b0629..0664fb7 100644 --- a/src/utils/Vec3Utils.jsx +++ b/src/utils/Vec3Utils.jsx @@ -20,8 +20,8 @@ export const randomAngularVelocity = () => { export const randomSpawnPosition = () => { return [ - Math.random() * 10 - 6, + Math.random() * 25 - 10, Math.random() * 7 + 3, - Math.random() * 10 - 6, + Math.random() * 25 - 10, ]; }; diff --git a/src/utils/index.js b/src/utils/index.js index e0449aa..72f3a69 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,4 +1,5 @@ import CannonUtils from "./CannonUtils"; +import { applyAtlasUVs, getDiceTexture } from "./DiceTextures"; import { defaultDiceAttributes, defaultDiceOptions, @@ -23,10 +24,12 @@ import { } from "./Vec3Utils"; export { + applyAtlasUVs, CannonUtils, defaultDiceAttributes, defaultDiceOptions, defaultVolumes, + getDiceTexture, D4_CONST, D6_CONST, D8_CONST,