<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎲 Dice Roller 3D</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
—bg: #04020d;
—panel: rgba(12, 6, 30, 0.82);
—border: rgba(110, 40, 255, 0.28);
—accent: #7c3aed;
—glow: #b06dff;
—text: #ddd4f5;
—dim: #7a6e93;
—pill: rgba(120, 50, 255, 0.14);
}
- { margin:0; padding:0; box-sizing:border-box; }
body { background:var(bg); overflow:hidden; font-family:'Rajdhani',sans-serif; color:var(text); user-select:none; }
#canvas { display:block; position:fixed; top:0; left:0; width:100%; height:100%; cursor:grab; }
#canvas:active { cursor:grabbing; }
#ui { position:fixed; inset:0; pointer-events:none; z-index:10; }
/* ── PANELS ── */
.panel {
pointer-events:all;
background:var(—panel);
border:1px solid var(—border);
backdrop-filter:blur(24px);
-webkit-backdrop-filter:blur(24px);
border-radius:18px;
padding:18px 20px;
position:absolute;
}
.panel-title {
font-size:10px; font-weight:700; letter-spacing:3.5px;
color:var(—glow); text-transform:uppercase;
margin-bottom:16px; padding-bottom:12px;
border-bottom:1px solid var(—border);
}
/* ── SETTINGS (left) ── */
#settings { left:18px; top:18px; width:230px; }
.sg { margin-bottom:16px; }
.sg-label { font-size:10px; letter-spacing:2px; color:var(—dim); text-transform:uppercase; margin-bottom:9px; display:block; }
/* Number control */
.num-row { display:flex; align-items:center; gap:10px; }
.nbtn {
width:30px; height:30px;
background:var(pill); border:1px solid var(border);
color:var(—text); border-radius:8px; cursor:pointer;
font-size:20px; line-height:1; display:flex; align-items:center; justify-content:center;
transition:.18s; font-family:'Space Mono',monospace;
}
.nbtn:hover { background:rgba(120,50,255,.35); border-color:var(—glow); box-shadow:0 0 14px rgba(176,109,255,.4); }
.nval { font-family:'Space Mono',monospace; font-size:26px; font-weight:700; color:var(—glow); min-width:32px; text-align:center; }
/* Slider */
input[type=range] {
-webkit-appearance:none; width:100%; height:4px;
background:rgba(110,40,255,.25); border-radius:2px; outline:none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance:none; width:15px; height:15px;
background:var(—glow); border-radius:50%; cursor:pointer;
box-shadow:0 0 10px rgba(176,109,255,.6);
}
.slider-ends { display:flex; justify-content:space-between; margin-top:5px; font-size:9px; color:var(—dim); letter-spacing:1px; }
/* Swatches */
.swatches { display:flex; gap:7px; flex-wrap:wrap; }
.sw {
width:26px; height:26px; border-radius:7px; cursor:pointer;
border:2px solid transparent; transition:.18s;
}
.sw:hover, .sw.active { border-color:var(—glow); box-shadow:0 0 12px rgba(176,109,255,.5); transform:scale(1.12); }
/* Toggles */
.trow { display:flex; align-items:center; justify-content:space-between; margin-bottom:9px; font-size:11px; letter-spacing:.5px; color:var(—dim); }
.tog {
width:34px; height:18px; background:rgba(90,40,120,.3);
border-radius:9px; position:relative; cursor:pointer;
border:1px solid var(—border); transition:.25s;
}
.tog.on { background:rgba(110,40,255,.4); border-color:var(—glow); box-shadow:0 0 10px rgba(176,109,255,.35); }
.tog::after {
content:''; position:absolute; top:2px; left:2px;
width:12px; height:12px; background:var(—dim);
border-radius:50%; transition:.25s;
}
.tog.on::after { left:18px; background:var(—glow); box-shadow:0 0 6px rgba(176,109,255,.7); }
/* ── RESULTS (right) ── */
#results { right:18px; top:18px; width:210px; }
.dr-grid { display:flex; flex-wrap:wrap; gap:7px; margin-bottom:14px; min-height:44px; }
.dr {
width:42px; height:42px;
background:var(pill); border:1px solid var(border);
border-radius:10px; display:flex; align-items:center; justify-content:center;
font-family:'Space Mono',monospace; font-size:18px; font-weight:700;
color:var(—glow); transition:.3s;
}
.dr.lit { border-color:var(—glow); box-shadow:0 0 16px rgba(176,109,255,.4); animation:pop .35s ease-out; }
@keyframes pop { from { transform:scale(.4); opacity:0; } to { transform:scale(1); opacity:1; } }
.sum-row { display:flex; justify-content:space-between; align-items:center; padding-top:12px; border-top:1px solid var(—border); }
.sum-lbl { font-size:10px; letter-spacing:2px; color:var(—dim); text-transform:uppercase; }
.sum-val { font-family:'Space Mono',monospace; font-size:26px; font-weight:700; color:var(—glow); text-shadow:0 0 18px rgba(176,109,255,.5); }
/* avg row */
.avg-row { display:flex; justify-content:space-between; align-items:center; padding-top:7px; }
.avg-lbl { font-size:9px; letter-spacing:2px; color:var(—dim); text-transform:uppercase; }
.avg-val { font-family:'Space Mono',monospace; font-size:13px; color:var(—dim); }
/* ── HISTORY (left-bottom) ── */
#history { left:18px; width:230px; bottom:100px; max-height:200px; overflow:hidden; }
.hi { display:flex; align-items:center; justify-content:space-between; padding:5px 0; border-bottom:1px solid rgba(110,40,255,.1); font-size:12px; }
.hi:last-child { border-bottom:none; }
.hi-dice { color:var(—dim); font-family:'Space Mono',monospace; font-size:10px; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:8px; }
.hi-sum { font-family:'Space Mono',monospace; font-weight:700; color:var(—glow); font-size:14px; }
.hi-n { font-size:9px; color:rgba(176,109,255,.5); margin-right:5px; letter-spacing:1px; }
/* ── ROLL BUTTON ── */
#rollbtn {
position:absolute; bottom:32px; left:50%; transform:translateX(-50%);
pointer-events:all;
background:linear-gradient(135deg,#6d28d9,#a855f7);
border:none; color:#fff;
font-family:'Rajdhani',sans-serif; font-weight:700;
font-size:18px; letter-spacing:4px; text-transform:uppercase;
padding:14px 52px; border-radius:50px; cursor:pointer;
box-shadow:0 0 32px rgba(168,85,247,.45), 0 4px 22px rgba(0,0,0,.6);
transition:.25s; white-space:nowrap;
}
#rollbtn:hover { box-shadow:0 0 55px rgba(168,85,247,.7), 0 4px 30px rgba(0,0,0,.7); transform:translateX(-50%) scale(1.05); }
#rollbtn:active { transform:translateX(-50%) scale(.96); }
#rollbtn.rolling { animation:pulse-b .5s ease-in-out infinite alternate; pointer-events:none; opacity:.85; }
@keyframes pulse-b { from { box-shadow:0 0 32px rgba(168,85,247,.45); } to { box-shadow:0 0 70px rgba(168,85,247,.9); } }
/* ── TITLE ── */
#title {
position:absolute; top:22px; left:50%; transform:translateX(-50%);
font-weight:700; font-size:12px; letter-spacing:7px; color:var(—dim);
text-transform:uppercase; pointer-events:none; white-space:nowrap;
}
#title em { color:var(—glow); font-style:normal; }
/* ── STATUS ── */
#status {
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
font-size:12px; letter-spacing:5px; text-transform:uppercase; color:var(—glow);
opacity:0; transition:opacity .4s; pointer-events:none;
text-shadow:0 0 22px rgba(176,109,255,.9);
}
#status.vis { opacity:1; }
/* ── KEYBOARD HINT ── */
#hint {
position:absolute; bottom:88px; left:50%; transform:translateX(-50%);
font-size:9px; letter-spacing:2px; color:rgba(120,100,160,.5);
pointer-events:none; white-space:nowrap;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="ui">
<div id="title">DICE <em>ROLLER</em> 3D</div>
<!— Settings —>
<div id="settings" class="panel">
<div class="panel-title">⚙ Настройки</div>
<div class="sg">
<span class="sg-label">Количество кубиков</span>
<div class="num-row">
<button class="nbtn" id="dminus">−</button>
<div class="nval" id="dcount">3</div>
<button class="nbtn" id="dplus">+</button>
</div>
</div>
<div class="sg">
<span class="sg-label">Сила броска</span>
<input type="range" id="force" min="1" max="3" step="0.05" value="2">
<div class="slider-ends"><span>Тихо</span><span>Ядерно</span></div>
</div>
<div class="sg">
<span class="sg-label">Цвет кубиков</span>
<div class="swatches" id="swatches">
<div class="sw active" data-c="purple" style="background:#1a0632;box-shadow:inset 0 0 0 2px #7c3aed;"></div>
<div class="sw" data-c="red" style="background:#180006;box-shadow:inset 0 0 0 2px #dc2626;"></div>
<div class="sw" data-c="blue" style="background:#000d1a;box-shadow:inset 0 0 0 2px #2563eb;"></div>
<div class="sw" data-c="green" style="background:#001208;box-shadow:inset 0 0 0 2px #16a34a;"></div>
<div class="sw" data-c="slate" style="background:#0a0a10;box-shadow:inset 0 0 0 2px #64748b;"></div>
<div class="sw" data-c="gold" style="background:#120900;box-shadow:inset 0 0 0 2px #d97706;"></div>
</div>
</div>
<div class="sg">
<div class="trow"><span>Тени</span> <div class="tog on" id="t-shadow"></div></div>
<div class="trow"><span>Авто-камера</span><div class="tog on" id="t-cam"></div></div>
<div class="trow"><span>Сетка пола</span> <div class="tog on" id="t-grid"></div></div>
</div>
</div>
<!— Results —>
<div id="results" class="panel">
<div class="panel-title">🎲 Результаты</div>
<div class="dr-grid" id="dr-grid"></div>
<div id="sum-block" style="display:none;">
<div class="sum-row">
<span class="sum-lbl">Сумма</span>
<span class="sum-val" id="sum-v">—</span>
</div>
<div class="avg-row">
<span class="avg-lbl">Среднее</span>
<span class="avg-val" id="avg-v">—</span>
</div>
</div>
</div>
<!— History —>
<div id="history" class="panel">
<div class="panel-title" style="margin-bottom:10px;">📜 История</div>
<div id="hist-list"><div style="font-size:10px;color:var(—dim);letter-spacing:1px;">Нет бросков</div></div>
</div>
<button id="rollbtn">🎲 БРОСИТЬ</button>
<div id="hint">ПРОБЕЛ — бросить · ЛКМ — вращать · КОЛЕСО — зум</div>
<div id="status">Кубики летят…</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js"></script>
<script>
// ═══════════════════════════════════════════════
// RENDERER
// ═══════════════════════════════════════════════
const canvas = document.getElementById('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.setClearColor(0x04020d);
// ═══════════════════════════════════════════════
// SCENE + CAMERA
// ═══════════════════════════════════════════════
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x04020d, 0.038);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 120);
camera.position.set(0, 16, 16);
camera.lookAt(0, 0, 0);
// ═══════════════════════════════════════════════
// LIGHTS
// ═══════════════════════════════════════════════
const aLight = new THREE.AmbientLight(0x1a0a38, 1.2);
scene.add(aLight);
const dLight = new THREE.DirectionalLight(0xfff4ff, 1.8);
dLight.position.set(8, 18, 8);
dLight.castShadow = true;
dLight.shadow.mapSize.set(2048, 2048);
dLight.shadow.camera.near = 1;
dLight.shadow.camera.far = 50;
dLight.shadow.camera.left = dLight.shadow.camera.bottom = -12;
dLight.shadow.camera.right = dLight.shadow.camera.top = 12;
dLight.shadow.bias = -0.0005;
scene.add(dLight);
const pL1 = new THREE.PointLight(0x8b2fff, 2.5, 22);
const pL2 = new THREE.PointLight(0xc060ff, 1.8, 18);
pL1.position.set(-5, 3, -4);
pL2.position.set(5, 3, 4);
scene.add(pL1, pL2);
// ═══════════════════════════════════════════════
// FLOOR
// ═══════════════════════════════════════════════
function makeFloorTex() {
const s = 512, c = document.createElement('canvas');
c.width = c.height = s;
const x = c.getContext('2d');
x.fillStyle = '#070115';
x.fillRect(0, 0, s, s);
const gs = 64;
x.strokeStyle = 'rgba(100,30,230,.18)';
x.lineWidth = 1;
for (let i = 0; i <= s; i += gs) {
x.beginPath(); x.moveTo(i, 0); x.lineTo(i, s); x.stroke();
x.beginPath(); x.moveTo(0, i); x.lineTo(s, i); x.stroke();
}
// center glow
const g = x.createRadialGradient(s/2,s/2,0, s/2,s/2,s*.5);
g.addColorStop(0, 'rgba(110,40,255,.12)');
g.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = g; x.fillRect(0, 0, s, s);
return new THREE.CanvasTexture(c);
}
const floorTex = makeFloorTex();
floorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;
floorTex.repeat.set(2, 2);
const floorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(22, 22),
new THREE.MeshStandardMaterial({ map: floorTex, roughness: .9, metalness: .05 })
);
floorMesh.rotation.x = -Math.PI / 2;
floorMesh.receiveShadow = true;
scene.add(floorMesh);
// Glow ring
const ringMesh = new THREE.Mesh(
new THREE.RingGeometry(4.8, 5, 80),
new THREE.MeshBasicMaterial({ color: 0x7c3aed, side: THREE.DoubleSide, transparent: true, opacity: .35 })
);
ringMesh.rotation.x = -Math.PI / 2;
ringMesh.position.y = 0.01;
scene.add(ringMesh);
// ═══════════════════════════════════════════════
// PHYSICS WORLD
// ═══════════════════════════════════════════════
const world = new CANNON.World();
world.gravity.set(0, -28, 0);
world.broadphase = new CANNON.NaiveBroadphase();
world.solver.iterations = 30;
const mDice = new CANNON.Material('dice');
const mFloor = new CANNON.Material('floor');
const mWall = new CANNON.Material('wall');
world.addContactMaterial(new CANNON.ContactMaterial(mDice, mFloor, { friction:.45, restitution:.38 }));
world.addContactMaterial(new CANNON.ContactMaterial(mDice, mWall, { friction:.3, restitution:.32 }));
world.addContactMaterial(new CANNON.ContactMaterial(mDice, mDice, { friction:.25, restitution:.25 }));
// Floor body
const fb = new CANNON.Body({ mass:0, material:mFloor });
fb.addShape(new CANNON.Plane());
fb.quaternion.setFromAxisAngle(new CANNON.Vec3(1,0,0), -Math.PI/2);
world.addBody(fb);
// Walls (invisible, arena 10×10)
function addWall(x, y, z, hx, hy, hz) {
const b = new CANNON.Body({ mass:0, material:mWall });
b.addShape(new CANNON.Box(new CANNON.Vec3(hx, hy, hz)));
b.position.set(x, y, z);
world.addBody(b);
}
const WS = 5.5, WH = 4;
addWall( WS+.5, WH, 0, .5, WH, WS+1);
addWall(-WS-.5, WH, 0, .5, WH, WS+1);
addWall(0, WH, WS+.5, WS+1, WH, .5);
addWall(0, WH, -WS-.5, WS+1, WH, .5);
// ═══════════════════════════════════════════════
// DICE TEXTURES
// ═══════════════════════════════════════════════
const PIPS = {
1: [[128,128]],
2: [[82,82],[174,174]],
3: [[82,82],[128,128],[174,174]],
4: [[82,82],[174,82],[82,174],[174,174]],
5: [[82,82],[174,82],[128,128],[82,174],[174,174]],
6: [[82,70],[82,128],[82,186],[174,70],[174,128],[174,186]],
};
const COLORS = {
purple: { bg:'#0e0520', border:'#7c3aed', pip:'#c084fc' },
red: { bg:'#130005', border:'#dc2626', pip:'#f87171' },
blue: { bg:'#00071a', border:'#2563eb', pip:'#60a5fa' },
green: { bg:'#001209', border:'#16a34a', pip:'#4ade80' },
slate: { bg:'#08080e', border:'#64748b', pip:'#cbd5e1' },
gold: { bg:'#110800', border:'#d97706', pip:'#fbbf24' },
};
let activeColor = 'purple';
let texCache = {};
function rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.lineTo(x+w-r, y);
ctx.arcTo(x+w, y, x+w, y+r, r);
ctx.lineTo(x+w, y+h-r);
ctx.arcTo(x+w, y+h, x+w-r, y+h, r);
ctx.lineTo(x+r, y+h);
ctx.arcTo(x, y+h, x, y+h-r, r);
ctx.lineTo(x, y+r);
ctx.arcTo(x, y, x+r, y, r);
ctx.closePath();
}
function buildTextures(colorKey) {
const col = COLORS[colorKey];
const out = {};
for (let v = 1; v <= 6; v++) {
const c = document.createElement('canvas');
c.width = c.height = 256;
const ctx = c.getContext('2d');
// bg
ctx.fillStyle = col.bg;
rrect(ctx, 0, 0, 256, 256, 34); ctx.fill();
// border
ctx.shadowColor = col.border; ctx.shadowBlur = 20;
ctx.strokeStyle = col.border; ctx.lineWidth = 11;
rrect(ctx, 7, 7, 242, 242, 28); ctx.stroke();
ctx.shadowBlur = 0;
// pips
ctx.fillStyle = col.pip;
ctx.shadowColor = col.pip; ctx.shadowBlur = 14;
for (const [px, py] of PIPS[v]) {
ctx.beginPath(); ctx.arc(px, py, 21, 0, Math.PI*2); ctx.fill();
}
out[v] = new THREE.CanvasTexture(c);
}
return out;
}
texCache = buildTextures(activeColor);
// BoxGeometry face order: +X, -X, +Y, -Y, +Z, -Z
// Die values assigned: 2, 5, 1, 6, 3, 4
const FACE_VALS = [2, 5, 1, 6, 3, 4];
const FACE_NORMALS = [
new CANNON.Vec3( 1, 0, 0),
new CANNON.Vec3(-1, 0, 0),
new CANNON.Vec3( 0, 1, 0),
new CANNON.Vec3( 0,-1, 0),
new CANNON.Vec3( 0, 0, 1),
new CANNON.Vec3( 0, 0,-1),
];
function getTopFace(body) {
let maxY = -Infinity, top = 1;
const tmp = new CANNON.Vec3();
for (let i = 0; i < 6; i++) {
body.quaternion.vmult(FACE_NORMALS[i], tmp);
if (tmp.y > maxY) { maxY = tmp.y; top = FACE_VALS[i]; }
}
return top;
}
// ═══════════════════════════════════════════════
// DICE POOL
// ═══════════════════════════════════════════════
let dice = [];
function createDieMesh() {
const geo = new THREE.BoxGeometry(1, 1, 1);
const mats = FACE_VALS.map(v => new THREE.MeshStandardMaterial({
map: texCache[v], roughness: .25, metalness: .3
}));
const mesh = new THREE.Mesh(geo, mats);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
function clearDice() {
for (const d of dice) {
scene.remove(d.mesh);
d.mesh.geometry.dispose();
d.mesh.material.forEach(m => { m.map && m.map.dispose(); m.dispose(); });
world.remove(d.body);
}
dice = [];
}
// ═══════════════════════════════════════════════
// SETTINGS STATE
// ═══════════════════════════════════════════════
const cfg = { n:3, force:2, shadows:true, autoCam:true, grid:true };
const history = [];
// ═══════════════════════════════════════════════
// UI BINDINGS
// ═══════════════════════════════════════════════
const el = id => document.getElementById(id);
el('dminus').onclick = () => {
if (cfg.n > 1) { cfg.n—; el('dcount').textContent = cfg.n; }
};
el('dplus').onclick = () => {
if (cfg.n < 10) { cfg.n++; el('dcount').textContent = cfg.n; }
};
el('force').oninput = e => cfg.force = parseFloat(e.target.value);
document.querySelectorAll('.sw').forEach(s => {
s.onclick = () => {
document.querySelectorAll('.sw').forEach(x => x.classList.remove('active'));
s.classList.add('active');
activeColor = s.dataset.c;
texCache = buildTextures(activeColor);
// Reapply to existing dice
for (const d of dice) {
FACE_VALS.forEach((v, i) => {
d.mesh.material[i].map = texCache[v];
d.mesh.material[i].needsUpdate = true;
});
}
};
});
function bindToggle(id, key, cb) {
const t = el(id);
t.onclick = () => {
cfg[key] = !cfg[key];
t.classList.toggle('on', cfg[key]);
cb && cb(cfg[key]);
};
}
bindToggle('t-shadow', 'shadows', v => {
renderer.shadowMap.enabled = v;
scene.traverse(o => { if (o.isMesh) { o.castShadow = v; o.receiveShadow = v; } });
});
bindToggle('t-cam', 'autoCam');
bindToggle('t-grid', 'grid', v => { floorMesh.visible = v; ringMesh.visible = v; });
// ═══════════════════════════════════════════════
// ROLL LOGIC
// ═══════════════════════════════════════════════
let state = 'idle'; // 'idle' | 'rolling' | 'done'
let settleTicks = 0;
const statusEl = el('status');
const rollBtn = el('rollbtn');
function roll() {
if (state === 'rolling') return;
clearDice();
clearResultsUI();
state = 'rolling';
settleTicks = 0;
rollBtn.classList.add('rolling');
rollBtn.textContent = '⏳ Летят…';
statusEl.classList.add('vis');
const f = cfg.force;
const spread = Math.min(cfg.n * 0.55, 3.5);
for (let i = 0; i < cfg.n; i++) {
const sx = (Math.random()-.5) * spread;
const sz = (Math.random()-.5) * spread;
const body = new CANNON.Body({ mass:1, material:mDice });
body.addShape(new CANNON.Box(new CANNON.Vec3(.5,.5,.5)));
body.linearDamping = 0.18;
body.angularDamping = 0.14;
body.position.set(sx, 9 + Math.random()*3, sz);
body.velocity.set(
(Math.random()-.5) * 9 * f,
-3 - Math.random()*2,
(Math.random()-.5) * 9 * f
);
body.angularVelocity.set(
(Math.random()-.5) * 28 * f,
(Math.random()-.5) * 28 * f,
(Math.random()-.5) * 28 * f
);
world.addBody(body);
const mesh = createDieMesh();
scene.add(mesh);
dice.push({ body, mesh, result: null });
}
}
function checkSettled() {
if (state !== 'rolling') return;
let calm = true;
for (const d of dice) {
if (d.body.velocity.norm() > 0.09 || d.body.angularVelocity.norm() > 0.09) {
calm = false;
break;
}
}
if (calm) {
settleTicks++;
if (settleTicks > 35) onSettled();
} else {
settleTicks = 0;
}
}
function onSettled() {
state = 'done';
rollBtn.classList.remove('rolling');
rollBtn.textContent = '🎲 БРОСИТЬ';
statusEl.classList.remove('vis');
const results = dice.map(d => getTopFace(d.body));
showResults(results);
addHistory(results);
}
// ═══════════════════════════════════════════════
// RESULTS UI
// ═══════════════════════════════════════════════
function clearResultsUI() {
el('dr-grid').innerHTML = '';
el('sum-block').style.display = 'none';
}
function showResults(results) {
const grid = el('dr-grid');
grid.innerHTML = '';
let sum = 0;
results.forEach((r, i) => {
const d = document.createElement('div');
d.className = 'dr';
d.textContent = '·';
grid.appendChild(d);
setTimeout(() => {
d.textContent = r;
d.classList.add('lit');
sum += r;
if (i === results.length - 1) {
el('sum-block').style.display = '';
el('sum-v').textContent = sum;
el('avg-v').textContent = (sum / results.length).toFixed(2);
}
}, i * 180 + 250);
});
}
function addHistory(results) {
const sum = results.reduce((a,b)=>a+b,0);
history.unshift({ results:[...results], sum });
if (history.length > 8) history.pop();
el('hist-list').innerHTML = history.map((h, idx) => `
<div class="hi">
<span class="hi-n">#${history.length - idx}</span>
<span class="hi-dice">[${h.results.join(', ')}]</span>
<span class="hi-sum">= ${h.sum}</span>
</div>
‘).join(’');
}
rollBtn.onclick = roll;
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); roll(); } });
// ═══════════════════════════════════════════════
// CAMERA ORBIT
// ═══════════════════════════════════════════════
let camTheta = Math.PI * 0.25;
let camPhi = Math.PI * 0.25;
let camRadius = 22;
let autoAngle = camTheta;
let drag = false;
let lastMX = 0, lastMY = 0;
canvas.addEventListener('mousedown', e => {
if (e.button !== 0) return;
drag = true;
lastMX = e.clientX; lastMY = e.clientY;
autoAngle = camTheta; // sync so resume is smooth
});
canvas.addEventListener('mousemove', e => {
if (!drag) return;
camTheta -= (e.clientX - lastMX) * 0.012;
camPhi = Math.max(.08, Math.min(1.45, camPhi + (e.clientY - lastMY) * 0.012));
lastMX = e.clientX; lastMY = e.clientY;
autoAngle = camTheta;
});
canvas.addEventListener('mouseup', () => drag = false);
canvas.addEventListener('mouseleave', () => drag = false);
canvas.addEventListener('wheel', e => {
camRadius = Math.max(9, Math.min(35, camRadius + e.deltaY * 0.02));
e.preventDefault();
}, { passive:false });
// Touch
let lastTouchDist = 0;
canvas.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
drag = true; lastMX = e.touches[0].clientX; lastMY = e.touches[0].clientY;
autoAngle = camTheta;
} else if (e.touches.length === 2) {
drag = false;
lastTouchDist = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
}
e.preventDefault();
}, { passive:false });
canvas.addEventListener('touchmove', e => {
if (e.touches.length === 1 && drag) {
camTheta -= (e.touches[0].clientX - lastMX) * 0.012;
camPhi = Math.max(.08, Math.min(1.45, camPhi + (e.touches[0].clientY - lastMY) * 0.012));
lastMX = e.touches[0].clientX; lastMY = e.touches[0].clientY;
autoAngle = camTheta;
} else if (e.touches.length === 2) {
const d = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
camRadius = Math.max(9, Math.min(35, camRadius - (d - lastTouchDist) * 0.05));
lastTouchDist = d;
}
e.preventDefault();
}, { passive:false });
canvas.addEventListener('touchend', () => drag = false);
// ═══════════════════════════════════════════════
// ANIMATION LOOP
// ═══════════════════════════════════════════════
const FTS = 1 / 60;
let lt = 0;
function animate(t) {
requestAnimationFrame(animate);
const dt = Math.min((t - lt) / 1000, 0.05);
lt = t;
// Physics
world.step(FTS, dt, 3);
// Sync meshes
for (const d of dice) {
d.mesh.position.copy(d.body.position);
d.mesh.quaternion.copy(d.body.quaternion);
}
// Settle check
checkSettled();
// Camera
if (cfg.autoCam && !drag) {
autoAngle += 0.0025;
camTheta = autoAngle;
}
const cx = camRadius * Math.sin(camPhi) * Math.cos(camTheta);
const cy = camRadius * Math.cos(camPhi);
const cz = camRadius * Math.sin(camPhi) * Math.sin(camTheta);
camera.position.set(cx, cy, cz);
camera.lookAt(0, 1, 0);
// Animate accent lights
const s = t * .001;
pL1.position.set(Math.sin(s*.6)*7, 3, Math.cos(s*.45)*7);
pL2.position.set(Math.sin(s*.4+2)*6, 3, Math.cos(s*.55+1)*6);
// Ring pulse
const pulse = .3 + Math.sin(s*1.5)*.1;
ringMesh.material.opacity = pulse;
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
requestAnimationFrame(animate);
</script>
</body>
</html>



