From 3809bd5425dcd3accb846208ba192ba34616d53c Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 8 Feb 2026 11:47:32 +0100 Subject: [PATCH] Refractor 3D Dice --- frontend/src/App.jsx | 338 +-------------------- frontend/src/AppLayout.css | 4 + frontend/src/components/Dice/Dice3D.jsx | 147 +++++++++ frontend/src/components/Dice/DicePanel.jsx | 188 ++++++++++++ 4 files changed, 340 insertions(+), 337 deletions(-) create mode 100644 frontend/src/components/Dice/Dice3D.jsx create mode 100644 frontend/src/components/Dice/DicePanel.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bb49433..ddb33f8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import { stylesTokens } from "./styles/theme"; import LoginPage from "./components/LoginPage"; import SheetSection from "./components/SheetSection"; import ChipModal from "./components/ChipModal"; +import DicePanel from "./components/Dice/DicePanel"; import "./AppLayout.css"; @@ -541,343 +542,6 @@ export default function App() { ); }; - // ✅ 3 Würfel: 2x d6 + 1x Spezial (Häuser + Hilfkarte + Dunkles Deck) - - const DicePanel = ({ onRoll }) => { - const LS_KEY = "hp_cluedo_dice_v1"; - - const [d1, setD1] = useState(4); - const [d2, setD2] = useState(2); - const [special, setSpecial] = useState("gryffindor"); - - // angles (absolute) - const [a1, setA1] = useState({ x: 0, y: 90 }); // start showing 4 (ry=90) - const [a2, setA2] = useState({ x: -90, y: 0 }); // start showing 2 (rx=-90) - const [as, setAs] = useState({ x: 0, y: 0 }); // gryffindor mapped below - - const [rolling, setRolling] = useState(false); - - // commit targets (avoid “result earlier than animation”) - const pendingRef = useRef(null); - const doneCountRef = useRef(0); - - const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; - const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; - const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; - - // restore last result - useEffect(() => { - try { - const raw = localStorage.getItem(LS_KEY); - if (!raw) return; - const parsed = JSON.parse(raw); - - if (parsed?.d1) setD1(parsed.d1); - if (parsed?.d2) setD2(parsed.d2); - if (parsed?.special) setSpecial(parsed.special); - - // set angles matching restored values - if (parsed?.d1) { - const r = cubeRotationForD6(parsed.d1); - setA1({ x: r.rx, y: r.ry }); - } - if (parsed?.d2) { - const r = cubeRotationForD6(parsed.d2); - setA2({ x: r.rx, y: r.ry }); - } - if (parsed?.special) { - const idx = Math.max(0, order.indexOf(parsed.special)); - const r = cubeRotationForD6(idx + 1); - setAs({ x: r.rx, y: r.ry }); - } - } catch {} - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - try { - localStorage.setItem(LS_KEY, JSON.stringify({ d1, d2, special })); - } catch {} - }, [d1, d2, special]); - - const normalize360 = (n) => ((n % 360) + 360) % 360; - - const rollTo = (currentAngles, targetBase) => { - // add big spins but land exactly on base orientation modulo 360 - const spinX = 720 + randInt(0, 2) * 360; - const spinY = 720 + randInt(0, 2) * 360; - - const currX = normalize360(currentAngles.x); - const currY = normalize360(currentAngles.y); - - const dx = targetBase.rx - currX; - const dy = targetBase.ry - currY; - - return { - x: currentAngles.x + spinX + dx, - y: currentAngles.y + spinY + dy, - }; - }; - - const rollAll = () => { - if (rolling) return; - - const nd1 = randInt(1, 6); - const nd2 = randInt(1, 6); - const ns = specialFaces[randInt(0, specialFaces.length - 1)]; - - const r1 = cubeRotationForD6(nd1); - const r2 = cubeRotationForD6(nd2); - const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1); - - pendingRef.current = { nd1, nd2, ns }; - doneCountRef.current = 0; - - setRolling(true); - - // start roll immediately (angles change drives the animation) - setA1((cur) => rollTo(cur, r1)); - setA2((cur) => rollTo(cur, r2)); - setAs((cur) => rollTo(cur, rs)); - }; - - const onOneDone = () => { - doneCountRef.current += 1; - if (doneCountRef.current < 3) return; - - const p = pendingRef.current; - if (!p) return; - - setD1(p.nd1); - setD2(p.nd2); - setSpecial(p.ns); - - setRolling(false); - pendingRef.current = null; - - onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns }); - }; - - return ( -
- {/* ✅ no container background/border anymore */} -
-
-
- Würfel -
-
- {rolling ? "ROLL…" : "READY"} -
-
- -
- - - -
- -
- Klicken zum Rollen -
-
-
- ); -}; - - - const DieShell = ({ children, rolling = false, onClick }) => { - return ( - - ); - }; - - - /* map value->cube rotation so that value face is FRONT - Face layout: - front=1, back=6, top=2, bottom=5, right=3, left=4 - */ - const cubeRotationForD6 = (value) => { - switch (value) { - case 1: return { rx: 0, ry: 0 }; - case 2: return { rx: -90, ry: 0 }; - case 3: return { rx: 0, ry: -90 }; - case 4: return { rx: 0, ry: 90 }; - case 5: return { rx: 90, ry: 0 }; - case 6: return { rx: 0, ry: 180 }; - default: return { rx: 0, ry: 0 }; - } - }; - - const PipFace = ({ value }) => { - // pip positions: 0..2 grid - const pos = (gx, gy) => ({ left: `${gx * 50}%`, top: `${gy * 50}%` }); - - const faces = { - 1: [[1, 1]], - 2: [[0, 0], [2, 2]], - 3: [[0, 0], [1, 1], [2, 2]], - 4: [[0, 0], [2, 0], [0, 2], [2, 2]], - 5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]], - 6: [[0, 0], [0, 1], [0, 2], [2, 0], [2, 1], [2, 2]], - }; - - const arr = faces[value] || faces[1]; - - return ( -
- {arr.map(([x, y], idx) => ( -
- ))} -
- ); - }; - - const DieD6 = ({ value = 1, rolling = false, onClick, ax, ay, onDone }) => { - return ( - -
-
{ - if (e.propertyName !== "transform") return; - if (rolling) onDone?.(); - }} - > -
-
-
-
-
-
-
-
-
- ); -}; - - - const HouseDie = ({ face = "gryffindor", rolling = false, onClick, ax, ay, onDone }) => { - const faces = { - gryffindor: { icon: "🦁", color: "#ef4444" }, - slytherin: { icon: "🐍", color: "#22c55e" }, - ravenclaw: { icon: "🦅", color: "#3b82f6" }, - hufflepuff: { icon: "🦡", color: "#facc15" }, - help: { icon: "🃏", color: "#f2d27a" }, - dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" }, - }; - - const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; - const f = faces[face] || faces.gryffindor; - - return ( - -
-
{ - if (e.propertyName !== "transform") return; - if (rolling) onDone?.(); - }} - > - {order.map((key, i) => { - const item = faces[key]; - const faceClass = - i === 0 ? "front" : - i === 1 ? "top" : - i === 2 ? "right" : - i === 3 ? "left" : - i === 4 ? "bottom" : - "back"; - - return ( -
-
- {item.icon} -
-
- ); - })} -
-
-
- ); -}; - - - - const PlayerIdentityCard = ({ name = "Harry Potter", houseLabel = "Gryffindor", diff --git a/frontend/src/AppLayout.css b/frontend/src/AppLayout.css index bb0b31f..55cc4b9 100644 --- a/frontend/src/AppLayout.css +++ b/frontend/src/AppLayout.css @@ -262,6 +262,10 @@ body { /* Prevent clipping */ .diceRow3d { overflow: visible; } +.dieCube.snap { + transition: none !important; +} + /* one face */ .dieFace { position: absolute; diff --git a/frontend/src/components/Dice/Dice3D.jsx b/frontend/src/components/Dice/Dice3D.jsx new file mode 100644 index 0000000..5d6d09c --- /dev/null +++ b/frontend/src/components/Dice/Dice3D.jsx @@ -0,0 +1,147 @@ +// frontend/src/components/Dice/Dice3D.jsx +import React from "react"; + +export const cubeRotationForD6 = (value) => { + switch (value) { + case 1: return { rx: 0, ry: 0 }; + case 2: return { rx: -90, ry: 0 }; + case 3: return { rx: 0, ry: -90 }; + case 4: return { rx: 0, ry: 90 }; + case 5: return { rx: 90, ry: 0 }; + case 6: return { rx: 0, ry: 180 }; + default: return { rx: 0, ry: 0 }; + } +}; + +export const PipFace = ({ value }) => { + const pos = (gx, gy) => ({ left: `${gx * 50}%`, top: `${gy * 50}%` }); + + const faces = { + 1: [[1, 1]], + 2: [[0, 0], [2, 2]], + 3: [[0, 0], [1, 1], [2, 2]], + 4: [[0, 0], [2, 0], [0, 2], [2, 2]], + 5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]], + 6: [[0, 0], [0, 1], [0, 2], [2, 0], [2, 1], [2, 2]], + }; + + const arr = faces[value] || faces[1]; + + return ( +
+ {arr.map(([x, y], idx) => ( +
+ ))} +
+ ); +}; + +export const DieShell = ({ children, rolling = false, onClick }) => { + return ( + + ); +}; + +export const DieD6 = ({ rolling, onClick, ax, ay, onDone, snap = false }) => { + return ( + +
+
{ + if (e.propertyName !== "transform") return; + if (rolling) onDone?.(); + }} + > +
+
+
+
+
+
+
+
+
+ ); +}; + +export const HouseDie = ({ face, rolling, onClick, ax, ay, onDone, snap = false }) => { + const faces = { + gryffindor: { icon: "🦁", color: "#ef4444" }, + slytherin: { icon: "🐍", color: "#22c55e" }, + ravenclaw: { icon: "🦅", color: "#3b82f6" }, + hufflepuff: { icon: "🦡", color: "#facc15" }, + help: { icon: "🃏", color: "#f2d27a" }, + dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" }, + }; + + const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; + + return ( + +
+
{ + if (e.propertyName !== "transform") return; + if (rolling) onDone?.(); + }} + > + {order.map((key, i) => { + const item = faces[key]; + const faceClass = + i === 0 ? "front" : + i === 1 ? "top" : + i === 2 ? "right" : + i === 3 ? "left" : + i === 4 ? "bottom" : + "back"; + + return ( +
+
+ {item.icon} +
+
+ ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/components/Dice/DicePanel.jsx b/frontend/src/components/Dice/DicePanel.jsx new file mode 100644 index 0000000..3b821a4 --- /dev/null +++ b/frontend/src/components/Dice/DicePanel.jsx @@ -0,0 +1,188 @@ +// frontend/src/components/Dice/DicePanel.jsx +import React, { useEffect, useRef, useState } from "react"; +import { stylesTokens } from "../../styles/theme"; +import { cubeRotationForD6, DieD6, HouseDie } from "./Dice/Dice3D.jsx"; + +export default function DicePanel({ onRoll }) { + const LS_KEY = "hp_cluedo_dice_v1"; + + const [d1, setD1] = useState(4); + const [d2, setD2] = useState(2); + const [special, setSpecial] = useState("gryffindor"); + + const [a1, setA1] = useState({ x: 0, y: 90 }); + const [a2, setA2] = useState({ x: -90, y: 0 }); + const [as, setAs] = useState({ x: 0, y: 0 }); + + const [rolling, setRolling] = useState(false); + const [snap, setSnap] = useState(false); + + const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; + const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; + const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; + + // roll bookkeeping + const pendingRef = useRef(null); + const rollIdRef = useRef(0); + const doneForRollRef = useRef({ d1: false, d2: false, s: false }); + + // restore last + useEffect(() => { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + + if (parsed?.d1) setD1(parsed.d1); + if (parsed?.d2) setD2(parsed.d2); + if (parsed?.special) setSpecial(parsed.special); + + if (parsed?.d1) { + const r = cubeRotationForD6(parsed.d1); + setA1({ x: r.rx, y: r.ry }); + } + if (parsed?.d2) { + const r = cubeRotationForD6(parsed.d2); + setA2({ x: r.rx, y: r.ry }); + } + if (parsed?.special) { + const idx = Math.max(0, order.indexOf(parsed.special)); + const r = cubeRotationForD6(idx + 1); + setAs({ x: r.rx, y: r.ry }); + } + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + try { + localStorage.setItem(LS_KEY, JSON.stringify({ d1, d2, special })); + } catch {} + }, [d1, d2, special]); + + const normalize360 = (n) => ((n % 360) + 360) % 360; + + const rollTo = (currentAngles, targetBase) => { + const spinX = 720 + randInt(0, 2) * 360; + const spinY = 720 + randInt(0, 2) * 360; + + const currX = normalize360(currentAngles.x); + const currY = normalize360(currentAngles.y); + + const dx = targetBase.rx - currX; + const dy = targetBase.ry - currY; + + return { x: currentAngles.x + spinX + dx, y: currentAngles.y + spinY + dy }; + }; + + const rollAll = () => { + if (rolling) return; + + const nd1 = randInt(1, 6); + const nd2 = randInt(1, 6); + const ns = specialFaces[randInt(0, specialFaces.length - 1)]; + + const r1 = cubeRotationForD6(nd1); + const r2 = cubeRotationForD6(nd2); + const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1); + + pendingRef.current = { nd1, nd2, ns }; + + rollIdRef.current += 1; + doneForRollRef.current = { d1: false, d2: false, s: false }; + + setRolling(true); + + setA1((cur) => rollTo(cur, r1)); + setA2((cur) => rollTo(cur, r2)); + setAs((cur) => rollTo(cur, rs)); + }; + + const maybeCommit = () => { + const flags = doneForRollRef.current; + if (!flags.d1 || !flags.d2 || !flags.s) return; + + const p = pendingRef.current; + if (!p) return; + + // ✅ rolling beenden + setRolling(false); + + // ✅ Ergebnis state setzen + setD1(p.nd1); + setD2(p.nd2); + setSpecial(p.ns); + + // ✅ Winkel auf kleine Basis normalisieren (ohne Transition) + const r1 = cubeRotationForD6(p.nd1); + const r2 = cubeRotationForD6(p.nd2); + const rs = cubeRotationForD6(Math.max(0, order.indexOf(p.ns)) + 1); + + setSnap(true); + setA1({ x: r1.rx, y: r1.ry }); + setA2({ x: r2.rx, y: r2.ry }); + setAs({ x: rs.rx, y: rs.ry }); + + // Snap nur 1 Frame aktiv lassen + requestAnimationFrame(() => { + requestAnimationFrame(() => setSnap(false)); + }); + + pendingRef.current = null; + + onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns }); + }; + + + const onDoneD1 = () => { + if (!rolling) return; + if (doneForRollRef.current.d1) return; + doneForRollRef.current.d1 = true; + maybeCommit(); + }; + + const onDoneD2 = () => { + if (!rolling) return; + if (doneForRollRef.current.d2) return; + doneForRollRef.current.d2 = true; + maybeCommit(); + }; + + const onDoneS = () => { + if (!rolling) return; + if (doneForRollRef.current.s) return; + doneForRollRef.current.s = true; + maybeCommit(); + }; + + return ( +
+
+
+
Würfel
+
+ {rolling ? "ROLL…" : "READY"} +
+
+ +
+ + + +
+ +
Klicken zum Rollen
+
+
+ ); +}