https://svonberg.org/wp-content/uploads/2025/11/Dicehtm.html
https://svonberg.org/wp-content/uploads/2025/11/Dicehtm.html
Code
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
<title>Appalachian Dice Roller</title>
<script src=”https://cdn.tailwindcss.com”></script>
<link href=”https://fonts.googleapis.com/css2?family=Merriweather:wght@700&family=Open+Sans:wght@400;600&display=swap” rel=”stylesheet”>
<style>
body {
margin: 0;
overflow: hidden;
font-family: ‘Open Sans’, sans-serif;
color: #d8dee9;
background: linear-gradient(to bottom, #4a6c8a, #78909c, #a7b7be);
background-image: url(‘https://image.pollinations.ai/prompt/Blue%20Ridge%20Mountains%20at%20dawn,%20misty%20valleys,%20river,%20pine%20trees,%20photorealistic,%20cinematic%20lighting’);
background-size: cover;
background-position: center;
}
#canvas-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-color: rgba(0, 0, 0, 0.3);
}
#ui-container {
position: relative;
z-index: 10;
pointer-events: none;
text-shadow: 1px 1px 3px rgba(0,0,0,0.5);
}
.interactive { pointer-events: auto; }
h1, h2 { font-family: ‘Merriweather’, serif; }
/* Custom Styles for Appalachian feel */
.wood-panel {
background-color: rgba(30, 40, 50, 0.85);
border: 1px solid #4a6c8a;
box-shadow: 0 4px 6px rgba(0,0,0,0.3), inset 0 0 8px rgba(74, 108, 138, 0.4);
}
.stone-input {
background-color: rgba(20, 30, 40, 0.9);
border: 1px solid #5a7d9b;
color: #e0f2f1;
text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
}
.stone-button {
background-color: #3b5a6d;
border: 1px solid #6f8fa3;
color: #ffffff;
font-family: ‘Merriweather’, serif;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
box-shadow: 0 2px 4px rgba(0,0,0,0.3), inset 0 1px 2px rgba(255,255,255,0.1);
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
line-height: 1.1;
}
.stone-button:hover {
background-color: #4a6c8a;
border-color: #8bb1c7;
box-shadow: 0 3px 5px rgba(0,0,0,0.4), inset 0 1px 3px rgba(255,255,255,0.15);
transform: translateY(-1px);
}
.stone-button:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0,0,0,0.2), inset 0 0 5px rgba(0,0,0,0.3);
}
/* Animations */
@keyframes pulse-crit {
0% { text-shadow: 0 0 8px #ffd700; transform: scale(1); color: #ffd700; }
50% { text-shadow: 0 0 15px #ffd700, 0 0 30px #e74c3c; transform: scale(1.1); color: #ffd700; }
100% { text-shadow: 0 0 8px #ffd700; transform: scale(1); color: #ffd700; }
}
.crit-anim { animation: pulse-crit 0.5s ease-in-out infinite; }
@keyframes pulse-fail {
0% { text-shadow: 0 0 5px #c0392b; color: #c0392b; }
50% { text-shadow: 0 0 15px #c0392b; color: #c0392b; }
100% { text-shadow: 0 0 5px #c0392b; color: #c0392b; }
}
.fail-anim { animation: pulse-fail 1s ease-in-out infinite; }
.result-text {
color: #d8dee9;
text-shadow: 1px 1px 5px rgba(0,0,0,0.6);
}
</style>
</head>
<body class=”h-screen w-screen flex flex-col”>
<div id=”canvas-container”></div>
<div id=”ui-container” class=”h-full w-full flex flex-col justify-between p-4 sm:p-6″>
<!– Header –>
<div class=”w-full flex justify-between items-start”>
<div>
<h1 class=”text-2xl sm:text-3xl font-bold text-teal-200 tracking-wide”>MOUNTAIN <span class=”text-gray-400″>ROLL</span></h1>
<p class=”text-xs sm:text-sm text-blue-300 mt-1″>ANCIENT STONES // ACTIVE</p>
</div>
</div>
<!– Result Display –>
<div class=”absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center w-full pointer-events-none”>
<div id=”result-label” class=”text-sm sm:text-lg text-blue-200 tracking-wider mb-2 sm:mb-3 opacity-0 transition-opacity duration-300″>TOTAL SUM</div>
<div id=”result-display” class=”text-6xl sm:text-8xl font-bold result-text opacity-0 transition-all duration-200″>–</div>
<div id=”result-breakdown” class=”text-base sm:text-xl text-green-300 mt-2 sm:mt-3 opacity-0 tracking-wider font-semibold”></div>
</div>
<!– Controls –>
<div class=”w-full max-w-lg mx-auto p-3 sm:p-4 rounded-lg wood-panel interactive”>
<div class=”flex flex-col gap-3 sm:gap-4″>
<!– Input Row –>
<div class=”flex items-end justify-between gap-2 sm:gap-4″>
<div class=”w-1/5 sm:w-1/4″>
<label class=”block text-[10px] sm:text-xs text-blue-300 mb-1 uppercase tracking-wider text-center”>Count</label>
<input type=”number” id=”count-input” value=”3″ min=”1″ max=”25″
class=”w-full p-2 rounded text-center text-lg sm:text-xl focus:outline-none stone-input”>
</div>
<div class=”text-blue-300 pb-2 sm:pb-3 font-bold text-xl sm:text-2xl”>X</div>
<div class=”w-1/5 sm:w-1/4″>
<label class=”block text-[10px] sm:text-xs text-blue-300 mb-1 uppercase tracking-wider text-center”>Faces</label>
<input type=”number” id=”faces-input” value=”6″ min=”2″ max=”100″
class=”w-full p-2 rounded text-center text-lg sm:text-xl focus:outline-none stone-input”>
</div>
<div class=”flex-1 min-w-0″> <!– min-w-0 prevents flex item from overflowing –>
<label class=”block text-[10px] sm:text-xs text-blue-300 mb-1 uppercase tracking-wider text-center”>Action</label>
<button id=”roll-btn”
class=”w-full py-2 px-1 sm:px-4 rounded stone-button uppercase h-[44px] sm:h-[46px] text-xs sm:text-sm font-bold tracking-wide whitespace-nowrap overflow-hidden text-ellipsis”>
ROLL STONES
</button>
</div>
</div>
<!– Quick Presets –>
<div class=”grid grid-cols-5 gap-2 mt-1″>
<button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”1″ data-faces=”20″>1d20</button>
<button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”2″ data-faces=”20″>2d20</button>
<button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”3″ data-faces=”6″>3d6</button>
<button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”4″ data-faces=”6″>4d6</button>
<button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”2″ data-faces=”8″>2d8</button>
</div>
</div>
</div>
</div>
<!– Three.js –>
<script src=”https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js”></script>
<script>
// — SCENE SETUP —
const container = document.getElementById(‘canvas-container’);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x4a6c8a, 0.02);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 10;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
renderer.setClearColor(0x000000, 0);
// — LIGHTING —
const ambientLight = new THREE.AmbientLight(0x708090);
scene.add(ambientLight);
const lights = [];
const lightColors = [0x8fbc8f, 0xb0e0e6, 0xd8bfd8];
lightColors.forEach((col, i) => {
const l = new THREE.PointLight(col, 0.6, 50);
l.position.set(Math.sin(i*2) * 10, Math.cos(i*2) * 10, 10);
scene.add(l);
lights.push(l);
});
// — MATERIALS —
const fillMaterial = new THREE.MeshLambertMaterial({
color: 0x5e6e7b,
reflectivity: 0.1,
shininess: 10,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
});
const wireMaterial = new THREE.LineBasicMaterial({
color: 0x8fbc8f,
transparent: true,
opacity: 0.7
});
// — STATE VARIABLES —
let diceObjects = [];
const mainGroup = new THREE.Group();
scene.add(mainGroup);
const countInput = document.getElementById(‘count-input’);
const facesInput = document.getElementById(‘faces-input’);
const rollBtn = document.getElementById(‘roll-btn’);
const resultDisplay = document.getElementById(‘result-display’);
const resultLabel = document.getElementById(‘result-label’);
const resultBreakdown = document.getElementById(‘result-breakdown’);
const presetBtns = document.querySelectorAll(‘.preset-btn’);
let isRolling = false;
// — GEOMETRY GENERATOR —
function getGeometry(faces) {
faces = parseInt(faces);
switch(faces) {
case 4: return new THREE.TetrahedronGeometry(0.9);
case 6: return new THREE.BoxGeometry(1.2, 1.2, 1.2);
case 8: return new THREE.OctahedronGeometry(0.9);
case 12: return new THREE.DodecahedronGeometry(0.9);
case 20: return new THREE.IcosahedronGeometry(0.9);
default: return new THREE.IcosahedronGeometry(0.9, 1);
}
}
// — LAYOUT ENGINE —
function updateScene() {
while(mainGroup.children.length > 0){
const child = mainGroup.children[0];
if(child.geometry) child.geometry.dispose();
mainGroup.remove(child);
}
diceObjects = [];
const count = parseInt(countInput.value) || 1;
const faces = parseInt(facesInput.value) || 6;
const cols = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / cols);
const spacing = 3.0;
const startX = -((cols – 1) * spacing) / 2;
const startY = ((rows – 1) * spacing) / 2;
const maxDim = Math.max(cols, rows);
const targetZ = 5 + (maxDim * 2.0);
camera.position.z = targetZ;
const geometry = getGeometry(faces);
const edges = new THREE.EdgesGeometry(geometry);
for(let i = 0; i < count; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const mesh = new THREE.Mesh(geometry, fillMaterial);
const wireframe = new THREE.LineSegments(edges, wireMaterial.clone());
mesh.add(wireframe);
mesh.position.x = startX + (col * spacing);
mesh.position.y = startY – (row * spacing);
diceObjects.push({
mesh: mesh,
wire: wireframe,
speed: { x: 0.002, y: 0.002 },
baseY: mesh.position.y
});
mainGroup.add(mesh);
}
}
// — INPUT LISTENERS —
function handleUpdate() {
if(isRolling) return;
let c = parseInt(countInput.value);
if(c > 25) { c = 25; countInput.value = 25; }
if(c < 1) { c = 1; countInput.value = 1; }
let f = parseInt(facesInput.value);
if(f < 2) { f = 2; facesInput.value = 2; }
updateScene();
}
countInput.addEventListener(‘change’, handleUpdate);
facesInput.addEventListener(‘change’, handleUpdate);
presetBtns.forEach(btn => {
btn.addEventListener(‘click’, () => {
if(isRolling) return;
countInput.value = btn.getAttribute(‘data-count’);
facesInput.value = btn.getAttribute(‘data-faces’);
updateScene();
});
});
// — ROLL LOGIC —
rollBtn.addEventListener(‘click’, () => {
if(isRolling) return;
isRolling = true;
const count = parseInt(countInput.value);
const faces = parseInt(facesInput.value);
resultDisplay.style.opacity = 0;
resultLabel.style.opacity = 0;
resultBreakdown.style.opacity = 0;
resultDisplay.className = “text-6xl sm:text-8xl font-bold result-text opacity-0 transition-all duration-200”;
// Store original text but show calculating text
const originalText = rollBtn.innerText;
rollBtn.innerText = “DIVINING…”;
rollBtn.classList.add(‘opacity-50’, ‘cursor-not-allowed’);
diceObjects.forEach(obj => {
obj.speed = {
x: (Math.random() – 0.5) * 1.5,
y: (Math.random() – 0.5) * 1.5
};
});
const results = [];
let total = 0;
for(let i=0; i<count; i++) {
const r = Math.floor(Math.random() * faces) + 1;
results.push(r);
total += r;
}
setTimeout(() => {
showResult(total, results, faces);
}, 1000);
});
function showResult(total, results, faces) {
const decayInt = setInterval(() => {
let allStopped = true;
diceObjects.forEach(obj => {
obj.speed.x *= 0.8;
obj.speed.y *= 0.8;
if(Math.abs(obj.speed.x) > 0.005) allStopped = false;
});
if(allStopped) {
clearInterval(decayInt);
diceObjects.forEach(obj => obj.speed = { x: 0.002, y: 0.002 });
isRolling = false;
rollBtn.innerText = “ROLL STONES”;
rollBtn.classList.remove(‘opacity-50’, ‘cursor-not-allowed’);
}
}, 50);
resultLabel.style.opacity = 1;
resultDisplay.textContent = total;
resultDisplay.style.opacity = 1;
resultBreakdown.textContent = `[ ${results.join(‘, ‘)} ]`;
resultBreakdown.style.opacity = 1;
const maxPossible = results.length * faces;
const minPossible = results.length;
diceObjects.forEach(obj => obj.wire.material.color.setHex(0x8fbc8f));
if(total === maxPossible) {
resultDisplay.classList.add(‘crit-anim’);
diceObjects.forEach(obj => obj.wire.material.color.setHex(0xffd700));
} else if (total === minPossible) {
resultDisplay.classList.add(‘fail-anim’);
diceObjects.forEach(obj => obj.wire.material.color.setHex(0xc0392b));
} else {
resultDisplay.classList.add(‘result-text’);
}
}
// — ANIMATION LOOP —
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
diceObjects.forEach((obj, index) => {
obj.mesh.rotation.x += obj.speed.x;
obj.mesh.rotation.y += obj.speed.y;
if(!isRolling) {
obj.mesh.position.y = obj.baseY + Math.sin(time + index) * 0.08;
}
obj.wire.material.opacity = 0.6 + Math.sin(time * 1.5) * 0.2;
});
renderer.render(scene, camera);
}
updateScene();
animate();
window.addEventListener(‘resize’, () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>