eaglelandsonce commited on
Commit
ebc3e62
·
verified ·
1 Parent(s): a64d9b6

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +360 -310
index.html CHANGED
@@ -5,40 +5,104 @@
5
  <title>d20 — Fixed Render + Weighted/Fair Roll</title>
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
  <style>
8
- :root{ --bg:#0b1020; --panel:#121a33; --text:#e6e9f2; --accent:#7dd3fc; }
9
- *{box-sizing:border-box}
10
- html,body{height:100%}
11
- body{
12
- margin:0; color:var(--text); background:#0b1020;
 
 
13
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
14
- display:flex; flex-direction:column; gap:14px; align-items:center; padding:14px;
 
 
 
 
15
  }
16
- h1{margin:8px 0 0; font-weight:600; font-size:20px}
17
- .wrap{width:min(1100px,95vw); display:grid; grid-template-columns: 1.2fr .8fr; gap:14px}
18
- /* FIX: explicit size so stage always has area */
19
- #stage{width:100%; height:60vh; min-height:380px;
20
- background:#04070f; border:1px solid #1f2b53; border-radius:14px; overflow:hidden; position:relative}
21
- #ui{background:var(--panel); border:1px solid #1f2b53; border-radius:14px; padding:14px; display:flex; flex-direction:column; gap:12px}
22
- .row{display:grid; grid-template-columns: 1fr 1fr; gap:10px}
23
- label{font-size:12px; color:#b9c2dc}
24
- input[type="number"], input[type="range"]{
25
- width:100%; background:#0e152b; color:var(--text); border:1px solid #243266; border-radius:10px; padding:8px 10px; outline:none;
26
  }
27
- input[type="range"]{height:32px}
28
- .switch{display:flex; align-items:center; gap:8px; font-size:13px}
29
- button{
30
- padding:10px 14px; border-radius:12px; cursor:pointer;
31
- background:linear-gradient(180deg,#1d4ed8,#1e40af); color:white; font-weight:600;
32
- border:1px solid #203986; box-shadow:0 6px 20px #1d4ed84d;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
- button:active{transform:translateY(1px)}
35
- .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}
36
- .badge{font-size:12px; color:#9fb3ff}
37
- #chartBox{background:#0e152b; border:1px solid #243266; border-radius:14px; padding:10px}
38
- #hist{width:100%; height:220px; display:block}
39
- .tiny{font-size:11px; color:#98a2c7}
40
- .note{font-size:12px; color:#bcd0ff}
41
- @media (max-width: 800px){ .wrap{grid-template-columns: 1fr} }
42
  </style>
43
  </head>
44
  <body>
@@ -56,22 +120,22 @@
56
  <div class="row">
57
  <div>
58
  <label for="mu">Center face (μ, 1–20)</label>
59
- <input id="mu" type="number" min="1" max="20" step="1" value="10"/>
60
  </div>
61
  <div>
62
  <label for="sigma">Spread (σ, 0.3–8)</label>
63
- <input id="sigma" type="number" min="0.3" max="8" step="0.1" value="2.5"/>
64
  </div>
65
  </div>
66
 
67
  <div class="row">
68
  <div>
69
  <label for="spin">Spin time (sec)</label>
70
- <input id="spin" type="range" min="0.6" max="2.8" step="0.1" value="1.6"/>
71
  </div>
72
  <div>
73
  <label for="bounciness">Bounciness</label>
74
- <input id="bounciness" type="range" min="0" max="1" step="0.05" value="0.35"/>
75
  </div>
76
  </div>
77
 
@@ -94,301 +158,287 @@
94
 
95
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r152/three.min.js"></script>
96
  <script>
97
- document.addEventListener('DOMContentLoaded', () => {
98
- // ----- Safety: WebGL support -----
99
- if (!window.WebGLRenderingContext) {
100
- alert('WebGL not supported in this browser.');
101
- return;
102
- }
103
 
104
- // ---------- Gaussian + sampling ----------
105
- function gaussianWeights(n, mu, sigma){
106
- const w = new Array(n);
107
- const s2 = sigma*sigma*2;
108
- let sum=0;
109
- for(let i=1;i<=n;i++){
110
- const val = Math.exp(-((i-mu)*(i-mu))/s2);
111
- w[i-1]=val; sum+=val;
 
 
 
 
112
  }
113
- for(let i=0;i<n;i++) w[i]/=sum;
114
- return w;
115
- }
116
- function sampleDiscrete(weights){
117
- const r = Math.random();
118
- let acc=0;
119
- for(let i=0;i<weights.length;i++){
120
- acc += weights[i];
121
- if(r <= acc) return i;
122
  }
123
- return weights.length-1;
124
- }
125
-
126
- // ---------- Histogram ----------
127
- const histCanvas = document.getElementById('hist');
128
- const hctx = histCanvas.getContext('2d');
129
- let counts = Array(20).fill(0);
130
- let totalRolls = 0;
131
- function drawHistogram(gauss){
132
- const w = histCanvas.width, h = histCanvas.height;
133
- hctx.clearRect(0,0,w,h);
134
- const padL = 28, padR = 10, padT = 10, padB = 20;
135
- const chartW = w - padL - padR, chartH = h - padT - padB;
136
- hctx.strokeStyle = '#3a4a7d'; hctx.lineWidth = 1;
137
- hctx.beginPath(); hctx.moveTo(padL, padT); hctx.lineTo(padL, padT+chartH); hctx.lineTo(padL+chartW, padT+chartH); hctx.stroke();
138
 
139
- const maxCount = Math.max(1, ...counts);
140
- const yScale = (v)=> padT+chartH - (v/maxCount)*chartH;
141
-
142
- const n=20, barGap=2, barW = (chartW/n) - barGap;
143
- for(let i=0;i<n;i++){
144
- const x = padL + i*(chartW/n) + barGap/2;
145
- const y = yScale(counts[i]);
146
- hctx.fillStyle = '#7dd3fc';
147
- hctx.fillRect(x, y, barW, (padT+chartH) - y);
148
- hctx.fillStyle = '#9fb3ff'; hctx.font='10px system-ui'; hctx.textAlign='center';
149
- hctx.fillText(String(i+1), x+barW/2, padT+chartH+12);
150
- }
151
- if(gauss){
152
  hctx.beginPath();
153
- for(let i=0;i<n;i++){
154
- const x = padL + i*(chartW/n) + (chartW/n)/2;
155
- const y = yScale(gauss[i]*maxCount); // FIX: Multiply by maxCount, not a new max
156
- if(i===0) hctx.moveTo(x,y); else hctx.lineTo(x,y);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
- hctx.strokeStyle = '#94a3b8'; hctx.setLineDash([4,4]); hctx.lineWidth=1.5; hctx.stroke(); hctx.setLineDash([]);
159
  }
160
- }
161
-
162
- // ---------- Three.js scene ----------
163
- const stage = document.getElementById('stage');
164
- const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:false });
165
- renderer.setClearColor(0x0a0f1d, 1);
166
- stage.appendChild(renderer.domElement);
167
-
168
- const scene = new THREE.Scene();
169
- const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
170
- camera.position.set(0, 2.6, 5.2);
171
- camera.lookAt(0,0,0);
172
-
173
- const hemi = new THREE.HemisphereLight(0x9fcfff, 0x203050, 0.9); scene.add(hemi);
174
- const dir = new THREE.DirectionalLight(0xffffff, 1.1); dir.position.set(3,4,2); scene.add(dir);
175
-
176
- // FIX: Added shadows to light and renderer
177
- dir.castShadow = true;
178
- renderer.shadowMap.enabled = true;
179
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
180
 
181
- // Helper grid so you always see *something*
182
- const grid = new THREE.GridHelper(20, 20, 0x2953ff, 0x1f2b53); grid.position.y = -1.05; scene.add(grid);
183
-
184
- const ground = new THREE.Mesh(new THREE.CircleGeometry(8, 64),
185
- new THREE.MeshStandardMaterial({ color:0x0b1226, roughness:0.95, metalness:0.0 }));
186
- ground.rotation.x = -Math.PI/2; ground.receiveShadow = true; scene.add(ground);
187
-
188
- const geo = new THREE.IcosahedronGeometry(1, 0);
189
- const mat = new THREE.MeshStandardMaterial({ color:0x1f4fff, metalness:0.35, roughness:0.4 });
190
- const d20 = new THREE.Mesh(geo, mat);
191
- d20.castShadow = true; // FIX: Enabled shadow casting for the dice
192
- scene.add(d20);
193
-
194
- // FIX: Added face numbers
195
- const loader = new THREE.FontLoader();
196
- loader.load('https://threejs.org/examples/fonts/helvetiker_regular.typeface.json', (font) => {
197
- const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
198
- const textMaterial = new THREE.MeshBasicMaterial({ color: 0xe6e9f2 });
199
-
200
- const positions = [
201
- new THREE.Vector3(0, 1.1, 0), new THREE.Vector3(0.5, 0.8, -0.5), new THREE.Vector3(-0.5, 0.8, -0.5),
202
- new THREE.Vector3(0.5, 0.8, 0.5), new THREE.Vector3(-0.5, 0.8, 0.5), new THREE.Vector3(1, 0, 0),
203
- new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1),
204
- new THREE.Vector3(0.5, -0.8, 0.5), new THREE.Vector3(-0.5, -0.8, 0.5), new THREE.Vector3(0.5, -0.8, -0.5),
205
- new THREE.Vector3(-0.5, -0.8, -0.5), new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
206
- new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1), new THREE.Vector3(0, -1.1, 0),
207
- new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0)
208
- ];
209
-
210
- const faceQuaternions = [
211
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2),
212
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2),
213
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2),
214
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2),
215
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2),
216
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -Math.PI / 2),
217
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI),
218
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI),
219
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI),
220
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 4),
221
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 4),
222
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 4),
223
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 4),
224
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 4),
225
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -Math.PI / 4),
226
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI * 0.75),
227
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI * 0.75),
228
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI * 0.75),
229
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI * 0.75),
230
- new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI * 0.75),
231
- ];
232
-
233
- for (let i = 0; i < faceInfo.length; i++) {
234
- const textGeo = new THREE.TextGeometry(String(i + 1), {
235
- font: font,
236
- size: 0.25,
237
- height: 0.05,
238
- });
239
- textGeo.computeBoundingBox();
240
- const centerOffset = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
241
- textGeo.translate(centerOffset, 0, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
- const textMesh = new THREE.Mesh(textGeo, textMaterial);
244
- const face = faceInfo[i];
245
- textMesh.position.copy(face.center).multiplyScalar(1.01);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  const up = new THREE.Vector3(0, 1, 0);
247
- const normal = face.normal;
248
- const axis = new THREE.Vector3().crossVectors(up, normal).normalize();
249
- const angle = Math.acos(up.dot(normal));
250
- textMesh.quaternion.setFromAxisAngle(axis, angle);
251
- d20.add(textMesh);
252
  }
253
- });
254
-
255
- geo.computeVertexNormals();
256
- const pos = geo.attributes.position, index = geo.index;
257
- const faceInfo = [];
258
- const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
259
- const cb = new THREE.Vector3(), ab = new THREE.Vector3();
260
-
261
- for(let i=0;i<index.count; i+=3){
262
- const a = index.getX(i), b = index.getX(i+1), c = index.getX(i+2);
263
- vA.fromBufferAttribute(pos, a); vB.fromBufferAttribute(pos, b); vC.fromBufferAttribute(pos, c);
264
- const center = new THREE.Vector3().addVectors(vA, vB).add(vC).multiplyScalar(1/3);
265
- cb.subVectors(vC, vB); ab.subVectors(vA, vB);
266
- const normal = new THREE.Vector3().crossVectors(cb, ab).normalize();
267
- faceInfo.push({ center, normal, number: faceInfo.length+1 });
268
- }
269
- d20.rotation.set(0.8, 0.4, 0.2);
270
-
271
- // ----- sizing (FIX: compute after layout) -----
272
- function sizeRenderer(){
273
- const w = stage.clientWidth || 800;
274
- const h = stage.clientHeight || 450;
275
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
276
- renderer.setSize(w, h, false);
277
- camera.aspect = w/h;
278
- camera.updateProjectionMatrix();
279
- }
280
- sizeRenderer();
281
- window.addEventListener('resize', sizeRenderer);
282
-
283
- // ---------- Animation helpers ----------
284
- let isRolling = false;
285
- function easeOutCubic(t){ return 1 - Math.pow(1-t, 3); }
286
- function targetQuaternionForFace(faceIdx){
287
- const currentQ = d20.quaternion.clone();
288
- const up = new THREE.Vector3(0,1,0);
289
- const localNormal = faceInfo[faceIdx].normal.clone();
290
- const worldNormal = localNormal.clone().applyQuaternion(currentQ);
291
- const deltaQ = new THREE.Quaternion().setFromUnitVectors(worldNormal, up);
292
- const yaw = new THREE.Quaternion().setFromAxisAngle(up, Math.random()*Math.PI*2);
293
- return yaw.multiply(deltaQ).multiply(currentQ).normalize();
294
- }
295
- function randomTumble(){
296
- const axis = new THREE.Vector3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1).normalize();
297
- const angle = (Math.PI*2) * (1 + Math.random()*2);
298
- const q = new THREE.Quaternion().setFromAxisAngle(axis, angle);
299
- d20.quaternion.multiply(q);
300
- }
301
- function animateToQuaternion(targetQ, durationSec, bounciness, onDone){
302
- const startQ = d20.quaternion.clone();
303
- const start = performance.now();
304
- const dur = durationSec*1000;
305
- function frame(now){
306
- const t = Math.min(1, (now - start)/dur);
307
- const e = easeOutCubic(t);
308
- THREE.Quaternion.slerp(startQ, targetQ, d20.quaternion, e);
309
- if(bounciness > 0 && t>0.7){
310
- const wob = (1 - e) * bounciness * 0.15;
311
- d20.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), wob*Math.sin(20*t)));
312
  }
313
- if(t < 1){ requestAnimationFrame(frame); } else { isRolling = false; onDone && onDone(); }
314
  }
315
- requestAnimationFrame(frame);
316
- }
317
 
318
- // ---------- UI / Roll ----------
319
- const muEl = document.getElementById('mu');
320
- const sigmaEl = document.getElementById('sigma');
321
- const useWeightEl = document.getElementById('useWeight');
322
- const spinEl = document.getElementById('spin');
323
- const bounceEl = document.getElementById('bounciness');
324
- const rollBtn = document.getElementById('rollBtn');
325
- const lastRollEl = document.getElementById('lastRoll');
326
- const totalEl = document.getElementById('totalRolls');
327
- const expectEl = document.getElementById('expect');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
- // histogram init
330
- function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
331
- function currentWeights(){
332
- if(!useWeightEl.checked) return Array(20).fill(1/20);
333
- const mu = clamp(parseInt(muEl.value||'10',10), 1, 20); // FIX: Added default string '10' for parseInt
334
- const sigma = clamp(parseFloat(sigmaEl.value||'2.5'), 0.3, 8.0); // FIX: Added default string '2.5' for parseFloat
335
- return gaussianWeights(20, mu, sigma);
336
- }
337
- function doRoll(){
338
- if(isRolling) return;
339
- isRolling = true;
340
- const mu = clamp(parseInt(muEl.value||'10',10), 1, 20);
341
- const sigma = clamp(parseFloat(sigmaEl.value||'2.5'), 0.3, 8.0);
342
- expectEl.textContent = useWeightEl.checked ? String(mu) : '—';
343
- const weights = currentWeights();
344
- randomTumble();
345
- const faceIdx = sampleDiscrete(weights);
346
- const qTarget = targetQuaternionForFace(faceIdx);
347
- const spinSec = parseFloat(spinEl.value||'1.6');
348
- const bouncy = parseFloat(bounceEl.value||'0.35');
349
- animateToQuaternion(qTarget, spinSec, bouncy, ()=>{ // FIX: Pass a function to onDone
350
- const rolledNumber = faceInfo[faceIdx].number;
351
- counts[rolledNumber-1] += 1; totalRolls += 1;
352
- lastRollEl.textContent = rolledNumber; totalEl.textContent = totalRolls;
353
- drawHistogram(useWeightEl.checked ? weights : null);
354
  });
355
- }
356
- rollBtn.addEventListener('click', doRoll);
357
-
358
- muEl.addEventListener('change', ()=> {
359
- muEl.value = clamp(parseInt(muEl.value||'10',10),1,20);
360
- drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value,10), parseFloat(sigmaEl.value||'2.5')) : null);
361
- expectEl.textContent = useWeightEl.checked ? muEl.value : '—';
362
- });
363
- sigmaEl.addEventListener('change', ()=> {
364
- sigmaEl.value = clamp(parseFloat(sigmaEl.value||'2.5'),0.3,8.0);
365
- drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value||'10',10), parseFloat(sigmaEl.value)) : null);
366
- });
367
- useWeightEl.addEventListener('change', ()=> {
368
- drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value||'10',10), parseFloat(sigmaEl.value||'2.5')) : null);
369
- expectEl.textContent = useWeightEl.checked ? (muEl.value||'10') : '—';
370
- });
371
-
372
- // FIX: The faceInfo array must be created before the FontLoader is called.
373
- // It was not defined when the loader tried to access it.
374
- // The loader must be inside the DOMContentLoaded listener.
375
- // The faceInfo generation must be outside of the loader callback.
376
 
377
- drawHistogram(gaussianWeights(20, parseInt(muEl.value||'10',10), parseFloat(sigmaEl.value||'2.5')));
378
 
379
- // ---------- Render loop ----------
380
- function tick(){
381
- if(!isRolling) d20.rotation.y += 0.0025;
382
- renderer.render(scene, camera);
383
- requestAnimationFrame(tick);
384
- }
385
- // One more size pass just before anim starts (fixes rare first-frame 0 size)
386
- sizeRenderer();
387
- tick();
388
 
389
- // Quick error surfacing
390
- window.addEventListener('error', e => console.log('Error:', e.message));
391
- });
392
  </script>
393
  </body>
394
  </html>
 
5
  <title>d20 — Fixed Render + Weighted/Fair Roll</title>
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
  <style>
8
+ :root { --bg:#0b1020; --panel:#121a33; --text:#e6e9f2; --accent:#7dd3fc; }
9
+ * { box-sizing: border-box; }
10
+ html, body { height: 100%; }
11
+ body {
12
+ margin: 0;
13
+ color: var(--text);
14
+ background: #0b1020;
15
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: 14px;
19
+ align-items: center;
20
+ padding: 14px;
21
  }
22
+ h1 { margin: 8px 0 0; font-weight: 600; font-size: 20px; }
23
+ .wrap {
24
+ width: min(1100px, 95vw);
25
+ display: grid;
26
+ grid-template-columns: 1.2fr 0.8fr;
27
+ gap: 14px;
 
 
 
 
28
  }
29
+ #stage {
30
+ width: 100%;
31
+ height: 60vh;
32
+ min-height: 380px;
33
+ background: #04070f;
34
+ border: 1px solid #1f2b53;
35
+ border-radius: 14px;
36
+ overflow: hidden;
37
+ position: relative;
38
+ }
39
+ #ui {
40
+ background: var(--panel);
41
+ border: 1px solid #1f2b53;
42
+ border-radius: 14px;
43
+ padding: 14px;
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 12px;
47
+ }
48
+ .row {
49
+ display: grid;
50
+ grid-template-columns: 1fr 1fr;
51
+ gap: 10px;
52
+ }
53
+ label { font-size: 12px; color: #b9c2dc; }
54
+ input[type="number"],
55
+ input[type="range"] {
56
+ width: 100%;
57
+ background: #0e152b;
58
+ color: var(--text);
59
+ border: 1px solid #243266;
60
+ border-radius: 10px;
61
+ padding: 8px 10px;
62
+ outline: none;
63
+ }
64
+ input[type="range"] { height: 32px; }
65
+ .switch {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 8px;
69
+ font-size: 13px;
70
+ }
71
+ button {
72
+ padding: 10px 14px;
73
+ border-radius: 12px;
74
+ cursor: pointer;
75
+ background: linear-gradient(180deg, #1d4ed8, #1e40af);
76
+ color: white;
77
+ font-weight: 600;
78
+ border: 1px solid #203986;
79
+ box-shadow: 0 6px 20px #1d4ed84d;
80
+ }
81
+ button:active { transform: translateY(1px); }
82
+ .readout {
83
+ display: grid;
84
+ grid-template-columns: repeat(3, 1fr);
85
+ gap: 8px;
86
+ text-align: center;
87
+ background: #0e152b;
88
+ border: 1px solid #243266;
89
+ border-radius: 12px;
90
+ padding: 10px;
91
+ font-variant-numeric: tabular-nums;
92
+ }
93
+ .badge { font-size: 12px; color: #9fb3ff; }
94
+ #chartBox {
95
+ background: #0e152b;
96
+ border: 1px solid #243266;
97
+ border-radius: 14px;
98
+ padding: 10px;
99
+ }
100
+ #hist { width: 100%; height: 220px; display: block; }
101
+ .tiny { font-size: 11px; color: #98a2c7; }
102
+ .note { font-size: 12px; color: #bcd0ff; }
103
+ @media (max-width: 800px) {
104
+ .wrap { grid-template-columns: 1fr; }
105
  }
 
 
 
 
 
 
 
 
106
  </style>
107
  </head>
108
  <body>
 
120
  <div class="row">
121
  <div>
122
  <label for="mu">Center face (μ, 1–20)</label>
123
+ <input id="mu" type="number" min="1" max="20" step="1" value="10" />
124
  </div>
125
  <div>
126
  <label for="sigma">Spread (σ, 0.3–8)</label>
127
+ <input id="sigma" type="number" min="0.3" max="8" step="0.1" value="2.5" />
128
  </div>
129
  </div>
130
 
131
  <div class="row">
132
  <div>
133
  <label for="spin">Spin time (sec)</label>
134
+ <input id="spin" type="range" min="0.6" max="2.8" step="0.1" value="1.6" />
135
  </div>
136
  <div>
137
  <label for="bounciness">Bounciness</label>
138
+ <input id="bounciness" type="range" min="0" max="1" step="0.05" value="0.35" />
139
  </div>
140
  </div>
141
 
 
158
 
159
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r152/three.min.js"></script>
160
  <script>
161
+ document.addEventListener('DOMContentLoaded', () => {
162
+ // ----- Safety: WebGL support -----
163
+ if (!window.WebGLRenderingContext) {
164
+ alert('WebGL not supported in this browser.');
165
+ return;
166
+ }
167
 
168
+ // ---------- Gaussian + sampling ----------
169
+ function gaussianWeights(n, mu, sigma) {
170
+ const w = new Array(n);
171
+ const s2 = sigma * sigma * 2;
172
+ let sum = 0;
173
+ for (let i = 1; i <= n; i++) {
174
+ const val = Math.exp(-((i - mu) * (i - mu)) / s2);
175
+ w[i - 1] = val;
176
+ sum += val;
177
+ }
178
+ for (let i = 0; i < n; i++) w[i] /= sum;
179
+ return w;
180
  }
181
+ function sampleDiscrete(weights) {
182
+ const r = Math.random();
183
+ let acc = 0;
184
+ for (let i = 0; i < weights.length; i++) {
185
+ acc += weights[i];
186
+ if (r <= acc) return i;
187
+ }
188
+ return weights.length - 1;
 
189
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
+ // ---------- Histogram ----------
192
+ const histCanvas = document.getElementById('hist');
193
+ const hctx = histCanvas.getContext('2d');
194
+ let counts = Array(20).fill(0);
195
+ let totalRolls = 0;
196
+ function drawHistogram(gauss) {
197
+ const w = histCanvas.width, h = histCanvas.height;
198
+ hctx.clearRect(0, 0, w, h);
199
+ const padL = 28, padR = 10, padT = 10, padB = 20;
200
+ const chartW = w - padL - padR, chartH = h - padT - padB;
201
+ hctx.strokeStyle = '#3a4a7d';
202
+ hctx.lineWidth = 1;
 
203
  hctx.beginPath();
204
+ hctx.moveTo(padL, padT);
205
+ hctx.lineTo(padL, padT + chartH);
206
+ hctx.lineTo(padL + chartW, padT + chartH);
207
+ hctx.stroke();
208
+
209
+ const maxCount = Math.max(1, ...counts);
210
+ const yScale = (v) => padT + chartH - (v / maxCount) * chartH;
211
+
212
+ const n = 20, barGap = 2, barW = (chartW / n) - barGap;
213
+ for (let i = 0; i < n; i++) {
214
+ const x = padL + i * (chartW / n) + barGap / 2;
215
+ const y = yScale(counts[i]);
216
+ hctx.fillStyle = '#7dd3fc';
217
+ hctx.fillRect(x, y, barW, (padT + chartH) - y);
218
+ hctx.fillStyle = '#9fb3ff';
219
+ hctx.font = '10px system-ui';
220
+ hctx.textAlign = 'center';
221
+ hctx.fillText(String(i + 1), x + barW / 2, padT + chartH + 12);
222
+ }
223
+ if (gauss) {
224
+ hctx.beginPath();
225
+ for (let i = 0; i < n; i++) {
226
+ const x = padL + i * (chartW / n) + (chartW / n) / 2;
227
+ const y = yScale(gauss[i] * maxCount);
228
+ if (i === 0) hctx.moveTo(x, y);
229
+ else hctx.lineTo(x, y);
230
+ }
231
+ hctx.strokeStyle = '#94a3b8';
232
+ hctx.setLineDash([4, 4]);
233
+ hctx.lineWidth = 1.5;
234
+ hctx.stroke();
235
+ hctx.setLineDash([]);
236
  }
 
237
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
+ // ---------- Three.js scene ----------
240
+ const stage = document.getElementById('stage');
241
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
242
+ renderer.setClearColor(0x0a0f1d, 1);
243
+ renderer.shadowMap.enabled = true;
244
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
245
+ stage.appendChild(renderer.domElement);
246
+
247
+ const scene = new THREE.Scene();
248
+ const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
249
+ camera.position.set(0, 2.6, 5.2);
250
+ camera.lookAt(0, 0, 0);
251
+
252
+ const hemi = new THREE.HemisphereLight(0x9fcfff, 0x203050, 0.9);
253
+ scene.add(hemi);
254
+ const dir = new THREE.DirectionalLight(0xffffff, 1.1);
255
+ dir.position.set(3, 4, 2);
256
+ dir.castShadow = true;
257
+ scene.add(dir);
258
+
259
+ const grid = new THREE.GridHelper(20, 20, 0x2953ff, 0x1f2b53);
260
+ grid.position.y = -1.05;
261
+ scene.add(grid);
262
+
263
+ const ground = new THREE.Mesh(
264
+ new THREE.CircleGeometry(8, 64),
265
+ new THREE.MeshStandardMaterial({ color: 0x0b1226, roughness: 0.95, metalness: 0.0 })
266
+ );
267
+ ground.rotation.x = -Math.PI / 2;
268
+ ground.receiveShadow = true;
269
+ scene.add(ground);
270
+
271
+ const geo = new THREE.IcosahedronGeometry(1, 0);
272
+ const mat = new THREE.MeshStandardMaterial({ color: 0x1f4fff, metalness: 0.35, roughness: 0.4 });
273
+ const d20 = new THREE.Mesh(geo, mat);
274
+ d20.castShadow = true;
275
+ scene.add(d20);
276
+
277
+ geo.computeVertexNormals();
278
+ const pos = geo.attributes.position, index = geo.index;
279
+ const faceInfo = [];
280
+ const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
281
+ const cb = new THREE.Vector3(), ab = new THREE.Vector3();
282
+
283
+ for (let i = 0; i < index.count; i += 3) {
284
+ const a = index.getX(i), b = index.getX(i + 1), c = index.getX(i + 2);
285
+ vA.fromBufferAttribute(pos, a);
286
+ vB.fromBufferAttribute(pos, b);
287
+ vC.fromBufferAttribute(pos, c);
288
+ const center = new THREE.Vector3().addVectors(vA, vB).add(vC).multiplyScalar(1 / 3);
289
+ cb.subVectors(vC, vB);
290
+ ab.subVectors(vA, vB);
291
+ const normal = new THREE.Vector3().crossVectors(cb, ab).normalize();
292
+ faceInfo.push({ center, normal, number: faceInfo.length + 1 });
293
+ }
294
+ d20.rotation.set(0.8, 0.4, 0.2);
295
+
296
+ const loader = new THREE.FontLoader();
297
+ loader.load('https://threejs.org/examples/fonts/helvetiker_regular.typeface.json', (font) => {
298
+ const textMaterial = new THREE.MeshBasicMaterial({ color: 0xe6e9f2 });
299
+ for (let i = 0; i < faceInfo.length; i++) {
300
+ const textGeo = new THREE.TextGeometry(String(i + 1), {
301
+ font: font,
302
+ size: 0.25,
303
+ height: 0.05,
304
+ });
305
+ textGeo.computeBoundingBox();
306
+ const centerOffset = -0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x);
307
+ textGeo.translate(centerOffset, 0, 0);
308
+
309
+ const textMesh = new THREE.Mesh(textGeo, textMaterial);
310
+ const face = faceInfo[i];
311
+ textMesh.position.copy(face.center).multiplyScalar(1.01);
312
+ const up = new THREE.Vector3(0, 1, 0);
313
+ const normal = face.normal;
314
+ const axis = new THREE.Vector3().crossVectors(up, normal).normalize();
315
+ const angle = Math.acos(up.dot(normal));
316
+ textMesh.quaternion.setFromAxisAngle(axis, angle);
317
+ d20.add(textMesh);
318
+ }
319
+ });
320
 
321
+ // ----- sizing -----
322
+ function sizeRenderer() {
323
+ const w = stage.clientWidth || 800;
324
+ const h = stage.clientHeight || 450;
325
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
326
+ renderer.setSize(w, h, false);
327
+ camera.aspect = w / h;
328
+ camera.updateProjectionMatrix();
329
+ }
330
+ sizeRenderer();
331
+ window.addEventListener('resize', sizeRenderer);
332
+
333
+ // ---------- Animation helpers ----------
334
+ let isRolling = false;
335
+ function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
336
+ function targetQuaternionForFace(faceIdx) {
337
+ const currentQ = d20.quaternion.clone();
338
  const up = new THREE.Vector3(0, 1, 0);
339
+ const localNormal = faceInfo[faceIdx].normal.clone();
340
+ const worldNormal = localNormal.clone().applyQuaternion(currentQ);
341
+ const deltaQ = new THREE.Quaternion().setFromUnitVectors(worldNormal, up);
342
+ const yaw = new THREE.Quaternion().setFromAxisAngle(up, Math.random() * Math.PI * 2);
343
+ return yaw.multiply(deltaQ).multiply(currentQ).normalize();
344
  }
345
+ function randomTumble() {
346
+ const axis = new THREE.Vector3(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize();
347
+ const angle = (Math.PI * 2) * (1 + Math.random() * 2);
348
+ const q = new THREE.Quaternion().setFromAxisAngle(axis, angle);
349
+ d20.quaternion.multiply(q);
350
+ }
351
+ function animateToQuaternion(targetQ, durationSec, bounciness, onDone) {
352
+ const startQ = d20.quaternion.clone();
353
+ const start = performance.now();
354
+ const dur = durationSec * 1000;
355
+ function frame(now) {
356
+ const t = Math.min(1, (now - start) / dur);
357
+ const e = easeOutCubic(t);
358
+ THREE.Quaternion.slerp(startQ, targetQ, d20.quaternion, e);
359
+ if (bounciness > 0 && t > 0.7) {
360
+ const wob = (1 - e) * bounciness * 0.15;
361
+ d20.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), wob * Math.sin(20 * t)));
362
+ }
363
+ if (t < 1) {
364
+ requestAnimationFrame(frame);
365
+ } else {
366
+ isRolling = false;
367
+ onDone && onDone();
368
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  }
370
+ requestAnimationFrame(frame);
371
  }
 
 
372
 
373
+ // ---------- UI / Roll ----------
374
+ const muEl = document.getElementById('mu');
375
+ const sigmaEl = document.getElementById('sigma');
376
+ const useWeightEl = document.getElementById('useWeight');
377
+ const spinEl = document.getElementById('spin');
378
+ const bounceEl = document.getElementById('bounciness');
379
+ const rollBtn = document.getElementById('rollBtn');
380
+ const lastRollEl = document.getElementById('lastRoll');
381
+ const totalEl = document.getElementById('totalRolls');
382
+ const expectEl = document.getElementById('expect');
383
+
384
+ // histogram init
385
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
386
+ function currentWeights() {
387
+ if (!useWeightEl.checked) return Array(20).fill(1 / 20);
388
+ const mu = clamp(parseInt(muEl.value || '10', 10), 1, 20);
389
+ const sigma = clamp(parseFloat(sigmaEl.value || '2.5'), 0.3, 8.0);
390
+ return gaussianWeights(20, mu, sigma);
391
+ }
392
+ function doRoll() {
393
+ if (isRolling) return;
394
+ isRolling = true;
395
+ const mu = clamp(parseInt(muEl.value || '10', 10), 1, 20);
396
+ const sigma = clamp(parseFloat(sigmaEl.value || '2.5'), 0.3, 8.0);
397
+ expectEl.textContent = useWeightEl.checked ? String(mu) : '—';
398
+ const weights = currentWeights();
399
+ randomTumble();
400
+ const faceIdx = sampleDiscrete(weights);
401
+ const qTarget = targetQuaternionForFace(faceIdx);
402
+ const spinSec = parseFloat(spinEl.value || '1.6');
403
+ const bouncy = parseFloat(bounceEl.value || '0.35');
404
+ animateToQuaternion(qTarget, spinSec, bouncy, () => {
405
+ const rolledNumber = faceInfo[faceIdx].number;
406
+ counts[rolledNumber - 1] += 1;
407
+ totalRolls += 1;
408
+ lastRollEl.textContent = rolledNumber;
409
+ totalEl.textContent = totalRolls;
410
+ drawHistogram(useWeightEl.checked ? weights : null);
411
+ });
412
+ }
413
+ rollBtn.addEventListener('click', doRoll);
414
 
415
+ muEl.addEventListener('change', () => {
416
+ muEl.value = clamp(parseInt(muEl.value || '10', 10), 1, 20);
417
+ drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value, 10), parseFloat(sigmaEl.value || '2.5')) : null);
418
+ expectEl.textContent = useWeightEl.checked ? muEl.value : '—';
419
+ });
420
+ sigmaEl.addEventListener('change', () => {
421
+ sigmaEl.value = clamp(parseFloat(sigmaEl.value || '2.5'), 0.3, 8.0);
422
+ drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value || '10', 10), parseFloat(sigmaEl.value)) : null);
423
+ });
424
+ useWeightEl.addEventListener('change', () => {
425
+ drawHistogram(useWeightEl.checked ? gaussianWeights(20, parseInt(muEl.value || '10', 10), parseFloat(sigmaEl.value || '2.5')) : null);
426
+ expectEl.textContent = useWeightEl.checked ? (muEl.value || '10') : '—';
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
 
429
+ drawHistogram(gaussianWeights(20, parseInt(muEl.value || '10', 10), parseFloat(sigmaEl.value || '2.5')));
430
 
431
+ // ---------- Render loop ----------
432
+ function tick() {
433
+ if (!isRolling) d20.rotation.y += 0.0025;
434
+ renderer.render(scene, camera);
435
+ requestAnimationFrame(tick);
436
+ }
437
+ sizeRenderer();
438
+ tick();
 
439
 
440
+ window.addEventListener('error', e => console.log('Error:', e.message));
441
+ });
 
442
  </script>
443
  </body>
444
  </html>