Spaces:
Running
Running
Download game.js from CyberMax-tools/stoopshake: direct link, hf CLI and curl.
- Browser
- Download file 52.8 kB
-
https://huggingface.co/spaces/CyberMax-tools/stoopshake/resolve/main/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/stoopshake/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/stoopshake/resolve/main/game.js
52.8 kB
| // Stoopshake: free 3D street dice (classic Cee-lo, 4-5-6) on a night-time stoop. Real-physics dice (dice3d.js), the rules | |
| // engine (rules.js), an original soundtrack (audio.js). Modes: Quick 1v1, Table (up to 6, pot), Banker, the Daily Table, | |
| // pass-and-play, async challenge links (state in the URL, no server), Crew tables (Crew Pass), Telegram Mini App + group | |
| // chats (/dice on the bot, rolled with Telegram's own animated dice). Free play chips only: daily top-up, earned by playing, | |
| // never bought, sold or cashed out; no prizes. Paid = looks and crew features only, never odds. (c) 2026 CyberMax. | |
| import { count, store, toast, share, download } from './cmx.js'; | |
| import * as FP from './funpass.js'; | |
| import * as pack from './pack.js'; | |
| import * as TGM from './tg.js'; | |
| import * as R from './rules.js'; | |
| import * as A from './audio.js'; | |
| const GAME = 'stoopshake'; | |
| const SITE = 'https://cybermaxtools.com'; | |
| const PATH = '/games/stoopshake/'; | |
| const DATA = 'https://data.cybermaxtools.com'; | |
| const BOT_LINK = 'https://t.me/CyberMaxTools_bot'; | |
| const $ = (id) => document.getElementById(id); | |
| const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms * (CAP ? 0 : FAST ? 0.15 : 1))); | |
| const Q = new URLSearchParams(location.search); | |
| const CAP = Q.has('cap'); // trailer / screenshots: frame-stepped from outside (window.__cap) | |
| const FAST = Q.has('fast'); // tests: shorter pauses | |
| const TODAY = R.dayNumber(); | |
| const dz = (d) => (d || []).map(R.face).join(''); | |
| // ------------------------------------------------------------------ paid extras (cosmetic / convenience only) | |
| pack.capture({ game: GAME, name: 'Stoopshake', title: 'Dice & Table Pack', skins: 4, price: '$1.99', preview: ['#D9DDE3', '#E6B54A', '#E4D6B8', '#3CFFE0'], | |
| perks: ['4 dice: Chrome, Gold, Bone and Neon', '3 tables: Brownstone Stoop, Rooftop and Arcade', '5 win emotes for your big rolls'] }); | |
| const CREW_PERKS = ['Crew tables: save your crew (up to 6) and seat them in one tap', 'Your crew name and colours on the dice and every share card', 'Stats history: every round, win rate, best rolls', '2 more tables: Night Court and Boardwalk']; | |
| // Named exactly as the Stripe product (prod "Stoopshake Crew Pass: crew tables, colours and stats"); the Crew Pass also | |
| // turns on every Dice & Table Pack look (ownPack() below), so the sheet says so. Every mode stays free (17 Oct 2026). | |
| FP.init({ game: GAME, name: 'Stoopshake', title: 'Stoopshake Crew Pass', short: 'Crew Pass', exact: true, | |
| free: 'Every mode, the Daily Table and the daily free chips stay free for everyone.', | |
| perks: [...CREW_PERKS, 'Also every Dice & Table Pack look: 4 dice, 3 tables, 5 emotes'], | |
| extra: 'The Crew Pass opens crew tables, your crew name and colours, stats history and two more tables. Same dice odds for everyone.' }); | |
| const ownPack = () => pack.owned() || FP.isPro(); | |
| const ownCrew = () => FP.isPro(); | |
| const EMOTES = [{ id: 'none', t: '' }, { id: 'mic', t: '🎤 MIC DROP' }, { id: 'ice', t: '🧊 ICE COLD' }, { id: 'easy', t: '😤 TOO EASY' }, { id: 'crown', t: '👑 RUN IT BACK' }, { id: 'dance', t: '🕺 DANCE BREAK' }]; | |
| // ------------------------------------------------------------------ saved on this device only | |
| const DEF = { name: '', wallet: null, rules: false, skin: 'classic', theme: 'corner', emote: 'none', daily: {}, hist: [], crew: { name: 'MY CREW', a: '#E23B5A', b: '#FFFFFF', members: [] }, board: false, dev: '', locals: [] }; | |
| const S = Object.assign({}, DEF, store('ssk:s') || {}); | |
| if (!S.dev) S.dev = Math.random().toString(36).slice(2, 12); | |
| if (!S.name) S.name = ['ACE', 'BLAZE', 'DOT', 'KASH', 'NOVA', 'RICO', 'JAY', 'MIKA', 'ZEE', 'LUX'][Math.floor(Math.random() * 10)] + (10 + Math.floor(Math.random() * 89)); | |
| const save = () => store('ssk:s', S); | |
| let gift = 0; | |
| ({ wallet: S.wallet, gift } = R.dailyRefill(S.wallet, TODAY)); save(); | |
| // ------------------------------------------------------------------ challenge link from a friend (?c=...) | |
| let chal = Q.get('c') ? R.decodeChallenge(Q.get('c')) : null; | |
| // ------------------------------------------------------------------ 3D boot | |
| let table = null, DT = null; | |
| const cv = $('c'); | |
| async function boot() { | |
| const ok = (() => { try { return !!document.createElement('canvas').getContext('webgl2'); } catch { return false; } })(); | |
| if (!ok) { $('loading').hidden = true; $('nogl').hidden = false; count(GAME, 'nogl'); return; } | |
| $('lbar').style.width = '35%'; | |
| DT = await import('./dice3d.js'); | |
| $('lbar').style.width = '75%'; | |
| try { await document.fonts.load('800 20px "Barlow Condensed"'); } catch { /* */ } | |
| table = new DT.DiceTable(cv, { theme: S.theme, skin: skinFor(), record: CAP, onImpact: (k, v) => A.hit(k, v), onSlow: () => { A.whoosh(0.3); $('app').classList.add('slow'); } }); | |
| if (S.skin === 'crew') table.setSkin('crew', S.crew); | |
| if (FAST) { table.speed = 3; table.stage.setQuality(0.5); table.stage.q.bloom = false; table.r.shadowMap.enabled = false; } // tests on software GL: same physics, faster clock, cheap frames | |
| addEventListener('resize', () => table.resize()); table.resize(); | |
| // compile every shader behind the loading screen (parallel where the GPU allows), so the first frame doesn't stall | |
| $('lbar').style.width = '90%'; | |
| const tc = performance.now(); | |
| try { table.stage.setCam(table.camPos, table.camLook); await table.r.compileAsync(table.scene, table.stage.cam); } catch { /* older three: compiles on first frame */ } | |
| table.update(1 / 60); // first frame (post-processing programs) | |
| window.__sskCompile = Math.round(performance.now() - tc); | |
| $('loading').hidden = true; | |
| count(GAME, 'view', TGM.app() ? 'telegram' : chal ? 'challenge' : ''); | |
| if (!CAP) requestAnimationFrame(loop); | |
| window.__ssk = { ready: true, readyAt: Math.round(performance.now()), rolls: 0, state: () => ({ mode: M && M.mode, turn: turnSeat && turnSeat.name, chips: S.wallet.chips, waiting: !!waiting }), table }; | |
| renderTop(); | |
| if (gift > 0 && S.wallet.played > 0) setTimeout(() => toast(`Daily top-up: +${gift} free chips. Come back tomorrow for more.`, 3500), 600); | |
| if (chal) home(); else if (!S.rules) { $('rules').hidden = false; } else home(); | |
| } | |
| const skinFor = () => { const sk = DT.SKINS[S.skin]; if (!sk || (sk.pack === 'skins' && !ownPack()) || (sk.pack === 'crew' && !ownCrew())) return 'classic'; return S.skin; }; | |
| const themeFor = () => { const th = DT.THEMES[S.theme]; if (!th || (th.pack === 'skins' && !ownPack()) || (th.pack === 'crew' && !ownCrew())) return 'corner'; return S.theme; }; | |
| let last = performance.now(); | |
| function loop(now) { | |
| const dt = Math.min(0.05, (now - last) / 1000); last = now; | |
| if (holding && table.state === 'hand') { table.shake(holdX, holdK); A.rattle(holdK); holdK = Math.max(0.35, holdK * 0.96); } | |
| table.update(dt); rec.frame(); | |
| if (table.state !== 'rolling') $('app').classList.remove('slow'); | |
| requestAnimationFrame(loop); | |
| } | |
| // frame-stepped capture for the trailer and store shots (no wall clock) | |
| window.__cap = { | |
| step: (dt = 1 / 30, draw = true) => { if (!table) return; table.noDraw = !draw; table.update(dt); table.noDraw = false; }, ready: () => !!table, | |
| // trailer + store shots: drive the real scene and the real HUD from outside (tests/trailer.mjs) | |
| hideAll: () => { hideAll(); actions([]); }, | |
| seats: (list, turn) => { M = { mode: 'cap', seats: list.map((x) => ({ ...x, last: x.last || null, sc: x.last ? R.score(x.last) : null })), chipsShown: !!list[0].chips, potLabel: () => list.pot || '' }; turnSeat = M.seats[turn ?? -1] || null; renderSeats(); }, | |
| pot: (t) => { $('pot').textContent = t; }, | |
| banner: (kind, text, sub, emote) => { const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `<small>${esc(sub)}</small>` : ''}${emote ? `<span class="emote">${esc(emote)}</span>` : ''}`; b.classList.add('show'); }, | |
| unbanner: () => $('banner').classList.remove('show'), | |
| talk: (who, text) => { const t = $('talk'); t.innerHTML = `<b>${esc(who)}:</b> ${esc(text)}`; t.classList.add('show'); }, | |
| untalk: () => $('talk').classList.remove('show'), | |
| hint: (t) => { $('hint').textContent = t || ''; $('hint').classList.toggle('hide', !t); }, | |
| roll: (on) => { $('roll').disabled = !on; }, | |
| hud: (on) => { $('hud').style.display = on ? '' : 'none'; }, | |
| theme: (id) => { table.setTheme(id); table.hand(0); }, skin: (id) => table.setSkin(id, S.crew), | |
| find: (want, from = 1, n = 400, power = 0.62) => { for (let s = from; s < from + n; s++) { const r = table.simulate({ seed: s, power }); if (!r.off && !r.cocked && R.score(r.dice).kind === want) return { seed: s, dice: r.dice }; } return null; }, | |
| throwSeed: (seed, power = 0.62) => { table.hand(0); table.throw({ seed, power }); }, | |
| state: () => table.state, celebrate: (k) => table.celebrate(k), shake: (x, k) => table.shake(x, k), hand: (x) => table.hand(x || 0), | |
| read: () => table.read().map((r) => r.val), | |
| score: () => R.score(table.read().map((r) => r.val)), | |
| }; | |
| // ------------------------------------------------------------------ input: hold to shake, flick to throw | |
| let waiting = null, holding = false, holdX = 0, holdK = 0, trail = []; | |
| const toX = (clientX) => { const r = cv.getBoundingClientRect(); return ((clientX - r.left) / r.width - 0.5) * 8; }; | |
| cv.addEventListener('pointerdown', (e) => { | |
| A.ensure(); A.startMusic(); | |
| if (!waiting || table.state !== 'hand') return; | |
| holding = true; holdX = toX(e.clientX); holdK = 1; trail = [{ x: e.clientX, y: e.clientY, t: performance.now() }]; | |
| try { cv.setPointerCapture(e.pointerId); } catch { /* */ } | |
| $('hint').classList.add('hide'); | |
| }); | |
| cv.addEventListener('pointermove', (e) => { | |
| if (!holding) return; | |
| const p = { x: e.clientX, y: e.clientY, t: performance.now() }; const q = trail[trail.length - 1]; | |
| holdX = toX(e.clientX); holdK = Math.min(1, holdK + Math.hypot(p.x - q.x, p.y - q.y) / 60); | |
| trail.push(p); if (trail.length > 30) trail.shift(); | |
| }); | |
| const endHold = (e) => { | |
| if (!holding) return; holding = false; | |
| const now = performance.now(); const pts = trail.filter((p) => now - p.t < 110); const a = pts[0] || trail[0], b = trail[trail.length - 1]; | |
| const dt = Math.max(16, b.t - a.t), vx = (b.x - a.x) / dt, vy = (b.y - a.y) / dt; // px per ms | |
| const r = cv.getBoundingClientRect(), k = 900 / Math.max(320, r.height); | |
| const flick = -vy * k; | |
| const power = flick > 0.25 ? Math.min(1, 0.3 + flick * 0.42) : 0.55; // a tap or a sideways drag = a steady toss | |
| doThrow({ x: Math.max(-1, Math.min(1, vx * k * 0.6)), power }); | |
| }; | |
| cv.addEventListener('pointerup', endHold); cv.addEventListener('pointercancel', endHold); | |
| addEventListener('keydown', (e) => { if ((e.key === ' ' || e.key === 'Enter') && waiting && document.activeElement === cv) { e.preventDefault(); doThrow({ power: 0.6 + Math.random() * 0.3, x: (Math.random() - 0.5) * 0.4 }); } }); | |
| $('roll').onclick = () => { A.ensure(); A.startMusic(); if (waiting) doThrow({ power: 0.55 + Math.random() * 0.4, x: (Math.random() - 0.5) * 0.5 }); }; | |
| function doThrow(o) { if (!waiting || table.state !== 'hand') return; if (window.__ssk && !window.__ssk.firstThrow) window.__ssk.firstThrow = Math.round(performance.now()); const w = waiting; waiting = null; $('roll').disabled = true; A.whoosh(o.power); TGM.haptic('medium'); w(o); } | |
| // ------------------------------------------------------------------ clip recorder (on this device only, watermark link) | |
| const rec = (() => { | |
| let mr = null, chunks = [], c2 = null, g = null, type = '', on = false; | |
| const ok = () => !CAP && window.MediaRecorder && HTMLCanvasElement.prototype.captureStream; | |
| return { | |
| start() { | |
| if (!ok() || on) return; | |
| type = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].find((t) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)); if (!type) return; | |
| const w = 720, h = Math.round(720 * cv.clientHeight / Math.max(1, cv.clientWidth)); c2 = c2 || document.createElement('canvas'); c2.width = w; c2.height = Math.min(1280, Math.max(480, h)); g = c2.getContext('2d'); | |
| try { const s = c2.captureStream(30); const au = A.stream(); if (au) au.getAudioTracks().forEach((t) => s.addTrack(t)); mr = new MediaRecorder(s, { mimeType: type, videoBitsPerSecond: 4_000_000 }); } catch { mr = null; return; } | |
| chunks = []; mr.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; mr.start(250); on = true; | |
| }, | |
| frame() { | |
| if (!on || !g) return; | |
| const W = c2.width, H = c2.height, sw = cv.width, sh = cv.height, k = Math.max(W / sw, H / sh); | |
| g.drawImage(cv, (W - sw * k) / 2, (H - sh * k) / 2, sw * k, sh * k); | |
| g.fillStyle = 'rgba(0,0,0,.55)'; g.fillRect(0, H - 54, W, 54); | |
| g.font = '800 30px "Barlow Condensed", sans-serif'; g.fillStyle = '#FFC56B'; g.textBaseline = 'middle'; g.fillText('STOOPSHAKE', 16, H - 27); | |
| g.fillStyle = '#FFFFFF'; g.textAlign = 'right'; g.font = '700 22px "Barlow Condensed", sans-serif'; g.fillText('cybermaxtools.com/games/stoopshake · free play chips', W - 16, H - 27); g.textAlign = 'left'; | |
| }, | |
| stop() { return new Promise((res) => { if (!on || !mr) { res(null); return; } on = false; mr.onstop = () => res(chunks.length ? { blob: new Blob(chunks, { type: type.split(';')[0] }), ext: type.includes('mp4') ? 'mp4' : 'webm' } : null); try { mr.stop(); } catch { res(null); } }); }, | |
| }; | |
| })(); | |
| let clip = null; | |
| // ------------------------------------------------------------------ UI bits | |
| function renderTop() { | |
| $('chipsN').textContent = S.wallet.chips.toLocaleString('en-US'); | |
| const rk = R.rankOf(S.wallet.rep); $('rank').textContent = rk.name.toUpperCase(); | |
| $('b-snd').classList.toggle('off', !A.settings().music); | |
| document.body.classList.toggle('cmx-pro', ownCrew()); | |
| } | |
| let bannerT = 0; | |
| function banner(kind, text, sub = '', ms = 1700, emote = '') { | |
| const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `<small>${esc(sub)}</small>` : ''}${emote ? `<span class="emote">${esc(emote)}</span>` : ''}`; | |
| requestAnimationFrame(() => b.classList.add('show')); clearTimeout(bannerT); bannerT = setTimeout(() => b.classList.remove('show'), ms * (FAST ? 0.3 : 1)); | |
| } | |
| let talkT = 0; | |
| function talk(who, text, ms = 2600) { const t = $('talk'); t.innerHTML = `<b>${esc(who)}:</b> ${esc(text)}`; t.classList.add('show'); clearTimeout(talkT); talkT = setTimeout(() => t.classList.remove('show'), ms); } | |
| function renderSeats() { | |
| if (!M) { $('seats').innerHTML = ''; $('pot').textContent = ''; return; } | |
| $('seats').innerHTML = M.seats.map((s, i) => `<div class="seat${s === turnSeat ? ' on' : ''}${s.out ? ' out' : ''}${s.win ? ' win' : ''}" style="border-left:4px solid ${s.color}">${M.bank === i ? '<span class="bank">BANK</span>' : ''}<b>${esc(s.name)}</b>` | |
| + `<span class="dz">${s.last ? dz(s.last) : ' '}</span><small>${s.sc ? esc(s.sc.short) : s.kind === 'bot' ? esc(s.bot.vibe) : s.kind === 'me' ? 'you' : ' '}${M.chipsShown && s.chips != null ? ` · ◉${s.chips}` : ''}</small></div>`).join(''); | |
| $('pot').textContent = M.potLabel ? M.potLabel() : ''; | |
| } | |
| function actions(btns) { $('actions').innerHTML = ''; for (const [label, fn, cls] of btns) { const b = document.createElement('button'); b.type = 'button'; b.className = cls || 'chipb'; b.textContent = label; b.onclick = fn; $('actions').appendChild(b); } } | |
| const hideAll = () => { for (const id of ['home', 'rules', 'setup', 'pass', 'end', 'style', 'board', 'me', 'shop']) $(id).hidden = true; }; | |
| // ------------------------------------------------------------------ one turn: a seat rolls until it scores | |
| let M = null, turnSeat = null; | |
| async function rollFor(seat, { label } = {}) { | |
| turnSeat = seat; renderSeats(); | |
| for (let tries = 0; tries < 40; tries++) { | |
| table.hand(seat.kind === 'bot' ? (Math.random() - 0.5) * 3 : 0); | |
| let throwOpts; | |
| if (seat.kind === 'bot') { | |
| $('roll').disabled = true; $('hint').classList.add('hide'); | |
| await sleep(450 + Math.random() * 400); | |
| if (tries === 0 && Math.random() < 0.45) talk(seat.name, R.line(seat.bot, 'hi')); | |
| const t0 = performance.now(); while (performance.now() - t0 < (CAP ? 0 : FAST ? 120 : 700)) { table.shake((Math.random() - 0.5) * 2, 1); A.rattle(0.8); await sleep(50); } | |
| throwOpts = { power: seat.bot.power * (0.75 + Math.random() * 0.25), x: (Math.random() - 0.5) * 0.6 }; | |
| A.whoosh(throwOpts.power); | |
| } else { | |
| if (seat.kind === 'local' && M.locals > 1) { $('pass-n').textContent = seat.name.toUpperCase(); $('pass').hidden = false; await new Promise((r) => { $('pass-ok').onclick = () => { $('pass').hidden = true; r(); }; }); } | |
| $('roll').disabled = false; $('hint').textContent = `${seat.kind === 'me' ? 'Your roll' : `${seat.name}'s roll`} · hold to shake, flick up to throw`; $('hint').classList.remove('hide'); | |
| throwOpts = await new Promise((r) => { waiting = r; }); | |
| } | |
| rec.start(); | |
| const res = await table.throw(throwOpts); | |
| window.__ssk && (window.__ssk.rolls += 1); | |
| if (res.off) { banner('none', 'OFF THE CURB!', 'Free re-roll', 1300); A.sting('none'); await sleep(1000); continue; } | |
| if (res.cocked) { banner('none', 'COCKED DIE', 'Tilted against something. Free re-roll', 1400); A.sting('none'); await sleep(1100); continue; } | |
| const sc = R.score(res.dice); seat.last = res.dice; seat.sc = R.scores(sc) ? sc : null; renderSeats(); | |
| if (!R.scores(sc)) { banner('none', 'NO SCORE', `${dz(res.dice)} · roll again`, 1100); A.sting('none'); await sleep(950); continue; } | |
| table.celebrate(sc.kind); A.sting(sc.kind); | |
| const big = sc.kind === '456' || sc.kind === 'trips' || sc.kind === '123'; | |
| banner(sc.kind === '456' ? '456' : sc.kind, sc.kind === '456' ? '4-5-6!' : sc.kind === '123' ? '1-2-3' : sc.kind === 'trips' ? `TRIP ${sc.value}s` : `POINT ${sc.value}`, | |
| `${seat.name} · ${sc.kind === '456' ? 'instant win' : sc.kind === '123' ? 'instant loss' : sc.kind === 'trips' ? 'beats any point' : 'the odd die'}`, big ? 2300 : 1500); | |
| if (seat.kind === 'bot' && big) setTimeout(() => talk(seat.name, R.line(seat.bot, 'big')), 900); | |
| if (big && seat.kind !== 'bot') TGM.haptic(sc.kind === '123' ? 'error' : 'success'); | |
| await sleep(big ? 1500 : 900); | |
| const c = await rec.stop(); if (c && big) { clip = { ...c, label: `${seat.name}-${sc.short}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-') }; } | |
| return { dice: res.dice, sc }; | |
| } | |
| return { dice: [2, 2, 1], sc: R.score([2, 2, 1]) }; | |
| } | |
| // ------------------------------------------------------------------ seats | |
| const ME = () => ({ id: 'me', name: S.name, kind: 'me', color: '#FFC56B' }); | |
| const botSeat = (id) => { const b = R.botById(id); return { id, name: b.name, kind: 'bot', bot: b, color: b.color, chips: 1000 }; }; | |
| const pickBots = (n) => { const ids = R.BOTS.map((b) => b.id).sort(() => Math.random() - 0.5); return ids.slice(0, n).map(botSeat); }; | |
| function settleMe(delta, sc, won) { | |
| const before = R.rankOf(S.wallet.rep).index; | |
| S.wallet = R.settle(S.wallet, { delta, sc, won }); | |
| S.hist.push({ d: TODAY, m: M ? M.mode : '', w: won ? 1 : won === false ? 0 : -1, c: delta, k: sc ? sc.kind : '' }); if (S.hist.length > 400) S.hist.splice(0, S.hist.length - 400); | |
| save(); renderTop(); if (delta) A.chips(Math.min(8, 2 + Math.round(Math.abs(delta) / 25))); | |
| const after = R.rankOf(S.wallet.rep); if (after.index > before) setTimeout(() => { banner('trips', `RANK UP`, after.name.toUpperCase(), 2400); A.sting('trips'); }, 1200); | |
| } | |
| const myEmote = () => (ownPack() ? (EMOTES.find((e) => e.id === S.emote) || EMOTES[0]).t : ''); | |
| // ------------------------------------------------------------------ modes | |
| async function runDuel({ stake, opp, practice = false }) { | |
| const me = ME(), them = opp || pickBots(1)[0]; | |
| M = { mode: practice ? 'practice' : 'duel', seats: [me, them], chipsShown: false, potLabel: () => (practice ? 'PRACTICE · no chips' : `STAKE ◉${stake} each`) }; | |
| let first = 0; | |
| count(GAME, 'play', M.mode); | |
| for (;;) { | |
| M.seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; }); renderSeats(); | |
| const a = M.seats[first], b = M.seats[1 - first]; | |
| const ra = await rollFor(a); | |
| let r = R.duel(ra.sc, null), rb = null; | |
| if (r.winner === null) { rb = await rollFor(b); r = R.duel(ra.sc, rb.sc); } | |
| if (r.winner === null) { banner('point', 'TIE!', 'Shoot-off', 1300); await sleep(1300); continue; } | |
| const winner = r.winner === 0 ? a : b; winner.win = true; renderSeats(); | |
| const iWon = winner === me, mySc = a === me ? ra.sc : rb && rb.sc; | |
| if (!practice) settleMe(iWon ? stake : -stake, mySc, iWon); | |
| if (them.kind === 'bot') setTimeout(() => talk(them.name, R.line(them.bot, iWon ? 'lose' : 'win')), 400); | |
| A.sting(iWon ? 'win' : 'lose'); | |
| await sleep(500); | |
| banner(iWon ? '456' : '123', iWon ? 'YOU WIN' : `${winner.name.toUpperCase()} WINS`, practice ? '' : `${iWon ? '+' : '-'}${stake} chips`, 2000, iWon ? myEmote() : ''); | |
| await sleep(1800); | |
| const again = await endChoice(iWon, practice ? null : stake); | |
| if (again !== 'again') return; | |
| if (!practice && S.wallet.chips < stake) { broke(); return; } | |
| first = iWon ? 1 : 0; // loser of the last round shoots first | |
| } | |
| } | |
| async function runTable({ stake, seats, mode = 'table', rounds = 0, daily = null }) { | |
| M = { mode, seats, chipsShown: true, pot: 0, potLabel: () => `POT ◉${M.pot}` }; | |
| count(GAME, 'play', daily ? 'daily' : mode); | |
| const log = []; | |
| for (let round = 1; ; round++) { | |
| seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; }); | |
| M.pot = 0; for (const s of seats) { const st = s.kind === 'me' ? stake : Math.min(stake, s.chips); s.stake = st; s.chips = (s.chips ?? 0) - (s.kind === 'me' ? 0 : st); M.pot += st; } | |
| renderSeats(); A.chips(5); | |
| if (daily) banner('point', `ROUND ${round}/${rounds}`, daily.label, 1200); | |
| await sleep(daily ? 1100 : 300); | |
| let live = [...seats], winners = null, mySc = null; | |
| for (;;) { | |
| const res = []; | |
| for (const s of live) { | |
| const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (s.kind === 'me') mySc = r.sc; | |
| if (r.sc.kind === '456') break; | |
| if (r.sc.kind === '123') { s.out = true; renderSeats(); } | |
| } | |
| const tw = R.tableWinners(res); | |
| if (tw.winners.length === 1) { winners = tw.winners; break; } | |
| banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500); | |
| live = tw.winners; live.forEach((s) => { s.last = null; s.sc = null; }); renderSeats(); | |
| } | |
| const w = winners[0]; w.win = true; if (w.kind !== 'me') w.chips += M.pot; | |
| const iWon = w.kind === 'me' || (w.kind === 'local' && false); | |
| if (seats.some((s) => s.kind === 'me')) settleMe(iWon ? M.pot - stake : -stake, mySc, iWon); | |
| if (w.kind === 'local') { w.chips = (w.chips ?? 500) + M.pot; } | |
| renderSeats(); A.sting(iWon ? 'win' : 'lose'); | |
| banner(iWon ? '456' : 'point', iWon ? 'YOU TAKE THE POT' : `${w.name.toUpperCase()} TAKES IT`, `◉${M.pot}`, 2000, iWon ? myEmote() : ''); | |
| if (w.kind === 'bot') setTimeout(() => talk(w.name, R.line(w.bot, 'win')), 500); | |
| log.push({ sc: mySc, won: iWon, delta: iWon ? M.pot - stake : -stake }); | |
| await sleep(2000); | |
| if (daily && round >= rounds) return finishDaily(daily, log); | |
| if (daily) { if (S.wallet.chips < stake) { broke(); return; } continue; } | |
| const again = await endChoice(iWon, stake, true); | |
| if (again !== 'again') return; | |
| if (seats.some((s) => s.kind === 'me') && S.wallet.chips < stake) { broke(); return; } | |
| seats.push(seats.shift()); // the deal moves round the table | |
| } | |
| } | |
| async function runBanker({ stake }) { | |
| const me = ME(); const bots = pickBots(3); const seats = [me, ...bots]; | |
| M = { mode: 'banker', seats, chipsShown: true, bank: 1, potLabel: () => `BANK: ${seats[M.bank].name}` }; | |
| count(GAME, 'play', 'banker'); | |
| for (;;) { | |
| seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; s.stake = s.kind === 'me' ? stake : R.botStake(s.bot, s.chips, 10); }); | |
| const bank = seats[M.bank]; renderSeats(); | |
| banner('point', `${bank.name.toUpperCase()} HAS THE BANK`, bank.kind === 'me' ? 'You cover every bet' : `Your stake ◉${stake}`, 1500); await sleep(1400); | |
| const br = await rollFor(bank); let next = M.bank; let myDelta = 0, mySc = bank.kind === 'me' ? br.sc : null; | |
| const players = seats.filter((s) => s !== bank); | |
| if (br.sc.kind === '456' || br.sc.kind === '123') { | |
| const o = R.bankerSettle(br.sc, null); | |
| banner(o === 'lose' ? '456' : '123', o === 'lose' ? 'BANK TAKES ALL' : 'BANK PAYS ALL', '', 1800); await sleep(1600); | |
| for (const p of players) { const d = o === 'win' ? p.stake : -p.stake; if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d; } | |
| } else { | |
| for (const p of players) { | |
| const pr = await rollFor(p); if (p.kind === 'me') mySc = pr.sc; | |
| const o = R.bankerSettle(br.sc, pr.sc); const d = o === 'win' ? p.stake : o === 'lose' ? -p.stake : 0; | |
| if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d; | |
| p.win = o === 'win'; renderSeats(); | |
| banner(o === 'win' ? 'point' : o === 'push' ? 'none' : '123', o === 'win' ? `${p.name.toUpperCase()} BEATS THE BANK` : o === 'push' ? 'PUSH' : 'BANK WINS', o === 'push' ? 'Stake back' : `◉${p.stake}`, 1300); | |
| await sleep(1200); | |
| if (R.takesBank(br.sc, pr.sc, o) && next === M.bank) next = seats.indexOf(p); | |
| } | |
| } | |
| const won = myDelta > 0 ? true : myDelta < 0 ? false : null; | |
| settleMe(myDelta, mySc, won); | |
| banner(myDelta >= 0 ? '456' : '123', myDelta > 0 ? 'UP THIS ROUND' : myDelta < 0 ? 'DOWN THIS ROUND' : 'EVEN', `${myDelta >= 0 ? '+' : ''}${myDelta} chips`, 1800, won ? myEmote() : ''); | |
| await sleep(1700); | |
| if (next !== M.bank) { toast(`${seats[next].name} beat the bank with ${seats[next].sc.short} and takes the bank.`, 2600); M.bank = next; } | |
| else if (Math.random() < 0.3) M.bank = (M.bank + 1) % seats.length; | |
| const again = await endChoice(won, stake, true); | |
| if (again !== 'again') return; | |
| if (S.wallet.chips < stake) { broke(); return; } | |
| } | |
| } | |
| // pass-and-play / crew: local players only, session chips (500 each) | |
| async function runLocal(names, crew = false) { | |
| const seats = names.map((n, i) => ({ id: `l${i}`, name: n, kind: 'local', color: ['#FFC56B', '#2FE6FF', '#FF3D6E', '#7CF7C8', '#C9A2FF', '#FF9A3D'][i % 6], chips: 500 })); | |
| if (crew) seats.forEach((s) => { s.color = S.crew.a; }); | |
| M = { mode: crew ? 'crew' : 'local', seats, locals: seats.length, chipsShown: true, pot: 0, potLabel: () => `POT ◉${M.pot}` }; | |
| count(GAME, 'play', M.mode); | |
| for (;;) { | |
| seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; }); | |
| M.pot = 0; for (const s of seats) { s.chips -= 25; M.pot += 25; } renderSeats(); | |
| let live = [...seats], w = null; | |
| for (;;) { | |
| const res = []; for (const s of live) { const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (r.sc.kind === '456') break; if (r.sc.kind === '123') { s.out = true; renderSeats(); } } | |
| const tw = R.tableWinners(res); if (tw.winners.length === 1) { w = tw.winners[0]; break; } | |
| banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500); live = tw.winners; | |
| } | |
| w.win = true; w.chips += M.pot; renderSeats(); A.sting('win'); | |
| banner('456', `${w.name.toUpperCase()} TAKES IT`, `◉${M.pot}`, 2000); | |
| if (crew) { S.crew.stats = S.crew.stats || {}; for (const s of seats) { const k = s.name; S.crew.stats[k] = S.crew.stats[k] || { p: 0, w: 0, best: 0 }; S.crew.stats[k].p++; if (s === w) S.crew.stats[k].w++; if (s.sc && s.sc.rank > S.crew.stats[k].best) S.crew.stats[k].best = s.sc.rank; } save(); } | |
| await sleep(2000); | |
| const again = await endChoice(null, null, true); if (again !== 'again') return; | |
| seats.push(seats.shift()); | |
| } | |
| } | |
| // async challenge link (no server): one turn per link | |
| async function runLinkTurn() { | |
| const st = chal; const me = st.t; const meName = st.n[me], them = st.n[1 - me]; | |
| const seat = { id: 'me', name: meName, kind: 'me', color: '#FFC56B' }, other = { id: 'them', name: them, kind: 'remote', color: '#2FE6FF', last: st.d, sc: st.d ? R.score(st.d) : null }; | |
| M = { mode: 'link', seats: me === 0 ? [seat, other] : [other, seat], chipsShown: false, potLabel: () => `${st.n[0]} ${st.s[0]} - ${st.s[1]} ${st.n[1]} · first to ${st.w}` }; | |
| count(GAME, 'play', 'link'); | |
| if (st.d) talk(them, `I rolled ${dz(st.d)} (${R.score(st.d).short}). Beat that.`, 4000); | |
| const r = await rollFor(seat); | |
| const out = R.challengeTurn(st, r.dice); chal = out.st; | |
| const code = R.encodeChallenge(chal); const url = `${SITE}${PATH}?c=${code}`; | |
| history.replaceState(null, '', `${location.pathname}?c=${code}`); | |
| const over = R.seriesOver(chal); | |
| let msg; | |
| if (out.round && out.round.tie) msg = `Tie on the point! Round ${chal.k} replays. ${them} rolls first.`; | |
| else if (out.round) msg = `${out.round.winner === me ? 'You take' : `${them} takes`} round ${chal.k - 1}. ${chal.n[0]} ${chal.s[0]} - ${chal.s[1]} ${chal.n[1]}.`; | |
| else msg = `You set ${r.sc.short}. Now ${them} has to beat it.`; | |
| if (over !== null) { msg = `${chal.n[over]} wins the series ${Math.max(...chal.s)}-${Math.min(...chal.s)}!`; if (over === me) settleMe(0, r.sc, true); } | |
| const text = over !== null ? `${msg} Rematch on Stoopshake:` : `🎲 ${meName} rolled ${dz(r.dice)} (${r.sc.short}) on Stoopshake. Your turn, ${them}. No real money, just bragging rights:`; | |
| const shareUrl = over !== null ? `${SITE}${PATH}?c=${R.encodeChallenge({ n: [chal.n[1], chal.n[0]], s: [0, 0], k: 1, t: 0, d: null, w: chal.w })}` : url; | |
| showEnd(`<div class="endsum"><p class="sub">CHALLENGE · FIRST TO ${chal.w}</p><div class="big">${esc(chal.n[0])} ${chal.s[0]}-${chal.s[1]} ${esc(chal.n[1])}</div><p class="sub">${esc(msg)}</p><div class="dzs">${dz(r.dice)}</div>` | |
| + `<p class="sub">${over !== null ? 'Send a rematch link:' : `Send this link to <b>${esc(them)}</b> so they can take their turn (anywhere, any time):`}</p></div>`, | |
| [['📤 SEND TO ' + them.toUpperCase().slice(0, 12), () => shareLink(text, shareUrl), 'btn'], ['COPY LINK', () => copy(shareUrl), 'ghost'], ['HOME', () => { chal = null; history.replaceState(null, '', location.pathname); home(); }, 'ghost']]); | |
| } | |
| function shareLink(text, url) { count(GAME, 'share', 'challenge'); if (TGM.app()) { TGM.challenge(text, url); return; } share(GAME, text, url.replace(SITE, ''), null, null); } | |
| async function copy(t) { try { await navigator.clipboard.writeText(t); toast('Link copied.'); } catch { prompt('Copy this link', t); } } | |
| // daily table | |
| async function runDaily() { | |
| const d = R.dailyTable(TODAY); const seats = [ME(), ...d.lineup.map(botSeat)]; | |
| if (S.daily[TODAY]) toast('You already played today\'s table. This run is for fun and won\'t change your daily card.', 3500); | |
| if (themeFor() !== d.scene && DT.THEMES[d.scene] && !DT.THEMES[d.scene].pack) table.setTheme(d.scene); | |
| await runTable({ stake: Math.min(d.stake, Math.max(10, S.wallet.chips)), seats, mode: 'daily', rounds: d.rounds, daily: d }); | |
| } | |
| function finishDaily(d, log) { | |
| const net = log.reduce((a, x) => a + x.delta, 0); const first = !S.daily[TODAY]; | |
| const text = R.dailyCard(d.day, log, net); | |
| if (first) { S.daily[TODAY] = { net, em: text.split('\n')[1] }; save(); count(GAME, 'done', 'daily'); } | |
| table.setTheme(themeFor()); | |
| showEnd(`<div class="endsum"><p class="sub">${esc(d.label)}</p><div class="big">${net >= 0 ? '+' : ''}${net}</div><p class="delta ${net >= 0 ? 'up' : 'down'}">chips over ${d.rounds} rounds</p><pre class="sub" style="white-space:pre-wrap;font-size:22px">${esc(text)}</pre></div>`, | |
| [['📤 SHARE', () => { count(GAME, 'share', 'daily'); if (TGM.app()) TGM.share(text, `${SITE}${PATH}`); else share(GAME, text, PATH, null, null); }, 'btn'], ['🏆 BOARD', () => openBoard(), 'ghost'], ['HOME', home, 'ghost']]); | |
| } | |
| // ------------------------------------------------------------------ round end / home / setup | |
| function endChoice(won, stake, table_ = false) { | |
| return new Promise((res) => { | |
| const b = [['RUN IT BACK', () => { actions([]); res('again'); }, 'btn']]; | |
| if (clip) b.push(['🎬 SAVE CLIP', () => { download(clip.blob, `stoopshake-${clip.label}.${clip.ext}`); count(GAME, 'share', 'clip'); toast('Clip saved on this device. Post it anywhere.'); }, 'chipb']); | |
| if (won && S.wallet.winStreak >= 2) b.push([`🔥 STREAK ${S.wallet.winStreak} CARD`, () => streakCard(), 'chipb']); | |
| b.push(['HOME', () => { actions([]); res('home'); home(); }, 'chipb']); | |
| actions(b); $('hint').classList.add('hide'); | |
| }); | |
| } | |
| function broke() { M = null; renderSeats(); showEnd(`<div class="endsum"><div class="big">OUT OF CHIPS</div><p class="sub">Free chips top up every day. Come back tomorrow, or keep playing the Practice table (no chips).</p></div>`, [['PRACTICE', () => { hideAll(); runDuel({ stake: 0, practice: true }); }, 'btn'], ['HOME', home, 'ghost']]); } | |
| function showEnd(html, btns) { hideAll(); actions([]); $('end-c').innerHTML = html + '<div class="btnrow" id="end-b"></div><p class="tiny" style="text-align:center">No real money. Just for fun.</p>'; for (const [l, f, c] of btns) { const b = document.createElement('button'); b.type = 'button'; b.className = c; b.textContent = l; b.onclick = f; $('end-b').appendChild(b); } $('end').hidden = false; } | |
| function home() { | |
| hideAll(); actions([]); M = null; turnSeat = null; renderSeats(); waiting = null; $('roll').disabled = true; | |
| if (table && table.state !== 'rolling') { if (table.themeId !== themeFor()) table.setTheme(themeFor()); table.hand(0); } | |
| const d = R.dailyTable(TODAY), done = S.daily[TODAY]; | |
| $('h-daily').textContent = `DAILY TABLE #${TODAY}${done ? ' ✓' : ''}`; | |
| $('h-daily-s').textContent = done ? `Played: ${done.em} ${done.net >= 0 ? '+' : ''}${done.net}` : `${d.lineup.map((id) => R.botById(id).name).join(', ')} · ${d.rounds} rounds`; | |
| $('crew-lock').hidden = ownCrew(); | |
| const hc = $('h-chal'); | |
| if (chal) { hc.hidden = false; const them = chal.n[1 - chal.t]; hc.innerHTML = `<b>${esc(them)}</b> challenged you${chal.d ? ` and rolled <b>${dz(chal.d)}</b> (${esc(R.score(chal.d).short)})` : ''}. Series ${chal.s[0]}-${chal.s[1]}, first to ${chal.w}.<br><button class="btn" id="h-go" type="button" style="margin-top:8px">🎲 TAKE YOUR TURN AS ${esc(chal.n[chal.t]).toUpperCase()}</button>`; $('h-go').onclick = () => { hideAll(); runLinkTurn(); }; } | |
| else hc.hidden = true; | |
| $('home').hidden = false; | |
| } | |
| function setup(mode) { | |
| if (mode === 'daily') { hideAll(); runDaily(); return; } | |
| if (mode === 'tg') { count(GAME, 'cta', 'tg-group'); window.open(`${BOT_LINK}?startgroup=dice`, '_blank', 'noopener'); return; } | |
| if (mode === 'crew' && !ownCrew()) { openCrew('crew-mode'); return; } | |
| const c = $('setup-c'); const stakes = R.STAKES.filter((s) => s <= Math.max(10, S.wallet.chips)); | |
| let stake = stakes.includes(25) ? 25 : stakes[0]; let n = mode === 'table' ? 4 : 2; | |
| const draw = () => { | |
| const stakeRow = `<span class="lbl">STAKE (free play chips · you have ◉${S.wallet.chips})</span><div class="chips-row">${stakes.map((s) => `<button class="chipb${s === stake ? ' on' : ''}" data-s="${s}" type="button">◉${s}</button>`).join('')}</div>`; | |
| if (mode === 'duel' || mode === 'banker') c.innerHTML = `<h2 class="tag">${mode === 'duel' ? 'QUICK 1v1' : 'BANKER'}</h2>${stakeRow}<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`; | |
| else if (mode === 'table') c.innerHTML = `<h2 class="tag">TABLE</h2><span class="lbl">SEATS (you + bots)</span><div class="chips-row">${[2, 3, 4, 5, 6].map((k) => `<button class="chipb${k === n ? ' on' : ''}" data-n="${k}" type="button">${k}</button>`).join('')}</div>${stakeRow}<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`; | |
| else if (mode === 'local' || mode === 'crew') { | |
| const names = mode === 'crew' ? (S.crew.members.length ? S.crew.members : [S.name, '', '']) : (S.locals.length ? S.locals : [S.name, 'Player 2']); | |
| c.innerHTML = `<h2 class="tag">${mode === 'crew' ? esc(S.crew.name) : 'PASS & PLAY'}</h2><p class="sub">${mode === 'crew' ? 'Your saved crew. Everyone shoots on this phone; crew stats are kept.' : 'Everyone on one phone. Each round: 25 table chips in the pot.'}</p>` | |
| + `<div id="nm">${[0, 1, 2, 3, 4, 5].map((i) => `<input class="inp" data-i="${i}" maxlength="14" placeholder="Player ${i + 1}${i > 1 ? ' (optional)' : ''}" value="${esc(names[i] || '')}" style="margin:4px 0">`).join('')}</div>` | |
| + `<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`; | |
| } else if (mode === 'link') c.innerHTML = `<h2 class="tag">CHALLENGE A FRIEND</h2><p class="sub">You roll first, then send the link. Your friend rolls on their phone and sends it back. No account, no server, first to 3.</p>` | |
| + `<span class="lbl">YOUR NAME</span><input class="inp" id="n1" maxlength="14" value="${esc(S.name)}"><span class="lbl">FRIEND'S NAME</span><input class="inp" id="n2" maxlength="14" placeholder="Their name"><div class="btnrow"><button class="btn" id="go" type="button">🎲 ROLL FIRST</button><button class="ghost" id="back" type="button">BACK</button></div>`; | |
| for (const b of c.querySelectorAll('[data-s]')) b.onclick = () => { stake = Number(b.dataset.s); A.tap(); draw(); }; | |
| for (const b of c.querySelectorAll('[data-n]')) b.onclick = () => { n = Number(b.dataset.n); A.tap(); draw(); }; | |
| $('back').onclick = home; | |
| $('go').onclick = () => { | |
| A.ensure(); A.startMusic(); hideAll(); | |
| if (mode === 'duel') runDuel({ stake }); | |
| else if (mode === 'banker') runBanker({ stake }); | |
| else if (mode === 'table') runTable({ stake, seats: [ME(), ...pickBots(n - 1)] }); | |
| else if (mode === 'local' || mode === 'crew') { | |
| const names = [...c.querySelectorAll('#nm input')].map((i) => R.cleanName(i.value)).filter((v, i, a) => v && v !== 'Player' || i < 2).slice(0, 6); | |
| const list = names.length >= 2 ? names : [S.name, 'Player 2']; | |
| if (mode === 'crew') S.crew.members = list; else S.locals = list; save(); | |
| runLocal(list, mode === 'crew'); | |
| } else if (mode === 'link') { | |
| S.name = R.cleanName($('n1').value) || S.name; save(); | |
| chal = { n: [S.name, R.cleanName($('n2').value || 'Friend')], s: [0, 0], k: 1, t: 0, d: null, w: 3 }; | |
| runLinkTurn(); | |
| } | |
| }; | |
| }; | |
| hideAll(); draw(); $('setup').hidden = false; | |
| } | |
| // ------------------------------------------------------------------ style, profile, board, shop | |
| function openStyle() { | |
| const c = $('style-c'); | |
| const lockP = (p) => (p === 'skins' && !ownPack()) || (p === 'crew' && !ownCrew()); | |
| const tagP = (p) => (p === 'skins' ? 'PACK' : p === 'crew' ? 'CREW' : ''); | |
| c.innerHTML = `<h2 class="tag">STYLE</h2><span class="lbl">DICE</span><div class="stylegrid">${Object.entries(DT.SKINS).map(([id, s]) => `<button class="chipb${S.skin === id ? ' on' : ''}" data-k="${id}" type="button"><span class="sw" style="background:${id === 'crew' ? S.crew.a : s.base}"></span>${esc(s.name)}${lockP(s.pack) ? `<i class="lock">${tagP(s.pack)}</i>` : ''}</button>`).join('')}</div>` | |
| + `<span class="lbl">TABLE</span><div class="stylegrid">${Object.entries(DT.THEMES).map(([id, t]) => `<button class="chipb${S.theme === id ? ' on' : ''}" data-t="${id}" type="button">${esc(t.name)}${lockP(t.pack) ? `<i class="lock">${tagP(t.pack)}</i>` : ''}</button>`).join('')}</div>` | |
| + `<span class="lbl">WIN EMOTE</span><div class="stylegrid">${EMOTES.map((e) => `<button class="chipb${S.emote === e.id ? ' on' : ''}" data-e="${e.id}" type="button">${e.t || 'None'}${e.id !== 'none' && !ownPack() ? '<i class="lock">PACK</i>' : ''}</button>`).join('')}</div>` | |
| + (ownCrew() ? `<span class="lbl">CREW NAME AND COLOURS</span><input class="inp" id="cn" maxlength="14" value="${esc(S.crew.name)}"><div class="chips-row" style="margin-top:6px"><input type="color" id="ca" value="${S.crew.a}"><input type="color" id="cb" value="${S.crew.b}"><button class="chipb" id="csave" type="button">SAVE CREW</button></div>` : '<p class="tiny">Crew colours, crew tables and stats history come with the <a href="#" id="s-crew">Crew Pass</a>.</p>') | |
| + `<p class="tiny">Looks only: every die rolls with exactly the same physics and odds.</p><div class="btnrow"><button class="btn" id="s-done" type="button">DONE</button></div>`; | |
| for (const b of c.querySelectorAll('[data-k]')) b.onclick = () => { const s = DT.SKINS[b.dataset.k]; if (lockP(s.pack)) { s.pack === 'crew' ? openCrew('style-dice') : openPack('style-dice'); return; } S.skin = b.dataset.k; save(); table.setSkin(S.skin, S.crew); A.tap(); openStyle(); }; | |
| for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { const t = DT.THEMES[b.dataset.t]; if (lockP(t.pack)) { t.pack === 'crew' ? openCrew('style-table') : openPack('style-table'); return; } S.theme = b.dataset.t; save(); table.setTheme(S.theme); table.hand(0); A.tap(); openStyle(); }; | |
| for (const b of c.querySelectorAll('[data-e]')) b.onclick = () => { if (b.dataset.e !== 'none' && !ownPack()) { openPack('style-emote'); return; } S.emote = b.dataset.e; save(); A.tap(); openStyle(); }; | |
| if ($('csave')) $('csave').onclick = () => { S.crew.name = R.cleanName($('cn').value).toUpperCase(); S.crew.a = $('ca').value; S.crew.b = $('cb').value; save(); if (S.skin === 'crew') table.setSkin('crew', S.crew); toast('Crew saved.'); }; | |
| if ($('s-crew')) $('s-crew').onclick = (e) => { e.preventDefault(); openCrew('style'); }; | |
| $('s-done').onclick = home; hideAll(); $('style').hidden = false; | |
| } | |
| function openMe() { | |
| const w = S.wallet, rk = R.rankOf(w.rep), hist = ownCrew() ? S.hist : S.hist.slice(-10); | |
| const wr = w.played ? Math.round((w.won / w.played) * 100) : 0; | |
| $('me-c').innerHTML = `<h2 class="tag">${esc(S.name)}</h2><p class="sub">${rk.name.toUpperCase()}${rk.next ? ` · ${rk.toNext} rep to ${rk.next.name}` : ' · top rank'}</p>` | |
| + `<div class="lbar" style="width:100%"><i style="width:${Math.round(rk.pct * 100)}%"></i></div>` | |
| + `<div class="statgrid" style="margin-top:10px"><div><b>${w.played}</b>rounds</div><div><b>${wr}%</b>won</div><div><b>${w.bestStreak || 0}</b>best streak</div><div><b>${w.big.r456}</b>4-5-6s</div><div><b>${w.big.trips}</b>trips</div><div><b>${w.best ? esc(w.best.short) : '-'}</b>best roll</div></div>` | |
| + `<span class="lbl">${ownCrew() ? `STATS HISTORY (last ${hist.length} rounds)` : 'LAST 10 ROUNDS (full history with the Crew Pass)'}</span><div class="hist">${hist.map((h) => `<i class="${h.w === 0 ? 'l' : ''}" style="height:${Math.min(100, 12 + Math.abs(h.c) / 2)}%"></i>`).join('')}</div>` | |
| + (ownCrew() && S.crew.stats ? `<span class="lbl">${esc(S.crew.name)}</span><table class="lb">${Object.entries(S.crew.stats).sort((a, b) => b[1].w - a[1].w).map(([n, s]) => `<tr><td>${esc(n)}</td><td>${s.w}/${s.p} won</td></tr>`).join('')}</table>` : '') | |
| + `<span class="lbl">NAME</span><input class="inp" id="me-n" maxlength="14" value="${esc(S.name)}">` | |
| + `<div class="btnrow"><button class="btn" id="me-card" type="button">🔥 STREAK CARD</button><button class="ghost" id="me-save" type="button">SAVE</button><button class="ghost" id="me-done" type="button">BACK</button></div>`; | |
| $('me-save').onclick = () => { S.name = R.cleanName($('me-n').value); save(); toast('Saved.'); }; | |
| $('me-card').onclick = () => streakCard(); $('me-done').onclick = home; hideAll(); $('me').hidden = false; | |
| } | |
| async function streakCard() { | |
| const c = document.createElement('canvas'); c.width = 1080; c.height = 1350; const g = c.getContext('2d'); const w = S.wallet, rk = R.rankOf(w.rep); | |
| const bg = g.createLinearGradient(0, 0, 0, 1350); bg.addColorStop(0, '#1D1430'); bg.addColorStop(1, '#07060C'); g.fillStyle = bg; g.fillRect(0, 0, 1080, 1350); | |
| try { g.drawImage(cv, 0, 0, cv.width, cv.height, 0, 260, 1080, 1080 * cv.height / cv.width); } catch { /* */ } | |
| g.fillStyle = 'rgba(7,6,12,.55)'; g.fillRect(0, 0, 1080, 1350); | |
| g.textAlign = 'center'; g.fillStyle = '#FFC56B'; g.font = '120px Spray, "Barlow Condensed", sans-serif'; g.fillText('STOOPSHAKE', 540, 190); | |
| g.fillStyle = '#fff'; g.font = '800 110px "Barlow Condensed", sans-serif'; g.fillText(`🔥 ${w.winStreak || w.bestStreak || 0} WIN STREAK`, 540, 520); | |
| g.font = '800 64px "Barlow Condensed", sans-serif'; g.fillStyle = '#2FE6FF'; g.fillText(`${(ownCrew() ? S.crew.name + ' · ' : '')}${S.name} · ${rk.name.toUpperCase()}`, 540, 640); | |
| g.fillStyle = '#fff'; g.font = '800 54px "Barlow Condensed", sans-serif'; g.fillText(`Best roll: ${w.best ? w.best.short : '-'} · 4-5-6s: ${w.big.r456}`, 540, 760); | |
| g.font = '700 46px "Barlow Condensed", sans-serif'; g.fillStyle = '#B9B1C9'; g.fillText('Free 3D street dice · No real money. Just for fun.', 540, 1170); g.fillStyle = '#FFC56B'; g.fillText('cybermaxtools.com/games/stoopshake', 540, 1250); | |
| const blob = await new Promise((r) => c.toBlob(r, 'image/png')); | |
| count(GAME, 'share', 'streak'); | |
| const text = `🔥 ${w.winStreak || w.bestStreak || 0} win streak on Stoopshake (${rk.name}). Come take it from me:`; | |
| if (TGM.app()) { TGM.share(text, `${SITE}${PATH}`); return; } | |
| share(GAME, text, PATH, blob, 'stoopshake-streak.png'); | |
| } | |
| async function openBoard() { | |
| const reg = R.regionOf(Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.language); | |
| const c = $('board-c'); hideAll(); $('board').hidden = false; | |
| const top = `<h2 class="tag">BOARD</h2><p class="sub">Ranked by rep (wins and big rolls). Region comes from your device's time zone (${esc(reg.label)}), never your exact location.</p>`; | |
| if (!S.board) { | |
| c.innerHTML = top + `<p class="sub">Show <b>${esc(S.name)}</b> on the <b>${esc(reg.label)}</b> and world boards? Only your nickname, rank and rep are sent, once a day.</p><div class="btnrow"><button class="btn" id="bd-on" type="button">JOIN THE BOARD</button><button class="ghost" id="bd-x" type="button">NOT NOW</button></div>`; | |
| $('bd-on').onclick = () => { S.board = true; save(); count(GAME, 'cta', 'board-join'); openBoard(); }; $('bd-x').onclick = home; return; | |
| } | |
| c.innerHTML = top + '<p class="sub">Loading…</p>'; | |
| let j = null; | |
| try { | |
| if (S.boardDay !== TODAY || S.boardRep !== S.wallet.rep) { const r = await fetch(`${DATA}/dice/board`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dev: S.dev, name: S.name, region: reg.id, label: reg.label, rep: S.wallet.rep, wins: S.wallet.won }) }); j = await r.json(); S.boardDay = TODAY; S.boardRep = S.wallet.rep; save(); } | |
| else { const r = await fetch(`${DATA}/dice/board?region=${encodeURIComponent(reg.id)}`); j = await r.json(); } | |
| } catch { j = null; } | |
| if (!j || !j.world) { c.innerHTML = top + '<p class="sub">The board is resting right now. Try again in a bit.</p><div class="btnrow"><button class="ghost" id="bd-b" type="button">BACK</button></div>'; $('bd-b').onclick = home; return; } | |
| const rows = (list) => `<table class="lb">${list.map((x, i) => `<tr class="${x.me ? 'me' : ''}"><td>${i + 1}. ${esc(x.name)} <small>${esc(x.label || '')}</small></td><td>${esc(R.rankOf(x.rep).name)} · ${x.rep}</td></tr>`).join('') || '<tr><td>Be the first.</td><td></td></tr>'}</table>`; | |
| let tab = 'near'; | |
| const draw = () => { c.innerHTML = top + `<div class="tabs"><button class="chipb${tab === 'near' ? ' on' : ''}" data-t="near" type="button">NEAR ME</button><button class="chipb${tab === 'world' ? ' on' : ''}" data-t="world" type="button">WORLD</button><button class="chipb${tab === 'regions' ? ' on' : ''}" data-t="regions" type="button">CITIES</button></div>` | |
| + (tab === 'near' ? rows(j.region || []) : tab === 'world' ? rows(j.world || []) : `<table class="lb">${(j.regions || []).map((x, i) => `<tr><td>${i + 1}. ${esc(x.label)}</td><td>${x.players} players · ${x.rep}</td></tr>`).join('')}</table>`) | |
| + `<div class="btnrow"><button class="ghost" id="bd-off" type="button">LEAVE THE BOARD</button><button class="btn" id="bd-b" type="button">BACK</button></div>`; | |
| for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { tab = b.dataset.t; draw(); }; | |
| $('bd-b').onclick = home; $('bd-off').onclick = () => { S.board = false; save(); fetch(`${DATA}/dice/board`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dev: S.dev, leave: true }) }).catch(() => {}); home(); }; }; | |
| draw(); | |
| } | |
| function openShop() { | |
| const c = $('shop-c'); const tg = !!TGM.app(); | |
| c.innerHTML = `<h2 class="tag">EXTRAS</h2><p class="sub">The game, every mode and the daily chips are free. Extras are looks and crew features, paid once. They never change the dice.</p>` | |
| + `<div class="offer"><h3>Stoopshake Crew Pass <span class="pr">${tg ? '235 ⭐' : '$2.99 once'}</span></h3><ul>${CREW_PERKS.map((p) => `<li>${esc(p)}</li>`).join('')}<li>Also every Dice & Table Pack look</li></ul><button class="btn pink" id="sh-c" type="button">${ownCrew() ? 'YOURS ✓' : 'GET THE CREW PASS'}</button></div>` | |
| + `<div class="offer"><h3>Stoopshake Dice & Table Pack <span class="pr">${tg ? '155 ⭐' : '$1.99 once'}</span></h3><ul><li>Dice: Chrome, Gold, Bone, Neon</li><li>Tables: Brownstone Stoop, Rooftop, Arcade</li><li>5 win emotes</li><li>Looks only: same dice odds</li></ul><button class="btn" id="sh-p" type="button">${ownPack() ? 'YOURS ✓' : 'GET THE PACK'}</button></div>` | |
| + (tg ? '' : `<p class="tiny">Prices in USD; tax for your country is added at checkout. Play more than one CyberMax game? The <a href="/games/#pass">CyberMax Fun Pass</a> is a separate pass that covers every CyberMax game, both Stoopshake extras included. Chips can never be bought.</p>`) | |
| + `<div class="btnrow"><button class="ghost" id="sh-b" type="button">BACK</button></div>`; | |
| $('sh-p').onclick = () => { if (!ownPack()) openPack('shop'); }; $('sh-c').onclick = () => { if (!ownCrew()) openCrew('shop'); }; $('sh-b').onclick = home; | |
| hideAll(); $('shop').hidden = false; count(GAME, 'cta', 'shop-open'); | |
| } | |
| // ------------------------------------------------------------------ Telegram Mini App | |
| let tgInfo = {}; | |
| function openCrew(from) { if (TGM.app()) TGM.sheet('stoopshake-full', { perks: CREW_PERKS, onPaid: claimTG }); else FP.open(from); } | |
| function openPack(from) { if (TGM.app()) TGM.sheet('stoopshake-skins', { perks: ['4 dice: Chrome, Gold, Bone, Neon', '3 tables: Stoop, Rooftop, Arcade', '5 win emotes', 'Looks only: same dice odds'], onPaid: claimTG }); else pack.open(from); } | |
| async function claimTG() { | |
| tgInfo = { ...tgInfo, ...(await TGM.status()) }; const codes = tgInfo.codes || {}; | |
| if (codes['stoopshake-full'] && !FP.ownsGame()) await FP.unlockGame(codes['stoopshake-full']); | |
| if (codes['stoopshake-skins'] && !pack.owned()) await pack.unlock(codes['stoopshake-skins']); | |
| renderTop(); | |
| } | |
| FP.onChange(() => renderTop()); pack.onChange(() => renderTop()); | |
| // ------------------------------------------------------------------ wiring | |
| for (const b of document.querySelectorAll('.mode')) b.onclick = () => { A.tap(); setup(b.dataset.m); }; | |
| $('rules-ok').onclick = () => { S.rules = true; save(); A.ensure(); A.startMusic(); hideAll(); if (chal) home(); else runDuel({ stake: Math.min(25, S.wallet.chips) || 0, practice: S.wallet.chips < 10 }); }; | |
| $('b-rules').onclick = () => { hideAll(); $('rules').hidden = false; $('rules-ok').textContent = 'BACK'; $('rules-ok').onclick = home; }; | |
| $('b-style').onclick = () => openStyle(); $('b-me').onclick = () => openMe(); $('b-board').onclick = () => openBoard(); | |
| $('b-shop').onclick = (e) => { e.preventDefault(); openShop(); }; | |
| $('b-extras').onclick = () => { A.tap(); openShop(); }; | |
| if (TGM.app()) $('b-extras').textContent = '✨ EXTRAS: CREW PASS · DICE & TABLE PACK'; | |
| $('b-menu').onclick = () => { A.tap(); if (M && !confirm('Leave this table? Chips already settled stay settled.')) return; location.hash = ''; if (M) { location.href = location.pathname + (chal ? `?c=${R.encodeChallenge(chal)}` : ''); return; } home(); }; | |
| $('b-snd').onclick = () => { A.ensure(); const on = !A.settings().music; A.setMusic(on); if (!on) A.stopMusic(); renderTop(); }; | |
| TGM.init({ onResize: () => table && table.resize(), onBack: () => home() }).then(async (W) => { if (!W) return; count(GAME, 'tg-open'); tgInfo = await TGM.hello(); claimTG(); }); | |
| boot(); | |