Twenty_Sided_Cube / index.html
eaglelandsonce's picture
Update index.html
ebc3e62 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>d20 β€” Fixed Render + Weighted/Fair Roll</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { --bg:#0b1020; --panel:#121a33; --text:#e6e9f2; --accent:#7dd3fc; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
color: var(--text);
background: #0b1020;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
display: flex;
flex-direction: column;
gap: 14px;
align-items: center;
padding: 14px;
}
h1 { margin: 8px 0 0; font-weight: 600; font-size: 20px; }
.wrap {
width: min(1100px, 95vw);
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 14px;
}
#stage {
width: 100%;
height: 60vh;
min-height: 380px;
background: #04070f;
border: 1px solid #1f2b53;
border-radius: 14px;
overflow: hidden;
position: relative;
}
#ui {
background: var(--panel);
border: 1px solid #1f2b53;
border-radius: 14px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
label { font-size: 12px; color: #b9c2dc; }
input[type="number"],
input[type="range"] {
width: 100%;
background: #0e152b;
color: var(--text);
border: 1px solid #243266;
border-radius: 10px;
padding: 8px 10px;
outline: none;
}
input[type="range"] { height: 32px; }
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
button {
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
background: linear-gradient(180deg, #1d4ed8, #1e40af);
color: white;
font-weight: 600;
border: 1px solid #203986;
box-shadow: 0 6px 20px #1d4ed84d;
}
button:active { transform: translateY(1px); }
.readout {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
text-align: center;
background: #0e152b;
border: 1px solid #243266;
border-radius: 12px;
padding: 10px;
font-variant-numeric: tabular-nums;
}
.badge { font-size: 12px; color: #9fb3ff; }
#chartBox {
background: #0e152b;
border: 1px solid #243266;
border-radius: 14px;
padding: 10px;
}
#hist { width: 100%; height: 220px; display: block; }
.tiny { font-size: 11px; color: #98a2c7; }
.note { font-size: 12px; color: #bcd0ff; }
@media (max-width: 800px) {
.wrap { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<h1>d20 Roll (Weighted or Fair)</h1>
<div class="wrap">
<div id="stage"></div>
<div id="ui">
<div class="switch">
<input id="useWeight" type="checkbox" checked />
<label for="useWeight">Use Gaussian weighting</label>
</div>
<div class="row">
<div>
<label for="mu">Center face (ΞΌ, 1–20)</label>
<input id="mu" type="number" min="1" max="20" step="1" value="10" />
</div>
<div>
<label for="sigma">Spread (Οƒ, 0.3–8)</label>
<input id="sigma" type="number" min="0.3" max="8" step="0.1" value="2.5" />
</div>
</div>
<div class="row">
<div>
<label for="spin">Spin time (sec)</label>
<input id="spin" type="range" min="0.6" max="2.8" step="0.1" value="1.6" />
</div>
<div>
<label for="bounciness">Bounciness</label>
<input id="bounciness" type="range" min="0" max="1" step="0.05" value="0.35" />
</div>
</div>
<button id="rollBtn">🎲 Roll d20</button>
<div class="readout">
<div><div class="badge">Last Roll</div><div id="lastRoll" style="font-size:26px; font-weight:700;">β€”</div></div>
<div><div class="badge">Total Rolls</div><div id="totalRolls" style="font-size:22px; font-weight:700;">0</div></div>
<div><div class="badge">Expected Center</div><div id="expect" style="font-size:22px; font-weight:700;">10</div></div>
</div>
<div id="chartBox">
<canvas id="hist" width="480" height="220"></canvas>
<div class="tiny">Histogram of faces 1..20. Dotted curve = current Gaussian (scaled). Turn off β€œUse Gaussian weighting” for a fair die.</div>
</div>
<div class="note">If nothing appears, press F12 β†’ Console and tell me any red errors you see.</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r152/three.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ----- Safety: WebGL support -----
if (!window.WebGLRenderingContext) {
alert('WebGL not supported in this browser.');
return;
}
// ---------- Gaussian + sampling ----------
function gaussianWeights(n, mu, sigma) {
const w = new Array(n);
const s2 = sigma * sigma * 2;
let sum = 0;
for (let i = 1; i <= n; i++) {
const val = Math.exp(-((i - mu) * (i - mu)) / s2);
w[i - 1] = val;
sum += val;
}
for (let i = 0; i < n; i++) w[i] /= sum;
return w;
}
function sampleDiscrete(weights) {
const r = Math.random();
let acc = 0;
for (let i = 0; i < weights.length; i++) {
acc += weights[i];
if (r <= acc) return i;
}
return weights.length - 1;
}
// ---------- Histogram ----------
const histCanvas = document.getElementById('hist');
const hctx = histCanvas.getContext('2d');
let counts = Array(20).fill(0);
let totalRolls = 0;
function drawHistogram(gauss) {
const w = histCanvas.width, h = histCanvas.height;
hctx.clearRect(0, 0, w, h);
const padL = 28, padR = 10, padT = 10, padB = 20;
const chartW = w - padL - padR, chartH = h - padT - padB;
hctx.strokeStyle = '#3a4a7d';
hctx.lineWidth = 1;
hctx.beginPath();
hctx.moveTo(padL, padT);
hctx.lineTo(padL, padT + chartH);
hctx.lineTo(padL + chartW, padT + chartH);
hctx.stroke();
const maxCount = Math.max(1, ...counts);
const yScale = (v) => padT + chartH - (v / maxCount) * chartH;
const n = 20, barGap = 2, barW = (chartW / n) - barGap;
for (let i = 0; i < n; i++) {
const x = padL + i * (chartW / n) + barGap / 2;
const y = yScale(counts[i]);
hctx.fillStyle = '#7dd3fc';
hctx.fillRect(x, y, barW, (padT + chartH) - y);
hctx.fillStyle = '#9fb3ff';
hctx.font = '10px system-ui';
hctx.textAlign = 'center';
hctx.fillText(String(i + 1), x + barW / 2, padT + chartH + 12);
}
if (gauss) {
hctx.beginPath();
for (let i = 0; i < n; i++) {
const x = padL + i * (chartW / n) + (chartW / n) / 2;
const y = yScale(gauss[i] * maxCount);
if (i === 0) hctx.moveTo(x, y);
else hctx.lineTo(x, y);
}
hctx.strokeStyle = '#94a3b8';
hctx.setLineDash([4, 4]);
hctx.lineWidth = 1.5;
hctx.stroke();
hctx.setLineDash([]);
}
}
// ---------- Three.js scene ----------
const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setClearColor(0x0a0f1d, 1);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 2.6, 5.2);
camera.lookAt(0, 0, 0);
const hemi = new THREE.HemisphereLight(0x9fcfff, 0x203050, 0.9);
scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 1.1);
dir.position.set(3, 4, 2);
dir.castShadow = true;
scene.add(dir);
const grid = new THREE.GridHelper(20, 20, 0x2953ff, 0x1f2b53);
grid.position.y = -1.05;
scene.add(grid);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(8, 64),
new THREE.MeshStandardMaterial({ color: 0x0b1226, roughness: 0.95, metalness: 0.0 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const geo = new THREE.IcosahedronGeometry(1, 0);
const mat = new THREE.MeshStandardMaterial({ color: 0x1f4fff, metalness: 0.35, roughness: 0.4 });
const d20 = new THREE.Mesh(geo, mat);
d20.castShadow = true;
scene.add(d20);
geo.computeVertexNormals();
const pos = geo.attributes.position, index = geo.index;
const faceInfo = [];
const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
const cb = new THREE.Vector3(), ab = new THREE.Vector3();
for (let i = 0; i < index.count; i += 3) {
const a = index.getX(i), b = index.getX(i + 1), c = index.getX(i + 2);
vA.fromBufferAttribute(pos, a);
vB.fromBufferAttribute(pos, b);
vC.fromBufferAttribute(pos, c);
const center = new THREE.Vector3().addVectors(vA, vB).add(vC).multiplyScalar(1 / 3);
cb.subVectors(vC, vB);
ab.subVectors(vA, vB);
const normal = new THREE.Vector3().crossVectors(cb, ab).normalize();
faceInfo.push({ center, normal, number: faceInfo.length + 1 });
}
d20.rotation.set(0.8, 0.4, 0.2);
const loader = new THREE.FontLoader();
loader.load('https://threejs.org/examples/fonts/helvetiker_regular.typeface.json', (font) => {
const textMaterial = new THREE.MeshBasicMaterial({ color: 0xe6e9f2 });
for (let i = 0; i < faceInfo.length; i++) {
const textGeo = new THREE.TextGeometry(String(i + 1), {
font: font,
size: 0.25,
height: 0.05,
});
textGeo.computeBoundingBox();
const centerOffset = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
textGeo.translate(centerOffset, 0, 0);
const textMesh = new THREE.Mesh(textGeo, textMaterial);
const face = faceInfo[i];
textMesh.position.copy(face.center).multiplyScalar(1.01);
const up = new THREE.Vector3(0, 1, 0);
const normal = face.normal;
const axis = new THREE.Vector3().crossVectors(up, normal).normalize();
const angle = Math.acos(up.dot(normal));
textMesh.quaternion.setFromAxisAngle(axis, angle);
d20.add(textMesh);
}
});
// ----- sizing -----
function sizeRenderer() {
const w = stage.clientWidth || 800;
const h = stage.clientHeight || 450;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
sizeRenderer();
window.addEventListener('resize', sizeRenderer);
// ---------- Animation helpers ----------
let isRolling = false;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function targetQuaternionForFace(faceIdx) {
const currentQ = d20.quaternion.clone();
const up = new THREE.Vector3(0, 1, 0);
const localNormal = faceInfo[faceIdx].normal.clone();
const worldNormal = localNormal.clone().applyQuaternion(currentQ);
const deltaQ = new THREE.Quaternion().setFromUnitVectors(worldNormal, up);
const yaw = new THREE.Quaternion().setFromAxisAngle(up, Math.random() * Math.PI * 2);
return yaw.multiply(deltaQ).multiply(currentQ).normalize();
}
function randomTumble() {
const axis = new THREE.Vector3(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize();
const angle = (Math.PI * 2) * (1 + Math.random() * 2);
const q = new THREE.Quaternion().setFromAxisAngle(axis, angle);
d20.quaternion.multiply(q);
}
function animateToQuaternion(targetQ, durationSec, bounciness, onDone) {
const startQ = d20.quaternion.clone();
const start = performance.now();
const dur = durationSec * 1000;
function frame(now) {
const t = Math.min(1, (now - start) / dur);
const e = easeOutCubic(t);
THREE.Quaternion.slerp(startQ, targetQ, d20.quaternion, e);
if (bounciness > 0 && t > 0.7) {
const wob = (1 - e) * bounciness * 0.15;
d20.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), wob * Math.sin(20 * t)));
}
if (t < 1) {
requestAnimationFrame(frame);
} else {
isRolling = false;
onDone && onDone();
}
}
requestAnimationFrame(frame);
}
// ---------- UI / Roll ----------
const muEl = document.getElementById('mu');
const sigmaEl = document.getElementById('sigma');
const useWeightEl = document.getElementById('useWeight');
const spinEl = document.getElementById('spin');
const bounceEl = document.getElementById('bounciness');
const rollBtn = document.getElementById('rollBtn');
const lastRollEl = document.getElementById('lastRoll');
const totalEl = document.getElementById('totalRolls');
const expectEl = document.getElementById('expect');
// histogram init
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function currentWeights() {
if (!useWeightEl.checked) return Array(20).fill(1 / 20);
const mu = clamp(parseInt(muEl.value || '10', 10), 1, 20);
const sigma = clamp(parseFloat(sigmaEl.value || '2.5'), 0.3, 8.0);
return gaussianWeights(20, mu, sigma);
}
function doRoll() {
if (isRolling) return;
isRolling = true;
const mu = clamp(parseInt(muEl.value || '10', 10), 1, 20);
const sigma = clamp(parseFloat(sigmaEl.value || '2.5'), 0.3, 8.0);
expectEl.textContent = useWeightEl.checked ? String(mu) : 'β€”';
const weights = currentWeights();
randomTumble();
const faceIdx = sampleDiscrete(weights);
const qTarget = targetQuaternionForFace(faceIdx);
const spinSec = parseFloat(spinEl.value || '1.6');
const bouncy = parseFloat(bounceEl.value || '0.35');
animateToQuaternion(qTarget, spinSec, bouncy, () => {
const rolledNumber = faceInfo[faceIdx].number;
counts[rolledNumber - 1] += 1;
totalRolls += 1;
lastRollEl.textContent = rolledNumber;
totalEl.textContent = totalRolls;
drawHistogram(useWeightEl.checked ? weights : null);
});
}
rollBtn.addEventListener('click', doRoll);
muEl.addEventListener('change', () => {
muEl.value = clamp(parseInt(muEl.value || '10', 10), 1, 20);
drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value, 10), parseFloat(sigmaEl.value || '2.5')) : null);
expectEl.textContent = useWeightEl.checked ? muEl.value : 'β€”';
});
sigmaEl.addEventListener('change', () => {
sigmaEl.value = clamp(parseFloat(sigmaEl.value || '2.5'), 0.3, 8.0);
drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value || '10', 10), parseFloat(sigmaEl.value)) : null);
});
useWeightEl.addEventListener('change', () => {
drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value || '10', 10), parseFloat(sigmaEl.value || '2.5')) : null);
expectEl.textContent = useWeightEl.checked ? (muEl.value || '10') : 'β€”';
});
drawHistogram(gaussianWeights(20, parseInt(muEl.value || '10', 10), parseFloat(sigmaEl.value || '2.5')));
// ---------- Render loop ----------
function tick() {
if (!isRolling) d20.rotation.y += 0.0025;
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
sizeRenderer();
tick();
window.addEventListener('error', e => console.log('Error:', e.message));
});
</script>
</body>
</html>