|
<!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', () => { |
|
|
|
if (!window.WebGLRenderingContext) { |
|
alert('WebGL not supported in this browser.'); |
|
return; |
|
} |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
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([]); |
|
} |
|
} |
|
|
|
|
|
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); |
|
} |
|
}); |
|
|
|
|
|
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); |
|
|
|
|
|
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); |
|
} |
|
|
|
|
|
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'); |
|
|
|
|
|
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'))); |
|
|
|
|
|
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> |