Files
entscheidomat/components/tools/dice-roller.tsx
2026-07-23 21:59:15 +02:00

271 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Dices } from "lucide-react";
import { gsap } from "@/lib/gsap";
import { popConfetti } from "@/lib/confetti";
import { useSound } from "@/components/providers/sound-provider";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
const SIZE = 104;
const HALF = SIZE / 2;
/** Würfelaugen-Positionen im 3×3-Raster (viewBox 40×40). */
const PIPS: Record<number, number[]> = {
1: [4],
2: [0, 8],
3: [0, 4, 8],
4: [0, 2, 6, 8],
5: [0, 2, 4, 6, 8],
6: [0, 2, 3, 5, 6, 8],
};
/** Platzierung der Seiten im Würfel (gegenüberliegende Seiten = 7). */
const FACE_TRANSFORMS = [
`translateZ(${HALF}px)`, // 1 vorne
`rotateX(90deg) translateZ(${HALF}px)`, // 2 oben
`rotateY(90deg) translateZ(${HALF}px)`, // 3 rechts
`rotateY(-90deg) translateZ(${HALF}px)`, // 4 links
`rotateX(-90deg) translateZ(${HALF}px)`, // 5 unten
`rotateY(180deg) translateZ(${HALF}px)`, // 6 hinten
];
/** Welche Würfel-Rotation bringt Wert v nach vorne? */
const FACE_ROTATION: Record<number, { rx: number; ry: number }> = {
1: { rx: 0, ry: 0 },
2: { rx: -90, ry: 0 },
3: { rx: 0, ry: -90 },
4: { rx: 0, ry: 90 },
5: { rx: 90, ry: 0 },
6: { rx: 0, ry: 180 },
};
const DEFAULT_CUSTOM = ["Ja", "Nein", "Vielleicht", "Frag später", "Gute Idee", "Lass es"];
export default function DiceRoller() {
const [mode, setMode] = useLocalStorage<"numbers" | "custom">(
"dice-mode",
"numbers",
);
const [customFaces, setCustomFaces] = useLocalStorage<string[]>(
"dice-custom",
DEFAULT_CUSTOM,
);
const [rolling, setRolling] = useState(false);
const [result, setResult] = useState<number | null>(null);
const diceRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const rotRef = useRef({ rx: -18, ry: 24 }); // hübscher Startwinkel
const { play } = useSound();
const reduced = usePrefersReducedMotion();
const roll = () => {
if (rolling) return;
const value = 1 + Math.floor(Math.random() * 6);
setRolling(true);
setResult(null);
play("dice");
const { rx, ry } = rotRef.current;
const target = FACE_ROTATION[value];
const targetRx = Math.ceil(rx / 360) * 360 + 720 + target.rx;
const targetRy = Math.ceil(ry / 360) * 360 + 720 + target.ry;
const dur = reduced ? 0.35 : 1.9;
gsap.to(diceRef.current, {
rotationX: targetRx,
rotationY: targetRy,
duration: dur,
ease: reduced ? "power1.out" : "power3.out",
onComplete: () => {
rotRef.current = { rx: targetRx, ry: targetRy };
setRolling(false);
setResult(value);
play("pop");
popConfetti();
},
});
if (!reduced) {
gsap.to(stageRef.current, {
y: -40,
duration: dur / 2,
yoyo: true,
repeat: 1,
ease: "power2.out",
});
}
};
const resultLabel =
result === null
? null
: mode === "custom"
? customFaces[result - 1]?.trim() || `Seite ${result}`
: String(result);
return (
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
{/* Bühne */}
<div className="flex flex-col items-center">
<div ref={stageRef} className="dice-scene p-6">
<button
type="button"
onClick={roll}
aria-label="Würfel rollen"
className="block cursor-pointer drop-shadow-[0_22px_26px_rgb(23_23_28/0.22)] transition-transform duration-200 hover:scale-[1.05] active:scale-95"
>
<div
ref={diceRef}
className="dice"
style={{
width: SIZE,
height: SIZE,
// Startwinkel als Literal (deckt sich mit rotRef's Initialwert)
// GSAP übernimmt danach die Rotation imperativ am DOM-Node.
transform: "rotateX(-18deg) rotateY(24deg)",
}}
>
{[1, 2, 3, 4, 5, 6].map((v, i) => (
<div
key={v}
className="dice-face"
style={{ transform: FACE_TRANSFORMS[i] }}
>
{mode === "numbers" ? (
<svg viewBox="0 0 40 40" className="h-full w-full">
{PIPS[v].map((p) => (
<circle
key={p}
cx={8 + (p % 3) * 12}
cy={8 + Math.floor(p / 3) * 12}
r={3.6}
fill="#17171c"
/>
))}
</svg>
) : (
<span className="dice-face-label">
{customFaces[v - 1]?.trim() || `Seite ${v}`}
</span>
)}
</div>
))}
</div>
</button>
</div>
<div className="mt-2 h-14" aria-live="polite">
<AnimatePresence mode="wait">
{resultLabel !== null && (
<motion.p
key={resultLabel + String(result)}
initial={{ opacity: 0, scale: 0.6, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 380, damping: 22 }}
className="font-display text-3xl font-semibold tracking-tight"
>
<span className="text-primary">{resultLabel}</span>
</motion.p>
)}
</AnimatePresence>
</div>
<motion.button
type="button"
onClick={roll}
disabled={rolling}
whileTap={{ scale: 0.95 }}
className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
>
<Dices className="h-5 w-5" />
{rolling ? "Rollt …" : "Würfeln!"}
</motion.button>
</div>
{/* Einstellungen */}
<div className="space-y-5">
<div className="rounded-3xl border border-line bg-cream/70 p-5">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
Würfel-Seiten
</h3>
<div className="mt-4 flex rounded-full border border-line bg-surface p-1">
{(
[
{ id: "numbers", label: "Zahlen 16" },
{ id: "custom", label: "Eigene Texte" },
] as const
).map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={`relative flex-1 cursor-pointer rounded-full px-3 py-2 text-sm font-semibold transition-colors duration-200 ${
mode === m.id ? "text-white" : "text-ink-soft hover:text-ink"
}`}
>
{mode === m.id && (
<motion.span
layoutId="dice-mode-pill"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-0 rounded-full bg-primary"
/>
)}
<span className="relative z-10">{m.label}</span>
</button>
))}
</div>
<AnimatePresence initial={false}>
{mode === "custom" && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="mt-4 grid grid-cols-2 gap-2">
{customFaces.map((face, i) => (
<label key={i} className="block">
<span className="mb-1 block text-[11px] font-medium text-ink-soft">
Seite {i + 1}
</span>
<input
value={face}
onChange={(e) =>
setCustomFaces((faces) =>
faces.map((f, idx) =>
idx === i ? e.target.value : f,
),
)
}
maxLength={18}
placeholder={`Seite ${i + 1}`}
className="w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
/>
</label>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
Klassisch mit Würfelaugen oder mit eigenen Texten perfekt für
Wer spült heute ab?.
</p>
</div>
</div>
</div>
);
}