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 (
-
- );
- })}
-
-
-
- );
-};
-
-
-
-
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 (
+
+ );
+ })}
+
+
+
+ );
+};
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
+
+
+ );
+}