diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..1d6b0a7
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "tailwindCSS.experimental.configFile": "src/app/globals.css"
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 05e726d..e1896a6 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -13,6 +13,11 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
+ {
+ rules: {
+ "@typescript-eslint/no-explicit-any": "warn"
+ }
+ }
]);
export default eslintConfig;
diff --git a/package-lock.json b/package-lock.json
index 878168a..ec74021 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,15 @@
"name": "dndextended",
"version": "0.1.0",
"dependencies": {
+ "@iconify-react/fa6-solid": "^1.0.2",
+ "@iconify-react/game-icons": "^1.0.3",
+ "@iconify-react/mdi": "^1.0.2",
+ "@react-three/drei": "^10.7.7",
+ "@react-three/fiber": "^9.6.1",
"next": "16.2.10",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "three": "^0.185.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -228,6 +234,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -276,6 +291,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -519,6 +540,54 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@iconify-react/fa6-solid": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@iconify-react/fa6-solid/-/fa6-solid-1.0.2.tgz",
+ "integrity": "sha512-Vv+G/RGv1SWdmo5Pq4jf2tghJQxWeTOsZsEQ+o4mFFdlwC36QDxqU3hX4UxqvFj+We/CH5HO7npCjXRIO1jh7A==",
+ "license": "CC-BY-4.0",
+ "dependencies": {
+ "@iconify/css-react": "^1.0.1"
+ }
+ },
+ "node_modules/@iconify-react/game-icons": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@iconify-react/game-icons/-/game-icons-1.0.3.tgz",
+ "integrity": "sha512-Xy5KKYawM2ViuylVtCiu2v6K6MUIlVS6o4WPrNTJDZyS3c5XJ7JJs++OoIFpxLA9LmIbZXCKbLgHr+maswoohg==",
+ "license": "CC-BY-3.0",
+ "dependencies": {
+ "@iconify/css-react": "^1.0.1"
+ }
+ },
+ "node_modules/@iconify-react/mdi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@iconify-react/mdi/-/mdi-1.0.2.tgz",
+ "integrity": "sha512-p7YgR1oh8523fLQydG6cu53I/de5EkAnsbusQVJSqb6PYmN2MiKO9ovo/RkBV/jQNx+hHRG5VHM1YWpUfJeUFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@iconify/css-react": "^1.0.1"
+ }
+ },
+ "node_modules/@iconify/css-react": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@iconify/css-react/-/css-react-1.0.2.tgz",
+ "integrity": "sha512-k33FKG4jGgd8odos+1HdSwCKZOQ5F1heQxwl/zDrA53tLg5IseDPMqsmQbcCEZ6E0Zgcrmfo4ceFSBmbuEvGtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iconify/types": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyberalien"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "license": "MIT"
+ },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1083,6 +1152,24 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mediapipe/tasks-vision": {
+ "version": "0.10.17",
+ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
+ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
+ "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1306,6 +1393,94 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@react-three/drei": {
+ "version": "10.7.7",
+ "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
+ "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^3.1.0",
+ "cross-env": "^7.0.3",
+ "detect-gpu": "^5.0.56",
+ "glsl-noise": "^0.0.0",
+ "hls.js": "^1.5.17",
+ "maath": "^0.10.8",
+ "meshline": "^3.3.1",
+ "stats-gl": "^2.2.8",
+ "stats.js": "^0.17.0",
+ "suspend-react": "^0.1.3",
+ "three-mesh-bvh": "^0.8.3",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.4",
+ "tunnel-rat": "^0.1.2",
+ "use-sync-external-store": "^1.4.0",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": "^9.0.0",
+ "react": "^19",
+ "react-dom": "^19",
+ "three": ">=0.159"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
+ "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.17.8",
+ "@types/webxr": "*",
+ "base64-js": "^1.5.1",
+ "buffer": "^6.0.3",
+ "its-fine": "^2.0.0",
+ "react-use-measure": "^2.1.7",
+ "scheduler": "^0.27.0",
+ "suspend-react": "^0.1.3",
+ "use-sync-external-store": "^1.4.0",
+ "zustand": "^5.0.3"
+ },
+ "peerDependencies": {
+ "expo": ">=43.0",
+ "expo-asset": ">=8.4",
+ "expo-file-system": ">=11.0",
+ "expo-gl": ">=11.0",
+ "react": ">=19 <19.3",
+ "react-dom": ">=19 <19.3",
+ "react-native": ">=0.78",
+ "three": ">=0.156"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-asset": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-gl": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1605,6 +1780,12 @@
"tailwindcss": "4.3.2"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -1616,6 +1797,12 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/draco3d": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
+ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1647,11 +1834,16 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/offscreencanvas": {
+ "version": "2019.7.3",
+ "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
+ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1667,6 +1859,41 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.185.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.0.tgz",
+ "integrity": "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
@@ -2316,6 +2543,24 @@
"win32"
]
},
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -2609,6 +2854,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.42",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
@@ -2621,6 +2886,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
@@ -2679,6 +2953,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2739,6 +3037,19 @@
"node": ">=6"
}
},
+ "node_modules/camera-controls": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
+ "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.0.0",
+ "npm": ">=10.5.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.126.1"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001803",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
@@ -2816,11 +3127,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "bin": {
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -2835,7 +3163,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -2960,6 +3287,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/detect-gpu": {
+ "version": "5.0.70",
+ "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
+ "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
+ "license": "MIT",
+ "dependencies": {
+ "webgl-constants": "^1.1.1"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2983,6 +3319,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/draco3d": {
+ "version": "1.5.7",
+ "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
+ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3715,6 +4057,12 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3972,6 +4320,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/glsl-noise": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
+ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
+ "license": "MIT"
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4103,6 +4457,32 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4113,6 +4493,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4441,6 +4827,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4597,7 +4989,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/iterator.prototype": {
@@ -4618,6 +5009,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/its-fine": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
+ "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.9"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -4765,6 +5168,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -5084,6 +5496,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/maath": {
+ "version": "0.10.8",
+ "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
+ "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/three": ">=0.134.0",
+ "three": ">=0.134.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5114,6 +5536,21 @@
"node": ">= 8"
}
},
+ "node_modules/meshline": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
+ "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -5527,7 +5964,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5598,6 +6034,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/potpack": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
+ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
+ "license": "ISC"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5608,6 +6050,16 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5679,6 +6131,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5723,6 +6190,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -5984,7 +6460,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -5997,7 +6472,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6095,6 +6569,32 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stats-gl": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
+ "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/three": "*",
+ "three": "^0.170.0"
+ },
+ "peerDependencies": {
+ "@types/three": "*",
+ "three": "*"
+ }
+ },
+ "node_modules/stats-gl/node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/stats.js": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
+ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
+ "license": "MIT"
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6295,6 +6795,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/suspend-react": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
@@ -6316,6 +6825,44 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/three": {
+ "version": "0.185.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
+ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
+ "license": "MIT"
+ },
+ "node_modules/three-mesh-bvh": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
+ "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
+ "node_modules/three-stdlib": {
+ "version": "2.36.1",
+ "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
+ "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/draco3d": "^1.4.0",
+ "@types/offscreencanvas": "^2019.6.4",
+ "@types/webxr": "^0.5.2",
+ "draco3d": "^1.4.1",
+ "fflate": "^0.6.9",
+ "potpack": "^1.0.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.128.0"
+ }
+ },
+ "node_modules/three-stdlib/node_modules/fflate": {
+ "version": "0.6.10",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
+ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6377,6 +6924,36 @@
"node": ">=8.0"
}
},
+ "node_modules/troika-three-text": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
+ "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.2",
+ "troika-three-utils": "^0.52.4",
+ "troika-worker-utils": "^0.52.0",
+ "webgl-sdf-generator": "1.1.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-three-utils": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
+ "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-worker-utils": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
+ "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -6422,6 +6999,43 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tunnel-rat": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zustand": "^4.3.2"
+ }
+ },
+ "node_modules/tunnel-rat/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6656,11 +7270,39 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/utility-types": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
+ "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/webgl-constants": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+ "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
+ },
+ "node_modules/webgl-sdf-generator": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
+ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -6813,6 +7455,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.14",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
+ "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index bfe2b67..05dc066 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,15 @@
"lint": "eslint"
},
"dependencies": {
+ "@iconify-react/fa6-solid": "^1.0.2",
+ "@iconify-react/game-icons": "^1.0.3",
+ "@iconify-react/mdi": "^1.0.2",
+ "@react-three/drei": "^10.7.7",
+ "@react-three/fiber": "^9.6.1",
"next": "16.2.10",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "three": "^0.185.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -22,5 +28,9 @@
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
+ },
+ "allowScripts": {
+ "unrs-resolver@1.12.2": true,
+ "sharp@0.34.5": true
}
}
diff --git a/src/app/content.tsx b/src/app/content.tsx
new file mode 100644
index 0000000..7c75f0c
--- /dev/null
+++ b/src/app/content.tsx
@@ -0,0 +1,9 @@
+export default function Content() {
+ return (
+ <>
+
+ Hello World
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
index a2dc41e..5ee8472 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,26 +1,53 @@
@import "tailwindcss";
-:root {
- --background: #ffffff;
- --foreground: #171717;
+@theme {
+ --color-bg: #1a1512;
+ --color-surface: #2a2420;
+ --color-ink: #f0e6d3;
+ --color-dim: #9e8e7a;
+ --color-gold: #d4a84b;
+ --color-gold-glow: #e6c06a;
+ --color-rust: #8b3a3a;
}
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+* {
+ box-sizing: border-box;
}
+html,
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ overflow: hidden;
+ background: var(--color-bg);
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+ color: var(--color-ink);
}
+
+#scene {
+ pointer-events: none;
+ -moz-user-select: none;
+ user-select: none;
+ position: fixed;
+ inset: 0;
+ z-index: 1;
+}
+
+canvas {
+ pointer-events: none;
+ -moz-user-select: none;
+ user-select: none;
+ display: block;
+}
+
+@keyframes morphIn {
+ from {
+ opacity: 0;
+ transform: scaleX(0.3) scaleY(0.3) translateY(-50%);
+ }
+ to {
+ opacity: 1;
+ transform: scaleX(1) scaleY(1) translateY(-50%);
+ }
+}
\ No newline at end of file
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3f36f7c..55e4218 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,65 +1,31 @@
-import Image from "next/image";
+"use client";
+import { useEffect, useRef, useState } from "react";
+import dynamic from "next/dynamic";
+import type { DiceRollerHandle, RollResult } from "@/components/DiceRoller";
+import { Button } from "@/components/DiceSelector";
+
+const DiceRoller = dynamic(() => import("@/components/DiceRoller"), {
+ ssr: false,
+});
export default function Home() {
+ const rollerRef = useRef(null);
+
+ const [savedRolls, setSavedRolls] = useState([])
+
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
+ <>
+
+
+ >
);
}
diff --git a/src/components/DiceRoller.tsx b/src/components/DiceRoller.tsx
new file mode 100644
index 0000000..ddf5e7a
--- /dev/null
+++ b/src/components/DiceRoller.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import React, { forwardRef, useImperativeHandle } from 'react';
+import { useDiceRoller, RollResult } from '../hooks/useDiceRoller';
+
+export type { RollResult };
+
+export interface DiceRollerHandle {
+ roll(pool: Record
): Promise;
+ isReady: boolean;
+ isRolling: boolean;
+}
+
+interface DiceRollerProps {
+ id?: string;
+ className?: string;
+}
+
+export const DiceRoller = forwardRef(
+ ({ id, className }, ref) => {
+ const { mountRef, rollDice, isReady, isRolling } = useDiceRoller();
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ roll: rollDice,
+ isReady,
+ isRolling,
+ }),
+ [rollDice, isReady, isRolling]
+ );
+
+ return ;
+ }
+);
+
+DiceRoller.displayName = 'DiceRoller';
+
+export default DiceRoller;
diff --git a/src/components/DiceSelector.tsx b/src/components/DiceSelector.tsx
new file mode 100644
index 0000000..adf8cbe
--- /dev/null
+++ b/src/components/DiceSelector.tsx
@@ -0,0 +1,105 @@
+import DiceD20FA6 from '@iconify-react/fa6-solid/dice-d20';
+import DiceD4Icon from '@iconify-react/mdi/dice-d4';
+import DiceD6Icon from '@iconify-react/mdi/dice-d6';
+import DiceD8Icon from '@iconify-react/mdi/dice-d8';
+import DiceD10Icon from '@iconify-react/mdi/dice-d10';
+import DiceD12Icon from '@iconify-react/mdi/dice-d12';
+import DiceD20Icon from '@iconify-react/mdi/dice-d20';
+import { RefObject, useState } from 'react';
+import { DiceRollerHandle } from './DiceRoller';
+import { div } from 'three/tsl';
+
+const diceIcons = {
+ 4: DiceD4Icon,
+ 6: DiceD6Icon,
+ 8: DiceD8Icon,
+ 10: DiceD10Icon,
+ 12: DiceD12Icon,
+ 20: DiceD20Icon
+};
+
+interface ButtonProps {
+ rollerRef: RefObject;
+}
+
+export function Button({ rollerRef }: ButtonProps) {
+ const [open, setOpen] = useState(false);
+
+ const [dice, setDice] = useState>({
+ 4: 0,
+ 6: 0,
+ 8: 0,
+ 10: 0,
+ 12: 0,
+ 20: 0
+ });
+
+ const increment = (sides: number) => {
+ setDice(prev => ({
+ ...prev,
+ [sides]: prev[sides] + 1
+ }));
+ };
+
+ const decrement = (sides: number) => {
+ setDice(prev => ({
+ ...prev,
+ [sides]: Math.max(0, prev[sides] - 1)
+ }));
+ };
+
+ function handleOpen() {
+ if (open)
+ handleRoll();
+
+ setOpen(v => !v);
+ }
+
+ async function handleRoll() {
+ if (!rollerRef || !rollerRef.current) return;
+
+ const diceToRoll = dice;
+
+ setDice({
+ 4: 0,
+ 6: 0,
+ 8: 0,
+ 10: 0,
+ 12: 0,
+ 20: 0
+ });
+
+ const res = rollerRef.current.roll(diceToRoll);
+ console.log(res);
+ }
+
+ return (
+
+
+ {
+ !open || (
+
+
+ {Object.entries(diceIcons).map(([sides, Die]) => {
+ const n = parseInt(sides);
+ return (
+
+ );
+ })}
+
+
+
+
+
+ )
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/RollCard.tsx b/src/components/RollCard.tsx
new file mode 100644
index 0000000..4879990
--- /dev/null
+++ b/src/components/RollCard.tsx
@@ -0,0 +1,3 @@
+export default function RollCard({ roll }: { roll: unknown }) {
+
+}
\ No newline at end of file
diff --git a/src/components/Scene.tsx b/src/components/Scene.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/src/hooks/useDiceRoller.ts b/src/hooks/useDiceRoller.ts
new file mode 100644
index 0000000..774e96f
--- /dev/null
+++ b/src/hooks/useDiceRoller.ts
@@ -0,0 +1,91 @@
+'use client';
+
+import { useRef, useState, useCallback, useEffect } from 'react';
+import * as THREE from 'three';
+import {
+ initScene,
+ Die,
+ DieInstance,
+ setDice,
+ stopRenderLoop,
+ initRenderLoop,
+ getW,
+ getD,
+} from '../lib/dicePhysics';
+
+export interface RollResult {
+ sides: number;
+ value: number;
+}
+
+export function useDiceRoller() {
+ const mountRef = useRef(null);
+ const [isReady, setIsReady] = useState(false);
+ const [isRolling, setIsRolling] = useState(false);
+ const sceneInitRef = useRef(false);
+
+ useEffect(() => {
+ if (!mountRef.current || sceneInitRef.current) return;
+ sceneInitRef.current = true;
+ initScene(mountRef.current);
+ initRenderLoop();
+
+ const timer = setTimeout(() => setIsReady(true), 300);
+
+ return () => {
+ clearTimeout(timer);
+ stopRenderLoop();
+ };
+ }, []);
+
+ const rollDice = useCallback(
+ (pool: Record): Promise => {
+ return new Promise((resolve) => {
+ const totalDice = Object.values(pool).reduce((a, b) => a + b, 0);
+ if (totalDice === 0) {
+ resolve([]);
+ return;
+ }
+
+ setIsRolling(true);
+
+ const newDice: DieInstance[] = [];
+ const W = getW();
+ const D = getD();
+ let idx = 0;
+ const order = [20, 12, 10, 8, 6, 4];
+
+ order.forEach((sides) => {
+ const count = pool[sides] || 0;
+ for (let i = 0; i < count; i++) {
+ const die = new Die(sides);
+ const side = idx % 2 === 0 ? -1 : 1;
+ const x = side * (W - 0.8);
+ const z = D - 1 - (idx % 3) * 1.1;
+ const dir = new THREE.Vector3(
+ -side * (9 + Math.random() * 4),
+ 0.5,
+ -(6 + Math.random() * 4)
+ );
+ die.throwFrom(x, z, dir);
+ die.pos.x -= side * idx * 0.15;
+ newDice.push(die);
+ idx++;
+ }
+ });
+
+ setDice(newDice, (completedDice) => {
+ const results: RollResult[] = completedDice.map((d) => ({
+ sides: d.sides,
+ value: d.value ?? 0,
+ }));
+ setIsRolling(false);
+ resolve(results);
+ });
+ });
+ },
+ []
+ );
+
+ return { mountRef, rollDice, isReady, isRolling };
+}
diff --git a/src/lib/dicePhysics.ts b/src/lib/dicePhysics.ts
new file mode 100644
index 0000000..28d78f5
--- /dev/null
+++ b/src/lib/dicePhysics.ts
@@ -0,0 +1,1031 @@
+import * as THREE from 'three';
+
+// ---------- DISSOLVE SHADER ----------
+const DISSOLVE_VERTEX = `
+ varying vec3 vPos;
+ varying vec3 vNormal;
+ void main() {
+ vPos = position;
+ vNormal = normalize(normalMatrix * normal);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+`;
+
+const DISSOLVE_FRAGMENT = `
+ uniform float dissolveAmount;
+ uniform vec3 baseColor;
+ uniform vec3 edgeColor;
+ uniform vec3 lightDir;
+
+ varying vec3 vPos;
+ varying vec3 vNormal;
+
+ float hash(vec3 p) {
+ p = fract(p * 0.3183099 + 0.1);
+ p *= 17.0;
+ return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
+ }
+
+ float noise(vec3 p) {
+ vec3 i = floor(p);
+ vec3 f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+ return mix(
+ mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
+ mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
+ mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
+ mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), f.z);
+ }
+
+ void main() {
+ float n = noise(vPos * 3.0);
+ if (n < dissolveAmount) discard;
+
+ float diff = max(0.0, dot(vNormal, normalize(lightDir))) * 0.7 + 0.3;
+ float edge = 1.0 - smoothstep(0.0, 0.12, n - dissolveAmount);
+ vec3 color = mix(edgeColor, baseColor * diff, edge);
+
+ gl_FragColor = vec4(color, 1.0);
+ }
+`;
+
+// ---------- CONFIGURATION ----------
+export const DIE_SCALE = 0.85;
+export const DIE_COLOR = 0xb1262b;
+export const DIE_ROUGHNESS = 0.32;
+export const DIE_METALNESS = 0.12;
+export const NUMBER_COLOR = '#f5efe2';
+export const EDGE_COLOR = 0xe8b56a;
+export const EDGE_OPACITY = 0.28;
+
+// ---------- PHYSICS PARAMETERS ----------
+const G = 22;
+const MASS = 1;
+const LIN_DAMP = 0.998;
+const ANG_DAMP = 0.995;
+const RESTITUTION = 0.35;
+const FRICTION = 0.45;
+
+// ---------- Type Definitions ----------
+export interface Plane {
+ n: THREE.Vector3;
+ d: number;
+ e: number;
+ mu: number;
+}
+
+export interface FaceData {
+ normal: THREE.Vector3;
+ value: number;
+}
+
+export interface DieData {
+ mesh: THREE.Mesh;
+ hull: THREE.Vector3[];
+ radius: number;
+ faces: FaceData[];
+ sides: number;
+ topRead: boolean;
+ vertVals?: number[];
+ hullRef?: THREE.Vector3[];
+}
+
+export interface DiePhysicsState {
+ pos: THREE.Vector3;
+ vel: THREE.Vector3;
+ angVel: THREE.Vector3;
+ quat: THREE.Quaternion;
+ still: number;
+ state: 'live' | 'snapping' | 'done';
+ value: number | null;
+ I: number;
+ snapFrom?: THREE.Quaternion;
+ snapTo?: THREE.Quaternion;
+ snapY0?: number;
+ snapY1?: number;
+ snapT?: number;
+ dissolveAmount: number;
+}
+
+export interface DieInstance extends DieData, DiePhysicsState {
+ throwFrom(x: number, z: number, dir: THREE.Vector3): void;
+ step(dt: number): void;
+ beginSnap(): void;
+ snapStep(dt: number): void;
+ sync(): void;
+ dispose(): void;
+ startDissolve(): void;
+ dissolveStep(dt: number): void;
+}
+
+// ---------- Three.js Setup ----------
+let renderer: THREE.WebGLRenderer | null = null;
+let scene: THREE.Scene | null = null;
+let camera: THREE.PerspectiveCamera | null = null;
+let W = 7.0;
+let D = 4.6;
+let audioCtx: AudioContext | null = null;
+
+const PLANES: Plane[] = [
+ { n: new THREE.Vector3(0, 1, 0), d: 0, e: RESTITUTION, mu: FRICTION },
+ { n: new THREE.Vector3(1, 0, 0), d: W, e: 0.3, mu: 0.1 },
+ { n: new THREE.Vector3(-1, 0, 0), d: W, e: 0.3, mu: 0.1 },
+ { n: new THREE.Vector3(0, 0, 1), d: D, e: 0.3, mu: 0.1 },
+ { n: new THREE.Vector3(0, 0, -1), d: D, e: 0.3, mu: 0.1 },
+];
+
+const CAM_H = 16;
+const tmpV = new THREE.Vector3();
+const wp = new THREE.Vector3();
+const rr2 = new THREE.Vector3();
+const pv = new THREE.Vector3();
+const rxn = new THREE.Vector3();
+const imp = new THREE.Vector3();
+const trq = new THREE.Vector3();
+const vt = new THREE.Vector3();
+const dq = new THREE.Quaternion();
+
+// ---------- Scene Initialization ----------
+export function initScene(mount: HTMLDivElement): {
+ renderer: THREE.WebGLRenderer;
+ scene: THREE.Scene;
+ camera: THREE.PerspectiveCamera;
+} {
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFShadowMap;
+ mount.appendChild(renderer.domElement);
+
+ scene = new THREE.Scene();
+
+ camera = new THREE.PerspectiveCamera(
+ 42,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 100
+ );
+ frameCamera();
+
+ // Lighting
+ scene.add(new THREE.HemisphereLight(0x8a93a8, 0x20160f, 0.65));
+ const sun = new THREE.DirectionalLight(0xfff2dc, 1.15);
+ sun.position.set(6, 14, 5);
+ sun.castShadow = true;
+ sun.shadow.mapSize.width = 2048;
+ sun.shadow.mapSize.height = 2048;
+ sun.shadow.camera.near = 0.5;
+ sun.shadow.camera.far = 30;
+ sun.shadow.camera.left = -12;
+ sun.shadow.camera.right = 12;
+ sun.shadow.camera.top = 12;
+ sun.shadow.camera.bottom = -12;
+ sun.shadow.bias = -0.0008;
+ sun.shadow.normalBias = 0.02;
+ scene.add(sun);
+
+ const fillLight = new THREE.DirectionalLight(0x8a9aaa, 0.3);
+ fillLight.position.set(-4, 8, -3);
+ scene.add(fillLight);
+
+ window.addEventListener('resize', () => {
+ if (renderer) renderer.setSize(window.innerWidth, window.innerHeight);
+ frameCamera();
+ });
+
+ return { renderer, scene, camera };
+}
+
+function frameCamera(): void {
+ if (!camera) return;
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.position.set(0, CAM_H, 0.01);
+ camera.lookAt(0, 0, 0);
+ camera.updateProjectionMatrix();
+ const halfZ = CAM_H * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
+ const halfX = halfZ * camera.aspect;
+ W = Math.max(2.4, halfX - 1.3);
+ D = Math.max(2.4, halfZ - 1.3);
+ PLANES[1].d = W;
+ PLANES[2].d = W;
+ PLANES[3].d = D;
+ PLANES[4].d = D;
+}
+
+// ---------- Materials ----------
+let dieMat: THREE.MeshStandardMaterial | null = null;
+let edgeMat: THREE.LineBasicMaterial | null = null;
+let maxAniso = 4;
+
+function getDieMat(): THREE.MeshStandardMaterial {
+ if (!dieMat) {
+ dieMat = new THREE.MeshStandardMaterial({
+ color: DIE_COLOR,
+ roughness: DIE_ROUGHNESS,
+ metalness: DIE_METALNESS,
+ flatShading: true,
+ });
+ }
+ return dieMat;
+}
+
+function getEdgeMat(): THREE.LineBasicMaterial {
+ if (!edgeMat) {
+ edgeMat = new THREE.LineBasicMaterial({
+ color: EDGE_COLOR,
+ transparent: true,
+ opacity: EDGE_OPACITY,
+ });
+ }
+ return edgeMat;
+}
+
+function getMaxAniso(): number {
+ if (renderer) {
+ maxAniso = renderer.capabilities.getMaxAnisotropy();
+ }
+ return maxAniso;
+}
+
+// ---------- Texture Helpers ----------
+function crispTexture(c: HTMLCanvasElement): THREE.CanvasTexture {
+ const t = new THREE.CanvasTexture(c);
+ t.anisotropy = getMaxAniso();
+ return t;
+}
+
+function numberTexture(text: string, underline: boolean): THREE.CanvasTexture {
+ const c = document.createElement('canvas');
+ c.width = 256;
+ c.height = 256;
+ const g = c.getContext('2d');
+ if (!g) throw new Error('Cannot get 2D context');
+ g.clearRect(0, 0, 256, 256);
+ g.fillStyle = NUMBER_COLOR;
+ g.font = '700 148px Georgia, serif';
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ g.shadowColor = 'rgba(0,0,0,.55)';
+ g.shadowBlur = 9;
+ g.fillText(text, 128, 120);
+ if (underline) {
+ g.shadowBlur = 0;
+ const w = g.measureText(text).width;
+ g.fillRect(128 - w / 2, 204, w, 13);
+ }
+ return crispTexture(c);
+}
+
+// ---------- Geometry Helpers ----------
+interface FaceGroup {
+ normal: THREE.Vector3;
+ centroid: THREE.Vector3;
+ corners: THREE.Vector3[];
+}
+
+function groupFaces(geo: THREE.BufferGeometry): FaceGroup[] {
+ const pos = geo.attributes.position;
+ if (!pos) return [];
+ const groups: { normal: THREE.Vector3; verts: THREE.Vector3[] }[] = [];
+ const a = new THREE.Vector3();
+ const b = new THREE.Vector3();
+ const c = new THREE.Vector3();
+ const ab = new THREE.Vector3();
+ const ac = new THREE.Vector3();
+ const n = new THREE.Vector3();
+
+ for (let i = 0; i < pos.count; i += 3) {
+ a.fromBufferAttribute(pos, i);
+ b.fromBufferAttribute(pos, i + 1);
+ c.fromBufferAttribute(pos, i + 2);
+ ab.subVectors(b, a);
+ ac.subVectors(c, a);
+ n.crossVectors(ab, ac).normalize();
+ let gr = groups.find((g) => g.normal.dot(n) > 0.999);
+ if (!gr) {
+ gr = { normal: n.clone(), verts: [] };
+ groups.push(gr);
+ }
+ gr.verts.push(a.clone(), b.clone(), c.clone());
+ }
+
+ return groups.map((gr) => {
+ const seen = new Map();
+ gr.verts.forEach((v) => {
+ const key = v.x.toFixed(3) + ',' + v.y.toFixed(3) + ',' + v.z.toFixed(3);
+ seen.set(key, v);
+ });
+ const uniq = Array.from(seen.values());
+ const centroid = uniq
+ .reduce((s, v) => s.add(v), new THREE.Vector3())
+ .multiplyScalar(1 / uniq.length);
+ return { normal: gr.normal, centroid, corners: uniq };
+ });
+}
+
+function d10Geometry(r: number): THREE.BufferGeometry {
+ const ring: THREE.Vector3[] = [];
+ const h = 0.1056 * r;
+ const rr = r;
+ for (let i = 0; i < 10; i++) {
+ const t = (i * Math.PI * 2) / 10;
+ ring.push(new THREE.Vector3(Math.cos(t) * rr, i % 2 ? h : -h, Math.sin(t) * rr));
+ }
+ const top = new THREE.Vector3(0, r, 0);
+ const bot = new THREE.Vector3(0, -r, 0);
+ const arr: number[] = [];
+ const push = (p: THREE.Vector3, q: THREE.Vector3, s: THREE.Vector3) => {
+ const n = new THREE.Vector3().crossVectors(
+ new THREE.Vector3().subVectors(q, p),
+ new THREE.Vector3().subVectors(s, p)
+ );
+ const ctr = new THREE.Vector3().addVectors(p, q).add(s);
+ if (n.dot(ctr) < 0) {
+ const t2 = q;
+ q = s;
+ s = t2;
+ }
+ arr.push(p.x, p.y, p.z, q.x, q.y, q.z, s.x, s.y, s.z);
+ };
+ for (let i = 0; i < 10; i++) {
+ const v0 = ring[(i + 9) % 10];
+ const v1 = ring[i];
+ const v2 = ring[(i + 1) % 10];
+ const pole = i % 2 ? bot : top;
+ push(pole, v0, v1);
+ push(pole, v1, v2);
+ }
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(arr, 3));
+ g.computeVertexNormals();
+ return g;
+}
+
+interface DieDef {
+ geo: () => THREE.BufferGeometry;
+ label: number;
+}
+
+const DIE_DEFS: Record = {
+ 4: { geo: () => new THREE.TetrahedronGeometry(1.12 * DIE_SCALE), label: 0.42 * DIE_SCALE },
+ 6: {
+ geo: () => new THREE.BoxGeometry(1.22 * DIE_SCALE, 1.22 * DIE_SCALE, 1.22 * DIE_SCALE),
+ label: 0.58 * DIE_SCALE,
+ },
+ 8: { geo: () => new THREE.OctahedronGeometry(1.08 * DIE_SCALE), label: 0.42 * DIE_SCALE },
+ 10: { geo: () => d10Geometry(1.05 * DIE_SCALE), label: 0.42 * DIE_SCALE },
+ 12: { geo: () => new THREE.DodecahedronGeometry(1.02 * DIE_SCALE), label: 0.44 * DIE_SCALE },
+ 20: { geo: () => new THREE.IcosahedronGeometry(1.05 * DIE_SCALE), label: 0.36 * DIE_SCALE },
+};
+
+function makeDie(sides: number): DieData {
+ const def = DIE_DEFS[sides];
+ if (!def) throw new Error(`Unknown die type: d${sides}`);
+
+ const geo = def.geo();
+ const nonIndexedGeo = geo.index ? geo.toNonIndexed() : geo;
+ nonIndexedGeo.computeVertexNormals();
+
+ const mesh = new THREE.Mesh(nonIndexedGeo, getDieMat());
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(nonIndexedGeo, 10), getEdgeMat()));
+
+ const faces = groupFaces(nonIndexedGeo);
+ const labelSize = def.label;
+
+ const seen = new Map();
+ faces.forEach((f) =>
+ f.corners.forEach((v) => {
+ const key = v.x.toFixed(3) + ',' + v.y.toFixed(3) + ',' + v.z.toFixed(3);
+ seen.set(key, v.clone());
+ })
+ );
+ const hull = Array.from(seen.values());
+ const radius = Math.max(...hull.map((v) => v.length()));
+
+ const faceData: FaceData[] = [];
+ const m = new THREE.Matrix4();
+ const u = new THREE.Vector3();
+ const v = new THREE.Vector3();
+
+ if (sides === 4) {
+ const vertVals = hull.map((_, i) => i + 1);
+ faces.forEach((f) => {
+ const c = document.createElement('canvas');
+ c.width = 512;
+ c.height = 512;
+ const g = c.getContext('2d');
+ if (!g) return;
+ g.fillStyle = NUMBER_COLOR;
+ g.font = '700 128px Georgia, serif';
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ g.shadowColor = 'rgba(0,0,0,.55)';
+ g.shadowBlur = 8;
+ u.copy(f.corners[0]).sub(f.centroid).normalize();
+ v.crossVectors(f.normal, u);
+ f.corners.forEach((corner) => {
+ const idx = hull.findIndex((hv) => hv.distanceToSquared(corner) < 1e-5);
+ const o = new THREE.Vector3().subVectors(corner, f.centroid);
+ const ox = o.dot(u);
+ const oy = o.dot(v);
+ const px = 256 + ox * 132;
+ const py = 256 - oy * 132;
+ g.save();
+ g.translate(px, py);
+ g.rotate(Math.atan2(ox, oy));
+ g.fillText(String(vertVals[idx]), 0, 0);
+ g.restore();
+ });
+ const plane = new THREE.Mesh(
+ new THREE.PlaneGeometry(1.3 * DIE_SCALE, 1.3 * DIE_SCALE),
+ new THREE.MeshBasicMaterial({ map: crispTexture(c), transparent: true })
+ );
+ m.makeBasis(u.clone(), v.clone(), f.normal);
+ plane.quaternion.setFromRotationMatrix(m);
+ plane.position.copy(f.centroid).addScaledVector(f.normal, 0.012 * DIE_SCALE);
+ mesh.add(plane);
+ });
+ return {
+ mesh,
+ hull,
+ radius,
+ faces: faceData,
+ sides,
+ vertVals,
+ hullRef: hull,
+ topRead: true,
+ };
+ }
+
+ faces.forEach((f, i) => {
+ const val = i + 1;
+ const tex = numberTexture(String(val), sides >= 10 && (val === 6 || val === 9));
+ const plane = new THREE.Mesh(
+ new THREE.PlaneGeometry(labelSize * 2, labelSize * 2),
+ new THREE.MeshBasicMaterial({ map: tex, transparent: true })
+ );
+ v.set(0, f.centroid.y >= 0 ? 1 : -1, 0);
+ v.addScaledVector(f.normal, -v.dot(f.normal));
+ if (v.lengthSq() < 0.01) v.set(0, 0, 1).addScaledVector(f.normal, -f.normal.z);
+ v.normalize();
+ u.crossVectors(v, f.normal);
+ m.makeBasis(u.clone(), v.clone(), f.normal);
+ plane.quaternion.setFromRotationMatrix(m);
+ let anchor = f.centroid;
+ if (sides === 10) {
+ let pi = 0;
+ let best = 0;
+ f.corners.forEach((cnr, idx) => {
+ if (Math.abs(cnr.y) > best) {
+ best = Math.abs(cnr.y);
+ pi = idx;
+ }
+ });
+ const pole = f.corners[pi];
+ let far: THREE.Vector3 | null = null;
+ let fd = 0;
+ f.corners.forEach((cnr, idx) => {
+ if (idx === pi) return;
+ const d2 = cnr.distanceToSquared(pole);
+ if (d2 > fd) {
+ fd = d2;
+ far = cnr;
+ }
+ });
+ if (far) {
+ anchor = pole.clone().lerp(far, 0.67);
+ }
+ }
+ plane.position.copy(anchor).addScaledVector(f.normal, 0.012 * DIE_SCALE);
+ mesh.add(plane);
+ faceData.push({ normal: f.normal.clone(), value: val });
+ });
+
+ return { mesh, hull, radius, faces: faceData, sides, topRead: false };
+}
+
+// ---------- Die Class ----------
+export class Die implements DieInstance {
+ mesh: THREE.Mesh;
+ hull: THREE.Vector3[];
+ radius: number;
+ faces: FaceData[];
+ sides: number;
+ topRead: boolean;
+ vertVals?: number[];
+ hullRef?: THREE.Vector3[];
+ pos: THREE.Vector3;
+ vel: THREE.Vector3;
+ angVel: THREE.Vector3;
+ quat: THREE.Quaternion;
+ still: number;
+ state: 'live' | 'snapping' | 'done';
+ value: number | null;
+ I: number;
+ snapFrom?: THREE.Quaternion;
+ snapTo?: THREE.Quaternion;
+ snapY0?: number;
+ snapY1?: number;
+ snapT?: number;
+ dissolveAmount: number = -1;
+ private frameCount: number = 0;
+ private _dissolveMat?: THREE.ShaderMaterial;
+
+ constructor(sides: number) {
+ this.mesh = new THREE.Mesh();
+ this.hull = [];
+ this.radius = 0;
+ this.faces = [];
+ this.sides = sides;
+ this.topRead = false;
+ this.pos = new THREE.Vector3();
+ this.vel = new THREE.Vector3();
+ this.angVel = new THREE.Vector3();
+ this.quat = new THREE.Quaternion();
+ this.still = -10; // Start negative so it takes time to settle
+ this.state = 'live';
+ this.value = null;
+ this.I = 0;
+
+ const data = makeDie(sides);
+ this.mesh = data.mesh;
+ this.hull = data.hull;
+ this.radius = data.radius;
+ this.faces = data.faces;
+ this.sides = data.sides;
+ this.topRead = data.topRead;
+ if (data.vertVals) this.vertVals = data.vertVals;
+ if (data.hullRef) this.hullRef = data.hullRef;
+ this.I = 0.4 * MASS * this.radius * this.radius;
+ if (scene) scene.add(this.mesh);
+ }
+
+ throwFrom(x: number, z: number, dir: THREE.Vector3): void {
+ this.pos.set(x, 2.5 + Math.random() * 1.0, z);
+ this.vel.copy(dir.clone().multiplyScalar(1.2)); // More initial velocity
+ this.quat.setFromEuler(
+ new THREE.Euler(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2)
+ );
+ this.angVel.set(
+ (Math.random() - 0.5) * 20, // More spin
+ (Math.random() - 0.5) * 15,
+ (Math.random() - 0.5) * 20
+ );
+ this.still = -10; // Reset to negative
+ this.state = 'live';
+ this.value = null;
+ this.frameCount = 0;
+ }
+
+ step(dt: number): void {
+ this.frameCount++;
+ if (this.state !== 'live' && this.state !== 'snapping') return;
+
+ if (this.state === 'live') {
+ this.vel.y -= G * dt;
+ this.vel.multiplyScalar(LIN_DAMP);
+ this.angVel.multiplyScalar(ANG_DAMP);
+ this.pos.addScaledVector(this.vel, dt);
+
+ const wl = this.angVel.length();
+ if (wl > 1e-6) {
+ dq.setFromAxisAngle(tmpV.copy(this.angVel).multiplyScalar(1 / wl), wl * dt);
+ this.quat.premultiply(dq).normalize();
+ }
+
+ let contact = false;
+ for (const pl of PLANES) {
+ let deepest = 0;
+ for (const vert of this.hull) {
+ wp.copy(vert).applyQuaternion(this.quat).add(this.pos);
+ const dist = wp.dot(pl.n) + pl.d;
+ if (dist < 0) {
+ contact = true;
+ rr2.copy(wp).sub(this.pos);
+ pv.copy(this.angVel).cross(rr2).add(this.vel);
+ const vn = pv.dot(pl.n);
+ if (vn < 0) {
+ rxn.copy(rr2).cross(pl.n);
+ const denom = 1 / MASS + rxn.lengthSq() / this.I;
+ const j = -(1 + pl.e) * vn / denom;
+ this.vel.addScaledVector(pl.n, j / MASS);
+ imp.copy(pl.n).multiplyScalar(j);
+ trq.copy(rr2).cross(imp);
+ this.angVel.addScaledVector(trq, 1 / this.I);
+ vt.copy(pv).addScaledVector(pl.n, -vn);
+ const vtl = vt.length();
+ if (vtl > 1e-4) {
+ const jt = Math.min(pl.mu * j, vtl / (1 / MASS + 1 / this.I));
+ vt.multiplyScalar(-jt / vtl);
+ this.vel.addScaledVector(vt, 1 / MASS);
+ trq.copy(rr2).cross(vt);
+ this.angVel.addScaledVector(trq, 1 / this.I);
+ }
+ if (pl.n.y > 0.5 && j > 0.8) clack(j);
+ }
+ if (dist < deepest) deepest = dist;
+ }
+ }
+ if (deepest < 0) this.pos.addScaledVector(pl.n, -deepest * 0.9);
+ }
+
+ if (contact && this.vel.lengthSq() < 2) {
+ this.vel.multiplyScalar(0.98);
+ this.angVel.multiplyScalar(0.98);
+ }
+
+ // Only increment still counter if we've been moving for a while
+ if (contact && this.frameCount > 5 && this.vel.length() < 0.15 && this.angVel.length() < 0.2) {
+ this.still++;
+ } else if (this.vel.length() > 0.1 || this.angVel.length() > 0.1) {
+ // If moving, reset still counter
+ this.still = 0;
+ }
+
+ if (this.still > 15) {
+ this.beginSnap();
+ }
+ } else if (this.state === 'snapping') {
+ this.snapStep(dt);
+ }
+ }
+
+ beginSnap(): void {
+ this.state = 'snapping';
+ let corrAxis: THREE.Vector3;
+ let corrAngle: number;
+
+ if (this.topRead && this.hullRef && this.vertVals) {
+ let best = -1e9;
+ let bi = 0;
+ this.hullRef.forEach((v, i) => {
+ const y = tmpV.copy(v).applyQuaternion(this.quat).y;
+ if (y > best) {
+ best = y;
+ bi = i;
+ }
+ });
+ this.value = this.vertVals[bi];
+ const dirW = tmpV.copy(this.hullRef[bi]).applyQuaternion(this.quat).normalize();
+ corrAxis = new THREE.Vector3().crossVectors(dirW, new THREE.Vector3(0, 1, 0));
+ corrAngle = Math.acos(THREE.MathUtils.clamp(dirW.y, -1, 1));
+ } else {
+ let best = -1e9;
+ let bf: FaceData | null = null;
+ for (const f of this.faces) {
+ const y = tmpV.copy(f.normal).applyQuaternion(this.quat).y;
+ if (y > best) {
+ best = y;
+ bf = f;
+ }
+ }
+ if (!bf) return;
+ this.value = bf.value;
+ const nW = tmpV.copy(bf.normal).applyQuaternion(this.quat).normalize();
+ corrAxis = new THREE.Vector3().crossVectors(nW, new THREE.Vector3(0, 1, 0));
+ corrAngle = Math.acos(THREE.MathUtils.clamp(nW.y, -1, 1));
+ }
+
+ const qc = new THREE.Quaternion();
+ if (corrAxis.lengthSq() > 1e-10) {
+ qc.setFromAxisAngle(corrAxis.normalize(), corrAngle);
+ }
+ this.snapFrom = this.quat.clone();
+ this.snapTo = qc.multiply(this.quat).normalize();
+
+ let minY = 1e9;
+ for (const v of this.hull) {
+ minY = Math.min(minY, tmpV.copy(v).applyQuaternion(this.snapTo).y);
+ }
+ this.snapY0 = this.pos.y;
+ this.snapY1 = -minY + 0.001;
+ this.snapT = 0;
+ }
+
+ snapStep(dt: number): void {
+ if (this.state !== 'snapping') return;
+ if (this.snapT === undefined) this.snapT = 0;
+ this.snapT = Math.min(1, this.snapT + dt / 0.3);
+ const k = 1 - Math.pow(1 - this.snapT, 3);
+ if (this.snapFrom && this.snapTo) {
+ this.quat.copy(this.snapFrom).slerp(this.snapTo, k);
+ }
+ if (this.snapY0 !== undefined && this.snapY1 !== undefined) {
+ this.pos.y = this.snapY0 + (this.snapY1 - this.snapY0) * k;
+ }
+ if (this.snapT >= 1) {
+ this.state = 'done';
+ }
+ }
+
+ sync(): void {
+ this.mesh.position.copy(this.pos);
+ this.mesh.quaternion.copy(this.quat);
+ }
+
+ startDissolve(): void {
+ this.dissolveAmount = 0;
+
+ const lightDir = new THREE.Vector3(6, 14, 5).normalize();
+ this._dissolveMat = new THREE.ShaderMaterial({
+ uniforms: {
+ dissolveAmount: { value: 0 },
+ baseColor: { value: new THREE.Color(DIE_COLOR) },
+ edgeColor: { value: new THREE.Color(EDGE_COLOR) },
+ lightDir: { value: lightDir },
+ },
+ vertexShader: DISSOLVE_VERTEX,
+ fragmentShader: DISSOLVE_FRAGMENT,
+ });
+
+ this.mesh.material = this._dissolveMat;
+
+ this.mesh.children.forEach((child) => {
+ if (child instanceof THREE.LineSegments) {
+ child.material = (child.material as THREE.Material).clone();
+ }
+ });
+ }
+
+ dissolveStep(dt: number): void {
+ if (this.dissolveAmount < 0) return;
+ this.dissolveAmount += dt / 0.8;
+ if (this.dissolveAmount > 1) this.dissolveAmount = 1;
+
+ if (this._dissolveMat) {
+ this._dissolveMat.uniforms.dissolveAmount.value = this.dissolveAmount;
+ }
+
+ this.mesh.children.forEach((child) => {
+ if (child instanceof THREE.LineSegments) {
+ child.material.opacity = (1 - this.dissolveAmount) * EDGE_OPACITY;
+ }
+ });
+ }
+
+ dispose(): void {
+ if (scene) scene.remove(this.mesh);
+ this._dissolveMat?.dispose();
+ }
+}
+
+// ---------- Collision ----------
+export function collideDice(list: DieInstance[]): void {
+ for (let i = 0; i < list.length; i++) {
+ for (let k = i + 1; k < list.length; k++) {
+ const A = list[i];
+ const B = list[k];
+ if ((A.state !== 'live' && A.state !== 'snapping') ||
+ (B.state !== 'live' && B.state !== 'snapping')) continue;
+ const rA = A.radius * 0.86;
+ const rB = B.radius * 0.86;
+ tmpV.copy(B.pos).sub(A.pos);
+ const d = tmpV.length();
+ const min = rA + rB;
+ if (d < min && d > 1e-5) {
+ const n = tmpV.multiplyScalar(1 / d);
+ const push = (min - d) / 2;
+ A.pos.addScaledVector(n, -push);
+ B.pos.addScaledVector(n, push);
+ const rel = pv.copy(B.vel).sub(A.vel).dot(n);
+ if (rel < 0) {
+ const j = -(1 + 0.2) * rel / 2;
+ A.vel.addScaledVector(n, -j);
+ B.vel.addScaledVector(n, j);
+ A.angVel.x += (Math.random() - 0.5) * 3;
+ B.angVel.z += (Math.random() - 0.5) * 3;
+ if (-rel > 2) clack(-rel * 0.6);
+ A.still = 0;
+ B.still = 0;
+ if (A.state === 'snapping') A.state = 'live';
+ if (B.state === 'snapping') B.state = 'live';
+ }
+ }
+ }
+ }
+}
+
+// ---------- Sound ----------
+function clack(strength: number): void {
+ if (!audioCtx) {
+ try {
+ audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
+ } catch (_e) {
+ return;
+ }
+ }
+ if (!audioCtx) return;
+ const t = audioCtx.currentTime;
+ const o = audioCtx.createOscillator();
+ const g2 = audioCtx.createGain();
+ o.type = 'triangle';
+ o.frequency.setValueAtTime(190 + Math.random() * 160, t);
+ o.frequency.exponentialRampToValueAtTime(70, t + 0.06);
+ const vol = Math.min(0.22, strength * 0.02);
+ g2.gain.setValueAtTime(vol, t);
+ g2.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
+ o.connect(g2);
+ g2.connect(audioCtx.destination);
+ o.start(t);
+ o.stop(t + 0.08);
+}
+
+// ---------- Render Loop ----------
+let animationFrameId: number | null = null;
+let allDice: DieInstance[] = [];
+let onCompleteCallback: ((completedDice: DieInstance[]) => void) | null = null;
+let loopInitialized = false;
+let isWarmedUp = false;
+let warmupFrames = 0;
+const WARMUP_FRAMES = 3; // Reduced warmup frames
+let hasCalledCallback = false;
+let pendingDice: DieInstance[] = [];
+let pendingCallback: ((completedDice: DieInstance[]) => void) | null = null;
+let frameCount = 0;
+
+export function initRenderLoop(): void {
+ if (loopInitialized) return;
+ loopInitialized = true;
+ console.log('Initializing render loop...');
+
+ const SUB = 6;
+ const FIXED_DT = 1 / 60;
+ let accumulator = 0;
+ let lastTime = performance.now();
+
+ function loop(currentTime: number): void {
+ animationFrameId = requestAnimationFrame(loop);
+ frameCount++;
+
+ const deltaTime = Math.min(0.05, (currentTime - lastTime) / 1000);
+ lastTime = currentTime;
+
+ // Warm-up: run physics with empty dice to initialize the engine
+ if (!isWarmedUp) {
+ warmupFrames++;
+ if (warmupFrames < WARMUP_FRAMES) {
+ if (renderer && scene && camera) {
+ renderer.render(scene, camera);
+ }
+ return;
+ } else {
+ isWarmedUp = true;
+ accumulator = 0;
+ console.log('Physics engine warmed up after', warmupFrames, 'frames');
+ // Apply any pending dice now that we're warmed up
+ if (pendingDice.length > 0) {
+ console.log('Applying pending dice after warmup:', pendingDice.length);
+ // Make sure dice are properly initialized
+ pendingDice.forEach(d => {
+ // Ensure the die is in live state with velocity
+ if (d.state === 'live') {
+ // Give a small initial kick to ensure physics starts
+ d.vel.y += 0.1;
+ }
+ });
+ allDice = pendingDice;
+ onCompleteCallback = pendingCallback;
+ pendingDice = [];
+ pendingCallback = null;
+ hasCalledCallback = false;
+ }
+ }
+ }
+
+ const diceToUpdate = allDice;
+
+ // Only accumulate time when there are dice to simulate, to prevent
+ // a huge time buildup during idle creating a physics burst on first roll
+ if (diceToUpdate.length > 0 || !isWarmedUp) {
+ accumulator += deltaTime;
+ }
+
+ if (diceToUpdate.length > 0) {
+ // Process physics
+ let steps = 0;
+ while (accumulator >= FIXED_DT && steps < 10) {
+ for (let s = 0; s < SUB; s++) {
+ const subDt = FIXED_DT / SUB;
+ diceToUpdate.forEach((d) => {
+ if (d.state === 'live' || d.state === 'snapping') {
+ d.step(subDt);
+ }
+ });
+ collideDice(diceToUpdate);
+ }
+ accumulator -= FIXED_DT;
+ steps++;
+ }
+
+ // Visual sync
+ diceToUpdate.forEach((d) => d.sync());
+
+ // Check if all dice are done - only call callback once
+ if (diceToUpdate.every((d) => d.state === 'done')) {
+ if (onCompleteCallback && !hasCalledCallback) {
+ hasCalledCallback = true;
+ console.log('All dice done, calling callback');
+ const callback = onCompleteCallback;
+ onCompleteCallback = null;
+ callback(diceToUpdate);
+ // Start dissolving all dice after score is given
+ diceToUpdate.forEach((d) => d.startDissolve());
+ }
+ } else {
+ // Reset callback flag if dice are still rolling
+ hasCalledCallback = false;
+ }
+ }
+
+ // Process dissolve animation
+ if (diceToUpdate.length > 0) {
+ let anyDissolving = false;
+ diceToUpdate.forEach((d) => {
+ if (d.dissolveAmount >= 0 && d.dissolveAmount < 1) {
+ d.dissolveStep(deltaTime);
+ anyDissolving = true;
+ }
+ });
+ // Clean up fully dissolved dice
+ if (!anyDissolving && diceToUpdate.every((d) => d.dissolveAmount === 1)) {
+ diceToUpdate.forEach((d) => d.dispose());
+ allDice = [];
+ }
+ }
+
+ if (renderer && scene && camera) {
+ renderer.render(scene, camera);
+ }
+ }
+
+ lastTime = performance.now();
+ requestAnimationFrame(loop);
+}
+
+export function setDice(dice: DieInstance[], onComplete?: (completedDice: DieInstance[]) => void): void {
+ console.log('Setting new dice, count:', dice.length);
+
+ // Clean up old dice
+ allDice.forEach(d => d.dispose());
+ allDice = [];
+ onCompleteCallback = null;
+ hasCalledCallback = false;
+
+ // If we're not warmed up yet, store the dice for later
+ if (!isWarmedUp) {
+ console.log('Engine not warmed up yet, storing dice for later');
+ pendingDice = dice;
+ pendingCallback = onComplete || null;
+ return;
+ }
+
+ // Otherwise, set them immediately
+ // Make sure dice are in a proper state
+ dice.forEach(d => {
+ if (d.state === 'live') {
+ // Ensure they have some initial energy
+ d.vel.y += 0.5;
+ }
+ });
+ allDice = dice;
+ onCompleteCallback = onComplete || null;
+}
+
+export function stopRenderLoop(): void {
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+ loopInitialized = false;
+ isWarmedUp = false;
+ warmupFrames = 0;
+ hasCalledCallback = false;
+ onCompleteCallback = null;
+ pendingDice = [];
+ pendingCallback = null;
+ frameCount = 0;
+}
+
+// ---------- Getters ----------
+export function getScene(): THREE.Scene | null {
+ return scene;
+}
+
+export function getRenderer(): THREE.WebGLRenderer | null {
+ return renderer;
+}
+
+export function getW(): number {
+ return W;
+}
+
+export function getD(): number {
+ return D;
+}
+
+export function getPlanes(): Plane[] {
+ return PLANES;
+}
\ No newline at end of file
diff --git a/src/lib/dndHelpers.ts b/src/lib/dndHelpers.ts
new file mode 100644
index 0000000..ae06c10
--- /dev/null
+++ b/src/lib/dndHelpers.ts
@@ -0,0 +1,200 @@
+import { AttackResult, Dice, ExpandedDice } from "@/types/core";
+import { Ability, Armor, Character, Class, Weapon } from "@/types/content";
+
+/**
+ * This file assumes the
+ * 5th Edition (2014)
+ * Ruleset
+ */
+
+/**
+ * Swapping rulesets invovles rewriting most of these functions.
+ */
+
+
+export function getAbilityModifier(score: number): number {
+ return Math.floor((score - 10) / 2);
+};
+
+export function getProficiencyBonus(level: number): number {
+ return Math.floor((level - 1) / 4) + 2;
+}
+
+export function calculateAC(character: Character, armor?: Armor): number {
+ const dex =
+ getAbilityModifier(
+ character.abilities.dexterity
+ );
+
+ if (!armor) {
+ return 10 + dex;
+ }
+
+ let ac = armor.armorClass;
+
+ if (armor.dexterityBonus === "full") {
+ ac += dex;
+ }
+
+ if (armor.dexterityBonus === "limited") {
+ ac += Math.min(
+ dex,
+ armor.maxDexterity ?? 2
+ );
+ }
+
+ return ac;
+}
+
+export function getAvailableAbilities(
+ weapon: Weapon
+): Ability[] {
+ if (
+ weapon.ability.options.some(
+ option => option.requirement !== undefined
+ )
+ ) {
+ console.warn(
+ "Weapon ability requirements are not implemented; requirements will be ignored."
+ );
+ }
+
+ const abilities = [
+ weapon.ability.default,
+ ...weapon.ability.options.map(opt => opt.ability)
+ ];
+
+ return [...new Set(abilities)];
+}
+
+/**
+ * Automatically selects the highest available ability modifier.
+ * If multiple abilities tie, the first defined ability is used.
+ * This does not affect mechanics, only displayed attribution.
+ */
+export function resolveAttackAbility(
+ weapon: Weapon,
+ character: Character,
+ preferred?: Ability
+): Ability {
+ const available =
+ getAvailableAbilities(
+ weapon
+ );
+
+ if (
+ preferred &&
+ available.includes(preferred)
+ ) {
+ return preferred;
+ }
+
+ return available.reduce(
+ (best, current) => {
+ const bestMod =
+ getAbilityModifier(
+ character.abilities[best]
+ );
+
+ const currentMod =
+ getAbilityModifier(
+ character.abilities[current]
+ )
+
+ return currentMod > bestMod
+ ? current
+ : best;
+ }
+ )
+}
+
+export function getDamageDice(
+ weapon: Weapon
+): Dice {
+ return weapon.damage.dice;
+}
+
+export function compressDice(expandedDice: ExpandedDice): Dice {
+ return {
+ [expandedDice.sides]:
+ expandedDice.count
+ }
+}
+
+export function expandDice(dice: Dice): ExpandedDice[] {
+ return Object.entries(dice)
+ .map(([sides, count]) => {
+ return {
+ sides: Number(sides),
+ count
+ };
+ });
+}
+
+export function getCharacterLevel(
+ character: Character
+): number {
+ return character.classes.reduce(
+ (total, current) =>
+ total + current.level,
+ 0
+ );
+}
+
+export function calculateAttack(
+ character: Character,
+ weapon: Weapon,
+ prefereredAbility?: Ability
+): AttackResult {
+ const ability =
+ resolveAttackAbility(
+ weapon,
+ character,
+ prefereredAbility
+ );
+
+ const modifier = getAbilityModifier(
+ character.abilities[ability]
+ );
+
+ return {
+ ability,
+ attackBonus:
+ modifier +
+ getProficiencyBonus(getCharacterLevel(character)),
+ damage: {
+ dice: weapon.damage.dice,
+ modifier,
+ type: weapon.damage.type
+ }
+ }
+}
+
+export function calculateSpellAttackBonus(
+ character: Character,
+ ability: Ability
+): number {
+ return (
+ getAbilityModifier(
+ character.abilities[ability]
+ ) +
+ getProficiencyBonus(
+ getCharacterLevel(character)
+ )
+ );
+}
+
+export function calculateSpellSaveDC(
+ character: Character,
+ ability: Ability
+): number {
+ return (
+ 8 +
+ getAbilityModifier(
+ character.abilities[ability]
+ ) +
+ getProficiencyBonus(
+ getCharacterLevel(character)
+ )
+ );
+}
\ No newline at end of file
diff --git a/src/types/content.ts b/src/types/content.ts
new file mode 100644
index 0000000..c367106
--- /dev/null
+++ b/src/types/content.ts
@@ -0,0 +1,139 @@
+import { Dice, ExpandedDice } from "./core";
+
+export type Ability =
+ | "strength"
+ | "dexterity"
+ | "constitution"
+ | "intelligence"
+ | "wisdom"
+ | "charisma";
+
+export type RollMethod = "rolled" | "average";
+
+export type ContentEntry = {
+ id: string;
+
+ type:
+ | "species"
+ | "class"
+ | "subclass"
+ | "spell"
+ | "item"
+ | "weapon"
+ | "armor"
+ | "feature"
+ | "background";
+
+ name: string;
+
+ data: unknown;
+}
+
+export type ContentPack = {
+ id: string;
+ name: string;
+ ownerId: string;
+
+ visibility: "private" | "public";
+
+ entries: ContentEntry[];
+}
+
+export type Armor = {
+ id: string;
+ name: string;
+
+ armorClass: number;
+
+ dexterityBonus:
+ | "full"
+ | "limited"
+ | "none";
+
+ maxDexterity?: number;
+ shieldBonus?: number;
+}
+
+export type Weapon = {
+ id: string;
+ name: string;
+
+ damage: {
+ dice: Dice;
+ type: string;
+ };
+
+ properties: string[];
+
+ ability: {
+ default: Ability;
+ options: {
+ name: string;
+ ability: Ability;
+ requirement?: unknown; // To be implemented (requirement based on character opts, i.e. wizards use int, dwarves use char, etc.)
+ }[]
+ }
+
+ proficientRequired: boolean;
+}
+
+export type AbilityScores = {
+ strength: number;
+ dexterity: number;
+ constitution: number;
+ intelligence: number;
+ wisdom: number;
+ charisma: number;
+}
+
+export type Class = {
+ id: string;
+ name: string;
+
+ hitDice: ExpandedDice;
+}
+
+export type CharacterClass = {
+ classId: string;
+ level: number;
+}
+
+export type CharacterHP = {
+ method: RollMethod;
+ max: number;
+
+ history: {
+ level: number;
+ classId: string;
+
+ roll?: number;
+ increase: number;
+ }[];
+}
+
+export type Character = {
+ id: string;
+
+ name: string;
+
+ speciesId: string;
+ classes: CharacterClass[];
+
+ hp: CharacterHP;
+
+ abilities: AbilityScores;
+
+ inventory: string[];
+
+ equipped: {
+ armor?: string;
+ weapons: string[];
+ };
+
+ magic: {
+ spells: Record; // { : }
+ slots: Record // { : [, ] }
+ }
+
+ contentSources: string[];
+}
\ No newline at end of file
diff --git a/src/types/core.ts b/src/types/core.ts
new file mode 100644
index 0000000..5c8fab0
--- /dev/null
+++ b/src/types/core.ts
@@ -0,0 +1,26 @@
+import { Ability } from "./content";
+
+export type Dice = Record;
+
+export type ExpandedDice = {
+ sides: number;
+ count: number;
+}
+
+export type Effect = {
+ target: string;
+ operation: "increase" | "decrease" | "roll";
+ value: number;
+}
+
+export type AttackResult = {
+ ability: Ability;
+
+ attackBonus: number;
+
+ damage: {
+ dice: Dice;
+ modifier: number;
+ type: string;
+ }
+}
\ No newline at end of file