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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+ <> +
+
+
-
- - Vercel logomark - Deploy Now - - - Documentation - +
+ {savedRolls.map((roll, i) => ( +
test
+ ))}
-
-
+ + + ); } 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