Tired of boilerplate code and endless configuration files? Same here.
I built this demo specifically to showcase how straightforward the implementation of "Sanctum RollDice" can be. No hidden dependencies, no complex setup—just clean code that does exactly what it says on the tin.
It’s genuinely effortless to incorporate into your existing projects. Simplicity was the main feature here.
Grab the code:
<!-- ========================================================================================== -->
<!-- SANCTUM DICE ROLLER - VERSIÓN FINAL: DISPERSIÓN DINÁMICA -->
<!-- ========================================================================================== -->
<!-- Librerías -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false }
}
</script>
<!-- Estilos -->
<style>
#sanctum-dice-root {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
line-height: 1.5;
position: relative;
width: 100%;
margin: 30px 0;
box-sizing: border-box;
color: #e2e8f0;
background-color: #020617;
border-radius: 12px;
overflow: hidden;
border: 1px solid #1e293b;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
/* Altura mínima garantizada para evitar cortes */
min-height: 860px;
height: auto;
}
#sanctum-dice-root:fullscreen {
border-radius: 0;
border: none;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow-y: auto;
}
#sanctum-dice-root *, #sanctum-dice-root ::before, #sanctum-dice-root ::after {
box-sizing: border-box;
}
@keyframes shake {
0% { transform: translate(1px, 1px) rotate(0deg); }
25% { transform: translate(-1px, -1px) rotate(-1deg); }
50% { transform: translate(1px, 0px) rotate(1deg); }
75% { transform: translate(-1px, 1px) rotate(0deg); }
100% { transform: translate(1px, -1px) rotate(-1deg); }
}
.animate-shake { animation: shake 0.2s infinite; }
.perspective-container { perspective: 1000px; }
.sanctum-scroll::-webkit-scrollbar { width: 6px; }
.sanctum-scroll::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); }
.sanctum-scroll::-webkit-scrollbar-thumb { background: #4f46e5; border-radius: 4px; }
.bg-grid-pattern {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(128,128,128,0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(128,128,128,0.1) 1px, transparent 1px);
}
</style>
<div id="sanctum-dice-root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- ICONOS ---
const Icon = ({ path, className, ...props }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>{path}</svg>
);
const Icons = {
Dices: (p) => <Icon path={<><path d="m10.083 2.15-1.027.556L4.32 5.488c-.604.327-.992.952-1.002 1.636L3.303 14.12c-.01.685.352 1.33.935 1.682l5.068 3.033c.636.38 1.45.362 2.067-.046l5.772-3.82"/><path d="m19.68 15.346-6.023-2.733c-.633-.287-1.077-.876-1.155-1.57L12.016 4.79"/><path d="m8.677 19.38 4.71 2.336c.552.274 1.22.15 1.635-.306l4.66-5.118c.37-.406.495-.98.33-1.506L18.47 10.36"/></>} {...p} />,
RotateCcw: (p) => <Icon path={<><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></>} {...p} />,
Trash2: (p) => <Icon path={<><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></>} {...p} />,
History: (p) => <Icon path={<><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></>} {...p} />,
Sparkles: (p) => <Icon path={<><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275-1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M9 3v4"/><path d="M3 9h4"/><path d="M3 5h4"/></>} {...p} />,
Swords: (p) => <Icon path={<><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/><polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5"/><line x1="5" x2="9" y1="14" y2="18"/><line x1="7" x2="4" y1="17" y2="20"/><line x1="3" x2="5" y1="19" y2="21"/></>} {...p} />,
Skull: (p) => <Icon path={<><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><path d="M8 20v2h8v-2"/><path d="m12.5 17-.5-1-.5 1h1z"/><path d="M16 20a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20"/></>} {...p} />,
Scroll: (p) => <Icon path={<><path d="M8 17.8a3.5 3.5 0 1 1 5.04-4.32L19.5 7.03"/><path d="M9.04 14.48a3.5 3.5 0 0 1-4.96-4.52L10.5 4.51"/><path d="M8 2h13a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1h-1"/><path d="M19 22v-6"/><path d="M8 22H3a1 1 0 0 1-1-1V5a3 3 0 0 1 6 0Z"/></>} {...p} />,
X: (p) => <Icon path={<><path d="M18 6 6 18"/><path d="m6 6 12 12"/></>} {...p} />,
Plus: (p) => <Icon path={<><path d="M5 12h14"/><path d="M12 5v14"/></>} {...p} />,
Minus: (p) => <Icon path={<path d="M5 12h14"/>} {...p} />,
Maximize: (p) => <Icon path={<><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></>} {...p} />,
Minimize: (p) => <Icon path={<><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></>} {...p} />
};
const DieVisual = ({ type, value, isRolling, id }) => {
const getColors = (t) => {
switch(t) {
case 4: return 'fill-emerald-600 stroke-emerald-400';
case 6: return 'fill-blue-600 stroke-blue-400';
case 8: return 'fill-violet-600 stroke-violet-400';
case 10: return 'fill-fuchsia-600 stroke-fuchsia-400';
case 12: return 'fill-orange-600 stroke-orange-400';
case 20: return 'fill-red-700 stroke-red-400';
default: return 'fill-slate-600 stroke-slate-400';
}
};
const getShapePath = (t) => {
const className = `${getColors(t)} stroke-2 opacity-90 drop-shadow-lg`;
switch(t) {
case 4: return <polygon points="50,15 15,85 85,85" className={className} />;
case 6: return <rect x="20" y="20" width="60" height="60" rx="4" className={className} />;
case 8: return <polygon points="50,10 90,50 50,90 10,50" className={className} />;
case 10: return <polygon points="50,10 85,50 50,90 15,50" className={className} />;
case 12: return <polygon points="50,10 88,38 73,85 27,85 12,38" className={className} />;
case 20: return <polygon points="50,5 90,25 90,75 50,95 10,75 10,25" className={className} />;
default: return null;
}
};
const gradId = `grad-${id}`;
return (
<div className={`relative w-14 h-14 flex items-center justify-center transition-transform duration-300 ${isRolling ? 'scale-90 blur-[1px]' : 'scale-100'}`}>
<svg viewBox="0 0 100 100" className="w-full h-full drop-shadow-[0_10px_10px_rgba(0,0,0,0.5)]">
{getShapePath(type)}
<circle cx="50" cy="50" r="30" fill={`url(#${gradId})`} opacity="0.4" className="pointer-events-none" />
<defs>
<radialGradient id={gradId} cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" stopColor="white" stopOpacity="0.5" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{isRolling ? (
<span className="text-xl font-bold text-white/50 animate-pulse">?</span>
) : (
<span className={`text-2xl font-black text-white drop-shadow-md ${type === 20 && value === 20 ? 'text-yellow-200 scale-125' : ''}`}>
{value}
</span>
)}
</div>
{!isRolling && (
<div className="absolute -bottom-2 w-full text-center">
<span className="bg-slate-900/80 text-slate-400 text-[9px] px-1.5 py-0 rounded-full border border-slate-700">
d{type}
</span>
</div>
)}
</div>
);
};
function DiceRollerApp() {
const [dice, setDice] = useState([]);
const [modifier, setModifier] = useState(0);
const [history, setHistory] = useState([]);
const [isRollingGlobal, setIsRollingGlobal] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const scrollRef = useRef(null);
const containerRef = useRef(null);
const generateId = () => Math.random().toString(36).substr(2, 9);
const addDie = (type) => {
if (dice.length >= 12) return;
const newDie = {
id: generateId(),
type,
value: type,
isRolling: false,
rotation: Math.random() * 360,
offsetX: (Math.random() - 0.5) * 20,
offsetY: (Math.random() - 0.5) * 20
};
setDice([...dice, newDie]);
};
const removeDie = (id) => setDice(dice.filter(d => d.id !== id));
const clearTable = () => { setDice([]); setModifier(0); };
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen().catch(err => console.error(err));
} else {
document.exitFullscreen();
}
};
useEffect(() => {
const handleFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handleFsChange);
return () => document.removeEventListener('fullscreenchange', handleFsChange);
}, []);
// --- ALGORITMO DE DISPERSIÓN DINÁMICA ---
const calculateScatteredPositions = (count) => {
const positions = [];
// Límites del tablero en porcentaje (-42 a 42 para evitar bordes)
const bounds = { min: -42, max: 42 };
for (let i = 0; i < count; i++) {
let placed = false;
let attempts = 0;
// Distancia mínima ideal (22% es aprox 2.5 veces el tamaño relativo)
let currentMinDist = 22;
while (!placed && attempts < 150) {
const x = Math.random() * (bounds.max - bounds.min) + bounds.min;
const y = Math.random() * (bounds.max - bounds.min) + bounds.min;
let collision = false;
for (const pos of positions) {
const dx = x - pos.x;
const dy = y - pos.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < currentMinDist) {
collision = true;
break;
}
}
if (!collision) {
positions.push({ x, y });
placed = true;
}
attempts++;
// Degollar la exigencia ligeramente si nos atascamos
if (attempts > 50) currentMinDist *= 0.98;
}
// Fallback seguro por si acaso
if (!placed) {
positions.push({
x: Math.random() * (bounds.max - bounds.min) + bounds.min,
y: Math.random() * (bounds.max - bounds.min) + bounds.min
});
}
}
return positions;
};
const rollDice = () => {
if (dice.length === 0 || isRollingGlobal) return;
setIsRollingGlobal(true);
const rollingDice = dice.map(d => ({ ...d, isRolling: true, value: null }));
setDice(rollingDice);
setTimeout(() => {
const securePositions = calculateScatteredPositions(dice.length);
const results = dice.map((d, index) => {
const val = Math.ceil(Math.random() * d.type);
const pos = securePositions[index];
return {
...d,
value: val,
isRolling: false,
offsetX: pos.x,
offsetY: pos.y,
rotation: Math.random() * 360
};
});
setDice(results);
setIsRollingGlobal(false);
const diceResults = results.map(d => ({ type: d.type, value: d.value }));
const total = diceResults.reduce((acc, curr) => acc + curr.value, 0) + modifier;
const newLog = {
id: generateId(),
timestamp: new Date(),
dice: diceResults,
modifier,
total,
isCrit: results.some(d => d.type === 20 && d.value === 20),
isFail: results.some(d => d.type === 20 && d.value === 1)
};
setHistory(prev => [newLog, ...prev]);
}, 400);
};
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [history]);
return (
<div ref={containerRef} id="sanctum-dice-root" className="flex flex-col">
{/* --- SECCIÓN 1: CONTROLES --- */}
<div className="w-full bg-slate-900/50 backdrop-blur-md border-b border-white/10 p-4 flex flex-col gap-4">
<div className="flex items-center justify-between mb-2 pb-2 border-b border-white/5">
<div className="flex items-center gap-2">
<Icons.Dices className="w-6 h-6 text-purple-400" />
<span className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-indigo-300 tracking-wider">SANCTUM</span>
</div>
<div className="flex gap-2">
<button onClick={clearTable} className="text-slate-400 hover:text-red-400 p-2" title="Limpiar Mesa"><Icons.Trash2 className="w-5 h-5" /></button>
<button onClick={toggleFullscreen} className="text-slate-400 hover:text-white p-2" title="Pantalla Completa">
{isFullscreen ? <Icons.Minimize className="w-5 h-5" /> : <Icons.Maximize className="w-5 h-5" />}
</button>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 bg-slate-900/80 border border-white/10 rounded-xl p-3 shadow-md">
<h2 className="text-xs text-indigo-300 font-semibold mb-2 flex items-center gap-2 uppercase"><Icons.Swords className="w-3 h-3" /> Armería</h2>
<div className="grid grid-cols-6 gap-2">
{[4, 6, 8, 10, 12, 20].map((d) => (
<button key={d} onClick={() => addDie(d)} className="group relative h-10 rounded-lg bg-slate-800 border border-slate-700 hover:border-purple-500 hover:bg-slate-700 transition-all flex flex-col items-center justify-center overflow-hidden">
<span className="text-sm font-bold text-slate-300 group-hover:text-white">d{d}</span>
</button>
))}
</div>
</div>
<div className="flex-1 sm:max-w-[200px] bg-slate-900/80 border border-white/10 rounded-xl p-3 shadow-md">
<h2 className="text-xs text-indigo-300 font-semibold mb-2 flex items-center gap-2 uppercase"><Icons.Sparkles className="w-3 h-3" /> Modificador</h2>
<div className="flex gap-2 h-10">
<button onClick={() => setModifier(m => m - 1)} className="flex-1 rounded bg-slate-800 border border-slate-700 hover:border-red-500 text-slate-400 hover:text-red-400 flex items-center justify-center"><Icons.Minus className="w-4 h-4" /></button>
<div className="flex-1 bg-slate-800 rounded border border-slate-700 flex items-center justify-center px-4">
<span className={`font-mono text-xl font-bold ${modifier > 0 ? 'text-green-400' : modifier < 0 ? 'text-red-400' : 'text-white'}`}>{modifier > 0 ? '+' : ''}{modifier}</span>
</div>
<button onClick={() => setModifier(m => m + 1)} className="flex-1 rounded bg-slate-800 border border-slate-700 hover:border-green-500 text-slate-400 hover:text-green-400 flex items-center justify-center"><Icons.Plus className="w-4 h-4" /></button>
</div>
</div>
</div>
</div>
{/* --- SECCIÓN 2: TABLERO --- */}
<div className={`w-full relative flex flex-col bg-slate-900/20 border-t border-b border-white/5 ${isFullscreen ? 'flex-1' : 'min-h-[400px]'}`}>
<div className="absolute inset-0 opacity-20 bg-grid-pattern pointer-events-none"></div>
<div className="flex-1 relative flex items-center justify-center perspective-container overflow-hidden min-h-[320px]">
{dice.length === 0 && (
<div className="text-center opacity-30 select-none">
<Icons.Dices className="w-16 h-16 mx-auto mb-4 animate-pulse" />
<p className="text-lg font-light">Mesa vacía.</p>
</div>
)}
<div className="relative w-full h-full pointer-events-none">
{dice.map((die) => (
<div key={die.id} className="absolute transition-all duration-500 ease-out will-change-transform" style={{ left: `${50 + die.offsetX}%`, top: `${50 + die.offsetY}%`, transform: `translate(-50%, -50%) rotate(${die.rotation}deg)`, zIndex: die.isRolling ? 10 : 1 }}>
<div className={`relative pointer-events-auto cursor-pointer group ${die.isRolling ? 'animate-shake' : ''}`}>
<button onClick={() => removeDie(die.id)} className="absolute -top-3 -right-3 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity z-20 shadow-lg scale-75 border-none cursor-pointer"><Icons.X className="w-3 h-3" /></button>
<DieVisual type={die.type} value={die.value} isRolling={die.isRolling} id={die.id} />
</div>
</div>
))}
</div>
</div>
<div className="p-4 bg-slate-900/50 backdrop-blur-sm border-t border-white/5 relative z-10">
<button onClick={rollDice} disabled={dice.length === 0 || isRollingGlobal} className={`w-full py-4 rounded-xl font-black text-xl tracking-widest uppercase transition-all transform ${dice.length === 0 ? 'bg-slate-800 text-slate-600 cursor-not-allowed' : isRollingGlobal ? 'bg-indigo-600 cursor-wait scale-95' : 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white shadow-lg hover:-translate-y-1 active:scale-95'}`}>
{isRollingGlobal ? <span className="flex items-center justify-center gap-2"><Icons.RotateCcw className="animate-spin w-5 h-5" /> RODANDO...</span> : "¡LANZAR!"}
</button>
</div>
</div>
{/* --- SECCIÓN 3: CRÓNICAS --- */}
<div className={`w-full bg-slate-950 p-4 flex flex-col ${isFullscreen ? 'h-[300px]' : 'min-h-[250px]'}`}>
<div className="flex justify-between items-center mb-3">
<h2 className="text-sm text-indigo-300 font-semibold flex items-center gap-2 uppercase"><Icons.Scroll className="w-4 h-4" /> Crónicas</h2>
{history.length > 0 && <button onClick={() => setHistory([])} className="text-xs text-slate-500 hover:text-red-400 transition-colors">Quemar Archivos</button>}
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-2 sanctum-scroll pr-2 max-h-[300px]">
{history.length === 0 ? (
<div className="h-24 flex flex-col items-center justify-center text-slate-700 border border-dashed border-slate-800 rounded-lg">
<p className="text-xs italic">El pergamino está en blanco.</p>
</div>
) : (
history.map((log) => (
<div key={log.id} className={`relative p-3 rounded-xl border text-sm transition-all animate-in slide-in-from-right-4 ${log.isCrit ? 'bg-yellow-900/10 border-yellow-500/30' : log.isFail ? 'bg-red-900/10 border-red-500/30' : 'bg-slate-800/40 border-slate-700/50'}`}>
<div className="flex justify-between items-start mb-2">
<span className="text-xs text-slate-500 font-mono">{log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
{log.isCrit && <span className="text-xs font-bold text-yellow-400 animate-pulse flex items-center gap-1">★ CRÍTICO</span>}
{log.isFail && <span className="text-xs font-bold text-red-400 flex items-center gap-1">☠ PIFIA</span>}
</div>
<div className="flex flex-wrap gap-2 mb-2">
{log.dice.map((d, i) => (
<span key={i} className={`text-xs px-2 py-0.5 rounded ${d.type === 20 && d.value === 20 ? 'bg-yellow-500 text-black font-bold' : d.type === 20 && d.value === 1 ? 'bg-red-500 text-white font-bold' : 'bg-slate-700 text-slate-300'}`}>
d{d.type}:<span className="text-white ml-1 font-bold">{d.value}</span>
</span>
))}
{log.modifier !== 0 && <span className="text-xs px-2 py-0.5 rounded bg-indigo-900/50 text-indigo-300 border border-indigo-800">{log.modifier > 0 ? '+' : ''}{log.modifier}</span>}
</div>
<div className="flex justify-end border-t border-white/5 pt-2"><span className="text-xl font-black text-white">{log.total}</span></div>
</div>
))
)}
</div>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('sanctum-dice-root'));
root.render(<DiceRollerApp />);
</script>
.jpg)