// Stoopshake 3D: real-physics dice on a night-time sidewalk. Three.js r170 (shared CyberMax stage: HDR, bloom, ACES, shake) // + cannon-es 0.20 (MIT) rigid bodies. Rounded dice with engraved pips, PBR materials and a generated environment map, // a warm street lamp with soft shadows, neon fill, and a backstop the dice bounce off (stoop step, curb, parapet...). // Throw: hold to shake, flick/drag to throw (power and direction from the gesture), or roll(power) for buttons and bots. // The result is read from whichever face points up once the dice sleep; a die that rests tilted (cocked) or leaves the // area is a free re-roll. Slow motion kicks in when the dice are about to settle on 4-5-6, 1-2-3 or trips. // Everything here is generated in code (no outside art). (c) 2026 CyberMax. All rights reserved. import { Stage, THREE, col, damp, clamp01 } from './stage3d.js'; import * as CANNON from './vendor/cannon-es.min.js'; const DIE = 1; // edge length (world units) const R_CORNER = 0.13; // corner rounding const G = -52; // gravity tuned so a throw reads like real dice in 1-2 s // face order in a BoxGeometry: +x, -x, +y, -y, +z, -z -> standard die, opposite faces sum to 7 const FACE_VAL = [2, 5, 1, 6, 3, 4]; const AXES = [new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1)]; // ------------------------------------------------------------------ looks export const SKINS = { classic: { name: 'Classic', base: '#F3EFE6', pip: '#121212', one: '#C3122F', rough: 0.32, metal: 0, clear: 0.6 }, chrome: { name: 'Chrome', base: '#D9DDE3', pip: '#0B0C10', rough: 0.12, metal: 1, clear: 0.3, pack: 'skins' }, gold: { name: 'Gold', base: '#E6B54A', pip: '#2A1A05', rough: 0.2, metal: 1, clear: 0.4, pack: 'skins' }, bone: { name: 'Bone', base: '#E4D6B8', pip: '#3B2A1A', rough: 0.62, metal: 0, clear: 0, aged: true, pack: 'skins' }, neon: { name: 'Neon', base: '#0E0B1A', pip: '#3CFFE0', rough: 0.25, metal: 0, clear: 1, glow: true, pack: 'skins' }, crew: { name: 'Crew colours', base: '#E23B5A', pip: '#FFFFFF', rough: 0.3, metal: 0, clear: 0.8, pack: 'crew' }, }; // tables: free Corner, Dice & Table Pack (stoop, rooftop, arcade), Crew Pass (court, boardwalk) export const THEMES = { corner: { name: 'Corner Store Curb', ground: 'concrete', back: 'step', wall: 'brick', sky: ['#05060E', '#1A1430', '#04050A'], lamp: '#FFC27A', neonA: '#FF3D6E', neonB: '#2FE6FF', sign: 'OPEN 24/7' }, stoop: { name: 'Brownstone Stoop', ground: 'concrete', back: 'stoop', wall: 'brownstone', sky: ['#060815', '#23183A', '#05060C'], lamp: '#FFB86B', neonA: '#FF9A3D', neonB: '#7C8CFF', sign: 'HOME', pack: 'skins' }, rooftop: { name: 'Rooftop', exposure: 1.45, ground: 'tar', back: 'parapet', wall: 'skyline', sky: ['#020515', '#22305E', '#0A0B1A'], lamp: '#FFD9A0', neonA: '#FF4FD8', neonB: '#48C2FF', sign: '', pack: 'skins' }, arcade: { name: 'Arcade', exposure: 1.2, ground: 'carpet', back: 'cabinet', wall: 'arcade', sky: ['#05020C', '#1A0630', '#05020C'], lamp: '#B98CFF', neonA: '#FF2BD6', neonB: '#00F0FF', sign: 'PLAY', pack: 'skins' }, court: { name: 'Night Court', exposure: 1.15, ground: 'asphalt', back: 'lowwall', wall: 'fence', sky: ['#030712', '#14243E', '#03050A'], lamp: '#E8F1FF', neonA: '#FF7A1A', neonB: '#3DA0FF', sign: '', pack: 'crew' }, boardwalk: { name: 'Boardwalk', exposure: 1.3, ground: 'wood', back: 'bench', wall: 'pier', sky: ['#040615', '#2B1D47', '#06040C'], lamp: '#FFCF8A', neonA: '#FF5577', neonB: '#FFE15C', sign: '', pack: 'crew' }, }; // ------------------------------------------------------------------ procedural textures (canvas) function canvasTex(w, h, draw, { repeat = [1, 1], srgb = true } = {}) { const c = document.createElement('canvas'); c.width = w; c.height = h; draw(c.getContext('2d'), w, h); const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(...repeat); t.anisotropy = 4; if (srgb) t.colorSpace = THREE.SRGBColorSpace; return t; } function noise(g, w, h, n, a, cols) { for (let i = 0; i < n; i++) { g.fillStyle = cols[i % cols.length]; g.globalAlpha = Math.random() * a; const s = Math.random() * 3 + 0.5; g.fillRect(Math.random() * w, Math.random() * h, s, s); } g.globalAlpha = 1; } const GROUNDS = { stone: (g, w, h) => { g.fillStyle = '#5E5A56'; g.fillRect(0, 0, w, h); noise(g, w, h, 16000, 0.35, ['#3A3735', '#7E7974', '#4C4946', '#8E8984']); g.fillStyle = 'rgba(0,0,0,.18)'; g.fillRect(0, 0, w, 6); }, concrete: (g, w, h) => { g.fillStyle = '#6E6A66'; g.fillRect(0, 0, w, h); noise(g, w, h, 26000, 0.35, ['#3E3B39', '#8E8984', '#575350', '#A39E98']); g.strokeStyle = 'rgba(20,18,16,.75)'; g.lineWidth = 3; g.beginPath(); g.moveTo(0, h / 2); g.lineTo(w, h / 2); g.moveTo(w / 2, 0); g.lineTo(w / 2, h); g.stroke(); g.fillStyle = 'rgba(30,28,26,.55)'; for (let i = 0; i < 9; i++) { g.beginPath(); g.arc(Math.random() * w, Math.random() * h, 3 + Math.random() * 5, 0, 7); g.fill(); } g.strokeStyle = 'rgba(40,36,34,.35)'; g.lineWidth = 1.2; for (let i = 0; i < 5; i++) { g.beginPath(); let x = Math.random() * w, y = Math.random() * h; g.moveTo(x, y); for (let k = 0; k < 6; k++) { x += (Math.random() - 0.5) * 40; y += (Math.random() - 0.5) * 40; g.lineTo(x, y); } g.stroke(); } }, tar: (g, w, h) => { g.fillStyle = '#2B2B2F'; g.fillRect(0, 0, w, h); noise(g, w, h, 30000, 0.5, ['#1A1A1D', '#45454B', '#5A5A60', '#121214']); }, asphalt: (g, w, h) => { g.fillStyle = '#2E3034'; g.fillRect(0, 0, w, h); noise(g, w, h, 30000, 0.45, ['#1C1D20', '#4A4D52', '#62656A']); g.strokeStyle = 'rgba(240,240,235,.85)'; g.lineWidth = 10; g.beginPath(); g.arc(w / 2, -h * 0.1, h * 0.55, 0, Math.PI); g.stroke(); }, carpet: (g, w, h) => { g.fillStyle = '#120A26'; g.fillRect(0, 0, w, h); noise(g, w, h, 12000, 0.4, ['#1E1240', '#0B061A']); const c = ['#FF2BD6', '#00F0FF', '#FFE15C', '#7CFF6B']; for (let i = 0; i < 70; i++) { g.strokeStyle = c[i % 4]; g.globalAlpha = 0.75; g.lineWidth = 4; const x = Math.random() * w, y = Math.random() * h; g.beginPath(); if (i % 3 === 0) g.arc(x, y, 8, 0, 7); else if (i % 3 === 1) { g.moveTo(x - 9, y); g.lineTo(x, y - 12); g.lineTo(x + 9, y); } else { g.moveTo(x - 10, y - 4); g.lineTo(x - 3, y + 4); g.lineTo(x + 4, y - 4); g.lineTo(x + 11, y + 4); } g.stroke(); } g.globalAlpha = 1; }, wood: (g, w, h) => { const n = 8; for (let i = 0; i < n; i++) { g.fillStyle = ['#6B4A2E', '#5E4128', '#73502F', '#664629'][i % 4]; g.fillRect(0, (i * h) / n, w, h / n - 3); g.fillStyle = '#1C120A'; g.fillRect(0, ((i + 1) * h) / n - 3, w, 3); } noise(g, w, h, 8000, 0.25, ['#3A2716', '#8A6440']); g.strokeStyle = 'rgba(40,25,12,.35)'; for (let i = 0; i < 60; i++) { g.beginPath(); const y = Math.random() * h; g.moveTo(0, y); g.bezierCurveTo(w / 3, y + 4, (2 * w) / 3, y - 4, w, y); g.stroke(); } }, }; const WALLS = { brick: (g, w, h) => { g.fillStyle = '#2A1612'; g.fillRect(0, 0, w, h); const bw = 64, bh = 24; for (let y = 0; y < h; y += bh) for (let x = -((y / bh) % 2) * bw / 2; x < w; x += bw) { const v = 0.75 + Math.random() * 0.35; g.fillStyle = `rgb(${120 * v | 0},${52 * v | 0},${40 * v | 0})`; g.fillRect(x + 2, y + 2, bw - 4, bh - 4); } noise(g, w, h, 9000, 0.25, ['#1A0C08', '#9A5A48']); // original tag-style mural strokes (abstract, no words): spray loops in the corner g.lineCap = 'round'; for (const [c, o] of [['#2FE6FF', 0], ['#FF3D6E', 14]]) { g.strokeStyle = c; g.globalAlpha = 0.5; g.lineWidth = 9; g.beginPath(); g.moveTo(w * 0.62 + o, h * 0.62); g.bezierCurveTo(w * 0.7 + o, h * 0.3, w * 0.8 + o, h * 0.85, w * 0.92 + o, h * 0.5); g.stroke(); } g.globalAlpha = 1; }, brownstone: (g, w, h) => { g.fillStyle = '#4A2E22'; g.fillRect(0, 0, w, h); noise(g, w, h, 20000, 0.3, ['#2C1A12', '#6B4532', '#5A3828']); g.fillStyle = 'rgba(20,10,6,.6)'; for (let y = 0; y < h; y += 46) g.fillRect(0, y, w, 3); }, }; /** A die face: pips with a soft engraved edge; also returns a bump map. */ function faceCanvas(v, skin, bump) { const S = 256, c = document.createElement('canvas'); c.width = c.height = S; const g = c.getContext('2d'); g.fillStyle = bump ? '#FFFFFF' : skin.base; g.fillRect(0, 0, S, S); if (!bump && skin.aged) { noise(g, S, S, 3500, 0.25, ['#BFAE8A', '#F5EBD5', '#A8946C']); } if (!bump && skin.metal) { const gr = g.createLinearGradient(0, 0, S, S); gr.addColorStop(0, 'rgba(255,255,255,.25)'); gr.addColorStop(1, 'rgba(0,0,0,.18)'); g.fillStyle = gr; g.fillRect(0, 0, S, S); } const P = { 1: [[0.5, 0.5]], 2: [[0.27, 0.27], [0.73, 0.73]], 3: [[0.25, 0.25], [0.5, 0.5], [0.75, 0.75]], 4: [[0.27, 0.27], [0.73, 0.27], [0.27, 0.73], [0.73, 0.73]], 5: [[0.26, 0.26], [0.74, 0.26], [0.5, 0.5], [0.26, 0.74], [0.74, 0.74]], 6: [[0.27, 0.24], [0.73, 0.24], [0.27, 0.5], [0.73, 0.5], [0.27, 0.76], [0.73, 0.76]] }[v]; const r = v === 1 ? 0.13 : 0.085; for (const [x, y] of P) { const gr = g.createRadialGradient(x * S - r * S * 0.25, y * S - r * S * 0.25, r * S * 0.1, x * S, y * S, r * S); if (bump) { gr.addColorStop(0, '#202020'); gr.addColorStop(0.8, '#3A3A3A'); gr.addColorStop(1, '#FFFFFF'); } else { const pc = v === 1 && skin.one ? skin.one : skin.pip; gr.addColorStop(0, pc); gr.addColorStop(0.75, pc); gr.addColorStop(1, skin.glow ? pc : 'rgba(0,0,0,.35)'); } g.fillStyle = gr; g.beginPath(); g.arc(x * S, y * S, r * S, 0, 7); g.fill(); } return c; } function emissiveFace(v, skin) { // neon skin: only the pips glow const S = 256, c = document.createElement('canvas'); c.width = c.height = S; const g = c.getContext('2d'); g.fillStyle = '#000'; g.fillRect(0, 0, S, S); const src = faceCanvas(v, { ...skin, base: '#000000' }, false); g.drawImage(src, 0, 0); return c; } function roundedBox(size, r, seg = 7) { const geo = new THREE.BoxGeometry(size, size, size, seg, seg, seg); const p = geo.attributes.position, h = size / 2 - r, v = new THREE.Vector3(), inner = new THREE.Vector3(); for (let i = 0; i < p.count; i++) { v.fromBufferAttribute(p, i); inner.set(Math.max(-h, Math.min(h, v.x)), Math.max(-h, Math.min(h, v.y)), Math.max(-h, Math.min(h, v.z))); const d = v.clone().sub(inner); if (d.lengthSq() > 1e-9) d.normalize().multiplyScalar(r); v.copy(inner).add(d); p.setXYZ(i, v.x, v.y, v.z); } geo.computeVertexNormals(); return geo; } // ------------------------------------------------------------------ the table export class DiceTable { constructor(canvas, opts = {}) { this.opts = opts; const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) && !opts.record; this.stage = new Stage(canvas, { shadows: true, record: !!opts.record, bloom: 0.55, threshold: 1.1, exposure: 1.05, sat: 1.08, con: 1.06, vig: 0.6, grain: 0.03, fov: 42, particles: 600, fixedQuality: !!opts.record }); const S = this.stage; this.scene = S.scene; this.r = S.r; this.r.shadowMap.enabled = true; this.r.shadowMap.type = THREE.PCFSoftShadowMap; this.shadowSize = mobile ? 1024 : 2048; this.world = new CANNON.World({ gravity: new CANNON.Vec3(0, G, 0) }); this.world.allowSleep = true; this.world.broadphase = new CANNON.NaiveBroadphase(); this.world.defaultContactMaterial.friction = 0.3; this.world.defaultContactMaterial.restitution = 0.3; this.mDie = new CANNON.Material('die'); this.mFloor = new CANNON.Material('floor'); this.mWall = new CANNON.Material('wall'); this.world.addContactMaterial(new CANNON.ContactMaterial(this.mDie, this.mFloor, { friction: 0.32, restitution: 0.32 })); this.world.addContactMaterial(new CANNON.ContactMaterial(this.mDie, this.mWall, { friction: 0.15, restitution: 0.55 })); this.world.addContactMaterial(new CANNON.ContactMaterial(this.mDie, this.mDie, { friction: 0.12, restitution: 0.45 })); this.dieGeo = roundedBox(DIE, R_CORNER); this.dice = []; this.state = 'idle'; this.timeScale = 1; this.slowT = 0; this.t = 0; this.settleT = 0; this.camPos = new THREE.Vector3(0, 7.2, 12.5); this.camLook = new THREE.Vector3(0, 0.6, -3); this.camPosT = this.camPos.clone(); this.camLookT = this.camLook.clone(); this.onImpact = opts.onImpact || (() => {}); this.onSlow = opts.onSlow || (() => {}); this.statics = new THREE.Group(); this.scene.add(this.statics); this.setTheme(opts.theme || 'corner'); this.makeDice(opts.skin || 'classic'); this.hand(); } // ---------- environment envFrom(th) { const pm = new THREE.PMREMGenerator(this.r); const sc = new THREE.Scene(); sc.background = col(th.sky[1]).multiplyScalar(0.25); const box = (c, k, x, y, z, sx, sy, sz) => { const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), new THREE.MeshBasicMaterial({ color: col(c).multiplyScalar(k) })); m.position.set(x, y, z); sc.add(m); }; box(th.lamp, 6, -3, 6, 2, 2, 0.5, 2); box(th.neonA, 4, 5, 2, -4, 3, 0.6, 0.3); box(th.neonB, 4, -5, 2.5, -4, 2.5, 0.5, 0.3); box('#FFFFFF', 0.6, 0, 9, 0, 14, 0.2, 14); box(th.sky[0], 1, 0, -2, 0, 30, 0.2, 30); const env = pm.fromScene(sc, 0.04).texture; pm.dispose(); sc.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); return env; } clearStatics() { for (const b of this.staticBodies || []) this.world.removeBody(b); this.staticBodies = []; this.statics.traverse((o) => { if (o.geometry && o.geometry !== this.dieGeo) o.geometry.dispose(); if (o.material) { (Array.isArray(o.material) ? o.material : [o.material]).forEach((m) => { if (m.map) m.map.dispose(); m.dispose(); }); } }); this.scene.remove(this.statics); this.statics = new THREE.Group(); this.scene.add(this.statics); } wallBody(x, y, z, sx, sy, sz, mat) { const b = new CANNON.Body({ mass: 0, material: mat || this.mWall, shape: new CANNON.Box(new CANNON.Vec3(sx / 2, sy / 2, sz / 2)) }); b.position.set(x, y, z); this.world.addBody(b); this.staticBodies.push(b); return b; } mesh(geo, mat, x, y, z, { cast = false, recv = true, parent } = {}) { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.castShadow = cast; m.receiveShadow = recv; (parent || this.statics).add(m); return m; } std(o) { return new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, ...o }); } glowMat(c, k = 3) { return new THREE.MeshBasicMaterial({ color: col(c).multiplyScalar(k), toneMapped: false }); } signTex(text, color) { return canvasTex(512, 160, (g, w, h) => { g.clearRect(0, 0, w, h); g.font = '900 108px "Barlow Condensed", Arial Narrow, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.shadowColor = color; g.shadowBlur = 30; g.strokeStyle = color; g.lineWidth = 7; g.strokeText(text, w / 2, h / 2 + 4); g.fillStyle = '#FFFFFF'; g.globalAlpha = 0.85; g.fillText(text, w / 2, h / 2 + 4); }); } setTheme(id) { const th = THEMES[id] || THEMES.corner; this.themeId = THEMES[id] ? id : 'corner'; this.theme = th; this.clearStatics(); const S = this.stage, sc = this.scene, P = this.statics; S.look.exposure = th.exposure || 1.05; S.sky({ top: th.sky[0], hor: th.sky[1], low: th.sky[2], glow: th.neonA, glowDir: [0.4, 0.1, -1], glowK: 0.25, stars: id === 'rooftop' || id === 'boardwalk' ? 0.8 : 0.25 }); if (this.env) this.env.dispose(); this.env = this.envFrom(th); sc.environment = this.env; sc.environmentIntensity = 0.55; if (this.lights) this.lights.forEach((l) => sc.remove(l)); // light: one warm lamp (shadows), a cool moon fill, two neon accents const lamp = new THREE.SpotLight(col(th.lamp), 700, 40, 0.62, 0.6, 2); lamp.position.set(-4.5, 13, 3.5); lamp.target.position.set(0.5, 0, -1.5); lamp.castShadow = true; lamp.shadow.mapSize.set(this.shadowSize, this.shadowSize); lamp.shadow.bias = -0.0004; lamp.shadow.normalBias = 0.02; lamp.shadow.radius = 4; lamp.shadow.camera.near = 4; lamp.shadow.camera.far = 30; const moon = new THREE.DirectionalLight(col('#7E92D6'), 0.35); moon.position.set(6, 10, 8); const nA = new THREE.PointLight(col(th.neonA), 60, 16, 2); nA.position.set(4.5, 3.2, -6.4); const nB = new THREE.PointLight(col(th.neonB), 45, 16, 2); nB.position.set(-5, 2.6, -6.2); const hemi = new THREE.HemisphereLight(col(th.sky[1]), col('#0A0806'), 0.35); this.lights = [lamp, lamp.target, moon, nA, nB, hemi]; this.lights.forEach((l) => sc.add(l)); // ground const gt = canvasTex(1024, 1024, GROUNDS[th.ground] || GROUNDS.concrete, { repeat: [3, 3] }); const ground = this.mesh(new THREE.PlaneGeometry(40, 40), this.std({ map: gt, roughness: th.ground === 'wood' ? 0.7 : 0.92, metalness: 0, envMapIntensity: 0.4 }), 0, 0, 0); ground.rotation.x = -Math.PI / 2; this.wallBody(0, -0.5, 0, 40, 1, 40, this.mFloor); // backstop the dice bounce off (z = -6) + soft invisible side rails, open front (off the curb = re-roll) const B = th.back, bmat = this.std({ map: canvasTex(512, 256, GROUNDS[th.ground === 'carpet' ? 'tar' : th.ground === 'wood' ? 'wood' : 'stone'], { repeat: [3, 1] }), roughness: 0.9 }); if (B === 'step' || B === 'stoop') { for (let i = 0; i < (B === 'stoop' ? 5 : 2); i++) { const h = 0.85, d = 1.2; this.mesh(new THREE.BoxGeometry(B === 'stoop' ? 7 : 16, h, d), bmat, B === 'stoop' ? -1.5 : 0, h / 2 + i * h, -6.6 - i * d, { cast: true }); } this.wallBody(0, 0.85, -6.6, 20, 1.7, 1.2); if (B === 'stoop') { // iron railings const iron = this.std({ color: '#151518', roughness: 0.45, metalness: 0.8 }); for (const x of [-5.1, 2.1]) { for (let i = 0; i < 7; i++) this.mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.2), iron, x, 1.6 + i * 0.6, -6.3 - i * 0.85, { cast: true }); const rail = this.mesh(new THREE.CylinderGeometry(0.07, 0.07, 7.6), iron, x, 4.2, -9.2, { cast: true }); rail.rotation.x = -0.82; } } } else if (B === 'parapet') { this.mesh(new THREE.BoxGeometry(24, 1.6, 0.8), this.std({ color: '#3A3634', roughness: 0.95 }), 0, 0.8, -6.4, { cast: true }); this.wallBody(0, 0.8, -6.4, 24, 1.6, 0.8); } else if (B === 'cabinet' || B === 'bench' || B === 'lowwall') { const c = B === 'cabinet' ? '#1A0F33' : B === 'bench' ? '#4A3220' : '#4C4C50'; this.mesh(new THREE.BoxGeometry(20, 1.2, 1), this.std({ color: c, roughness: 0.7 }), 0, 0.6, -6.5, { cast: true }); this.wallBody(0, 0.6, -6.5, 20, 1.2, 1); } this.rails = [-1, 1].map((sg) => this.wallBody(sg * 7.2, 1, -1, 0.4, 2, 14)); this.railX = 7.2; // invisible side rails (dice still look free); narrower on a portrait phone // the curb at the front edge, then the street this.mesh(new THREE.BoxGeometry(40, 0.35, 0.5), this.std({ color: th.ground === 'carpet' ? '#2B1A4A' : '#8C8781', roughness: 0.9 }), 0, -0.02, 7.5, { cast: true }); if (th.ground !== 'carpet') { const st = this.mesh(new THREE.PlaneGeometry(40, 14), this.std({ color: '#141518', roughness: 0.55, metalness: 0.1, envMapIntensity: 0.9 }), 0, -0.2, 14.7); st.rotation.x = -Math.PI / 2; } // the background wall / skyline const W = th.wall; if (W === 'brick' || W === 'brownstone') { const wt = canvasTex(1024, 512, WALLS[W], { repeat: [3, 2] }); this.mesh(new THREE.PlaneGeometry(40, 16), this.std({ map: wt, roughness: 0.95 }), 0, 8, -10.6); // lit store window + door glow const win = this.mesh(new THREE.PlaneGeometry(6.5, 2.6), this.glowMat('#FFD9A0', 1.4), 4.8, 3.3, -10.55, { recv: false }); const shelves = canvasTex(512, 256, (g, w, h) => { g.fillStyle = '#FFE6B8'; g.fillRect(0, 0, w, h); for (let y = 40; y < h; y += 60) { g.fillStyle = '#5A3A1E'; g.fillRect(0, y, w, 6); for (let x = 6; x < w; x += 18) { g.fillStyle = ['#D8473A', '#3A82D8', '#F2C94C', '#4CB863', '#E8E8E8'][(x + y) % 5]; g.fillRect(x, y - 26 + (x % 5), 12, 26 - (x % 5)); } } }); win.material = new THREE.MeshBasicMaterial({ map: shelves, color: col('#FFE2B0').multiplyScalar(0.62), toneMapped: false }); const frame = this.std({ color: '#1A1A1C', roughness: 0.5, metalness: 0.6 }); this.mesh(new THREE.BoxGeometry(6.9, 0.22, 0.3), frame, 4.8, 4.7, -10.4); this.mesh(new THREE.BoxGeometry(6.9, 0.22, 0.3), frame, 4.8, 1.95, -10.4); for (const x of [1.45, 4.8, 8.15]) this.mesh(new THREE.BoxGeometry(0.18, 2.9, 0.3), frame, x, 3.3, -10.4); const glass = this.mesh(new THREE.PlaneGeometry(6.5, 2.6), new THREE.MeshPhysicalMaterial({ color: '#0A0C10', roughness: 0.05, metalness: 0, transparent: true, opacity: 0.28, envMapIntensity: 1.6 }), 4.8, 3.3, -10.45, { recv: false }); glass.renderOrder = 1; if (th.sign) { const s = this.mesh(new THREE.PlaneGeometry(3.6, 1.15), new THREE.MeshBasicMaterial({ map: this.signTex(th.sign, th.neonA), transparent: true, color: col('#FFFFFF').multiplyScalar(2.2), toneMapped: false, depthWrite: false }), 4.8, 5.45, -10.45, { recv: false }); s.renderOrder = 2; } for (const [x, y] of [[-6, 9], [-2, 9], [2, 11], [-6, 12.5], [6, 11]]) this.mesh(new THREE.PlaneGeometry(1.8, 2.4), this.glowMat(Math.random() > 0.4 ? '#FFC98A' : '#1A1830', 1.1), x, y, -10.5, { recv: false }); } else if (W === 'skyline') { for (let i = 0; i < 46; i++) { const h = 6 + Math.random() * 26, w = 2 + Math.random() * 4, x = -60 + i * 2.7 + Math.random(), z = -40 - Math.random() * 40; const b = this.mesh(new THREE.BoxGeometry(w, h, w), this.std({ color: '#0A0C16', roughness: 1 }), x, h / 2 - 2, z, { recv: false }); const lit = canvasTex(64, 256, (g) => { g.fillStyle = '#080A12'; g.fillRect(0, 0, 64, 256); for (let y = 6; y < 256; y += 12) for (let x2 = 6; x2 < 64; x2 += 12) if (Math.random() > 0.55) { g.fillStyle = Math.random() > 0.2 ? '#FFD48A' : '#9FD8FF'; g.fillRect(x2, y, 6, 7); } }); b.material = new THREE.MeshBasicMaterial({ map: lit, color: col('#FFFFFF').multiplyScalar(1.4), toneMapped: false }); } const tank = this.mesh(new THREE.CylinderGeometry(1.6, 1.6, 3.2, 20), this.std({ color: '#2A1E16', roughness: 0.9 }), -8, 5.2, -12, { cast: true }); // water tower this.mesh(new THREE.ConeGeometry(1.8, 1.2, 20), this.std({ color: '#1E1610' }), -8, 7.4, -12); for (const x of [-9.2, -6.8]) this.mesh(new THREE.CylinderGeometry(0.1, 0.1, 3.6), this.std({ color: '#111' }), x, 1.8, -12); tank.receiveShadow = false; this.stringLights(th); } else if (W === 'arcade') { for (let i = 0; i < 6; i++) { const x = -8.5 + i * 3.4; this.mesh(new THREE.BoxGeometry(2.4, 5, 1.6), this.std({ color: '#150A2C', roughness: 0.6 }), x, 2.5, -9.4, { cast: true }); this.mesh(new THREE.PlaneGeometry(1.8, 1.4), this.glowMat([th.neonA, th.neonB, '#FFE15C', '#7CFF6B'][i % 4], 1.8), x, 3.4, -8.58, { recv: false }); this.mesh(new THREE.BoxGeometry(2.4, 0.3, 1.62), this.glowMat(i % 2 ? th.neonA : th.neonB, 2.2), x, 4.9, -9.4, { recv: false }); } this.mesh(new THREE.PlaneGeometry(40, 16), this.std({ color: '#0B0618' }), 0, 8, -10.6); if (th.sign) { const s = this.mesh(new THREE.PlaneGeometry(5, 1.6), new THREE.MeshBasicMaterial({ map: this.signTex(th.sign, th.neonB), transparent: true, color: col('#FFFFFF').multiplyScalar(2.4), toneMapped: false, depthWrite: false }), 0, 7.4, -10.5, { recv: false }); s.renderOrder = 2; } } else if (W === 'fence') { const ft = canvasTex(256, 256, (g, w, h) => { g.clearRect(0, 0, w, h); g.strokeStyle = 'rgba(200,205,215,.85)'; g.lineWidth = 3; for (let i = -w; i < w * 2; i += 26) { g.beginPath(); g.moveTo(i, 0); g.lineTo(i + h, h); g.stroke(); g.beginPath(); g.moveTo(i + h, 0); g.lineTo(i, h); g.stroke(); } }, { repeat: [10, 4] }); this.mesh(new THREE.PlaneGeometry(40, 10), new THREE.MeshStandardMaterial({ map: ft, transparent: true, alphaTest: 0.3, roughness: 0.4, metalness: 0.7, side: THREE.DoubleSide }), 0, 5, -9, { recv: false }); for (let x = -18; x <= 18; x += 6) this.mesh(new THREE.CylinderGeometry(0.08, 0.08, 10), this.std({ color: '#888', metalness: 0.8, roughness: 0.4 }), x, 5, -9); // a hoop in the distance + floodlight this.mesh(new THREE.BoxGeometry(3, 2, 0.12), this.std({ color: '#EEE', roughness: 0.4 }), 6, 7, -14); const rim = this.mesh(new THREE.TorusGeometry(0.6, 0.05, 8, 24), this.std({ color: th.neonA, metalness: 0.5, roughness: 0.4 }), 6, 6.2, -13.4); rim.rotation.x = Math.PI / 2; this.mesh(new THREE.BoxGeometry(2, 0.6, 0.4), this.glowMat('#F4F8FF', 5), -9, 11, -12, { recv: false }); } else if (W === 'pier') { for (let x = -20; x <= 20; x += 2.5) this.mesh(new THREE.BoxGeometry(0.2, 1.4, 0.2), this.std({ color: '#3A2616' }), x, 1.9, -9.6, { cast: true }); this.mesh(new THREE.BoxGeometry(42, 0.18, 0.25), this.std({ color: '#4A3220' }), 0, 2.6, -9.6, { cast: true }); const sea = this.mesh(new THREE.PlaneGeometry(200, 80), this.std({ color: '#060A1A', roughness: 0.2, metalness: 0.2 }), 0, -1, -50); sea.rotation.x = -Math.PI / 2; const wheel = new THREE.Group(); wheel.position.set(10, 9, -34); P.add(wheel); // ferris wheel lights for (let i = 0; i < 36; i++) { const a = (i / 36) * Math.PI * 2; this.mesh(new THREE.SphereGeometry(0.22, 8, 6), this.glowMat(i % 2 ? th.neonA : th.neonB, 4), Math.cos(a) * 7, Math.sin(a) * 7, 0, { parent: wheel, recv: false }); } this.wheel = wheel; this.stringLights(th); } // props: two milk crates (seats) and a boombox, a lamp post with a glowing head if (th.ground !== 'carpet') { const crate = canvasTex(256, 256, (g, w, h) => { g.fillStyle = '#1D4FB8'; g.fillRect(0, 0, w, h); g.fillStyle = 'rgba(0,0,0,.85)'; for (let y = 24; y < h - 20; y += 34) for (let x = 20; x < w - 20; x += 34) g.fillRect(x, y, 22, 22); }); const cm = this.std({ map: crate, roughness: 0.6, color: id === 'court' ? '#FF9A3D' : '#FFFFFF' }); this.mesh(new THREE.BoxGeometry(1.5, 1.2, 1.5), cm, -6.4, 0.6, 2.2, { cast: true }).rotation.y = 0.3; this.mesh(new THREE.BoxGeometry(1.5, 1.2, 1.5), cm, 6.6, 0.6, 3.4, { cast: true }).rotation.y = -0.2; const bb = this.mesh(new THREE.BoxGeometry(1.9, 1, 0.6), this.std({ color: '#1A1A1E', roughness: 0.4, metalness: 0.4 }), 6.4, 1.7, 3.3, { cast: true }); bb.rotation.y = -0.4; for (const dx of [-0.5, 0.5]) { const sp = this.mesh(new THREE.CircleGeometry(0.32, 20), this.std({ color: '#0C0C0E', roughness: 0.9, emissive: col(th.neonB), emissiveIntensity: 0.08 }), dx, 0, 0.31, { parent: bb }); sp.receiveShadow = false; } } const pole = this.mesh(new THREE.CylinderGeometry(0.12, 0.16, 14), this.std({ color: '#1C1E22', metalness: 0.7, roughness: 0.5 }), -7.5, 7, 6.5, { cast: true }); pole.castShadow = false; this.mesh(new THREE.BoxGeometry(1.3, 0.3, 0.7), this.glowMat(th.lamp, 4), -6.9, 13.8, 6.2, { recv: false }); if (this.dice.length) this.dice.forEach((d) => this.world.addBody(d.body)); // bodies were never removed; noop safety } stringLights(th) { for (let i = 0; i < 26; i++) { const x = -13 + i, y = 7.6 - Math.sin((i / 25) * Math.PI) * 1.4; this.mesh(new THREE.SphereGeometry(0.11, 8, 6), this.glowMat(i % 3 ? '#FFD48A' : th.neonA, 5), x, y, -8.6, { recv: false }); } } // ---------- dice diceMats(skinId, crew) { const sk = { ...(SKINS[skinId] || SKINS.classic) }; if (skinId === 'crew' && crew) { sk.base = crew.a || sk.base; sk.pip = crew.b || sk.pip; } return FACE_VAL.map((v) => { const map = new THREE.CanvasTexture(faceCanvas(v, sk, false)); map.colorSpace = THREE.SRGBColorSpace; map.anisotropy = 4; const bump = new THREE.CanvasTexture(faceCanvas(v, sk, true)); const lite = !!this.lite; const m = lite ? new THREE.MeshStandardMaterial({ map, bumpMap: bump, bumpScale: 3.5, roughness: Math.max(0.12, sk.rough - 0.08), metalness: sk.metal, envMapIntensity: sk.metal ? 1.4 : 1.0 }) : new THREE.MeshPhysicalMaterial({ map, bumpMap: bump, bumpScale: 3.5, roughness: sk.rough, metalness: sk.metal, clearcoat: sk.clear, clearcoatRoughness: 0.18, envMapIntensity: sk.metal ? 1.4 : 0.9 }); if (sk.glow) { const em = new THREE.CanvasTexture(emissiveFace(v, sk)); em.colorSpace = THREE.SRGBColorSpace; m.emissiveMap = em; m.emissive = col('#FFFFFF'); m.emissiveIntensity = 2.6; m.transmission = 0; } return m; }); } makeDice(skinId, crew) { this.skinId = skinId; const mats = this.diceMats(skinId, crew); if (this.dice.length) { for (const d of this.dice) { (Array.isArray(d.mesh.material) ? d.mesh.material : []).forEach((m) => { m.map?.dispose(); m.bumpMap?.dispose(); m.emissiveMap?.dispose(); m.dispose(); }); d.mesh.material = mats.map((m) => m.clone()); } return; } for (let i = 0; i < 3; i++) { const mesh = new THREE.Mesh(this.dieGeo, mats.map((m) => m.clone())); mesh.castShadow = true; mesh.receiveShadow = true; this.scene.add(mesh); const body = new CANNON.Body({ mass: 1, material: this.mDie, shape: new CANNON.Box(new CANNON.Vec3(DIE / 2 - 0.02, DIE / 2 - 0.02, DIE / 2 - 0.02)), linearDamping: 0.08, angularDamping: 0.12, allowSleep: true, sleepSpeedLimit: 0.25, sleepTimeLimit: 0.35 }); body.addEventListener('collide', (e) => { const v = Math.abs(e.contact.getImpactVelocityAlongNormal()); if (v > 1.2 && this.state === 'rolling') this.onImpact(e.body.material === this.mDie ? 'die' : e.body.material === this.mWall ? 'wall' : 'floor', v, i); }); this.world.addBody(body); this.dice.push({ mesh, body, i }); } } setSkin(id, crew) { this.makeDice(id, crew); } /** Dice in the hand: hovering near the thrower, gently jiggling (also the pose between rolls). */ hand(px = 0) { this.state = 'hand'; this.timeScale = 1; this.handX = px; this.shakeK = 0; this.dice.forEach((d, i) => { d.body.type = CANNON.Body.KINEMATIC; d.body.velocity.setZero(); d.body.angularVelocity.setZero(); d.body.position.set(px + (i - 1) * 1.15, 1.9, 3.6 + (i === 1 ? -0.3 : 0)); d.body.quaternion.setFromEuler(Math.random() * 6, Math.random() * 6, Math.random() * 6); d.body.wakeUp(); }); this.camPosT.set(0, 7.2, 12.5); this.camLookT.set(0, 0.6, -3); } /** Shaking in the hand (pointer held): x follows the finger, the dice rattle. */ shake(px, k = 1) { if (this.state !== 'hand') return; this.handX = Math.max(-4, Math.min(4, px)); this.shakeK = k; } /** Throw. dir: {x, y} screen-space flick (y < 0 = away from you); power 0..1. Resolves { dice, scores?, cocked, off }. */ throw({ x = 0, power = 0.6, seed } = {}) { if (this.state === 'rolling') return this.pending; const rnd = seed != null ? mulberry(seed) : Math.random; power = Math.max(0.25, Math.min(1, power)); this.state = 'rolling'; this.t = 0; this.settleT = 0; this.timeScale = 1; this.slowT = 0; this.slowFired = false; this.launch(x, power, rnd); this.camPosT.set(0, 8.4, 11.5); this.camLookT.set(0, 0.2, -3.2); return (this.pending = new Promise((res) => { this.resolve = res; })); } launch(x, power, rnd) { this.world.accumulator = 0; this.dice.forEach((d, i) => { // cannon-es derives box inertia from the world-space AABB, so compute it unrotated (correct + identical every throw) const b = d.body; b.type = CANNON.Body.DYNAMIC; b.mass = 1; b.quaternion.set(0, 0, 0, 1); b.updateMassProperties(); b.wakeUp(); b.sleepState = 0; b.timeLastSleepy = 0; b.force.setZero(); b.torque.setZero(); b.quaternion.setFromEuler(rnd() * 6.283, rnd() * 6.283, rnd() * 6.283); b.position.set(this.handX + (i - 1) * 1.05 + (rnd() - 0.5) * 0.3, 2.0 + rnd() * 0.6, 3.7 + (rnd() - 0.5) * 0.4); const sp = 11 + power * 15; b.velocity.set(x * 9 + (rnd() - 0.5) * 2.5, 2.5 + power * 4 + rnd() * 2, -sp * (0.85 + rnd() * 0.3)); b.angularVelocity.set((rnd() - 0.5) * 40, (rnd() - 0.5) * 30, (rnd() - 0.5) * 40); }); } /** Physics only, no drawing: where would this seeded throw land? (trailer: pick a real throw that lands 4-5-6). */ simulate({ x = 0, power = 0.6, seed = 1, handX = 0 } = {}) { const keep = this.handX; this.handX = handX; this.launch(x, Math.max(0.25, Math.min(1, power)), mulberry(seed)); let still = 0, t = 0, off = false; while (t < 7) { this.world.step(1 / 120, 1 / 60, 8); t += 1 / 60; const rd = this.read(); off = rd.some((r) => r.p.y < -1.5 || r.p.z > 8 || Math.abs(r.p.x) > 9); if (off) break; const moving = this.dice.some((d) => d.body.velocity.length() > 0.25 || d.body.angularVelocity.length() > 0.6); still = moving ? 0 : still + 1 / 60; if (still > 0.28) break; } const rd = this.read(); const out = { dice: rd.map((r) => r.val), cocked: !off && rd.some((r) => r.up < 0.9), off }; this.handX = keep; this.hand(keep); return out; } read() { const q = new THREE.Quaternion(), v = new THREE.Vector3(); return this.dice.map((d) => { q.set(d.body.quaternion.x, d.body.quaternion.y, d.body.quaternion.z, d.body.quaternion.w); let best = -2, val = 1; AXES.forEach((a, k) => { v.copy(a).applyQuaternion(q); if (v.y > best) { best = v.y; val = FACE_VAL[k]; } }); return { val, up: best, p: d.body.position }; }); } update(realDt) { const dt = Math.min(0.05, realDt); if (this.state === 'hand') { const tt = performance.now() / 1000; this.dice.forEach((d, i) => { const b = d.body, k = this.shakeK; b.position.set(this.handX + (i - 1) * 1.15 + Math.sin(tt * 31 + i * 2) * 0.12 * k, 1.9 + Math.sin(tt * 2 + i) * 0.08 + Math.abs(Math.sin(tt * 27 + i)) * 0.25 * k, 3.6 + (i === 1 ? -0.3 : 0) + Math.cos(tt * 23 + i) * 0.1 * k); const e = new CANNON.Quaternion(); e.setFromEuler(dt * (0.8 + 12 * k) * (i + 1), dt * (0.6 + 10 * k), dt * 0.4 * (2 - i)); b.quaternion = b.quaternion.mult(e); }); } if (this.state === 'rolling') { const step = dt * this.timeScale * (this.speed || 1); this.world.step(1 / 120, step, this.speed > 1 ? 30 : 8); this.t += step; const rd = this.read(); const moving = this.dice.some((d) => d.body.velocity.length() > 0.25 || d.body.angularVelocity.length() > 0.6); const slowish = this.dice.every((d) => d.body.velocity.length() < 5 && d.body.position.y < 1.6); // slow motion when the dice are about to land on something big if (!this.slowFired && this.t > 0.55 && slowish && moving) { const vals = rd.map((r) => r.val).sort((a, b) => a - b), flat = rd.every((r) => r.up > 0.94); const big = flat && ((vals[0] === 4 && vals[1] === 5 && vals[2] === 6) || (vals[0] === 1 && vals[1] === 2 && vals[2] === 3) || (vals[0] === vals[2])); if (big) { this.slowFired = true; this.slowT = 1.1; this.onSlow(vals); } } if (this.slowT > 0) { this.slowT -= dt; this.timeScale = damp(this.timeScale, 0.22, 10, dt); } else this.timeScale = damp(this.timeScale, 1, 6, dt); const c = rd.reduce((a, r) => a.add(new THREE.Vector3(r.p.x, 0, r.p.z)), new THREE.Vector3()).multiplyScalar(1 / 3); this.camLookT.set(c.x * 0.6, 0, Math.min(1, c.z) * 0.7 - 1.2); const off = rd.some((r) => r.p.y < -1.5 || r.p.z > 8 || Math.abs(r.p.x) > 9); if (!moving) this.settleT += dt; else this.settleT = 0; if (off || this.settleT > 0.28 || this.t > 7) this.finish(rd, off); } // sync meshes for (const d of this.dice) { d.mesh.position.set(d.body.position.x, d.body.position.y, d.body.position.z); d.mesh.quaternion.set(d.body.quaternion.x, d.body.quaternion.y, d.body.quaternion.z, d.body.quaternion.w); } if (this.wheel) this.wheel.rotation.z += realDt * 0.05; // camera (aspect-aware so a phone in portrait still sees the whole throw) const S = this.stage, asp = S.cam.aspect || 1, k = asp < 1 ? 1 + (1 - asp) * 1.25 : 1; const rx = asp < 0.8 ? 4.3 : asp < 1.2 ? 5.6 : 7.2; if (this.rails && rx !== this.railX) { this.railX = rx; this.rails.forEach((b, i) => { b.position.x = (i ? 1 : -1) * rx; }); } this.camPos.x = damp(this.camPos.x, this.camPosT.x, 3, dt); this.camPos.y = damp(this.camPos.y, this.camPosT.y * k, 3, dt); this.camPos.z = damp(this.camPos.z, this.camPosT.z * k, 3, dt); this.camLook.x = damp(this.camLook.x, this.camLookT.x, 3, dt); this.camLook.y = damp(this.camLook.y, this.camLookT.y, 3, dt); this.camLook.z = damp(this.camLook.z, this.camLookT.z, 3, dt); S.setCam(this.camPos, this.camLook); if (!this.noDraw) S.render(realDt); } finish(rd, off) { this.state = 'settled'; this.timeScale = 1; const cocked = !off && rd.some((r) => r.up < 0.9); const out = { dice: rd.map((r) => r.val), cocked, off, at: rd.map((r) => [r.p.x, r.p.z]) }; const c = rd.reduce((a, r) => a.add(new THREE.Vector3(r.p.x, 0, r.p.z)), new THREE.Vector3()).multiplyScalar(1 / 3); if (!off) { const spread = Math.max(...rd.map((r) => Math.hypot(r.p.x - c.x, r.p.z - c.z))); const d = 4.4 + spread * 1.15; this.camPosT.set(c.x * 0.7, d * 1.45, c.z + d * 0.5); this.camLookT.set(c.x, 0, c.z - 0.15); } const r = this.resolve; this.resolve = null; if (r) r(out); } /** Juice for a result (big = 4-5-6 / trips: gold flash + sparks; bust = 1-2-3: red flash). */ celebrate(kind) { const S = this.stage, c = this.dice.reduce((a, d) => a.add(new THREE.Vector3(d.body.position.x, 0.6, d.body.position.z)), new THREE.Vector3()).multiplyScalar(1 / 3); if (kind === '456' || kind === 'trips') { S.shake(0.7); S.flash('#FFE08A', 0.18); S.punch(0.004); S.burst(c, { n: 90, colors: ['#FFD36B', '#FFF2C2'], speed: 7, life: 1.1, size: 0.16, up: 5, hdr: 3 }); S.burst(c, { n: 50, colors: [this.theme.neonB, this.theme.neonA], speed: 5, life: 0.9, size: 0.12, up: 3, hdr: 3 }); } else if (kind === '123') { S.shake(0.5); S.flash('#FF2A3D', 0.18); S.punch(0.004); } else if (kind === 'point') S.shake(0.15); } resize() { this.stage.fit(); } /** Frame-stepped rendering (trailer): fixed dt, no wall clock. */ stepFixed(dt) { this.update(dt); } } function mulberry(seed) { let t = seed >>> 0; return () => { t += 0x6D2B79F5; let r = Math.imul(t ^ (t >>> 15), 1 | t); r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } export { clamp01 };