This commit is contained in:
2026-02-08 11:39:25 +01:00
parent 81065f1054
commit 85bb30ae73
2 changed files with 301 additions and 274 deletions

View File

@@ -545,20 +545,53 @@ export default function App() {
const DicePanel = ({ onRoll }) => { const DicePanel = ({ onRoll }) => {
const LS_KEY = "hp_cluedo_dice_v1"; const LS_KEY = "hp_cluedo_dice_v1";
const [d1, setD1] = useState(4); const [d1, setD1] = useState(4);
const [d2, setD2] = useState(2); const [d2, setD2] = useState(2);
const [special, setSpecial] = useState("gryffindor"); 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); 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(() => { useEffect(() => {
try { try {
const raw = localStorage.getItem(LS_KEY); const raw = localStorage.getItem(LS_KEY);
if (!raw) return; if (!raw) return;
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (parsed?.d1) setD1(parsed.d1); if (parsed?.d1) setD1(parsed.d1);
if (parsed?.d2) setD2(parsed.d2); if (parsed?.d2) setD2(parsed.d2);
if (parsed?.special) setSpecial(parsed.special); 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 {} } catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -567,68 +600,73 @@ export default function App() {
} catch {} } catch {}
}, [d1, d2, special]); }, [d1, d2, special]);
const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; const normalize360 = (n) => ((n % 360) + 360) % 360;
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
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 = () => { const rollAll = () => {
if (rolling) return; if (rolling) return;
setRolling(true);
// wir picken final direkt (kein Sound, kein Flicker nötig Animation macht den Job)
const nd1 = randInt(1, 6); const nd1 = randInt(1, 6);
const nd2 = randInt(1, 6); const nd2 = randInt(1, 6);
const ns = specialFaces[randInt(0, specialFaces.length - 1)]; const ns = specialFaces[randInt(0, specialFaces.length - 1)];
setTimeout(() => { const r1 = cubeRotationForD6(nd1);
setD1(nd1); const r2 = cubeRotationForD6(nd2);
setD2(nd2); const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1);
setSpecial(ns);
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); setRolling(false);
onRoll?.({ d1: nd1, d2: nd2, special: ns }); pendingRef.current = null;
}, 820);
onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns });
}; };
return ( return (
<div style={{ pointerEvents: "auto" }}> <div style={{ pointerEvents: "auto" }}>
<div {/* ✅ no container background/border anymore */}
style={{ <div style={{ display: "grid", gap: 8 }}>
borderRadius: 18, <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
border: `1px solid ${stylesTokens.panelBorder}`, <div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 13 }}>
background: stylesTokens.panelBg,
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
backdropFilter: "blur(10px)",
padding: 12,
overflow: "visible",
minWidth: 0,
}}
>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 10 }}>
<div>
<div
style={{
fontWeight: 900,
color: stylesTokens.textMain,
fontSize: 13,
letterSpacing: 0.2,
lineHeight: 1.15,
}}
>
Würfel Würfel
</div> </div>
<div style={{ marginTop: 6, color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}> <div style={{ color: stylesTokens.textDim, fontWeight: 900, fontSize: 11.5, letterSpacing: 0.7, opacity: 0.75 }}>
Klicken zum Rollen
</div>
</div>
<div
style={{
fontSize: 11.5,
fontWeight: 900,
color: stylesTokens.textDim,
opacity: rolling ? 1 : 0.7,
letterSpacing: 0.7,
}}
>
{rolling ? "ROLL…" : "READY"} {rolling ? "ROLL…" : "READY"}
</div> </div>
</div> </div>
@@ -636,24 +674,50 @@ export default function App() {
<div <div
className="diceRow3d" className="diceRow3d"
style={{ style={{
marginTop: 10,
display: "grid", display: "grid",
gridTemplateColumns: "repeat(3, 1fr)", gridTemplateColumns: "repeat(3, 1fr)",
gap: 10, gap: 10,
alignItems: "center", alignItems: "center",
justifyItems: "center", justifyItems: "center",
overflow: "visible",
}} }}
> >
<DieD6 value={d1} rolling={rolling} onClick={rollAll} /> <DieD6
<DieD6 value={d2} rolling={rolling} onClick={rollAll} /> value={d1}
<HouseDie face={special} rolling={rolling} onClick={rollAll} /> rolling={rolling}
onClick={rollAll}
ax={a1.x}
ay={a1.y}
onDone={onOneDone}
/>
<DieD6
value={d2}
rolling={rolling}
onClick={rollAll}
ax={a2.x}
ay={a2.y}
onDone={onOneDone}
/>
<HouseDie
face={special}
rolling={rolling}
onClick={rollAll}
ax={as.x}
ay={as.y}
onDone={onOneDone}
/>
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}>
Klicken zum Rollen
</div> </div>
</div> </div>
</div> </div>
); );
}; };
const DieShell = ({ children, ringColor = "rgba(255,255,255,0.10)", rolling = false, onClick }) => {
const DieShell = ({ children, rolling = false, onClick }) => {
return ( return (
<button <button
type="button" type="button"
@@ -663,62 +727,20 @@ export default function App() {
width: 64, width: 64,
height: 64, height: 64,
borderRadius: 18, borderRadius: 18,
border: `1px solid ${stylesTokens.panelBorder}`, border: "none", // ✅ no border
background: background: "transparent", // ✅ no tile bg
"radial-gradient(120% 120% at 20% 10%, rgba(255,255,255,0.12), rgba(0,0,0,0.35) 55%, rgba(0,0,0,0.62))", boxShadow: "none", // ✅ no frame shadow
boxShadow: "0 18px 50px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(255,255,255,0.06)",
position: "relative", position: "relative",
overflow: "visible", // ✅ WICHTIG für Fix 4 overflow: "visible",
display: "grid", display: "grid",
placeItems: "center", placeItems: "center",
cursor: rolling ? "default" : "pointer", cursor: rolling ? "default" : "pointer",
transition: "transform 160ms ease, box-shadow 160ms ease", transition: "transform 160ms ease",
padding: 0, padding: 0,
outline: "none", outline: "none",
}} }}
disabled={rolling} disabled={rolling}
> >
{/* inner ring */}
<div
style={{
position: "absolute",
inset: 7,
borderRadius: 14,
border: `1px solid ${ringColor}`,
opacity: 0.85,
pointerEvents: "none",
zIndex: 1,
}}
/>
{/* bevel light */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(135deg, rgba(255,255,255,0.16) 0%, transparent 36%, transparent 64%, rgba(0,0,0,0.22) 100%)",
opacity: 0.9,
pointerEvents: "none",
mixBlendMode: "screen",
zIndex: 1,
}}
/>
{/* gloss stripe */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(120deg, rgba(255,255,255,0.10) 0%, transparent 38%, transparent 70%, rgba(255,255,255,0.06) 100%)",
opacity: 0.8,
pointerEvents: "none",
zIndex: 1,
}}
/>
{/* 👇 Cube kommt IMMER darüber */}
<div style={{ position: "relative", zIndex: 2 }}> <div style={{ position: "relative", zIndex: 2 }}>
{children} {children}
</div> </div>
@@ -733,13 +755,13 @@ export default function App() {
*/ */
const cubeRotationForD6 = (value) => { const cubeRotationForD6 = (value) => {
switch (value) { switch (value) {
case 1: return { rx: "0deg", ry: "0deg" }; case 1: return { rx: 0, ry: 0 };
case 2: return { rx: "-90deg", ry: "0deg" }; case 2: return { rx: -90, ry: 0 };
case 3: return { rx: "0deg", ry: "-90deg" }; case 3: return { rx: 0, ry: -90 };
case 4: return { rx: "0deg", ry: "90deg" }; case 4: return { rx: 0, ry: 90 };
case 5: return { rx: "90deg", ry: "0deg" }; case 5: return { rx: 90, ry: 0 };
case 6: return { rx: "0deg", ry: "180deg" }; case 6: return { rx: 0, ry: 180 };
default: return { rx: "0deg", ry: "0deg" }; default: return { rx: 0, ry: 0 };
} }
}; };
@@ -774,17 +796,18 @@ export default function App() {
); );
}; };
const DieD6 = ({ value = 1, rolling = false, onClick }) => { const DieD6 = ({ value = 1, rolling = false, onClick, ax, ay, onDone }) => {
const rot = cubeRotationForD6(value);
return ( return (
<DieShell ringColor="rgba(242,210,122,0.18)" rolling={rolling} onClick={onClick}> <DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap" style={{ zIndex: 2 }}> <div className="dieCubeWrap">
<div <div
className={`dieCube ${rolling ? "rolling" : ""}`} className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": rot.rx, "--ry": rot.ry }} style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
> >
{/* faces show correct pips like a real die */}
<div className="dieFace front"><PipFace value={1} /></div> <div className="dieFace front"><PipFace value={1} /></div>
<div className="dieFace back"><PipFace value={6} /></div> <div className="dieFace back"><PipFace value={6} /></div>
<div className="dieFace top"><PipFace value={2} /></div> <div className="dieFace top"><PipFace value={2} /></div>
@@ -793,55 +816,35 @@ export default function App() {
<div className="dieFace left"><PipFace value={4} /></div> <div className="dieFace left"><PipFace value={4} /></div>
</div> </div>
</div> </div>
<div
style={{
position: "absolute",
bottom: 6,
right: 8,
fontSize: 10,
fontWeight: 900,
color: "rgba(255,255,255,0.55)",
letterSpacing: 0.6,
}}
>
d6
</div>
</DieShell> </DieShell>
); );
}; };
const HouseDie = ({ face = "gryffindor", rolling = false, onClick }) => {
// icon instead of letters const HouseDie = ({ face = "gryffindor", rolling = false, onClick, ax, ay, onDone }) => {
const faces = { const faces = {
gryffindor: { icon: "🦁", color: "#ef4444", sub: "Gryff." }, gryffindor: { icon: "🦁", color: "#ef4444" },
slytherin: { icon: "🐍", color: "#22c55e", sub: "Slyth." }, slytherin: { icon: "🐍", color: "#22c55e" },
ravenclaw: { icon: "🦅", color: "#3b82f6", sub: "Raven." }, ravenclaw: { icon: "🦅", color: "#3b82f6" },
hufflepuff: { icon: "🦡", color: "#facc15", sub: "Huff." }, hufflepuff: { icon: "🦡", color: "#facc15" },
help: { icon: "🃏", color: "#f2d27a", sub: "Hilf" }, help: { icon: "🃏", color: "#f2d27a" },
dark: { icon: "🌙", color: "rgba(255,255,255,0.85)", sub: "Dark" }, dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" },
}; };
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"]; const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const idx = Math.max(0, order.indexOf(face));
const value = idx + 1; // 1..6
const rot = cubeRotationForD6(value);
const f = faces[face] || faces.gryffindor; const f = faces[face] || faces.gryffindor;
const ring = `${f.color}55`;
return ( return (
<DieShell ringColor={ring} rolling={rolling} onClick={onClick}> <DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap"> <div className="dieCubeWrap">
<div <div
className={`dieCube ${rolling ? "rolling" : ""}`} className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ style={{ "--rx": ax, "--ry": ay }}
"--rx": rot.rx, onTransitionEnd={(e) => {
"--ry": rot.ry, if (e.propertyName !== "transform") return;
"--ring": `${f.color}22`, if (rolling) onDone?.();
}} }}
> >
{/* order mapped to 6 faces */}
{order.map((key, i) => { {order.map((key, i) => {
const item = faces[key]; const item = faces[key];
const faceClass = const faceClass =
@@ -853,7 +856,7 @@ export default function App() {
"back"; "back";
return ( return (
<div key={key} className={`dieFace ${faceClass}`} style={{ "--ring": `${item.color}22` }}> <div key={key} className={`dieFace ${faceClass}`}>
<div <div
className="specialIcon" className="specialIcon"
style={{ style={{
@@ -868,26 +871,13 @@ export default function App() {
})} })}
</div> </div>
</div> </div>
<div
style={{
position: "absolute",
bottom: 6,
left: 10,
fontSize: 10,
fontWeight: 900,
color: "rgba(255,255,255,0.55)",
letterSpacing: 0.6,
}}
>
Spezial
</div>
</DieShell> </DieShell>
); );
}; };
const PlayerIdentityCard = ({ const PlayerIdentityCard = ({
name = "Harry Potter", name = "Harry Potter",
houseLabel = "Gryffindor", houseLabel = "Gryffindor",

View File

@@ -177,7 +177,7 @@ body {
opacity: 0.98; opacity: 0.98;
} }
/* ===== True 3D Cube Dice ===== */ /* ===== True 3D Cube Dice (transition-based roll) ===== */
.die3d { .die3d {
transform-style: preserve-3d; transform-style: preserve-3d;
will-change: transform; will-change: transform;
@@ -187,11 +187,6 @@ body {
transform: translateY(-3px) rotateX(8deg) rotateY(-10deg); transform: translateY(-3px) rotateX(8deg) rotateY(-10deg);
} }
.diceRow3d {
overflow: visible;
}
/* Cube container inside the button */
.dieCubeWrap { .dieCubeWrap {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -199,32 +194,74 @@ body {
transform-style: preserve-3d; transform-style: preserve-3d;
} }
/* actual cube */
.dieCube { .dieCube {
width: 44px; width: 44px;
height: 44px; height: 44px;
position: absolute; position: absolute;
inset: 0; inset: 0;
transform-style: preserve-3d; transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
/* absolute angles (numbers are set in JS) */
transform: rotateX(calc(var(--rx, 0) * 1deg)) rotateY(calc(var(--ry, 0) * 1deg));
/* default: snappy */
transition: transform 260ms cubic-bezier(0.22, 1.0, 0.25, 1); transition: transform 260ms cubic-bezier(0.22, 1.0, 0.25, 1);
} }
/* rolling animation (spins, then we snap by updating --rx/--ry) */ .dieCube.rolling {
@keyframes cubeRoll { /* roll duration */
0% { transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); } transition: transform 820ms cubic-bezier(0.18, 0.88, 0.22, 1);
100% {
/* WICHTIG: NUR Vielfache von 360°, damit Ende = gleiche Orientierung */
transform:
rotateX(calc(var(--rx, 0deg) + 720deg))
rotateY(calc(var(--ry, 0deg) + 720deg));
}
} }
.dieCube.rolling { /* Faces: remove hard borders / inner rings */
animation: cubeRoll 820ms cubic-bezier(0.18, 0.88, 0.22, 1) both; .dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: none; /* ✅ no face border */
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.05), /* very subtle */
inset 0 14px 24px rgba(255,255,255,0.04);
display: grid;
place-items: center;
backface-visibility: hidden;
} }
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* Prevent clipping */
.diceRow3d { overflow: visible; }
/* one face */ /* one face */
.dieFace { .dieFace {
position: absolute; position: absolute;