CyberMax-tools commited on
Commit
9759528
·
verified ·
1 Parent(s): 45079a5

Deploy stoopshake

Browse files
Files changed (28) hide show
  1. .gitattributes +3 -0
  2. CREDITS.txt +15 -0
  3. README.md +29 -5
  4. audio.js +106 -0
  5. cmx.js +249 -0
  6. dice3d.js +399 -0
  7. fonts/barlow-condensed-800.woff2 +0 -0
  8. fonts/rubik-spray-paint.woff2 +3 -0
  9. fun.css +69 -0
  10. funpass.js +298 -0
  11. game.js +585 -0
  12. icon-192.png +0 -0
  13. icon-512.png +0 -0
  14. icon-maskable.png +0 -0
  15. icon.svg +11 -0
  16. index.html +158 -18
  17. manifest.webmanifest +47 -0
  18. offer.js +2 -0
  19. og.png +3 -0
  20. pack.js +133 -0
  21. rules.js +206 -0
  22. shot-phone.png +3 -0
  23. stage3d.js +325 -0
  24. style.css +127 -28
  25. sw.js +25 -0
  26. tg.js +108 -0
  27. vendor/cannon-es.min.js +0 -0
  28. vendor/three.module.min.js +0 -0
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ fonts/rubik-spray-paint.woff2 filter=lfs diff=lfs merge=lfs -text
37
+ og.png filter=lfs diff=lfs merge=lfs -text
38
+ shot-phone.png filter=lfs diff=lfs merge=lfs -text
CREDITS.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Stoopshake (c) 2026 CyberMax. All rights reserved.
2
+ Game code, rules engine, scene, textures, dice, characters and sound (including the "Stoop Night" loop): CyberMax. Everything is generated in code; no outside art or samples.
3
+ Characters, places and signs are original and fictional.
4
+
5
+ Third-party software and fonts:
6
+ - three.js r170 (MIT License, Copyright 2010-2024 three.js authors), vendor/three.module.min.js
7
+ - cannon-es 0.20.0 (MIT License, Copyright (c) 2015 cannon.js Authors, pmndrs), vendor/cannon-es.min.js (minified copy)
8
+ - Rubik Spray Paint by NaN and Luke Prowse / Hubert and Fischer (SIL Open Font License 1.1), fonts/rubik-spray-paint.woff2
9
+ - Barlow Condensed by Jeremy Tribby (SIL Open Font License 1.1), fonts/barlow-condensed-800.woff2
10
+ Licence texts: MIT, https://opensource.org/license/mit ; OFL 1.1, https://openfontlicense.org
11
+
12
+ The MIT License (three.js and cannon-es)
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
14
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
README.md CHANGED
@@ -1,10 +1,34 @@
1
  ---
2
- title: Stoopshake
3
- emoji: 👁
4
- colorFrom: green
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
 
 
 
 
 
 
 
 
 
8
  ---
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: "Stoopshake: Dice game (Cee-lo street dice, bots, pass-and-play, challenge links, Telegram groups)"
3
+ emoji: 🎮
4
+ colorFrom: gray
5
+ colorTo: blue
6
  sdk: static
7
  pinned: false
8
+ license: other
9
+ thumbnail: https://cybermaxtools.com/games/stoopshake/og.png
10
+ short_description: "Street dice on the stoop: real 3D physics, 4-5-6"
11
+ tags:
12
+ - game
13
+ - html5
14
+ - browser-game
15
+ - pwa
16
+ - cybermax
17
  ---
18
+ **Stoopshake**: Street dice on the stoop: real 3D physics, 4-5-6. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
19
 
20
+ Play it full screen: https://cybermaxtools.com/games/stoopshake/?utm_source=huggingface
21
+
22
+ **Watch the real gameplay demo:** https://cybermaxtools.com/media/demos/stoopshake/demo-16x9.mp4
23
+
24
+ ![Stoopshake gameplay](https://cybermaxtools.com/media/demos/stoopshake/poster.jpg)
25
+
26
+ **Embed it free on your site** (works on phones, keeps a small link to CyberMax):
27
+
28
+ ```html
29
+ <iframe src="https://cybermaxtools.com/embed/stoopshake/" width="480" height="720" style="border:0;max-width:100%;aspect-ratio:2/3;height:auto" title="Stoopshake: free game by CyberMax" loading="lazy" allowfullscreen></iframe>
30
+ ```
31
+
32
+ The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $29.99 for life) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermaxtools.com/games/?utm_source=huggingface
33
+
34
+ More free games: https://cybermaxtools.com/games/ · © 2026 CyberMax. All rights reserved.
audio.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stoopshake sound: everything synthesised in Web Audio (no samples, original): dice clacks on concrete / wall / each other,
2
+ // the shake in the hand, UI taps, result stings, and "Stoop Night", an original 88 BPM boom-bap loop (kick, snare, swung hats,
3
+ // a warm electric-piano chord loop and a bass line, with soft vinyl crackle). Music and effects toggle separately.
4
+ // (c) 2026 CyberMax. All rights reserved.
5
+ let ac = null, master = null, sfxBus = null, musBus = null, noiseBuf = null;
6
+ const st = { sfx: true, music: true };
7
+ try { const s = JSON.parse(localStorage.getItem('ssk-audio') || 'null'); if (s) Object.assign(st, s); } catch { /* private mode */ }
8
+ const save = () => { try { localStorage.setItem('ssk-audio', JSON.stringify(st)); } catch { /* */ } };
9
+
10
+ export function ensure() {
11
+ if (ac) { if (ac.state === 'suspended') ac.resume().catch(() => {}); return ac; }
12
+ const C = window.AudioContext || window.webkitAudioContext; if (!C) return null;
13
+ ac = new C(); master = ac.createGain(); master.gain.value = 0.9;
14
+ const comp = ac.createDynamicsCompressor(); comp.threshold.value = -14; comp.ratio.value = 3; master.connect(comp).connect(ac.destination);
15
+ sfxBus = ac.createGain(); sfxBus.gain.value = st.sfx ? 1 : 0; sfxBus.connect(master);
16
+ musBus = ac.createGain(); musBus.gain.value = st.music ? 0.42 : 0; musBus.connect(master);
17
+ noiseBuf = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
18
+ return ac;
19
+ }
20
+ export const settings = () => ({ ...st });
21
+ export function setSfx(on) { st.sfx = !!on; save(); if (sfxBus) sfxBus.gain.value = on ? 1 : 0; }
22
+ export function setMusic(on) { st.music = !!on; save(); if (musBus) musBus.gain.setTargetAtTime(on ? 0.42 : 0, ac.currentTime, 0.2); if (on) startMusic(); }
23
+ /** Expose the mix as a MediaStream for clip recording (optional). */
24
+ export function stream() { if (!ensure()) return null; if (!master._dest) { master._dest = ac.createMediaStreamDestination(); master.connect(master._dest); } return master._dest.stream; }
25
+
26
+ function noise(t, dur, { f = 2000, q = 1, type = 'bandpass', g = 0.5, bus = sfxBus, attack = 0.001 } = {}) {
27
+ const s = ac.createBufferSource(); s.buffer = noiseBuf; s.playbackRate.value = 0.8 + Math.random() * 0.4;
28
+ const fl = ac.createBiquadFilter(); fl.type = type; fl.frequency.value = f; fl.Q.value = q;
29
+ const e = ac.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + attack); e.gain.exponentialRampToValueAtTime(0.0008, t + dur);
30
+ s.connect(fl).connect(e).connect(bus); s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.05);
31
+ }
32
+ function tone(t, f, dur, { type = 'sine', g = 0.3, bus = sfxBus, f2 = null, attack = 0.004 } = {}) {
33
+ const o = ac.createOscillator(); o.type = type; o.frequency.setValueAtTime(f, t); if (f2) o.frequency.exponentialRampToValueAtTime(f2, t + dur);
34
+ const e = ac.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + attack); e.gain.exponentialRampToValueAtTime(0.0008, t + dur);
35
+ o.connect(e).connect(bus); o.start(t); o.stop(t + dur + 0.05);
36
+ }
37
+
38
+ // ---------- dice
39
+ let lastHit = 0;
40
+ /** One impact: kind 'floor' | 'wall' | 'die', v = impact speed. Rate-limited so piles don't buzz. */
41
+ export function hit(kind, v) {
42
+ if (!ensure() || !st.sfx) return; const t = ac.currentTime; if (t - lastHit < 0.018) return; lastHit = t;
43
+ const k = Math.min(1, v / 14);
44
+ if (kind === 'die') { noise(t, 0.05, { f: 3800 + Math.random() * 900, q: 7, g: 0.55 * k }); tone(t, 2600 + Math.random() * 500, 0.035, { type: 'triangle', g: 0.12 * k }); }
45
+ else if (kind === 'wall') { noise(t, 0.09, { f: 1700, q: 2.5, g: 0.7 * k }); tone(t, 220, 0.08, { g: 0.2 * k, f2: 120 }); }
46
+ else { noise(t, 0.07, { f: 2300 + Math.random() * 600, q: 3.2, g: 0.6 * k }); tone(t, 1300 + Math.random() * 300, 0.03, { type: 'triangle', g: 0.08 * k }); }
47
+ }
48
+ let rattleT = 0;
49
+ /** Dice shaken in the hand (call every frame while held; k = shake strength 0..1). */
50
+ export function rattle(k = 1) {
51
+ if (!ensure() || !st.sfx) return; const t = ac.currentTime; if (t - rattleT < 0.055 + Math.random() * 0.05) return; rattleT = t;
52
+ for (let i = 0; i < 2; i++) noise(t + i * 0.012, 0.03, { f: 3200 + Math.random() * 1500, q: 6, g: 0.18 * k });
53
+ }
54
+ export function whoosh(power = 0.6) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; noise(t, 0.28, { f: 900 + power * 900, q: 0.7, g: 0.25, attack: 0.06 }); }
55
+ export function tap() { if (!ensure() || !st.sfx) return; const t = ac.currentTime; tone(t, 880, 0.06, { type: 'square', g: 0.05 }); tone(t + 0.03, 1320, 0.05, { type: 'square', g: 0.04 }); }
56
+ export function chips(n = 4) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; for (let i = 0; i < n; i++) { noise(t + i * 0.045, 0.04, { f: 5200, q: 9, g: 0.22 }); tone(t + i * 0.045, 3100 + i * 60, 0.03, { type: 'triangle', g: 0.05 }); } }
57
+ /** Result stings: '456' horn stab + crowd-ish burst, 'trips' rising chord, 'point' two notes, '123' low drop, 'none' shrug. */
58
+ export function sting(kind) {
59
+ if (!ensure() || !st.sfx) return; const t = ac.currentTime;
60
+ if (kind === '456') { [0, 4, 7, 12].forEach((s, i) => tone(t + i * 0.06, 349.2 * 2 ** (s / 12), 0.5, { type: 'sawtooth', g: 0.07 })); noise(t, 1.1, { f: 1500, q: 0.4, g: 0.18, attack: 0.15 }); tone(t, 55, 0.6, { g: 0.5, f2: 40 }); }
61
+ else if (kind === 'trips') { [0, 3, 7, 10, 14].forEach((s, i) => tone(t + i * 0.07, 293.7 * 2 ** (s / 12), 0.55, { type: 'triangle', g: 0.09 })); }
62
+ else if (kind === 'point') { tone(t, 523.3, 0.16, { type: 'triangle', g: 0.12 }); tone(t + 0.12, 659.3, 0.22, { type: 'triangle', g: 0.12 }); }
63
+ else if (kind === '123') { tone(t, 220, 0.7, { type: 'sawtooth', g: 0.09, f2: 70 }); tone(t, 110, 0.7, { g: 0.3, f2: 45 }); }
64
+ else if (kind === 'win') { [0, 7, 12].forEach((s, i) => tone(t + i * 0.08, 440 * 2 ** (s / 12), 0.3, { type: 'square', g: 0.05 })); }
65
+ else if (kind === 'lose') { tone(t, 330, 0.25, { type: 'square', g: 0.05, f2: 220 }); }
66
+ else { tone(t, 392, 0.1, { type: 'triangle', g: 0.07 }); tone(t + 0.1, 370, 0.14, { type: 'triangle', g: 0.07 }); }
67
+ }
68
+
69
+ // ---------- "Stoop Night": original boom-bap loop (scheduled ahead with the audio clock)
70
+ const BPM = 88, SPB = 60 / BPM, STEP = SPB / 4;
71
+ // two-bar pattern, 32 sixteenths. K = kick, S = snare, H = hat
72
+ const KICK = [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0];
73
+ const SNARE = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1];
74
+ // chords (MIDI notes): Fm9 -> Db maj7 -> Bbm9 -> C7(b9) one per half bar... over 2 bars: one per bar half
75
+ const CHORDS = [[53, 56, 60, 63, 67], [49, 53, 56, 60, 65], [46, 49, 53, 56, 60], [48, 52, 55, 58, 61]];
76
+ const BASS = [41, 0, 0, 41, 0, 0, 44, 0, 37, 0, 0, 37, 0, 0, 39, 0, 34, 0, 0, 34, 0, 0, 37, 0, 36, 0, 0, 36, 0, 0, 40, 43];
77
+ const mf = (m) => 440 * 2 ** ((m - 69) / 12);
78
+ let playing = false, nextT = 0, stepI = 0, timer = 0, crackle = null;
79
+ function kick(t) { const o = ac.createOscillator(), e = ac.createGain(); o.frequency.setValueAtTime(130, t); o.frequency.exponentialRampToValueAtTime(42, t + 0.14); e.gain.setValueAtTime(0.95, t); e.gain.exponentialRampToValueAtTime(0.001, t + 0.42); o.connect(e).connect(musBus); o.start(t); o.stop(t + 0.45); noise(t, 0.02, { f: 3000, q: 1, g: 0.15, bus: musBus }); }
80
+ function snare(t) { noise(t, 0.2, { f: 1900, q: 0.9, g: 0.55, bus: musBus }); tone(t, 190, 0.12, { type: 'triangle', g: 0.28, bus: musBus, f2: 150 }); }
81
+ function hat(t, open) { noise(t, open ? 0.16 : 0.035, { f: 8200, q: 1.3, type: 'highpass', g: open ? 0.13 : 0.1, bus: musBus }); }
82
+ function keys(t, notes, dur) { // warm EP: sine + soft FM bell, slow tremolo
83
+ for (const n of notes) { const f = mf(n); const o = ac.createOscillator(), m = ac.createOscillator(), mg = ac.createGain(), e = ac.createGain();
84
+ o.type = 'sine'; o.frequency.value = f; m.frequency.value = f * 2; mg.gain.value = f * 0.9; m.connect(mg).connect(o.frequency);
85
+ e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(0.045, t + 0.02); e.gain.exponentialRampToValueAtTime(0.012, t + dur * 0.7); e.gain.exponentialRampToValueAtTime(0.0005, t + dur);
86
+ mg.gain.setValueAtTime(f * 0.9, t); mg.gain.exponentialRampToValueAtTime(f * 0.05, t + 0.4);
87
+ o.connect(e).connect(musBus); o.start(t); m.start(t); o.stop(t + dur + 0.05); m.stop(t + dur + 0.05); }
88
+ }
89
+ function bass(t, n) { if (!n) return; const o = ac.createOscillator(), fl = ac.createBiquadFilter(), e = ac.createGain(); o.type = 'sawtooth'; o.frequency.value = mf(n - 12); fl.type = 'lowpass'; fl.frequency.value = 260;
90
+ e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(0.26, t + 0.01); e.gain.exponentialRampToValueAtTime(0.001, t + STEP * 2.6); o.connect(fl).connect(e).connect(musBus); o.start(t); o.stop(t + STEP * 3); }
91
+ function schedule() {
92
+ while (nextT < ac.currentTime + 0.25) {
93
+ const i = stepI % 32, swing = i % 2 ? STEP * 0.18 : 0, t = nextT + swing;
94
+ if (KICK[i]) kick(t); if (SNARE[i]) snare(t); hat(t, i % 8 === 6);
95
+ if (i % 8 === 0) keys(t, CHORDS[(i / 8) | 0], STEP * 7.5);
96
+ bass(t, BASS[i]);
97
+ nextT += STEP; stepI++;
98
+ }
99
+ }
100
+ export function startMusic() {
101
+ if (!ensure() || !st.music || playing) return; playing = true; nextT = ac.currentTime + 0.1; stepI = 0;
102
+ timer = setInterval(schedule, 60); schedule();
103
+ if (!crackle) { crackle = ac.createBufferSource(); crackle.buffer = noiseBuf; crackle.loop = true; const f = ac.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6000; const g = ac.createGain(); g.gain.value = 0.012; crackle.connect(f).connect(g).connect(musBus); crackle.start(); }
104
+ }
105
+ export function stopMusic() { playing = false; clearInterval(timer); }
106
+ document.addEventListener('visibilitychange', () => { if (!ac) return; if (document.hidden) ac.suspend().catch(() => {}); else ac.resume().catch(() => {}); });
cmx.js ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by
2
+ // scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own).
3
+ // Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install.
4
+ // (c) 2026 CyberMax. All rights reserved.
5
+
6
+ export const SITE = 'https://cybermaxtools.com';
7
+ export const API = 'https://toys.cybermaxtools.com';
8
+ export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game
9
+
10
+ /** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */
11
+ export function today(d = new Date()) {
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
13
+ }
14
+ /** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */
15
+ export function dayNumber(iso) {
16
+ const [y, m, d] = iso.split('-').map(Number);
17
+ return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1;
18
+ }
19
+ export function dateOf(n) {
20
+ const t = new Date(EPOCH + (n - 1) * 864e5);
21
+ return t.toISOString().slice(0, 10);
22
+ }
23
+
24
+ /** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */
25
+ export function hash(str) {
26
+ let h = 0x811c9dc5;
27
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
28
+ return h >>> 0;
29
+ }
30
+ export function rng(seed) {
31
+ let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
32
+ return function () {
33
+ a = (a + 0x6d2b79f5) >>> 0;
34
+ let t = a;
35
+ t = Math.imul(t ^ (t >>> 15), t | 1);
36
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
38
+ };
39
+ }
40
+
41
+ /** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */
42
+ export function count(game, ev, ch = '') {
43
+ try {
44
+ if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return;
45
+ const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`;
46
+ if (navigator.sendBeacon && navigator.sendBeacon(u)) return;
47
+ fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {});
48
+ } catch { /* never break the game */ }
49
+ }
50
+
51
+ export function store(key, val) {
52
+ try {
53
+ if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
54
+ localStorage.setItem(key, JSON.stringify(val));
55
+ } catch { /* private mode */ }
56
+ return val;
57
+ }
58
+
59
+ export function toast(msg, ms = 2400) {
60
+ let t = document.getElementById('toast');
61
+ if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); }
62
+ t.textContent = msg;
63
+ t.classList.add('on');
64
+ clearTimeout(t._h);
65
+ t._h = setTimeout(() => t.classList.remove('on'), ms);
66
+ }
67
+
68
+ /** The link people share: the canonical game page with UTM tags the site view counter reads. */
69
+ export function shareLink(path, channel, game) {
70
+ const u = new URL(SITE + path);
71
+ u.searchParams.set('utm_source', channel);
72
+ u.searchParams.set('utm_medium', 'share');
73
+ u.searchParams.set('utm_campaign', `game-${game}`);
74
+ return u.toString();
75
+ }
76
+
77
+ /**
78
+ * Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard.
79
+ * text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card.
80
+ */
81
+ export async function share(game, text, path, blob, filename) {
82
+ const url = shareLink(path, 'share', game);
83
+ const full = `${text}\n${url}`;
84
+ if (navigator.share) {
85
+ try {
86
+ const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null;
87
+ const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full };
88
+ await navigator.share(data);
89
+ count(game, 'share', 'native');
90
+ return 'shared';
91
+ } catch (e) {
92
+ if (e && e.name === 'AbortError') return 'cancelled';
93
+ }
94
+ }
95
+ try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); }
96
+ count(game, 'share', 'copy');
97
+ return 'copied';
98
+ }
99
+
100
+ export function intents(game, text, path) {
101
+ const u = (ch) => shareLink(path, ch, game);
102
+ return {
103
+ x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`,
104
+ bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`,
105
+ whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`,
106
+ };
107
+ }
108
+
109
+ export function download(blob, filename) {
110
+ const a = document.createElement('a');
111
+ a.href = URL.createObjectURL(blob);
112
+ a.download = filename;
113
+ document.body.appendChild(a);
114
+ a.click();
115
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
116
+ }
117
+
118
+ /**
119
+ * Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button.
120
+ * Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint.
121
+ * The button only appears after the first finished round (offer(): never on first load) and never once installed.
122
+ */
123
+ export function pwa(game, btn) {
124
+ if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) {
125
+ navigator.serviceWorker.register('sw.js').catch(() => {});
126
+ }
127
+ const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
128
+ if (standalone) { count(game, 'play', 'app'); return { offer() {} }; }
129
+ let deferred = null;
130
+ let wanted = false;
131
+ const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
132
+ window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn && !store(`cmx-install-no:${game}`)) btn.hidden = false; });
133
+ window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
134
+ if (btn) {
135
+ btn.addEventListener('click', async () => {
136
+ if (deferred) {
137
+ deferred.prompt();
138
+ const r = await deferred.userChoice.catch(() => null);
139
+ if (r && r.outcome === 'accepted') btn.hidden = true;
140
+ else if (r && r.outcome === 'dismissed') { store(`cmx-install-no:${game}`, 1); btn.hidden = true; } // "Not now" is remembered
141
+ deferred = null;
142
+ } else if (ios) {
143
+ toast('Tap the Share button, then "Add to Home Screen".', 5000);
144
+ count(game, 'install', 'ios-hint');
145
+ store(`cmx-install-no:${game}`, 1);
146
+ }
147
+ });
148
+ }
149
+ return {
150
+ offer() {
151
+ wanted = true;
152
+ if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
153
+ },
154
+ };
155
+ }
156
+
157
+ /** Rounded rectangle path for canvas cards. */
158
+ export function rr(ctx, x, y, w, h, r) {
159
+ ctx.beginPath();
160
+ ctx.moveTo(x + r, y);
161
+ ctx.arcTo(x + w, y, x + w, y + h, r);
162
+ ctx.arcTo(x + w, y + h, x, y + h, r);
163
+ ctx.arcTo(x, y + h, x, y, r);
164
+ ctx.arcTo(x, y, x + w, y, r);
165
+ ctx.closePath();
166
+ }
167
+
168
+ export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
169
+
170
+ /** Theme toggle shared by every page (light/dark, remembered). */
171
+ export function themeToggle(btn) {
172
+ if (!btn) return;
173
+ btn.addEventListener('click', () => {
174
+ const r = document.documentElement;
175
+ const c = r.dataset.theme || 'dark'; // games default to the dark arcade look (fun.css)
176
+ const n = c === 'dark' ? 'light' : 'dark';
177
+ r.dataset.theme = n;
178
+ store('cm-theme-v', n);
179
+ try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
180
+ });
181
+ }
182
+
183
+ // ------------------------------------------------------------------ first-touch attribution on buy links
184
+ // First-sale review (29 Oct 2026, docs/ceo/first-sale-2026-10-27.md): the first paid order could be traced to the page
185
+ // that sold it but not to where the buyer came from. So the first time this browser opens a CyberMax page, one short tag
186
+ // is kept in localStorage (no cookie, no id, never sent on its own): where it came from (utm_source + utm_campaign, s=,
187
+ // the referrer's host or "direct") and the landing page, e.g. "ft-youtube-brimbloom-at-games-brimbloom". A Buy click
188
+ // carries it: s=<source>--<tag> on data.cybermaxtools.com/buy links (the Worker copies s into client_reference_id
189
+ // "data_<s>"), client_reference_id=<id>--<tag> on buy.stripe.com links. No extra request, page view or Worker call.
190
+ // The same code is in shared/convert-kit/convert.js (site pages), so the tag is shared across cybermaxtools.com.
191
+ const FT_KEY = 'cmx-ft';
192
+ const FT_DAYS = 90;
193
+ const ftSlug = (x, n) => String(x || '').toLowerCase().replace(/^www\./, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').slice(0, n).replace(/-+$/, '');
194
+ // words collect.py reads as "our own test" (TEST_MARK) never appear in a buyer's tag
195
+ const ftClean = (t) => t.replace(/(^|-)(test|e2e|check|probe|audit|lane\d*|internal)(?=-|$)/g, '$1x');
196
+ export function ftCompute(loc, referrer) {
197
+ const q = new URLSearchParams(loc.search || '');
198
+ const us = q.get('utm_source'), uc = q.get('utm_campaign'), s = q.get('s');
199
+ let ch;
200
+ if (us) ch = ftSlug(us, 14) + (uc ? `-${ftSlug(String(uc).replace(/^\d{4}-\d{2}-\d{2}-(\d+-)?/, ''), 18)}` : ''); // post ids start with a date
201
+ else if (s) ch = `s-${ftSlug(s, 24)}`;
202
+ else {
203
+ let host = '';
204
+ try { host = referrer ? new URL(referrer).hostname.toLowerCase() : ''; } catch { host = 'other'; }
205
+ ch = !host ? 'direct' : /(^|\.)cybermaxtools\.com$|cybermax-tools/.test(host) ? `site-${ftSlug(host.split('.')[0], 12)}` : `r-${ftSlug(host, 24)}`;
206
+ }
207
+ const segs = String(loc.pathname || '/').toLowerCase().split('/').filter((x) => x && !/^index\.html?$/.test(x));
208
+ const land = ftSlug(segs.slice(-2).join('-'), 18) || 'home';
209
+ return ftClean(`ft-${ch || 'direct'}-at-${land}`).slice(0, 48).replace(/-+$/, '');
210
+ }
211
+ /** The first-touch tag of this browser ('' when storage is blocked). Recorded once, kept FT_DAYS days. */
212
+ export function firstTouch() {
213
+ try {
214
+ const old = JSON.parse(localStorage.getItem(FT_KEY) || 'null');
215
+ if (old && old.t && Date.now() - old.at < FT_DAYS * 864e5) return old.t;
216
+ const t = ftCompute(location, document.referrer);
217
+ localStorage.setItem(FT_KEY, JSON.stringify({ t, at: Date.now() }));
218
+ return t;
219
+ } catch { return ''; }
220
+ }
221
+ /** href with the first-touch tag added (data /buy links: s; Stripe links: client_reference_id). Pure, for tests too. */
222
+ export function ftTagUrl(href, t) {
223
+ if (!t) return href;
224
+ try {
225
+ const u = new URL(href);
226
+ if (u.hostname === 'data.cybermaxtools.com' && u.pathname.startsWith('/buy/')) {
227
+ const s = (u.searchParams.get('s') || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, '');
228
+ if (s.includes('--ft-')) return href;
229
+ u.searchParams.set('s', `${s}--${t}`.slice(0, 80).replace(/-+$/, '')); // the Worker keeps s to 80 chars of [a-z0-9-]
230
+ return u.toString();
231
+ }
232
+ if (u.hostname === 'buy.stripe.com') {
233
+ const c = u.searchParams.get('client_reference_id') || 'data_unknown';
234
+ if (c.includes('--ft-')) return href;
235
+ u.searchParams.set('client_reference_id', `${c}--${t}`.slice(0, 200)); // Stripe: 200 chars of letters, digits, - and _
236
+ return u.toString();
237
+ }
238
+ } catch { /* not a URL */ }
239
+ return href;
240
+ }
241
+ if (typeof document !== 'undefined' && typeof location !== 'undefined' && !window.__cmxFt) {
242
+ window.__cmxFt = 1;
243
+ firstTouch();
244
+ // capture phase, registered before funpass.js/skins.js add their direct-checkout handler (they import this module)
245
+ document.addEventListener('click', (e) => {
246
+ const a = e.target && e.target.closest && e.target.closest('a[href*="data.cybermaxtools.com/buy/"],a[href*="buy.stripe.com/"]');
247
+ if (a) { const t = firstTouch(); if (t) a.href = ftTagUrl(a.href, t); }
248
+ }, true);
249
+ }
dice3d.js ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stoopshake 3D: real-physics dice on a night-time sidewalk. Three.js r170 (shared CyberMax stage: HDR, bloom, ACES, shake)
2
+ // + cannon-es 0.20 (MIT) rigid bodies. Rounded dice with engraved pips, PBR materials and a generated environment map,
3
+ // a warm street lamp with soft shadows, neon fill, and a backstop the dice bounce off (stoop step, curb, parapet...).
4
+ // Throw: hold to shake, flick/drag to throw (power and direction from the gesture), or roll(power) for buttons and bots.
5
+ // The result is read from whichever face points up once the dice sleep; a die that rests tilted (cocked) or leaves the
6
+ // 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.
7
+ // Everything here is generated in code (no outside art). (c) 2026 CyberMax. All rights reserved.
8
+ import { Stage, THREE, col, damp, clamp01 } from './stage3d.js';
9
+ import * as CANNON from './vendor/cannon-es.min.js';
10
+
11
+ const DIE = 1; // edge length (world units)
12
+ const R_CORNER = 0.13; // corner rounding
13
+ const G = -52; // gravity tuned so a throw reads like real dice in 1-2 s
14
+ // face order in a BoxGeometry: +x, -x, +y, -y, +z, -z -> standard die, opposite faces sum to 7
15
+ const FACE_VAL = [2, 5, 1, 6, 3, 4];
16
+ 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)];
17
+
18
+ // ------------------------------------------------------------------ looks
19
+ export const SKINS = {
20
+ classic: { name: 'Classic', base: '#F3EFE6', pip: '#121212', one: '#C3122F', rough: 0.32, metal: 0, clear: 0.6 },
21
+ chrome: { name: 'Chrome', base: '#D9DDE3', pip: '#0B0C10', rough: 0.12, metal: 1, clear: 0.3, pack: 'skins' },
22
+ gold: { name: 'Gold', base: '#E6B54A', pip: '#2A1A05', rough: 0.2, metal: 1, clear: 0.4, pack: 'skins' },
23
+ bone: { name: 'Bone', base: '#E4D6B8', pip: '#3B2A1A', rough: 0.62, metal: 0, clear: 0, aged: true, pack: 'skins' },
24
+ neon: { name: 'Neon', base: '#0E0B1A', pip: '#3CFFE0', rough: 0.25, metal: 0, clear: 1, glow: true, pack: 'skins' },
25
+ crew: { name: 'Crew colours', base: '#E23B5A', pip: '#FFFFFF', rough: 0.3, metal: 0, clear: 0.8, pack: 'crew' },
26
+ };
27
+ // tables: free Corner, Dice & Table Pack (stoop, rooftop, arcade), Crew Pass (court, boardwalk)
28
+ export const THEMES = {
29
+ 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' },
30
+ stoop: { name: 'Brownstone Stoop', ground: 'concrete', back: 'stoop', wall: 'brownstone', sky: ['#060815', '#23183A', '#05060C'], lamp: '#FFB86B', neonA: '#FF9A3D', neonB: '#7C8CFF', sign: 'HOME', pack: 'skins' },
31
+ 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' },
32
+ 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' },
33
+ 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' },
34
+ 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' },
35
+ };
36
+
37
+ // ------------------------------------------------------------------ procedural textures (canvas)
38
+ function canvasTex(w, h, draw, { repeat = [1, 1], srgb = true } = {}) {
39
+ const c = document.createElement('canvas'); c.width = w; c.height = h;
40
+ draw(c.getContext('2d'), w, h);
41
+ const t = new THREE.CanvasTexture(c);
42
+ t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(...repeat); t.anisotropy = 4;
43
+ if (srgb) t.colorSpace = THREE.SRGBColorSpace;
44
+ return t;
45
+ }
46
+ function noise(g, w, h, n, a, cols) {
47
+ 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); }
48
+ g.globalAlpha = 1;
49
+ }
50
+ const GROUNDS = {
51
+ 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); },
52
+ concrete: (g, w, h) => { g.fillStyle = '#6E6A66'; g.fillRect(0, 0, w, h); noise(g, w, h, 26000, 0.35, ['#3E3B39', '#8E8984', '#575350', '#A39E98']);
53
+ 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();
54
+ 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(); }
55
+ 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(); } },
56
+ tar: (g, w, h) => { g.fillStyle = '#2B2B2F'; g.fillRect(0, 0, w, h); noise(g, w, h, 30000, 0.5, ['#1A1A1D', '#45454B', '#5A5A60', '#121214']); },
57
+ asphalt: (g, w, h) => { g.fillStyle = '#2E3034'; g.fillRect(0, 0, w, h); noise(g, w, h, 30000, 0.45, ['#1C1D20', '#4A4D52', '#62656A']);
58
+ 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(); },
59
+ carpet: (g, w, h) => { g.fillStyle = '#120A26'; g.fillRect(0, 0, w, h); noise(g, w, h, 12000, 0.4, ['#1E1240', '#0B061A']);
60
+ 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();
61
+ 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; },
62
+ 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); }
63
+ 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(); } },
64
+ };
65
+ const WALLS = {
66
+ 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) {
67
+ 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); }
68
+ noise(g, w, h, 9000, 0.25, ['#1A0C08', '#9A5A48']);
69
+ // original tag-style mural strokes (abstract, no words): spray loops in the corner
70
+ 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; },
71
+ brownstone: (g, w, h) => { g.fillStyle = '#4A2E22'; g.fillRect(0, 0, w, h); noise(g, w, h, 20000, 0.3, ['#2C1A12', '#6B4532', '#5A3828']);
72
+ g.fillStyle = 'rgba(20,10,6,.6)'; for (let y = 0; y < h; y += 46) g.fillRect(0, y, w, 3); },
73
+ };
74
+ /** A die face: pips with a soft engraved edge; also returns a bump map. */
75
+ function faceCanvas(v, skin, bump) {
76
+ const S = 256, c = document.createElement('canvas'); c.width = c.height = S; const g = c.getContext('2d');
77
+ g.fillStyle = bump ? '#FFFFFF' : skin.base; g.fillRect(0, 0, S, S);
78
+ if (!bump && skin.aged) { noise(g, S, S, 3500, 0.25, ['#BFAE8A', '#F5EBD5', '#A8946C']); }
79
+ 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); }
80
+ 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]],
81
+ 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];
82
+ const r = v === 1 ? 0.13 : 0.085;
83
+ for (const [x, y] of P) {
84
+ 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);
85
+ if (bump) { gr.addColorStop(0, '#202020'); gr.addColorStop(0.8, '#3A3A3A'); gr.addColorStop(1, '#FFFFFF'); }
86
+ 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)'); }
87
+ g.fillStyle = gr; g.beginPath(); g.arc(x * S, y * S, r * S, 0, 7); g.fill();
88
+ }
89
+ return c;
90
+ }
91
+ function emissiveFace(v, skin) { // neon skin: only the pips glow
92
+ 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);
93
+ const src = faceCanvas(v, { ...skin, base: '#000000' }, false); g.drawImage(src, 0, 0); return c;
94
+ }
95
+ function roundedBox(size, r, seg = 7) {
96
+ const geo = new THREE.BoxGeometry(size, size, size, seg, seg, seg);
97
+ const p = geo.attributes.position, h = size / 2 - r, v = new THREE.Vector3(), inner = new THREE.Vector3();
98
+ for (let i = 0; i < p.count; i++) {
99
+ v.fromBufferAttribute(p, i);
100
+ 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)));
101
+ const d = v.clone().sub(inner); if (d.lengthSq() > 1e-9) d.normalize().multiplyScalar(r);
102
+ v.copy(inner).add(d); p.setXYZ(i, v.x, v.y, v.z);
103
+ }
104
+ geo.computeVertexNormals();
105
+ return geo;
106
+ }
107
+
108
+ // ------------------------------------------------------------------ the table
109
+ export class DiceTable {
110
+ constructor(canvas, opts = {}) {
111
+ this.opts = opts;
112
+ const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) && !opts.record;
113
+ 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 });
114
+ const S = this.stage; this.scene = S.scene; this.r = S.r;
115
+ this.r.shadowMap.enabled = true; this.r.shadowMap.type = THREE.PCFSoftShadowMap;
116
+ this.shadowSize = mobile ? 1024 : 2048;
117
+ this.world = new CANNON.World({ gravity: new CANNON.Vec3(0, G, 0) });
118
+ this.world.allowSleep = true; this.world.broadphase = new CANNON.NaiveBroadphase();
119
+ this.world.defaultContactMaterial.friction = 0.3; this.world.defaultContactMaterial.restitution = 0.3;
120
+ this.mDie = new CANNON.Material('die'); this.mFloor = new CANNON.Material('floor'); this.mWall = new CANNON.Material('wall');
121
+ this.world.addContactMaterial(new CANNON.ContactMaterial(this.mDie, this.mFloor, { friction: 0.32, restitution: 0.32 }));
122
+ this.world.addContactMaterial(new CANNON.ContactMaterial(this.mDie, this.mWall, { friction: 0.15, restitution: 0.55 }));
123
+ this.world.addContactMaterial(new CANNON.ContactMaterial(this.mDie, this.mDie, { friction: 0.12, restitution: 0.45 }));
124
+ this.dieGeo = roundedBox(DIE, R_CORNER);
125
+ this.dice = []; this.state = 'idle'; this.timeScale = 1; this.slowT = 0; this.t = 0; this.settleT = 0;
126
+ 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();
127
+ this.onImpact = opts.onImpact || (() => {}); this.onSlow = opts.onSlow || (() => {});
128
+ this.statics = new THREE.Group(); this.scene.add(this.statics);
129
+ this.setTheme(opts.theme || 'corner');
130
+ this.makeDice(opts.skin || 'classic');
131
+ this.hand();
132
+ }
133
+
134
+ // ---------- environment
135
+ envFrom(th) {
136
+ const pm = new THREE.PMREMGenerator(this.r);
137
+ const sc = new THREE.Scene(); sc.background = col(th.sky[1]).multiplyScalar(0.25);
138
+ 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); };
139
+ 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);
140
+ const env = pm.fromScene(sc, 0.04).texture; pm.dispose();
141
+ sc.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });
142
+ return env;
143
+ }
144
+ clearStatics() {
145
+ for (const b of this.staticBodies || []) this.world.removeBody(b);
146
+ this.staticBodies = [];
147
+ 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(); }); } });
148
+ this.scene.remove(this.statics); this.statics = new THREE.Group(); this.scene.add(this.statics);
149
+ }
150
+ 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; }
151
+ 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; }
152
+ std(o) { return new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, ...o }); }
153
+ glowMat(c, k = 3) { return new THREE.MeshBasicMaterial({ color: col(c).multiplyScalar(k), toneMapped: false }); }
154
+ 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';
155
+ 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); }); }
156
+
157
+ setTheme(id) {
158
+ const th = THEMES[id] || THEMES.corner; this.themeId = THEMES[id] ? id : 'corner'; this.theme = th;
159
+ this.clearStatics();
160
+ const S = this.stage, sc = this.scene, P = this.statics;
161
+ S.look.exposure = th.exposure || 1.05;
162
+ 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 });
163
+ if (this.env) this.env.dispose();
164
+ this.env = this.envFrom(th); sc.environment = this.env; sc.environmentIntensity = 0.55;
165
+ if (this.lights) this.lights.forEach((l) => sc.remove(l));
166
+ // light: one warm lamp (shadows), a cool moon fill, two neon accents
167
+ 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);
168
+ 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;
169
+ const moon = new THREE.DirectionalLight(col('#7E92D6'), 0.35); moon.position.set(6, 10, 8);
170
+ const nA = new THREE.PointLight(col(th.neonA), 60, 16, 2); nA.position.set(4.5, 3.2, -6.4);
171
+ const nB = new THREE.PointLight(col(th.neonB), 45, 16, 2); nB.position.set(-5, 2.6, -6.2);
172
+ const hemi = new THREE.HemisphereLight(col(th.sky[1]), col('#0A0806'), 0.35);
173
+ this.lights = [lamp, lamp.target, moon, nA, nB, hemi]; this.lights.forEach((l) => sc.add(l));
174
+ // ground
175
+ const gt = canvasTex(1024, 1024, GROUNDS[th.ground] || GROUNDS.concrete, { repeat: [3, 3] });
176
+ 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);
177
+ ground.rotation.x = -Math.PI / 2;
178
+ this.wallBody(0, -0.5, 0, 40, 1, 40, this.mFloor);
179
+ // backstop the dice bounce off (z = -6) + soft invisible side rails, open front (off the curb = re-roll)
180
+ 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 });
181
+ if (B === 'step' || B === 'stoop') {
182
+ 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 }); }
183
+ this.wallBody(0, 0.85, -6.6, 20, 1.7, 1.2);
184
+ if (B === 'stoop') { // iron railings
185
+ const iron = this.std({ color: '#151518', roughness: 0.45, metalness: 0.8 });
186
+ 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; }
187
+ }
188
+ } 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); }
189
+ else if (B === 'cabinet' || B === 'bench' || B === 'lowwall') {
190
+ const c = B === 'cabinet' ? '#1A0F33' : B === 'bench' ? '#4A3220' : '#4C4C50';
191
+ 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);
192
+ }
193
+ 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
194
+ // the curb at the front edge, then the street
195
+ 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 });
196
+ 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; }
197
+ // the background wall / skyline
198
+ const W = th.wall;
199
+ if (W === 'brick' || W === 'brownstone') {
200
+ const wt = canvasTex(1024, 512, WALLS[W], { repeat: [3, 2] });
201
+ this.mesh(new THREE.PlaneGeometry(40, 16), this.std({ map: wt, roughness: 0.95 }), 0, 8, -10.6);
202
+ // lit store window + door glow
203
+ const win = this.mesh(new THREE.PlaneGeometry(6.5, 2.6), this.glowMat('#FFD9A0', 1.4), 4.8, 3.3, -10.55, { recv: false });
204
+ 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)); } } });
205
+ win.material = new THREE.MeshBasicMaterial({ map: shelves, color: col('#FFE2B0').multiplyScalar(0.62), toneMapped: false });
206
+ const frame = this.std({ color: '#1A1A1C', roughness: 0.5, metalness: 0.6 });
207
+ 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);
208
+ 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);
209
+ 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;
210
+ 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; }
211
+ 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 });
212
+ } else if (W === 'skyline') {
213
+ 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;
214
+ const b = this.mesh(new THREE.BoxGeometry(w, h, w), this.std({ color: '#0A0C16', roughness: 1 }), x, h / 2 - 2, z, { recv: false });
215
+ 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); } });
216
+ b.material = new THREE.MeshBasicMaterial({ map: lit, color: col('#FFFFFF').multiplyScalar(1.4), toneMapped: false }); }
217
+ 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
218
+ this.mesh(new THREE.ConeGeometry(1.8, 1.2, 20), this.std({ color: '#1E1610' }), -8, 7.4, -12);
219
+ 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);
220
+ tank.receiveShadow = false;
221
+ this.stringLights(th);
222
+ } else if (W === 'arcade') {
223
+ 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 });
224
+ 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 });
225
+ 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 }); }
226
+ this.mesh(new THREE.PlaneGeometry(40, 16), this.std({ color: '#0B0618' }), 0, 8, -10.6);
227
+ 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; }
228
+ } else if (W === 'fence') {
229
+ 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] });
230
+ 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 });
231
+ 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);
232
+ // a hoop in the distance + floodlight
233
+ this.mesh(new THREE.BoxGeometry(3, 2, 0.12), this.std({ color: '#EEE', roughness: 0.4 }), 6, 7, -14);
234
+ 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;
235
+ this.mesh(new THREE.BoxGeometry(2, 0.6, 0.4), this.glowMat('#F4F8FF', 5), -9, 11, -12, { recv: false });
236
+ } else if (W === 'pier') {
237
+ 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 });
238
+ this.mesh(new THREE.BoxGeometry(42, 0.18, 0.25), this.std({ color: '#4A3220' }), 0, 2.6, -9.6, { cast: true });
239
+ 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;
240
+ const wheel = new THREE.Group(); wheel.position.set(10, 9, -34); P.add(wheel); // ferris wheel lights
241
+ 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 }); }
242
+ this.wheel = wheel;
243
+ this.stringLights(th);
244
+ }
245
+ // props: two milk crates (seats) and a boombox, a lamp post with a glowing head
246
+ if (th.ground !== 'carpet') {
247
+ 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); });
248
+ const cm = this.std({ map: crate, roughness: 0.6, color: id === 'court' ? '#FF9A3D' : '#FFFFFF' });
249
+ this.mesh(new THREE.BoxGeometry(1.5, 1.2, 1.5), cm, -6.4, 0.6, 2.2, { cast: true }).rotation.y = 0.3;
250
+ this.mesh(new THREE.BoxGeometry(1.5, 1.2, 1.5), cm, 6.6, 0.6, 3.4, { cast: true }).rotation.y = -0.2;
251
+ 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;
252
+ 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; }
253
+ }
254
+ 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 });
255
+ pole.castShadow = false;
256
+ this.mesh(new THREE.BoxGeometry(1.3, 0.3, 0.7), this.glowMat(th.lamp, 4), -6.9, 13.8, 6.2, { recv: false });
257
+ if (this.dice.length) this.dice.forEach((d) => this.world.addBody(d.body)); // bodies were never removed; noop safety
258
+ }
259
+ stringLights(th) {
260
+ 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 }); }
261
+ }
262
+
263
+ // ---------- dice
264
+ diceMats(skinId, crew) {
265
+ const sk = { ...(SKINS[skinId] || SKINS.classic) }; if (skinId === 'crew' && crew) { sk.base = crew.a || sk.base; sk.pip = crew.b || sk.pip; }
266
+ return FACE_VAL.map((v) => {
267
+ const map = new THREE.CanvasTexture(faceCanvas(v, sk, false)); map.colorSpace = THREE.SRGBColorSpace; map.anisotropy = 4;
268
+ const bump = new THREE.CanvasTexture(faceCanvas(v, sk, true));
269
+ const lite = !!this.lite;
270
+ 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 })
271
+ : 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 });
272
+ 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; }
273
+ return m;
274
+ });
275
+ }
276
+ makeDice(skinId, crew) {
277
+ this.skinId = skinId;
278
+ const mats = this.diceMats(skinId, crew);
279
+ 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; }
280
+ for (let i = 0; i < 3; i++) {
281
+ const mesh = new THREE.Mesh(this.dieGeo, mats.map((m) => m.clone())); mesh.castShadow = true; mesh.receiveShadow = true; this.scene.add(mesh);
282
+ 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 });
283
+ 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); });
284
+ this.world.addBody(body);
285
+ this.dice.push({ mesh, body, i });
286
+ }
287
+ }
288
+ setSkin(id, crew) { this.makeDice(id, crew); }
289
+
290
+ /** Dice in the hand: hovering near the thrower, gently jiggling (also the pose between rolls). */
291
+ hand(px = 0) {
292
+ this.state = 'hand'; this.timeScale = 1; this.handX = px; this.shakeK = 0;
293
+ 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(); });
294
+ this.camPosT.set(0, 7.2, 12.5); this.camLookT.set(0, 0.6, -3);
295
+ }
296
+ /** Shaking in the hand (pointer held): x follows the finger, the dice rattle. */
297
+ shake(px, k = 1) { if (this.state !== 'hand') return; this.handX = Math.max(-4, Math.min(4, px)); this.shakeK = k; }
298
+
299
+ /** Throw. dir: {x, y} screen-space flick (y < 0 = away from you); power 0..1. Resolves { dice, scores?, cocked, off }. */
300
+ throw({ x = 0, power = 0.6, seed } = {}) {
301
+ if (this.state === 'rolling') return this.pending;
302
+ const rnd = seed != null ? mulberry(seed) : Math.random;
303
+ power = Math.max(0.25, Math.min(1, power));
304
+ this.state = 'rolling'; this.t = 0; this.settleT = 0; this.timeScale = 1; this.slowT = 0; this.slowFired = false;
305
+ this.launch(x, power, rnd);
306
+ this.camPosT.set(0, 8.4, 11.5); this.camLookT.set(0, 0.2, -3.2);
307
+ return (this.pending = new Promise((res) => { this.resolve = res; }));
308
+ }
309
+ launch(x, power, rnd) {
310
+ this.world.accumulator = 0;
311
+ this.dice.forEach((d, i) => {
312
+ // cannon-es derives box inertia from the world-space AABB, so compute it unrotated (correct + identical every throw)
313
+ const b = d.body; b.type = CANNON.Body.DYNAMIC; b.mass = 1; b.quaternion.set(0, 0, 0, 1); b.updateMassProperties(); b.wakeUp();
314
+ b.sleepState = 0; b.timeLastSleepy = 0; b.force.setZero(); b.torque.setZero();
315
+ b.quaternion.setFromEuler(rnd() * 6.283, rnd() * 6.283, rnd() * 6.283);
316
+ 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);
317
+ const sp = 11 + power * 15;
318
+ b.velocity.set(x * 9 + (rnd() - 0.5) * 2.5, 2.5 + power * 4 + rnd() * 2, -sp * (0.85 + rnd() * 0.3));
319
+ b.angularVelocity.set((rnd() - 0.5) * 40, (rnd() - 0.5) * 30, (rnd() - 0.5) * 40);
320
+ });
321
+ }
322
+ /** Physics only, no drawing: where would this seeded throw land? (trailer: pick a real throw that lands 4-5-6). */
323
+ simulate({ x = 0, power = 0.6, seed = 1, handX = 0 } = {}) {
324
+ const keep = this.handX; this.handX = handX;
325
+ this.launch(x, Math.max(0.25, Math.min(1, power)), mulberry(seed));
326
+ let still = 0, t = 0, off = false;
327
+ while (t < 7) { this.world.step(1 / 120, 1 / 60, 8); t += 1 / 60;
328
+ 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;
329
+ const moving = this.dice.some((d) => d.body.velocity.length() > 0.25 || d.body.angularVelocity.length() > 0.6);
330
+ still = moving ? 0 : still + 1 / 60; if (still > 0.28) break; }
331
+ const rd = this.read(); const out = { dice: rd.map((r) => r.val), cocked: !off && rd.some((r) => r.up < 0.9), off };
332
+ this.handX = keep; this.hand(keep); return out;
333
+ }
334
+ read() {
335
+ const q = new THREE.Quaternion(), v = new THREE.Vector3();
336
+ 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;
337
+ 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 }; });
338
+ }
339
+ update(realDt) {
340
+ const dt = Math.min(0.05, realDt);
341
+ if (this.state === 'hand') {
342
+ const tt = performance.now() / 1000;
343
+ this.dice.forEach((d, i) => { const b = d.body, k = this.shakeK;
344
+ 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);
345
+ 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); });
346
+ }
347
+ if (this.state === 'rolling') {
348
+ const step = dt * this.timeScale * (this.speed || 1);
349
+ this.world.step(1 / 120, step, this.speed > 1 ? 30 : 8);
350
+ this.t += step;
351
+ const rd = this.read();
352
+ const moving = this.dice.some((d) => d.body.velocity.length() > 0.25 || d.body.angularVelocity.length() > 0.6);
353
+ const slowish = this.dice.every((d) => d.body.velocity.length() < 5 && d.body.position.y < 1.6);
354
+ // slow motion when the dice are about to land on something big
355
+ if (!this.slowFired && this.t > 0.55 && slowish && moving) {
356
+ const vals = rd.map((r) => r.val).sort((a, b) => a - b), flat = rd.every((r) => r.up > 0.94);
357
+ 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]));
358
+ if (big) { this.slowFired = true; this.slowT = 1.1; this.onSlow(vals); }
359
+ }
360
+ 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);
361
+ const c = rd.reduce((a, r) => a.add(new THREE.Vector3(r.p.x, 0, r.p.z)), new THREE.Vector3()).multiplyScalar(1 / 3);
362
+ this.camLookT.set(c.x * 0.6, 0, Math.min(1, c.z) * 0.7 - 1.2);
363
+ const off = rd.some((r) => r.p.y < -1.5 || r.p.z > 8 || Math.abs(r.p.x) > 9);
364
+ if (!moving) this.settleT += dt; else this.settleT = 0;
365
+ if (off || this.settleT > 0.28 || this.t > 7) this.finish(rd, off);
366
+ }
367
+ // sync meshes
368
+ 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); }
369
+ if (this.wheel) this.wheel.rotation.z += realDt * 0.05;
370
+ // camera (aspect-aware so a phone in portrait still sees the whole throw)
371
+ const S = this.stage, asp = S.cam.aspect || 1, k = asp < 1 ? 1 + (1 - asp) * 1.25 : 1;
372
+ 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; }); }
373
+ 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);
374
+ 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);
375
+ S.setCam(this.camPos, this.camLook);
376
+ if (!this.noDraw) S.render(realDt);
377
+ }
378
+ finish(rd, off) {
379
+ this.state = 'settled'; this.timeScale = 1;
380
+ const cocked = !off && rd.some((r) => r.up < 0.9);
381
+ const out = { dice: rd.map((r) => r.val), cocked, off, at: rd.map((r) => [r.p.x, r.p.z]) };
382
+ const c = rd.reduce((a, r) => a.add(new THREE.Vector3(r.p.x, 0, r.p.z)), new THREE.Vector3()).multiplyScalar(1 / 3);
383
+ 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); }
384
+ const r = this.resolve; this.resolve = null; if (r) r(out);
385
+ }
386
+ /** Juice for a result (big = 4-5-6 / trips: gold flash + sparks; bust = 1-2-3: red flash). */
387
+ celebrate(kind) {
388
+ 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);
389
+ 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 }); }
390
+ else if (kind === '123') { S.shake(0.5); S.flash('#FF2A3D', 0.18); S.punch(0.004); }
391
+ else if (kind === 'point') S.shake(0.15);
392
+ }
393
+ resize() { this.stage.fit(); }
394
+ /** Frame-stepped rendering (trailer): fixed dt, no wall clock. */
395
+ stepFixed(dt) { this.update(dt); }
396
+ }
397
+
398
+ 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; }; }
399
+ export { clamp01 };
fonts/barlow-condensed-800.woff2 ADDED
Binary file (22.5 kB). View file
 
fonts/rubik-spray-paint.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:95618dc0a362160126211c88bd0391851c47b64816f8a340609dcbefba19d50b
3
+ size 120264
fun.css ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CyberMax Fun kit: shared look for CyberMax games (copied into each game). Mobile first. 2026 look: dark arcade night by
2
+ default (glass panels, a glow in each game's own colours behind the stage), light on request. (c) 2026 CyberMax. */
3
+ :root{--bg:#07080F;--bg2:#0E1020;--card:rgba(22,25,42,.72);--card-solid:#151829;--ink:#F1EFF8;--muted:#A3A8BE;--line:rgba(255,255,255,.10);--chip:rgba(255,255,255,.06);--acc:#FF6A3D;--acc-ink:#11141B;--ok:#2FD3A0;--bad:#FF7A5C;--game:#5CF2FF;--game2:#FF4D9A;--shadow:0 1px 0 rgba(255,255,255,.04) inset,0 18px 50px rgba(0,0,0,.45);color-scheme:dark}
4
+ :root[data-theme=light]{--bg:#F6F3EC;--bg2:#EFEADF;--card:#fff;--card-solid:#fff;--ink:#11141B;--muted:#565C6B;--line:#E2DCD0;--chip:#ECE6DA;--ok:#0B7F5E;--bad:#C2361B;--shadow:0 1px 2px rgba(17,20,27,.06),0 10px 30px rgba(17,20,27,.08);color-scheme:light}
5
+ *{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}
6
+ body{margin:0;background:radial-gradient(1100px 620px at 50% -12%,color-mix(in srgb,var(--game) 20%,transparent),transparent 70%),radial-gradient(800px 520px at 105% 38%,color-mix(in srgb,var(--game2) 13%,transparent),transparent 70%),radial-gradient(700px 500px at -8% 70%,color-mix(in srgb,var(--game) 9%,transparent),transparent 70%),linear-gradient(180deg,var(--bg),var(--bg2));background-attachment:fixed;color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden;min-height:100vh}
7
+ :root[data-theme=light] body{background:var(--bg)}
8
+ a{color:inherit}img,canvas{max-width:100%}
9
+ .wrap{max-width:760px;margin:0 auto;padding:0 16px}
10
+ .top{display:flex;align-items:center;gap:6px;padding:12px 0}
11
+ .top .brand{display:flex;align-items:center;gap:8px;font-weight:800;text-decoration:none;font-size:18px;margin-right:auto}.top .brand svg{width:28px;height:28px}
12
+ .top nav a,.top .icon{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:10px;font-size:15px;background:none;border:0;font-family:inherit;cursor:pointer;min-height:40px}
13
+ .top nav a:hover,.top .icon:hover{color:var(--ink);background:var(--chip)}
14
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:10px 18px;border-radius:12px;border:0;background:var(--acc);color:var(--acc-ink);font:inherit;font-weight:800;font-size:16px;cursor:pointer;text-decoration:none;touch-action:manipulation}
15
+ .btn:hover{filter:brightness(1.08)}.btn:not(.ghost){box-shadow:0 6px 22px color-mix(in srgb,var(--acc) 35%,transparent)}.btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)}.btn.sm{min-height:40px;padding:6px 14px;font-size:15px}
16
+ .btn.pass{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B}
17
+ .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
18
+ .chips{display:flex;gap:6px;flex-wrap:wrap}
19
+ .chip{border:1.5px solid var(--line);background:var(--chip);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:var(--ink);border-radius:999px;padding:6px 12px;font:inherit;font-size:14px;font-weight:700;cursor:pointer;min-height:36px}
20
+ .chip[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}.chip .lock{opacity:.7;font-size:12px}
21
+ .panel{background:var(--card);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border:1px solid var(--line);border-radius:20px;padding:16px;box-shadow:var(--shadow)}
22
+ .muted{color:var(--muted)}.small{font-size:14px}
23
+ h1{font-size:clamp(28px,6.4vw,46px);font-weight:900;line-height:1.05;letter-spacing:-.025em;margin:0 0 6px;text-wrap:balance}
24
+ h2{font-size:21px;letter-spacing:-.01em;margin:30px 0 8px}
25
+ .seo h3{font-size:17px;margin:18px 0 4px}.seo p,.seo li{color:var(--muted)}
26
+ .more{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:12px 0}
27
+ .more a{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:var(--card);text-decoration:none;transition:transform .15s,border-color .15s}.more a:hover{transform:translateY(-2px);border-color:color-mix(in srgb,var(--game) 50%,var(--line))}.more a b{display:block}.more a span{color:var(--muted);font-size:14px}
28
+ footer{color:var(--muted);font-size:14px;padding:26px 0 40px;border-top:1px solid var(--line);margin-top:36px}
29
+ #toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,40px);opacity:0;background:var(--ink);color:var(--bg);padding:12px 16px;border-radius:12px;font-weight:700;transition:.25s;z-index:99;max-width:calc(100% - 32px);text-align:center;pointer-events:none}
30
+ #toast.on{transform:translate(-50%,0);opacity:1}
31
+ .nudge{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;border:1.5px dashed var(--line);border-radius:14px;padding:10px 12px;margin-top:12px;font-size:15px}
32
+ html.cmx-pro .nudge{display:none}
33
+ .pro-badge{display:none;font-size:12px;font-weight:800;background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:2px 8px;vertical-align:middle}
34
+ html.cmx-pro .pro-badge{display:inline-block}
35
+ dialog.funpass{border:0;padding:0;border-radius:20px;max-width:min(520px,calc(100% - 24px));width:100%;background:var(--card-solid);color:var(--ink);box-shadow:0 20px 60px rgba(0,0,0,.35)}
36
+ dialog.funpass::backdrop{background:rgba(10,12,18,.55)}
37
+ .fp{padding:22px 20px 18px;position:relative}.fp h2{margin:2px 0 10px;font-size:24px}
38
+ .fp-x{position:absolute;right:10px;top:8px;border:0;background:none;color:var(--muted);font-size:28px;cursor:pointer;width:44px;height:44px}
39
+ .fp-kick{margin:0;color:var(--acc);font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
40
+ .fp-perks{margin:0 0 14px;padding-left:20px}.fp-perks li{margin:2px 0}
41
+ .fp-plans{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
42
+ .fp-plan{position:relative;display:flex;flex-direction:column;gap:2px;padding:14px 10px 12px;border:1.5px solid var(--line);border-radius:14px;text-decoration:none;text-align:center;background:var(--bg2)}
43
+ .fp-plan:hover{border-color:var(--acc)}.fp-plan.best{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
44
+ .fp-plan i{position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:var(--acc);color:#11141B;font-style:normal;font-size:11px;font-weight:800;padding:2px 8px;border-radius:999px;white-space:nowrap}
45
+ .fp-plan b{font-size:14px}.fp-price{font-size:22px;font-weight:900}.fp-price small{font-size:12px;font-weight:600;color:var(--muted)}.fp-note{font-size:12px;color:var(--muted)}
46
+ .fp-small{font-size:13px;color:var(--muted);margin:10px 0 4px}
47
+ .fp-code{display:block;user-select:all;word-break:break-all;background:var(--chip);padding:8px;border-radius:8px;font-size:12px}
48
+ .fp details{margin-top:6px;font-size:14px}.fp-form{display:flex;gap:6px;margin-top:8px}.fp-form input{flex:1;min-width:0;font:inherit;font-size:14px;padding:8px 10px;border-radius:10px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink)}
49
+ @media (max-width:420px){.fp-plans{grid-template-columns:1fr}.fp-plan{flex-direction:row;align-items:center;justify-content:space-between;text-align:left;padding:12px}.fp-plan i{left:auto;right:10px;transform:none}}
50
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
51
+ @media (max-width:560px){.chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -16px;padding:2px 16px 4px}.chips::-webkit-scrollbar{display:none}.chip{flex:0 0 auto}}
52
+ @media (min-width:760px){dialog.funpass{max-width:720px}.fp-perks{columns:2;column-gap:28px}}
53
+ .fp-after{margin:14px 0 0;padding:12px 14px;border:1px solid var(--line, rgba(127,127,127,.35));border-radius:14px;text-align:left}.fp-after p{margin:0 0 8px}.fp-after .fp-row{display:flex;gap:8px;flex-wrap:wrap}
54
+ .fp-game{border-color:var(--acc);display:grid;grid-template-columns:1fr auto;align-items:center;text-align:left;gap:2px 12px;padding:14px 14px 12px;margin:0 0 6px}.fp-game b{font-size:16px}.fp-game .fp-price{grid-row:span 2}.fp-game .fp-note{grid-column:1}
55
+ .fp-or{margin:12px 0 8px;font-size:14px}
56
+
57
+ .top{position:relative;z-index:2}.top .brand{letter-spacing:-.01em}
58
+ /* 3D games on wide screens: a bigger stage, centred header (2026 upgrade) */
59
+ @media (min-width:1100px){.wrap:has(.cmx3d-wrap){max-width:1080px}.wrap:has(.cmx3d-wrap) .seo,.wrap:has(.cmx3d-wrap) footer{max-width:760px;margin-left:auto;margin-right:auto}
60
+ .wrap:has(.cmx3d-wrap) section.game>.headline{text-align:center}.wrap:has(.cmx3d-wrap) section.game>.headline .tag{margin-left:auto;margin-right:auto;max-width:640px}
61
+ .wrap:has(.cmx3d-wrap) section.game>.chips{justify-content:center}.wrap:has(.cmx3d-wrap) section.game>.lv-goal,.wrap:has(.cmx3d-wrap) section.game>#goal{text-align:center;justify-content:center}
62
+ .wrap:has(.cmx3d-wrap) section.game>.panel{max-width:760px;margin-left:auto;margin-right:auto}}
63
+ /* living background: two slow glows in the game's colours drift behind the page (dark theme; off for reduced motion) */
64
+ body::before,body::after{content:"";position:fixed;z-index:-1;width:60vmax;height:60vmax;border-radius:50%;filter:blur(70px);opacity:.16;pointer-events:none;animation:cmx-drift 26s ease-in-out infinite alternate}
65
+ body::before{left:-20vmax;top:-25vmax;background:var(--game)}body::after{right:-25vmax;bottom:-30vmax;background:var(--game2);animation-duration:34s;animation-direction:alternate-reverse}
66
+ :root[data-theme=light] body::before,:root[data-theme=light] body::after{display:none}
67
+ @keyframes cmx-drift{from{transform:translate(0,0) scale(1)}to{transform:translate(12vmax,8vmax) scale(1.15)}}
68
+ .panel{transition:border-color .2s,box-shadow .2s}.panel:hover{border-color:color-mix(in srgb,var(--game) 28%,var(--line))}
69
+ .fp-rbx{font-size:13px;color:var(--muted);margin:10px 0 0;text-align:center}.fp-rbx a{color:inherit;font-weight:700}
funpass.js ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax games checkout: the per-game "full game" unlock + the Fun Pass (every game), copied into each game's web/ folder.
2
+ // Model (games lane GM, 9 Oct 2026; evidence docs/ceo/games-monetization-2026-10-09.md):
3
+ // - Free demo: every game's daily challenge stays free for everyone, plus the first levels (OFFER.demo once the per-game
4
+ // unlock is live; levels 1-20 until then). A level the player has opened is never locked again.
5
+ // - Full game: ONE payment per game (OFFER.price, Stripe pack revenue/packs/stripe/<game>-full) unlocks every level,
6
+ // mode, the daily archive and themes in that game, for good. Shown only when OFFER.live is true (owner-approved).
7
+ // - Fun Pass = the all-games bundle: Monthly $2.99 · Yearly $19 · Lifetime $29.99 (pack revenue/packs/stripe/fun-pass).
8
+ // - Portal builds (scripts/games/portals.py) never show a checkout; where the portal SDK offers rewarded ads, the player
9
+ // may watch one to open the full game for an hour (shared/portal-adapter/cmx-portal.js CMXPortal.rewarded()).
10
+ // Buy: counted redirect https://data.cybermaxtools.com/buy/st-<pack>-<plan>?s=game-<id> -> Stripe checkout.
11
+ // After checkout the data Worker (/unlock/fun-pass or /unlock/<game>-full) checks the Checkout Session and returns with
12
+ // #cmx-pro=<code> (the full-game return adds ?unlock=full). The code on the receipt page unlocks any other device.
13
+ // No account, no cookies, nothing personal stored. (c) 2026 CyberMax. All rights reserved.
14
+ import { count, store, toast } from './cmx.js';
15
+ import { OFFER } from './offer.js'; // written by scripts/games/sync_kit.py from products/fun-pass/games.json
16
+ export { OFFER };
17
+
18
+ const DATA = 'https://data.cybermaxtools.com';
19
+ const KEY = 'cmx-pro:fun-pass';
20
+ const SID = /^(cmxk1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i; // Stripe order code or Gumroad licence key
21
+ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
22
+ export const PLANS = [
23
+ { id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
24
+ { id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
25
+ { id: 'unlock', label: 'Lifetime', price: '$29.99', per: ' once', note: 'Pay once, keep forever' },
26
+ ];
27
+ export const COMMON_PERKS = ['Every level and mode in every CyberMax game', 'The full archive of past dailies', 'Custom colour themes', 'New games included as they launch'];
28
+ const FULL_KEY = `cmx-full:${OFFER.game}`;
29
+ const REWARD_KEY = `cmx-reward:${OFFER.game}`;
30
+ const REWARD_MS = 60 * 60 * 1000; // one rewarded ad on a portal build opens the full game for an hour
31
+
32
+ let state = store(KEY);
33
+ let full = store(FULL_KEY);
34
+ const listeners = [];
35
+ let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
36
+ // Per-game offer naming (17 Oct 2026 conversion fix): games whose one-time unlock is not a "full game" (no levels, every
37
+ // mode free) pass init({ title: 'Stoopshake Crew Pass', exact: true, free: '...' }): the sheet then names the offer exactly
38
+ // as the Stripe product, lists only its own perks and states what stays free, instead of "every level / bonus mode".
39
+ const fullTitle = () => cfg.title || `Full ${OFFER.name}`;
40
+
41
+ const passOn = () => !!(state && state.code);
42
+ export const ownsGame = () => !!(full && full.code);
43
+ const rewardOn = () => { const r = store(REWARD_KEY); return !!(r && Date.now() - r.at < REWARD_MS); };
44
+ /** True when this player may use everything in this game: Fun Pass, this game's full unlock, or a portal reward hour. */
45
+ export const isPro = () => passOn() || ownsGame() || rewardOn();
46
+ /** Free levels in this game's demo: OFFER.demo once the per-game unlock is live, else the game's current number. */
47
+ export const freeLevels = (current = 20) => (OFFER.live && OFFER.demo ? Math.min(current, OFFER.demo) : current);
48
+ const portal = () => (typeof window !== 'undefined' && window.CMXPortal && window.CMXPortal.portal) || '';
49
+ export function onChange(fn) { listeners.push(fn); fn(isPro()); }
50
+ // The header pass button names the real price (convert lane, 15 Oct 2026): "Full game $2.99" where this game sells its
51
+ // own unlock, else the Fun Pass monthly price. Pass holders see the button's own label.
52
+ function priceButton() {
53
+ const btn = typeof document !== 'undefined' && document.getElementById('pass-btn');
54
+ if (!btn) return;
55
+ if (!btn.dataset.label) btn.dataset.label = btn.textContent;
56
+ btn.textContent = isPro() || portal() ? btn.dataset.label : sellsGame() ? `✨ ${cfg.title ? cfg.short || cfg.title : 'Full game'} ${OFFER.price}` : `✨ Fun Pass ${PLANS[0].price}/mo`;
57
+ }
58
+ function emit() {
59
+ document.documentElement.classList.toggle('cmx-pro', isPro());
60
+ priceButton();
61
+ listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
62
+ }
63
+
64
+ // Offline Pro keys (17 Oct 2026): "cmxk1.<payload>.<Ed25519 signature>" from data.cybermaxtools.com/unlock is verified here
65
+ // with WebCrypto and this public key, so an unlock needs no Worker call. Expired, foreign-signed or unverifiable keys fall
66
+ // back to the online check of the order code inside them, as before.
67
+ const CMX_PUB = 'u0FDxbu0d9B9Sz5-yLT6AuL5IagyTlFcbBgEDqZxWtk';
68
+ const b64d = (s) => { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); };
69
+ async function cmxKeyOk(k, app, online) {
70
+ const [tag, body, sig] = k.split('.');
71
+ let p;
72
+ try { p = JSON.parse(new TextDecoder().decode(b64d(body))); } catch { return false; }
73
+ if (!p || p.a !== app) return false;
74
+ try {
75
+ const key = await crypto.subtle.importKey('raw', b64d(CMX_PUB), { name: 'Ed25519' }, false, ['verify']);
76
+ if (await crypto.subtle.verify({ name: 'Ed25519' }, key, b64d(sig), new TextEncoder().encode(tag + '.' + body)) && (!p.x || p.x * 1000 > Date.now())) return true;
77
+ } catch { /* no Ed25519 here: online check below */ }
78
+ return p.c ? online(p.c) : false;
79
+ }
80
+ async function check(code, what = 'fun-pass') {
81
+ if (/^cmxk1\./.test(code)) return cmxKeyOk(code, what, (c) => checkOnline(c, what));
82
+ return checkOnline(code, what);
83
+ }
84
+ async function checkOnline(code, what = 'fun-pass') {
85
+ try {
86
+ const r = await fetch(`${DATA}/unlock/${what}?json=1&session_id=${encodeURIComponent(code)}`);
87
+ const j = await r.json();
88
+ return j && typeof j.pro === 'boolean' ? j.pro : null;
89
+ } catch { return null; }
90
+ }
91
+
92
+ /** Turn on a full-game code for this game (receipt code of the <game>-full Payment Link). */
93
+ export async function unlockGame(code) {
94
+ const ok = await check(code, `${OFFER.game}-full`);
95
+ if (ok) {
96
+ full = store(FULL_KEY, { code, at: new Date().toISOString() });
97
+ count(cfg.game, 'pro', 'full-unlocked');
98
+ toast(cfg.title ? `${cfg.title} is on. Thank you for supporting CyberMax games!` : `${OFFER.name}: the full game is on. Thank you for supporting CyberMax games!`, 3500);
99
+ emit(); render();
100
+ } else if (ok === false) toast('That code is not a full-game order for this game.', 3500);
101
+ else toast('Could not reach the unlock check. Try again in a minute.', 3500);
102
+ return ok;
103
+ }
104
+
105
+ /** Turn on a pasted or returned code: a Fun Pass code, or (where this game sells its own unlock) a full-game code. */
106
+ export async function unlock(code) {
107
+ const ok = await check(code);
108
+ if (ok) {
109
+ state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
110
+ count(cfg.game, 'pro', 'unlocked');
111
+ toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
112
+ emit();
113
+ render();
114
+ return ok;
115
+ }
116
+ if (sellsGame() && (await check(code, `${OFFER.game}-full`))) return unlockGame(code);
117
+ if (ok === false) toast('That code is not an active Fun Pass.', 3500);
118
+ else toast('Could not reach the pass check. Try again in a minute.', 3500);
119
+ return ok;
120
+ }
121
+
122
+ async function recheck() {
123
+ if (!passOn() || Date.now() - (state.checked || 0) < RECHECK) return; // only a Fun Pass is re-checked (a full-game or Founder owner has no pass state)
124
+ const ok = await check(state.code);
125
+ if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
126
+ else if (ok) state = store(KEY, { ...state, checked: Date.now() });
127
+ }
128
+
129
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
130
+ const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
131
+ const gameUrl = (from = '') => `${DATA}/buy/st-${OFFER.game}-full-unlock?s=game-${cfg.game}${from ? `-${from}` : ''}`;
132
+ const sellsGame = () => !!(OFFER.live && OFFER.price && OFFER.game !== 'games');
133
+
134
+ function dialog() {
135
+ let d = document.getElementById('funpass');
136
+ if (!d) {
137
+ d = document.createElement('dialog');
138
+ d.id = 'funpass';
139
+ d.className = 'funpass';
140
+ document.body.appendChild(d);
141
+ d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
142
+ }
143
+ return d;
144
+ }
145
+
146
+ const planHtml = () => PLANS.map((p) => `<a class="fp-plan${p.best ? ' best' : ''}" href="${buyUrl(p.id)}" rel="nofollow" data-plan="${p.id}">${p.best ? '<i>Best value</i>' : ''}<b>${p.label}</b><span class="fp-price">${p.price}<small>${p.per}</small></span><span class="fp-note">${p.note}</span></a>`).join('');
147
+ const codeForm = `<details><summary>Already bought?</summary><form class="fp-form"><input aria-label="Order code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details>`;
148
+
149
+ function render() {
150
+ const d = dialog();
151
+ const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
152
+ if (isPro()) {
153
+ const what = passOn() ? 'The Fun Pass is on in this browser for every CyberMax game' : ownsGame() ? (cfg.title ? `You own the ${esc(cfg.title)}` : `You own the full ${esc(OFFER.name)}`) : 'The full game is open for the next hour (rewarded ad)';
154
+ const code = passOn() ? state.code : ownsGame() ? full.code : '';
155
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${passOn() ? 'CyberMax Fun Pass' : esc(OFFER.name)}</p><h2>Everything is open. Thank you!</h2>
156
+ <p>${what}: ${perks.map(esc).join(' · ')}.</p>
157
+ ${code ? `<p class="fp-small">Your code (paste it on another device to turn it on there):</p><code class="fp-code">${esc(code)}</code>` : ''}
158
+ ${passOn() ? '<p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p>' : ''}</div>`;
159
+ return;
160
+ }
161
+ if (portal()) { // portal build: no outside checkout; a rewarded ad (when the portal offers one) opens the game for an hour
162
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name || 'Full game')}</p>
163
+ <h2>Open the full game for an hour</h2><ul class="fp-perks">${perks.slice(0, 4).map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
164
+ <p><button class="btn" type="button" data-reward>▶ Watch a short ad</button></p>
165
+ <p class="fp-small">Optional. The daily challenge and the free levels stay free without ads.</p></div>`;
166
+ d.querySelector('[data-reward]').addEventListener('click', async () => {
167
+ count(cfg.game, 'cta', 'reward-ad');
168
+ const ok = await window.CMXPortal.rewarded().catch(() => false);
169
+ if (ok) { store(REWARD_KEY, { at: Date.now() }); count(cfg.game, 'pro', 'reward-hour'); toast('Everything is open for the next hour. Have fun!', 3000); emit(); d.close(); }
170
+ else toast('No ad is available right now. Try again later.', 3000);
171
+ });
172
+ return;
173
+ }
174
+ const allGames = `${OFFER.games || 'every'} CyberMax games`;
175
+ const own = cfg.perks || [];
176
+ const gamePerks = cfg.exact ? own.slice(0, 6) : [...own, ...(own.some((p) => /archive/i.test(p)) ? [] : ['The full archive of past dailies']), 'Every bonus mode in this game'].slice(0, 6);
177
+ const freeLine = cfg.free || `The daily challenge and ${OFFER.levels === false ? 'the free modes stay' : `the first ${OFFER.demo || ''} levels stay`} free for everyone.`;
178
+ d.innerHTML = sellsGame()
179
+ ? `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name)} · ${cfg.title ? 'one payment' : 'full game'}</p>
180
+ <h2>${cfg.title ? `Get the ${esc(cfg.title)}` : `Unlock the full ${esc(OFFER.name)}`}</h2>
181
+ <ul class="fp-perks">${gamePerks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
182
+ <a class="fp-plan fp-game" href="${gameUrl()}" rel="nofollow" data-game><i>This game only</i><b>${esc(fullTitle())}</b><span class="fp-price">${esc(OFFER.price)}<small> once</small></span><span class="fp-note">Pay once, keep it forever. No subscription.</span></a>
183
+ <p class="fp-or"><b>Or get all ${esc(allGames)}</b> with the Fun Pass (a separate pass for every game):</p>
184
+ <div class="fp-plans">${planHtml()}</div>
185
+ <p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · your code works on any device · 14-day fix-or-refund if it doesn't work as described (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). ${esc(freeLine)} Prices in USD; tax for your country is added at checkout.</p>
186
+ ${codeForm}</div>`
187
+ : `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
188
+ <h2>One pass. Every game. More to play.</h2>
189
+ <ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
190
+ <div class="fp-plans">${planHtml()}</div>
191
+ <p class="fp-small">Secure Stripe checkout (card, Apple Pay, Google Pay) · turns on as soon as you pay · cancel any time from your receipt email · 14-day fix-or-refund if it doesn't work as described (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). Works in every CyberMax game. The daily challenges and the free levels stay free for everyone.</p>
192
+ ${codeForm}</div>`;
193
+ d.querySelector('.fp-form').addEventListener('submit', (e) => {
194
+ e.preventDefault();
195
+ const c = e.target.querySelector('input').value.trim();
196
+ if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
197
+ });
198
+ d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
199
+ d.querySelector('[data-game]')?.addEventListener('click', () => count(cfg.game, 'cta', 'full-game'));
200
+ }
201
+
202
+ /** Open the pass sheet; `from` names the button that opened it (counted as cta). */
203
+ export function open(from = 'button') {
204
+ render();
205
+ const d = dialog();
206
+ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
207
+ count(cfg.game, 'cta', `pass-open-${from}`);
208
+ }
209
+
210
+ /** After a finished round (the value moment): one small inline card under the result, never a pop-up. Shows the
211
+ * pass price, the honest trust line and an optional "email me" weekly reminder (double opt-in).
212
+ * `host` is the result panel; the card is added once and removed for pass holders. */
213
+ // One quiet line under the result: CyberMax's free Roblox games (24 Oct 2026). Not on game portals (their rules ban
214
+ // outside links), never a pop-up; links carry the game id so clicks can be told apart.
215
+ const ROBLOX = [['Skyline Flight Circuit', 'https://www.roblox.com/games/89786604994377'], ['Hatchlume', 'https://www.roblox.com/games/80122923062327'],
216
+ ['Sled Volt', 'https://www.roblox.com/games/134881648422275']];
217
+ function robloxRow(host) {
218
+ if (portal() || host.querySelector('.fp-rbx')) return;
219
+ const p = document.createElement('p'); p.className = 'fp-rbx';
220
+ const tag = `?utm_source=cybermax&utm_medium=game&utm_campaign=${encodeURIComponent(cfg.game || 'games')}-end`;
221
+ p.innerHTML = 'Play our Roblox games: ' + ROBLOX.map(([n, u]) => `<a href="${u}${tag}" target="_blank" rel="noopener">${esc(n)}</a>`).join(' · ');
222
+ p.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', 'roblox-end')));
223
+ host.appendChild(p);
224
+ }
225
+
226
+ export function afterWin(host, from = 'win') {
227
+ if (!host) return;
228
+ robloxRow(host);
229
+ if (isPro() || portal()) { host.querySelector('.fp-after')?.remove(); return; }
230
+ let el = host.querySelector('.fp-after');
231
+ if (!el) { el = document.createElement('div'); el.className = 'fp-after'; host.appendChild(el); }
232
+ const best = PLANS.find((p) => p.best) || PLANS[0];
233
+ const game = sellsGame();
234
+ const href = game ? gameUrl('after') : `${buyUrl(best.id)}-after`;
235
+ const label = game ? (cfg.title ? `Get the ${cfg.title}: ${OFFER.price} once` : `Get the full ${OFFER.name}: ${OFFER.price} once`) : `Get the Fun Pass: ${best.price}${best.per}`;
236
+ const line = game ? (cfg.extra || `The full game opens every level, mode, the daily archive and themes in ${OFFER.name}.`)
237
+ : (cfg.extra || 'The Fun Pass opens every level, mode, the full archive and themes in every CyberMax game.');
238
+ el.innerHTML = `<p><b>Want more today?</b> ${esc(line)}</p>
239
+ <p class="fp-row"><a class="btn sm" href="${href}" rel="nofollow" data-after>${esc(label)}</a> <button class="btn sm ghost" type="button" data-plans>${game ? 'All games pass' : 'See all plans'}</button></p>
240
+ <p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · ${game ? 'one payment, no subscription' : 'cancel any time from your receipt email'} · 14-day fix-or-refund if it doesn't work as described (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). Today's daily challenge stays free.</p>
241
+ <div class="fp-cap"></div>`;
242
+ el.querySelector('[data-after]').addEventListener('click', () => { count(cfg.game, 'cta', game ? 'full-game-after' : `pass-${best.id}-after`); kit((K) => K.event('upgrade-click', cfg.game)); });
243
+ el.querySelector('[data-plans]').addEventListener('click', () => open(`after-${from}`));
244
+ kit((K) => {
245
+ K.event('result', cfg.game); K.event('upgrade-view', cfg.game);
246
+ K.capture(el.querySelector('.fp-cap'), { list: `weekly-${cfg.game}`, src: `game-${cfg.game}`, label: cfg.remind || 'Email me a weekly reminder with new puzzles (free)', button: 'Remind me' });
247
+ });
248
+ }
249
+
250
+ // Convert kit (funnel beacons + email capture) from the CyberMax site; optional, the game works without it.
251
+ let kitQ = null;
252
+ function kit(fn) {
253
+ if (window.CMXConvert) return fn(window.CMXConvert);
254
+ if (kitQ) return kitQ.push(fn);
255
+ kitQ = [fn];
256
+ const sc = document.createElement('script');
257
+ sc.src = 'https://cybermaxtools.com/store/convert.js'; sc.async = true;
258
+ sc.onload = () => kitQ.splice(0).forEach((f) => { try { f(window.CMXConvert); } catch { /* optional */ } });
259
+ document.head.appendChild(sc);
260
+ }
261
+
262
+ /** Call once per page: game id, display name and the game's own perks (listed first). Reads #cmx-pro=<code> after
263
+ * checkout (?unlock=full marks this game's full-game return). Skin-pack codes are taken first by skins.js capture(). */
264
+ export function init(options) {
265
+ cfg = { ...cfg, ...options };
266
+ const m = /[#&]cmx-key=([^&]+)/.exec(location.hash || '') || /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
267
+ if (m && SID.test(decodeURIComponent(m[1]))) {
268
+ const q = new URLSearchParams(location.search);
269
+ const fullReturn = q.get('unlock') === 'full';
270
+ q.delete('unlock');
271
+ history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
272
+ if (fullReturn) unlockGame(decodeURIComponent(m[1])); else unlock(decodeURIComponent(m[1]));
273
+ } else recheck();
274
+ emit();
275
+ }
276
+
277
+ // Direct checkout (17 Oct 2026, Workers cap fix): a click on a data.cybermaxtools.com/buy/<offer> link goes straight to the
278
+ // Stripe/Polar checkout (static map https://cybermaxtools.com/store/buy-direct.json); the click is counted by a beacon.
279
+ (() => {
280
+ if (typeof document === 'undefined' || window.__cmxBd) return;
281
+ window.__cmxBd = 1;
282
+ let MAP = null;
283
+ try { MAP = JSON.parse(sessionStorage.getItem('cmx-bd') || 'null'); } catch { /* none */ }
284
+ if (!MAP) fetch('https://cybermaxtools.com/store/buy-direct.json').then((r) => r.json()).then((j) => { MAP = j; try { sessionStorage.setItem('cmx-bd', JSON.stringify(j)); } catch { /* full */ } }).catch(() => {});
285
+ document.addEventListener('click', (e) => {
286
+ const a = e.target && e.target.closest && e.target.closest('a[href*="data.cybermaxtools.com/buy/"]');
287
+ if (!a || !MAP || !MAP.offers) return;
288
+ try {
289
+ const u = new URL(a.href);
290
+ const k = /^\/buy\/([a-z0-9-]+)\/?$/.exec(u.pathname);
291
+ const t = k && MAP.offers[k[1]];
292
+ if (!t) return;
293
+ const s = String(u.searchParams.get('s') || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 80) || 'unknown';
294
+ a.href = t.split(MAP.ph).join(s);
295
+ if (!navigator.webdriver) navigator.sendBeacon(u.href + (u.search ? '&' : '?') + 'b=1');
296
+ } catch { /* keep the counted link */ }
297
+ }, true);
298
+ })();
game.js ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stoopshake: free 3D street dice (classic Cee-lo, 4-5-6) on a night-time stoop. Real-physics dice (dice3d.js), the rules
2
+ // engine (rules.js), an original soundtrack (audio.js). Modes: Quick 1v1, Table (up to 6, pot), Banker, the Daily Table,
3
+ // pass-and-play, async challenge links (state in the URL, no server), Crew tables (Crew Pass), Telegram Mini App + group
4
+ // chats (/dice on the bot, rolled with Telegram's own animated dice). Free play chips only: daily top-up, earned by playing,
5
+ // never bought, sold or cashed out; no prizes. Paid = looks and crew features only, never odds. (c) 2026 CyberMax.
6
+ import { count, store, toast, share, download } from './cmx.js';
7
+ import * as FP from './funpass.js';
8
+ import * as pack from './pack.js';
9
+ import * as TGM from './tg.js';
10
+ import * as R from './rules.js';
11
+ import * as A from './audio.js';
12
+
13
+ const GAME = 'stoopshake';
14
+ const SITE = 'https://cybermaxtools.com';
15
+ const PATH = '/games/stoopshake/';
16
+ const DATA = 'https://data.cybermaxtools.com';
17
+ const BOT_LINK = 'https://t.me/CyberMaxTools_bot';
18
+ const $ = (id) => document.getElementById(id);
19
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms * (CAP ? 0 : FAST ? 0.15 : 1)));
21
+ const Q = new URLSearchParams(location.search);
22
+ const CAP = Q.has('cap'); // trailer / screenshots: frame-stepped from outside (window.__cap)
23
+ const FAST = Q.has('fast'); // tests: shorter pauses
24
+ const TODAY = R.dayNumber();
25
+ const dz = (d) => (d || []).map(R.face).join('');
26
+
27
+ // ------------------------------------------------------------------ paid extras (cosmetic / convenience only)
28
+ pack.capture({ game: GAME, name: 'Stoopshake', title: 'Dice & Table Pack', skins: 4, price: '$1.99', preview: ['#D9DDE3', '#E6B54A', '#E4D6B8', '#3CFFE0'],
29
+ perks: ['4 dice: Chrome, Gold, Bone and Neon', '3 tables: Brownstone Stoop, Rooftop and Arcade', '5 win emotes for your big rolls'] });
30
+ 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'];
31
+ // Named exactly as the Stripe product (prod "Stoopshake Crew Pass: crew tables, colours and stats"); the Crew Pass also
32
+ // turns on every Dice & Table Pack look (ownPack() below), so the sheet says so. Every mode stays free (17 Oct 2026).
33
+ FP.init({ game: GAME, name: 'Stoopshake', title: 'Stoopshake Crew Pass', short: 'Crew Pass', exact: true,
34
+ free: 'Every mode, the Daily Table and the daily free chips stay free for everyone.',
35
+ perks: [...CREW_PERKS, 'Also every Dice & Table Pack look: 4 dice, 3 tables, 5 emotes'],
36
+ extra: 'The Crew Pass opens crew tables, your crew name and colours, stats history and two more tables. Same dice odds for everyone.' });
37
+ const ownPack = () => pack.owned() || FP.isPro();
38
+ const ownCrew = () => FP.isPro();
39
+ 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' }];
40
+
41
+ // ------------------------------------------------------------------ saved on this device only
42
+ 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: [] };
43
+ const S = Object.assign({}, DEF, store('ssk:s') || {});
44
+ if (!S.dev) S.dev = Math.random().toString(36).slice(2, 12);
45
+ 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));
46
+ const save = () => store('ssk:s', S);
47
+ let gift = 0;
48
+ ({ wallet: S.wallet, gift } = R.dailyRefill(S.wallet, TODAY)); save();
49
+
50
+ // ------------------------------------------------------------------ challenge link from a friend (?c=...)
51
+ let chal = Q.get('c') ? R.decodeChallenge(Q.get('c')) : null;
52
+
53
+ // ------------------------------------------------------------------ 3D boot
54
+ let table = null, DT = null;
55
+ const cv = $('c');
56
+ async function boot() {
57
+ const ok = (() => { try { return !!document.createElement('canvas').getContext('webgl2'); } catch { return false; } })();
58
+ if (!ok) { $('loading').hidden = true; $('nogl').hidden = false; count(GAME, 'nogl'); return; }
59
+ $('lbar').style.width = '35%';
60
+ DT = await import('./dice3d.js');
61
+ $('lbar').style.width = '75%';
62
+ try { await document.fonts.load('800 20px "Barlow Condensed"'); } catch { /* */ }
63
+ 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'); } });
64
+ if (S.skin === 'crew') table.setSkin('crew', S.crew);
65
+ 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
66
+ addEventListener('resize', () => table.resize()); table.resize();
67
+ // compile every shader behind the loading screen (parallel where the GPU allows), so the first frame doesn't stall
68
+ $('lbar').style.width = '90%';
69
+ const tc = performance.now();
70
+ try { table.stage.setCam(table.camPos, table.camLook); await table.r.compileAsync(table.scene, table.stage.cam); } catch { /* older three: compiles on first frame */ }
71
+ table.update(1 / 60); // first frame (post-processing programs)
72
+ window.__sskCompile = Math.round(performance.now() - tc);
73
+ $('loading').hidden = true;
74
+ count(GAME, 'view', TGM.app() ? 'telegram' : chal ? 'challenge' : '');
75
+ if (!CAP) requestAnimationFrame(loop);
76
+ 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 };
77
+ renderTop();
78
+ if (gift > 0 && S.wallet.played > 0) setTimeout(() => toast(`Daily top-up: +${gift} free chips. Come back tomorrow for more.`, 3500), 600);
79
+ if (chal) home(); else if (!S.rules) { $('rules').hidden = false; } else home();
80
+ }
81
+ const skinFor = () => { const sk = DT.SKINS[S.skin]; if (!sk || (sk.pack === 'skins' && !ownPack()) || (sk.pack === 'crew' && !ownCrew())) return 'classic'; return S.skin; };
82
+ const themeFor = () => { const th = DT.THEMES[S.theme]; if (!th || (th.pack === 'skins' && !ownPack()) || (th.pack === 'crew' && !ownCrew())) return 'corner'; return S.theme; };
83
+ let last = performance.now();
84
+ function loop(now) {
85
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
86
+ if (holding && table.state === 'hand') { table.shake(holdX, holdK); A.rattle(holdK); holdK = Math.max(0.35, holdK * 0.96); }
87
+ table.update(dt); rec.frame();
88
+ if (table.state !== 'rolling') $('app').classList.remove('slow');
89
+ requestAnimationFrame(loop);
90
+ }
91
+ // frame-stepped capture for the trailer and store shots (no wall clock)
92
+ window.__cap = {
93
+ step: (dt = 1 / 30, draw = true) => { if (!table) return; table.noDraw = !draw; table.update(dt); table.noDraw = false; }, ready: () => !!table,
94
+ // trailer + store shots: drive the real scene and the real HUD from outside (tests/trailer.mjs)
95
+ hideAll: () => { hideAll(); actions([]); },
96
+ 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(); },
97
+ pot: (t) => { $('pot').textContent = t; },
98
+ 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'); },
99
+ unbanner: () => $('banner').classList.remove('show'),
100
+ talk: (who, text) => { const t = $('talk'); t.innerHTML = `<b>${esc(who)}:</b> ${esc(text)}`; t.classList.add('show'); },
101
+ untalk: () => $('talk').classList.remove('show'),
102
+ hint: (t) => { $('hint').textContent = t || ''; $('hint').classList.toggle('hide', !t); },
103
+ roll: (on) => { $('roll').disabled = !on; },
104
+ hud: (on) => { $('hud').style.display = on ? '' : 'none'; },
105
+ theme: (id) => { table.setTheme(id); table.hand(0); }, skin: (id) => table.setSkin(id, S.crew),
106
+ 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; },
107
+ throwSeed: (seed, power = 0.62) => { table.hand(0); table.throw({ seed, power }); },
108
+ state: () => table.state, celebrate: (k) => table.celebrate(k), shake: (x, k) => table.shake(x, k), hand: (x) => table.hand(x || 0),
109
+ read: () => table.read().map((r) => r.val),
110
+ score: () => R.score(table.read().map((r) => r.val)),
111
+ };
112
+
113
+ // ------------------------------------------------------------------ input: hold to shake, flick to throw
114
+ let waiting = null, holding = false, holdX = 0, holdK = 0, trail = [];
115
+ const toX = (clientX) => { const r = cv.getBoundingClientRect(); return ((clientX - r.left) / r.width - 0.5) * 8; };
116
+ cv.addEventListener('pointerdown', (e) => {
117
+ A.ensure(); A.startMusic();
118
+ if (!waiting || table.state !== 'hand') return;
119
+ holding = true; holdX = toX(e.clientX); holdK = 1; trail = [{ x: e.clientX, y: e.clientY, t: performance.now() }];
120
+ try { cv.setPointerCapture(e.pointerId); } catch { /* */ }
121
+ $('hint').classList.add('hide');
122
+ });
123
+ cv.addEventListener('pointermove', (e) => {
124
+ if (!holding) return;
125
+ const p = { x: e.clientX, y: e.clientY, t: performance.now() }; const q = trail[trail.length - 1];
126
+ holdX = toX(e.clientX); holdK = Math.min(1, holdK + Math.hypot(p.x - q.x, p.y - q.y) / 60);
127
+ trail.push(p); if (trail.length > 30) trail.shift();
128
+ });
129
+ const endHold = (e) => {
130
+ if (!holding) return; holding = false;
131
+ const now = performance.now(); const pts = trail.filter((p) => now - p.t < 110); const a = pts[0] || trail[0], b = trail[trail.length - 1];
132
+ const dt = Math.max(16, b.t - a.t), vx = (b.x - a.x) / dt, vy = (b.y - a.y) / dt; // px per ms
133
+ const r = cv.getBoundingClientRect(), k = 900 / Math.max(320, r.height);
134
+ const flick = -vy * k;
135
+ const power = flick > 0.25 ? Math.min(1, 0.3 + flick * 0.42) : 0.55; // a tap or a sideways drag = a steady toss
136
+ doThrow({ x: Math.max(-1, Math.min(1, vx * k * 0.6)), power });
137
+ };
138
+ cv.addEventListener('pointerup', endHold); cv.addEventListener('pointercancel', endHold);
139
+ 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 }); } });
140
+ $('roll').onclick = () => { A.ensure(); A.startMusic(); if (waiting) doThrow({ power: 0.55 + Math.random() * 0.4, x: (Math.random() - 0.5) * 0.5 }); };
141
+ 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); }
142
+
143
+ // ------------------------------------------------------------------ clip recorder (on this device only, watermark link)
144
+ const rec = (() => {
145
+ let mr = null, chunks = [], c2 = null, g = null, type = '', on = false;
146
+ const ok = () => !CAP && window.MediaRecorder && HTMLCanvasElement.prototype.captureStream;
147
+ return {
148
+ start() {
149
+ if (!ok() || on) return;
150
+ 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;
151
+ 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');
152
+ 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; }
153
+ chunks = []; mr.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; mr.start(250); on = true;
154
+ },
155
+ frame() {
156
+ if (!on || !g) return;
157
+ const W = c2.width, H = c2.height, sw = cv.width, sh = cv.height, k = Math.max(W / sw, H / sh);
158
+ g.drawImage(cv, (W - sw * k) / 2, (H - sh * k) / 2, sw * k, sh * k);
159
+ g.fillStyle = 'rgba(0,0,0,.55)'; g.fillRect(0, H - 54, W, 54);
160
+ g.font = '800 30px "Barlow Condensed", sans-serif'; g.fillStyle = '#FFC56B'; g.textBaseline = 'middle'; g.fillText('STOOPSHAKE', 16, H - 27);
161
+ 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';
162
+ },
163
+ 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); } }); },
164
+ };
165
+ })();
166
+ let clip = null;
167
+
168
+ // ------------------------------------------------------------------ UI bits
169
+ function renderTop() {
170
+ $('chipsN').textContent = S.wallet.chips.toLocaleString('en-US');
171
+ const rk = R.rankOf(S.wallet.rep); $('rank').textContent = rk.name.toUpperCase();
172
+ $('b-snd').classList.toggle('off', !A.settings().music);
173
+ document.body.classList.toggle('cmx-pro', ownCrew());
174
+ }
175
+ let bannerT = 0;
176
+ function banner(kind, text, sub = '', ms = 1700, emote = '') {
177
+ const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `<small>${esc(sub)}</small>` : ''}${emote ? `<span class="emote">${esc(emote)}</span>` : ''}`;
178
+ requestAnimationFrame(() => b.classList.add('show')); clearTimeout(bannerT); bannerT = setTimeout(() => b.classList.remove('show'), ms * (FAST ? 0.3 : 1));
179
+ }
180
+ let talkT = 0;
181
+ 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); }
182
+ function renderSeats() {
183
+ if (!M) { $('seats').innerHTML = ''; $('pot').textContent = ''; return; }
184
+ $('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>`
185
+ + `<span class="dz">${s.last ? dz(s.last) : '&nbsp;'}</span><small>${s.sc ? esc(s.sc.short) : s.kind === 'bot' ? esc(s.bot.vibe) : s.kind === 'me' ? 'you' : '&nbsp;'}${M.chipsShown && s.chips != null ? ` · ◉${s.chips}` : ''}</small></div>`).join('');
186
+ $('pot').textContent = M.potLabel ? M.potLabel() : '';
187
+ }
188
+ 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); } }
189
+ const hideAll = () => { for (const id of ['home', 'rules', 'setup', 'pass', 'end', 'style', 'board', 'me', 'shop']) $(id).hidden = true; };
190
+
191
+ // ------------------------------------------------------------------ one turn: a seat rolls until it scores
192
+ let M = null, turnSeat = null;
193
+ async function rollFor(seat, { label } = {}) {
194
+ turnSeat = seat; renderSeats();
195
+ for (let tries = 0; tries < 40; tries++) {
196
+ table.hand(seat.kind === 'bot' ? (Math.random() - 0.5) * 3 : 0);
197
+ let throwOpts;
198
+ if (seat.kind === 'bot') {
199
+ $('roll').disabled = true; $('hint').classList.add('hide');
200
+ await sleep(450 + Math.random() * 400);
201
+ if (tries === 0 && Math.random() < 0.45) talk(seat.name, R.line(seat.bot, 'hi'));
202
+ 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); }
203
+ throwOpts = { power: seat.bot.power * (0.75 + Math.random() * 0.25), x: (Math.random() - 0.5) * 0.6 };
204
+ A.whoosh(throwOpts.power);
205
+ } else {
206
+ 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(); }; }); }
207
+ $('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');
208
+ throwOpts = await new Promise((r) => { waiting = r; });
209
+ }
210
+ rec.start();
211
+ const res = await table.throw(throwOpts);
212
+ window.__ssk && (window.__ssk.rolls += 1);
213
+ if (res.off) { banner('none', 'OFF THE CURB!', 'Free re-roll', 1300); A.sting('none'); await sleep(1000); continue; }
214
+ if (res.cocked) { banner('none', 'COCKED DIE', 'Tilted against something. Free re-roll', 1400); A.sting('none'); await sleep(1100); continue; }
215
+ const sc = R.score(res.dice); seat.last = res.dice; seat.sc = R.scores(sc) ? sc : null; renderSeats();
216
+ if (!R.scores(sc)) { banner('none', 'NO SCORE', `${dz(res.dice)} · roll again`, 1100); A.sting('none'); await sleep(950); continue; }
217
+ table.celebrate(sc.kind); A.sting(sc.kind);
218
+ const big = sc.kind === '456' || sc.kind === 'trips' || sc.kind === '123';
219
+ 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}`,
220
+ `${seat.name} · ${sc.kind === '456' ? 'instant win' : sc.kind === '123' ? 'instant loss' : sc.kind === 'trips' ? 'beats any point' : 'the odd die'}`, big ? 2300 : 1500);
221
+ if (seat.kind === 'bot' && big) setTimeout(() => talk(seat.name, R.line(seat.bot, 'big')), 900);
222
+ if (big && seat.kind !== 'bot') TGM.haptic(sc.kind === '123' ? 'error' : 'success');
223
+ await sleep(big ? 1500 : 900);
224
+ const c = await rec.stop(); if (c && big) { clip = { ...c, label: `${seat.name}-${sc.short}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-') }; }
225
+ return { dice: res.dice, sc };
226
+ }
227
+ return { dice: [2, 2, 1], sc: R.score([2, 2, 1]) };
228
+ }
229
+
230
+ // ------------------------------------------------------------------ seats
231
+ const ME = () => ({ id: 'me', name: S.name, kind: 'me', color: '#FFC56B' });
232
+ const botSeat = (id) => { const b = R.botById(id); return { id, name: b.name, kind: 'bot', bot: b, color: b.color, chips: 1000 }; };
233
+ const pickBots = (n) => { const ids = R.BOTS.map((b) => b.id).sort(() => Math.random() - 0.5); return ids.slice(0, n).map(botSeat); };
234
+
235
+ function settleMe(delta, sc, won) {
236
+ const before = R.rankOf(S.wallet.rep).index;
237
+ S.wallet = R.settle(S.wallet, { delta, sc, won });
238
+ 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);
239
+ save(); renderTop(); if (delta) A.chips(Math.min(8, 2 + Math.round(Math.abs(delta) / 25)));
240
+ const after = R.rankOf(S.wallet.rep); if (after.index > before) setTimeout(() => { banner('trips', `RANK UP`, after.name.toUpperCase(), 2400); A.sting('trips'); }, 1200);
241
+ }
242
+ const myEmote = () => (ownPack() ? (EMOTES.find((e) => e.id === S.emote) || EMOTES[0]).t : '');
243
+
244
+ // ------------------------------------------------------------------ modes
245
+ async function runDuel({ stake, opp, practice = false }) {
246
+ const me = ME(), them = opp || pickBots(1)[0];
247
+ M = { mode: practice ? 'practice' : 'duel', seats: [me, them], chipsShown: false, potLabel: () => (practice ? 'PRACTICE · no chips' : `STAKE ◉${stake} each`) };
248
+ let first = 0;
249
+ count(GAME, 'play', M.mode);
250
+ for (;;) {
251
+ M.seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; }); renderSeats();
252
+ const a = M.seats[first], b = M.seats[1 - first];
253
+ const ra = await rollFor(a);
254
+ let r = R.duel(ra.sc, null), rb = null;
255
+ if (r.winner === null) { rb = await rollFor(b); r = R.duel(ra.sc, rb.sc); }
256
+ if (r.winner === null) { banner('point', 'TIE!', 'Shoot-off', 1300); await sleep(1300); continue; }
257
+ const winner = r.winner === 0 ? a : b; winner.win = true; renderSeats();
258
+ const iWon = winner === me, mySc = a === me ? ra.sc : rb && rb.sc;
259
+ if (!practice) settleMe(iWon ? stake : -stake, mySc, iWon);
260
+ if (them.kind === 'bot') setTimeout(() => talk(them.name, R.line(them.bot, iWon ? 'lose' : 'win')), 400);
261
+ A.sting(iWon ? 'win' : 'lose');
262
+ await sleep(500);
263
+ banner(iWon ? '456' : '123', iWon ? 'YOU WIN' : `${winner.name.toUpperCase()} WINS`, practice ? '' : `${iWon ? '+' : '-'}${stake} chips`, 2000, iWon ? myEmote() : '');
264
+ await sleep(1800);
265
+ const again = await endChoice(iWon, practice ? null : stake);
266
+ if (again !== 'again') return;
267
+ if (!practice && S.wallet.chips < stake) { broke(); return; }
268
+ first = iWon ? 1 : 0; // loser of the last round shoots first
269
+ }
270
+ }
271
+
272
+ async function runTable({ stake, seats, mode = 'table', rounds = 0, daily = null }) {
273
+ M = { mode, seats, chipsShown: true, pot: 0, potLabel: () => `POT ◉${M.pot}` };
274
+ count(GAME, 'play', daily ? 'daily' : mode);
275
+ const log = [];
276
+ for (let round = 1; ; round++) {
277
+ seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; });
278
+ 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; }
279
+ renderSeats(); A.chips(5);
280
+ if (daily) banner('point', `ROUND ${round}/${rounds}`, daily.label, 1200);
281
+ await sleep(daily ? 1100 : 300);
282
+ let live = [...seats], winners = null, mySc = null;
283
+ for (;;) {
284
+ const res = [];
285
+ for (const s of live) {
286
+ const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (s.kind === 'me') mySc = r.sc;
287
+ if (r.sc.kind === '456') break;
288
+ if (r.sc.kind === '123') { s.out = true; renderSeats(); }
289
+ }
290
+ const tw = R.tableWinners(res);
291
+ if (tw.winners.length === 1) { winners = tw.winners; break; }
292
+ banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500);
293
+ live = tw.winners; live.forEach((s) => { s.last = null; s.sc = null; }); renderSeats();
294
+ }
295
+ const w = winners[0]; w.win = true; if (w.kind !== 'me') w.chips += M.pot;
296
+ const iWon = w.kind === 'me' || (w.kind === 'local' && false);
297
+ if (seats.some((s) => s.kind === 'me')) settleMe(iWon ? M.pot - stake : -stake, mySc, iWon);
298
+ if (w.kind === 'local') { w.chips = (w.chips ?? 500) + M.pot; }
299
+ renderSeats(); A.sting(iWon ? 'win' : 'lose');
300
+ banner(iWon ? '456' : 'point', iWon ? 'YOU TAKE THE POT' : `${w.name.toUpperCase()} TAKES IT`, `◉${M.pot}`, 2000, iWon ? myEmote() : '');
301
+ if (w.kind === 'bot') setTimeout(() => talk(w.name, R.line(w.bot, 'win')), 500);
302
+ log.push({ sc: mySc, won: iWon, delta: iWon ? M.pot - stake : -stake });
303
+ await sleep(2000);
304
+ if (daily && round >= rounds) return finishDaily(daily, log);
305
+ if (daily) { if (S.wallet.chips < stake) { broke(); return; } continue; }
306
+ const again = await endChoice(iWon, stake, true);
307
+ if (again !== 'again') return;
308
+ if (seats.some((s) => s.kind === 'me') && S.wallet.chips < stake) { broke(); return; }
309
+ seats.push(seats.shift()); // the deal moves round the table
310
+ }
311
+ }
312
+
313
+ async function runBanker({ stake }) {
314
+ const me = ME(); const bots = pickBots(3); const seats = [me, ...bots];
315
+ M = { mode: 'banker', seats, chipsShown: true, bank: 1, potLabel: () => `BANK: ${seats[M.bank].name}` };
316
+ count(GAME, 'play', 'banker');
317
+ for (;;) {
318
+ 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); });
319
+ const bank = seats[M.bank]; renderSeats();
320
+ banner('point', `${bank.name.toUpperCase()} HAS THE BANK`, bank.kind === 'me' ? 'You cover every bet' : `Your stake ◉${stake}`, 1500); await sleep(1400);
321
+ const br = await rollFor(bank); let next = M.bank; let myDelta = 0, mySc = bank.kind === 'me' ? br.sc : null;
322
+ const players = seats.filter((s) => s !== bank);
323
+ if (br.sc.kind === '456' || br.sc.kind === '123') {
324
+ const o = R.bankerSettle(br.sc, null);
325
+ banner(o === 'lose' ? '456' : '123', o === 'lose' ? 'BANK TAKES ALL' : 'BANK PAYS ALL', '', 1800); await sleep(1600);
326
+ 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; }
327
+ } else {
328
+ for (const p of players) {
329
+ const pr = await rollFor(p); if (p.kind === 'me') mySc = pr.sc;
330
+ const o = R.bankerSettle(br.sc, pr.sc); const d = o === 'win' ? p.stake : o === 'lose' ? -p.stake : 0;
331
+ if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d;
332
+ p.win = o === 'win'; renderSeats();
333
+ 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);
334
+ await sleep(1200);
335
+ if (R.takesBank(br.sc, pr.sc, o) && next === M.bank) next = seats.indexOf(p);
336
+ }
337
+ }
338
+ const won = myDelta > 0 ? true : myDelta < 0 ? false : null;
339
+ settleMe(myDelta, mySc, won);
340
+ banner(myDelta >= 0 ? '456' : '123', myDelta > 0 ? 'UP THIS ROUND' : myDelta < 0 ? 'DOWN THIS ROUND' : 'EVEN', `${myDelta >= 0 ? '+' : ''}${myDelta} chips`, 1800, won ? myEmote() : '');
341
+ await sleep(1700);
342
+ if (next !== M.bank) { toast(`${seats[next].name} beat the bank with ${seats[next].sc.short} and takes the bank.`, 2600); M.bank = next; }
343
+ else if (Math.random() < 0.3) M.bank = (M.bank + 1) % seats.length;
344
+ const again = await endChoice(won, stake, true);
345
+ if (again !== 'again') return;
346
+ if (S.wallet.chips < stake) { broke(); return; }
347
+ }
348
+ }
349
+
350
+ // pass-and-play / crew: local players only, session chips (500 each)
351
+ async function runLocal(names, crew = false) {
352
+ const seats = names.map((n, i) => ({ id: `l${i}`, name: n, kind: 'local', color: ['#FFC56B', '#2FE6FF', '#FF3D6E', '#7CF7C8', '#C9A2FF', '#FF9A3D'][i % 6], chips: 500 }));
353
+ if (crew) seats.forEach((s) => { s.color = S.crew.a; });
354
+ M = { mode: crew ? 'crew' : 'local', seats, locals: seats.length, chipsShown: true, pot: 0, potLabel: () => `POT ◉${M.pot}` };
355
+ count(GAME, 'play', M.mode);
356
+ for (;;) {
357
+ seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; });
358
+ M.pot = 0; for (const s of seats) { s.chips -= 25; M.pot += 25; } renderSeats();
359
+ let live = [...seats], w = null;
360
+ for (;;) {
361
+ 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(); } }
362
+ const tw = R.tableWinners(res); if (tw.winners.length === 1) { w = tw.winners[0]; break; }
363
+ banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500); live = tw.winners;
364
+ }
365
+ w.win = true; w.chips += M.pot; renderSeats(); A.sting('win');
366
+ banner('456', `${w.name.toUpperCase()} TAKES IT`, `◉${M.pot}`, 2000);
367
+ 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(); }
368
+ await sleep(2000);
369
+ const again = await endChoice(null, null, true); if (again !== 'again') return;
370
+ seats.push(seats.shift());
371
+ }
372
+ }
373
+
374
+ // async challenge link (no server): one turn per link
375
+ async function runLinkTurn() {
376
+ const st = chal; const me = st.t; const meName = st.n[me], them = st.n[1 - me];
377
+ 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 };
378
+ 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}` };
379
+ count(GAME, 'play', 'link');
380
+ if (st.d) talk(them, `I rolled ${dz(st.d)} (${R.score(st.d).short}). Beat that.`, 4000);
381
+ const r = await rollFor(seat);
382
+ const out = R.challengeTurn(st, r.dice); chal = out.st;
383
+ const code = R.encodeChallenge(chal); const url = `${SITE}${PATH}?c=${code}`;
384
+ history.replaceState(null, '', `${location.pathname}?c=${code}`);
385
+ const over = R.seriesOver(chal);
386
+ let msg;
387
+ if (out.round && out.round.tie) msg = `Tie on the point! Round ${chal.k} replays. ${them} rolls first.`;
388
+ 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]}.`;
389
+ else msg = `You set ${r.sc.short}. Now ${them} has to beat it.`;
390
+ 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); }
391
+ 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:`;
392
+ 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;
393
+ 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>`
394
+ + `<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>`,
395
+ [['📤 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']]);
396
+ }
397
+ function shareLink(text, url) { count(GAME, 'share', 'challenge'); if (TGM.app()) { TGM.challenge(text, url); return; } share(GAME, text, url.replace(SITE, ''), null, null); }
398
+ async function copy(t) { try { await navigator.clipboard.writeText(t); toast('Link copied.'); } catch { prompt('Copy this link', t); } }
399
+
400
+ // daily table
401
+ async function runDaily() {
402
+ const d = R.dailyTable(TODAY); const seats = [ME(), ...d.lineup.map(botSeat)];
403
+ if (S.daily[TODAY]) toast('You already played today\'s table. This run is for fun and won\'t change your daily card.', 3500);
404
+ if (themeFor() !== d.scene && DT.THEMES[d.scene] && !DT.THEMES[d.scene].pack) table.setTheme(d.scene);
405
+ await runTable({ stake: Math.min(d.stake, Math.max(10, S.wallet.chips)), seats, mode: 'daily', rounds: d.rounds, daily: d });
406
+ }
407
+ function finishDaily(d, log) {
408
+ const net = log.reduce((a, x) => a + x.delta, 0); const first = !S.daily[TODAY];
409
+ const text = R.dailyCard(d.day, log, net);
410
+ if (first) { S.daily[TODAY] = { net, em: text.split('\n')[1] }; save(); count(GAME, 'done', 'daily'); }
411
+ table.setTheme(themeFor());
412
+ 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>`,
413
+ [['📤 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']]);
414
+ }
415
+
416
+ // ------------------------------------------------------------------ round end / home / setup
417
+ function endChoice(won, stake, table_ = false) {
418
+ return new Promise((res) => {
419
+ const b = [['RUN IT BACK', () => { actions([]); res('again'); }, 'btn']];
420
+ 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']);
421
+ if (won && S.wallet.winStreak >= 2) b.push([`🔥 STREAK ${S.wallet.winStreak} CARD`, () => streakCard(), 'chipb']);
422
+ b.push(['HOME', () => { actions([]); res('home'); home(); }, 'chipb']);
423
+ actions(b); $('hint').classList.add('hide');
424
+ });
425
+ }
426
+ 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']]); }
427
+ 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; }
428
+
429
+ function home() {
430
+ hideAll(); actions([]); M = null; turnSeat = null; renderSeats(); waiting = null; $('roll').disabled = true;
431
+ if (table && table.state !== 'rolling') { if (table.themeId !== themeFor()) table.setTheme(themeFor()); table.hand(0); }
432
+ const d = R.dailyTable(TODAY), done = S.daily[TODAY];
433
+ $('h-daily').textContent = `DAILY TABLE #${TODAY}${done ? ' ✓' : ''}`;
434
+ $('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`;
435
+ $('crew-lock').hidden = ownCrew();
436
+ const hc = $('h-chal');
437
+ 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(); }; }
438
+ else hc.hidden = true;
439
+ $('home').hidden = false;
440
+ }
441
+ function setup(mode) {
442
+ if (mode === 'daily') { hideAll(); runDaily(); return; }
443
+ if (mode === 'tg') { count(GAME, 'cta', 'tg-group'); window.open(`${BOT_LINK}?startgroup=dice`, '_blank', 'noopener'); return; }
444
+ if (mode === 'crew' && !ownCrew()) { openCrew('crew-mode'); return; }
445
+ const c = $('setup-c'); const stakes = R.STAKES.filter((s) => s <= Math.max(10, S.wallet.chips));
446
+ let stake = stakes.includes(25) ? 25 : stakes[0]; let n = mode === 'table' ? 4 : 2;
447
+ const draw = () => {
448
+ 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>`;
449
+ 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>`;
450
+ 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>`;
451
+ else if (mode === 'local' || mode === 'crew') {
452
+ const names = mode === 'crew' ? (S.crew.members.length ? S.crew.members : [S.name, '', '']) : (S.locals.length ? S.locals : [S.name, 'Player 2']);
453
+ 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>`
454
+ + `<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>`
455
+ + `<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`;
456
+ } 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>`
457
+ + `<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>`;
458
+ for (const b of c.querySelectorAll('[data-s]')) b.onclick = () => { stake = Number(b.dataset.s); A.tap(); draw(); };
459
+ for (const b of c.querySelectorAll('[data-n]')) b.onclick = () => { n = Number(b.dataset.n); A.tap(); draw(); };
460
+ $('back').onclick = home;
461
+ $('go').onclick = () => {
462
+ A.ensure(); A.startMusic(); hideAll();
463
+ if (mode === 'duel') runDuel({ stake });
464
+ else if (mode === 'banker') runBanker({ stake });
465
+ else if (mode === 'table') runTable({ stake, seats: [ME(), ...pickBots(n - 1)] });
466
+ else if (mode === 'local' || mode === 'crew') {
467
+ const names = [...c.querySelectorAll('#nm input')].map((i) => R.cleanName(i.value)).filter((v, i, a) => v && v !== 'Player' || i < 2).slice(0, 6);
468
+ const list = names.length >= 2 ? names : [S.name, 'Player 2'];
469
+ if (mode === 'crew') S.crew.members = list; else S.locals = list; save();
470
+ runLocal(list, mode === 'crew');
471
+ } else if (mode === 'link') {
472
+ S.name = R.cleanName($('n1').value) || S.name; save();
473
+ chal = { n: [S.name, R.cleanName($('n2').value || 'Friend')], s: [0, 0], k: 1, t: 0, d: null, w: 3 };
474
+ runLinkTurn();
475
+ }
476
+ };
477
+ };
478
+ hideAll(); draw(); $('setup').hidden = false;
479
+ }
480
+
481
+ // ------------------------------------------------------------------ style, profile, board, shop
482
+ function openStyle() {
483
+ const c = $('style-c');
484
+ const lockP = (p) => (p === 'skins' && !ownPack()) || (p === 'crew' && !ownCrew());
485
+ const tagP = (p) => (p === 'skins' ? 'PACK' : p === 'crew' ? 'CREW' : '');
486
+ 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>`
487
+ + `<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>`
488
+ + `<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>`
489
+ + (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>')
490
+ + `<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>`;
491
+ 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(); };
492
+ 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(); };
493
+ 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(); };
494
+ 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.'); };
495
+ if ($('s-crew')) $('s-crew').onclick = (e) => { e.preventDefault(); openCrew('style'); };
496
+ $('s-done').onclick = home; hideAll(); $('style').hidden = false;
497
+ }
498
+ function openMe() {
499
+ const w = S.wallet, rk = R.rankOf(w.rep), hist = ownCrew() ? S.hist : S.hist.slice(-10);
500
+ const wr = w.played ? Math.round((w.won / w.played) * 100) : 0;
501
+ $('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>`
502
+ + `<div class="lbar" style="width:100%"><i style="width:${Math.round(rk.pct * 100)}%"></i></div>`
503
+ + `<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>`
504
+ + `<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>`
505
+ + (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>` : '')
506
+ + `<span class="lbl">NAME</span><input class="inp" id="me-n" maxlength="14" value="${esc(S.name)}">`
507
+ + `<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>`;
508
+ $('me-save').onclick = () => { S.name = R.cleanName($('me-n').value); save(); toast('Saved.'); };
509
+ $('me-card').onclick = () => streakCard(); $('me-done').onclick = home; hideAll(); $('me').hidden = false;
510
+ }
511
+ async function streakCard() {
512
+ 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);
513
+ 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);
514
+ try { g.drawImage(cv, 0, 0, cv.width, cv.height, 0, 260, 1080, 1080 * cv.height / cv.width); } catch { /* */ }
515
+ g.fillStyle = 'rgba(7,6,12,.55)'; g.fillRect(0, 0, 1080, 1350);
516
+ g.textAlign = 'center'; g.fillStyle = '#FFC56B'; g.font = '120px Spray, "Barlow Condensed", sans-serif'; g.fillText('STOOPSHAKE', 540, 190);
517
+ g.fillStyle = '#fff'; g.font = '800 110px "Barlow Condensed", sans-serif'; g.fillText(`🔥 ${w.winStreak || w.bestStreak || 0} WIN STREAK`, 540, 520);
518
+ g.font = '800 64px "Barlow Condensed", sans-serif'; g.fillStyle = '#2FE6FF'; g.fillText(`${(ownCrew() ? S.crew.name + ' · ' : '')}${S.name} · ${rk.name.toUpperCase()}`, 540, 640);
519
+ 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);
520
+ 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);
521
+ const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
522
+ count(GAME, 'share', 'streak');
523
+ const text = `🔥 ${w.winStreak || w.bestStreak || 0} win streak on Stoopshake (${rk.name}). Come take it from me:`;
524
+ if (TGM.app()) { TGM.share(text, `${SITE}${PATH}`); return; }
525
+ share(GAME, text, PATH, blob, 'stoopshake-streak.png');
526
+ }
527
+ async function openBoard() {
528
+ const reg = R.regionOf(Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.language);
529
+ const c = $('board-c'); hideAll(); $('board').hidden = false;
530
+ 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>`;
531
+ if (!S.board) {
532
+ 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>`;
533
+ $('bd-on').onclick = () => { S.board = true; save(); count(GAME, 'cta', 'board-join'); openBoard(); }; $('bd-x').onclick = home; return;
534
+ }
535
+ c.innerHTML = top + '<p class="sub">Loading…</p>';
536
+ let j = null;
537
+ try {
538
+ 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(); }
539
+ else { const r = await fetch(`${DATA}/dice/board?region=${encodeURIComponent(reg.id)}`); j = await r.json(); }
540
+ } catch { j = null; }
541
+ 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; }
542
+ 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>`;
543
+ let tab = 'near';
544
+ 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>`
545
+ + (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>`)
546
+ + `<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>`;
547
+ for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { tab = b.dataset.t; draw(); };
548
+ $('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(); }; };
549
+ draw();
550
+ }
551
+ function openShop() {
552
+ const c = $('shop-c'); const tg = !!TGM.app();
553
+ 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>`
554
+ + `<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 &amp; Table Pack look</li></ul><button class="btn pink" id="sh-c" type="button">${ownCrew() ? 'YOURS ✓' : 'GET THE CREW PASS'}</button></div>`
555
+ + `<div class="offer"><h3>Stoopshake Dice &amp; 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>`
556
+ + (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>`)
557
+ + `<div class="btnrow"><button class="ghost" id="sh-b" type="button">BACK</button></div>`;
558
+ $('sh-p').onclick = () => { if (!ownPack()) openPack('shop'); }; $('sh-c').onclick = () => { if (!ownCrew()) openCrew('shop'); }; $('sh-b').onclick = home;
559
+ hideAll(); $('shop').hidden = false; count(GAME, 'cta', 'shop-open');
560
+ }
561
+
562
+ // ------------------------------------------------------------------ Telegram Mini App
563
+ let tgInfo = {};
564
+ function openCrew(from) { if (TGM.app()) TGM.sheet('stoopshake-full', { perks: CREW_PERKS, onPaid: claimTG }); else FP.open(from); }
565
+ 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); }
566
+ async function claimTG() {
567
+ tgInfo = { ...tgInfo, ...(await TGM.status()) }; const codes = tgInfo.codes || {};
568
+ if (codes['stoopshake-full'] && !FP.ownsGame()) await FP.unlockGame(codes['stoopshake-full']);
569
+ if (codes['stoopshake-skins'] && !pack.owned()) await pack.unlock(codes['stoopshake-skins']);
570
+ renderTop();
571
+ }
572
+ FP.onChange(() => renderTop()); pack.onChange(() => renderTop());
573
+
574
+ // ------------------------------------------------------------------ wiring
575
+ for (const b of document.querySelectorAll('.mode')) b.onclick = () => { A.tap(); setup(b.dataset.m); };
576
+ $('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 }); };
577
+ $('b-rules').onclick = () => { hideAll(); $('rules').hidden = false; $('rules-ok').textContent = 'BACK'; $('rules-ok').onclick = home; };
578
+ $('b-style').onclick = () => openStyle(); $('b-me').onclick = () => openMe(); $('b-board').onclick = () => openBoard();
579
+ $('b-shop').onclick = (e) => { e.preventDefault(); openShop(); };
580
+ $('b-extras').onclick = () => { A.tap(); openShop(); };
581
+ if (TGM.app()) $('b-extras').textContent = '✨ EXTRAS: CREW PASS · DICE & TABLE PACK';
582
+ $('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(); };
583
+ $('b-snd').onclick = () => { A.ensure(); const on = !A.settings().music; A.setMusic(on); if (!on) A.stopMusic(); renderTop(); };
584
+ TGM.init({ onResize: () => table && table.resize(), onBack: () => home() }).then(async (W) => { if (!W) return; count(GAME, 'tg-open'); tgInfo = await TGM.hello(); claimTG(); });
585
+ boot();
icon-192.png ADDED
icon-512.png ADDED
icon-maskable.png ADDED
icon.svg ADDED
index.html CHANGED
@@ -1,19 +1,159 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
3
+ <title>Stoopshake: Free 3D Street Dice Game (Cee-lo 4-5-6), No Download</title>
4
+ <meta name="description" content="Play Stoopshake free: real-physics 3D street dice (Cee-lo, 4-5-6). Flick to throw, beat bots, play friends by link or in Telegram groups. No real money.">
5
+ <link rel="canonical" href="https://cybermaxtools.com/games/stoopshake/">
6
+ <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
+ <meta name="theme-color" content="#0B0912">
8
+ <link rel="manifest" href="manifest.webmanifest">
9
+ <link rel="icon" href="icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icon-192.png">
10
+ <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Stoopshake">
11
+ <link rel="preload" href="fonts/barlow-condensed-800.woff2" as="font" type="font/woff2" crossorigin>
12
+ <link rel="preload" href="fonts/rubik-spray-paint.woff2" as="font" type="font/woff2" crossorigin>
13
+ <link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
14
+ <script type="importmap">{"imports":{"three":"./vendor/three.module.min.js"}}</script>
15
+ <meta property="og:title" content="Stoopshake: Free 3D Street Dice Game (Cee-lo 4-5-6)">
16
+ <meta property="og:description" content="Play Stoopshake free: real-physics 3D street dice (Cee-lo, 4-5-6). Flick to throw, beat bots, play friends by link or in Telegram groups. No real money.">
17
+ <meta property="og:url" content="https://cybermaxtools.com/games/stoopshake/">
18
+ <meta property="og:image" content="https://cybermaxtools.com/games/stoopshake/og.png">
19
+ <meta name="twitter:card" content="summary_large_image">
20
+ <meta name="twitter:title" content="Stoopshake: Free 3D Street Dice Game (Cee-lo 4-5-6)">
21
+ <meta name="twitter:description" content="Play Stoopshake free: real-physics 3D street dice (Cee-lo, 4-5-6). Flick to throw, beat bots, play friends by link or in Telegram groups. No real money.">
22
+ <meta name="twitter:image" content="https://cybermaxtools.com/games/stoopshake/og.png">
23
+ <style>.h1kw{display:block;font-size:.42em;font-weight:600;opacity:.75;letter-spacing:.01em;margin-top:.15em}.seo ol,.seo ul{padding-left:1.2em}.seo li{margin:.25em 0}</style>
24
+ <script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/stoopshake/#game", "name": "Stoopshake", "alternateName": "3D street dice game cee-lo", "url": "https://cybermaxtools.com/games/stoopshake/", "image": "https://cybermaxtools.com/games/stoopshake/og.png", "description": "Stoopshake is a free 3D dice game of Cee-lo, the classic three-dice street game, set on a lamp-lit corner at night. The dice are real physics: hold the screen to shake them, flick up to throw, and watch them bounce off the step and settle, with slow motion when a 4-5-6 or a 1-2-3 is about to land. 4-5-6 wins on the spot and 1-2-3 loses on the spot; trips beat any point, higher trips win; a pair plus one sets your point (the odd die); anything else rolls again. Play Quick 1v1, a Table of up to six with a pot, or Banker, where you try to beat the bank's point and take the bank. The bots have personalities and friendly trash talk. Play friends on one phone, send a challenge link and take turns from anywhere, or add the bot to a Telegram group and roll with Telegram's own animated dice. Chips are free play chips with a free top-up every day: no real money, nothing to cash out and no prizes. Big rolls are recorded as a short clip on your device to share.", "genre": ["Dice", "Casual", "Party", "Multiplayer"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": ["SinglePlayer", "MultiPlayer"], "inLanguage": "en", "isAccessibleForFree": true, "offers": [{"@type": "Offer", "name": "Free to play (every mode, the Daily Table and daily free chips)", "price": "0", "priceCurrency": "USD"}, {"@type": "Offer", "name": "Stoopshake Crew Pass (one-time unlock)", "price": "2.99", "priceCurrency": "USD"}, {"@type": "Offer", "name": "Stoopshake Dice & Table Pack (one-time unlock)", "price": "1.99", "priceCurrency": "USD"}], "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-17", "keywords": "3D street dice game cee-lo, free 3d street dice game: cee-lo, 4-5-6, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/stoopshake/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/stoopshake/#faq", "mainEntity": [{"@type": "Question", "name": "Is Stoopshake free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. Every mode, the Daily Table, Telegram group tables and the daily free chips are free, with no ads and no sign-up. Optional extras, paid once, are looks (Stoopshake Dice & Table Pack, $1.99) and crew features (Stoopshake Crew Pass, $2.99, which also includes every Dice & Table Pack look). The CyberMax Fun Pass covers both, in every CyberMax game."}}, {"@type": "Question", "name": "Is Stoopshake gambling?", "acceptedAnswer": {"@type": "Answer", "text": "No. There is no real money anywhere: chips are free play chips that top up every day, they can't be bought, sold, traded or cashed out, and there are no prizes. Paid extras never change the dice or give chips."}}, {"@type": "Question", "name": "What are the Cee-lo rules?", "acceptedAnswer": {"@type": "Answer", "text": "Roll three dice. 4-5-6 wins on the spot and 1-2-3 loses on the spot. Three of a kind beats any point (higher trips win). A pair plus one sets your point, the odd die. Anything else, roll again."}}, {"@type": "Question", "name": "Are the dice really random?", "acceptedAnswer": {"@type": "Answer", "text": "Each throw is a real physics simulation of three dice bouncing off the step and the ground, started by your own flick, so nobody (including us) picks the result. In Telegram groups the dice are Telegram's own animated dice, rolled by Telegram's servers."}}, {"@type": "Question", "name": "How do I play with friends far away?", "acceptedAnswer": {"@type": "Answer", "text": "Tap Challenge a friend: you roll first and send the link; your friend rolls on their phone and sends it back, first to 3. Or add @CyberMaxTools_bot to your group chat and type /dice."}}, {"@type": "Question", "name": "How do I play in a Telegram group?", "acceptedAnswer": {"@type": "Answer", "text": "Add @CyberMaxTools_bot to the group and type /dice (or /dice 50 for a bigger stake). Players tap Join, the starter taps Start, and each player taps Roll on their turn; the bot rolls three Telegram dice. /dicetop shows the group's top shooters and /crews the weekly crew league."}}, {"@type": "Question", "name": "What is in the Dice & Table Pack?", "acceptedAnswer": {"@type": "Answer", "text": "Chrome, Gold, Bone and Neon dice, three tables (Brownstone Stoop, Rooftop and Arcade) and five win emotes, $1.99 once. Looks only: the dice roll exactly the same."}}, {"@type": "Question", "name": "What does the Crew Pass add?", "acceptedAnswer": {"@type": "Answer", "text": "Crew tables (save your crew and seat them in one tap, with crew stats), your crew name and colours on the dice and share cards, full stats history and two more tables (Night Court and Boardwalk), plus every Dice & Table Pack look; $2.99 once."}}, {"@type": "Question", "name": "Does it work on phones?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. It is built phone-first: hold and flick to throw, and it adjusts its graphics to keep the dice smooth. It also installs as an app and runs inside Telegram."}}, {"@type": "Question", "name": "Is the board tracking my location?", "acceptedAnswer": {"@type": "Answer", "text": "No. The region board is opt-in and uses only the region your device's time zone already reports (for example New York), never GPS or your exact location."}}, {"@type": "Question", "name": "How much is the Stoopshake Crew Pass?", "acceptedAnswer": {"@type": "Answer", "text": "$2.99 once, no subscription: it adds crew tables (save your crew and seat them in one tap, with crew stats), your crew name and colours on the dice and share cards, full stats history, two more tables (Night Court and Boardwalk) and every Dice & Table Pack look, for good, and your receipt code works on any device. Every mode, the Daily Table and the daily free chips stay free for everyone. The Dice & Table Pack alone is $1.99 once, and the CyberMax Fun Pass covers every CyberMax game."}}]}, {"@type": "VideoObject", "name": "Stoopshake demo: Shake the dice. Hit 4-5-6.", "description": "A 18-second screen recording of the live Stoopshake page: Street dice on the stoop; Big Ace sets a point; A real 4-5-6, slow motion; Rooftop table, gold dice, trips. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/stoopshake/poster.jpg"], "uploadDate": "2026-10-17T10:12:23+00:00", "duration": "PT18S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/stoopshake/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/stoopshake/", "inLanguage": "en", "publisher": {"@type": "Organization", "name": "CyberMax", "logo": {"@type": "ImageObject", "url": "https://cybermax.cybermax-tools.workers.dev/store/img/favicon.webp"}}, "@id": "https://cybermaxtools.com/games/stoopshake/#video"}]}</script>
25
+ </head><body>
26
+ <div class="app" id="app">
27
+ <canvas id="c" aria-label="Stoopshake dice table. Hold to shake the dice, flick or drag up to throw, or press Roll." tabindex="0"></canvas>
28
+ <div class="hud" id="hud">
29
+ <div class="bar">
30
+ <a class="logo" href="https://cybermaxtools.com/games/" aria-label="CyberMax games">STOOP<i>SHAKE</i></a>
31
+ <span class="pill chips" id="chips" title="Free play chips (no real money)">◉ <b id="chipsN">500</b></span>
32
+ <span class="pill rank" id="rank">ROOKIE</span>
33
+ <span class="sp"></span>
34
+ <button class="ib" id="b-snd" type="button" aria-label="Sound">♪</button>
35
+ <button class="ib" id="b-menu" type="button" aria-label="Menu">☰</button>
36
+ </div>
37
+ <div class="seats" id="seats"></div>
38
+ <div class="talk" id="talk" aria-live="polite"></div>
39
+ <div class="banner" id="banner" aria-live="assertive"></div>
40
+ <div class="hint" id="hint">Hold to shake · flick up to throw</div>
41
+ <div class="actions" id="actions"></div>
42
+ <div class="bottom">
43
+ <div class="pot" id="pot"></div>
44
+ <button class="roll" id="roll" type="button">🎲 ROLL</button>
45
+ </div>
46
+ <p class="nrm">No real money. Just for fun.</p>
47
+ </div>
48
+
49
+ <div class="ov load" id="loading"><p class="biglogo">STOOP<i>SHAKE</i></p><div class="lbar"><i id="lbar"></i></div><p class="hint2">Setting up the stoop…</p></div>
50
+ <div class="ov" id="nogl" hidden><p class="biglogo">STOOP<i>SHAKE</i></p><p>This browser can't run WebGL2, which Stoopshake's 3D dice need. Try Chrome, Edge, Safari 15+ or Firefox.</p><a class="btn" href="https://cybermaxtools.com/games/">More CyberMax games</a></div>
51
+
52
+ <div class="ov sheet" id="home" hidden>
53
+ <div class="card">
54
+ <p class="biglogo sm">STOOP<i>SHAKE</i></p>
55
+ <p class="sub">Street dice, 4-5-6. Real 3D physics. <b>No real money. Just for fun.</b></p>
56
+ <div class="chal" id="h-chal" hidden></div>
57
+ <div class="modes">
58
+ <button class="mode hot" data-m="duel" type="button"><b>QUICK 1v1</b><span>You vs a bot. Best roll takes the chips.</span></button>
59
+ <button class="mode" data-m="table" type="button"><b>TABLE</b><span>Up to 6 seats. Everyone in the pot.</span></button>
60
+ <button class="mode" data-m="banker" type="button"><b>BANKER</b><span>Beat the bank's point. Win it, take the bank.</span></button>
61
+ <button class="mode day" data-m="daily" type="button"><b id="h-daily">DAILY TABLE</b><span id="h-daily-s">Same table for everyone today. 5 rounds.</span></button>
62
+ <button class="mode" data-m="local" type="button"><b>PASS &amp; PLAY</b><span>2-6 friends on one phone.</span></button>
63
+ <button class="mode" data-m="link" type="button"><b>CHALLENGE A FRIEND</b><span>Send a link. Take turns anywhere. First to 3.</span></button>
64
+ <button class="mode tg" data-m="tg" type="button"><b>PLAY IN YOUR GROUP CHAT</b><span>Add the bot, type /dice. Telegram's own dice.</span></button>
65
+ <button class="mode" data-m="crew" type="button"><b>CREW TABLE <em class="lock" id="crew-lock">CREW PASS</em></b><span>Your saved crew, colours and stats.</span></button>
66
+ </div>
67
+ <div class="row3">
68
+ <button class="ghost" id="b-style" type="button">🎨 STYLE</button>
69
+ <button class="ghost" id="b-board" type="button">🏆 BOARD</button>
70
+ <button class="ghost" id="b-me" type="button">👤 ME</button>
71
+ <button class="ghost" id="b-rules" type="button">📜 RULES</button>
72
+ </div>
73
+ <button class="btn pink xbtn" id="b-extras" type="button">✨ EXTRAS: CREW PASS $2.99 · DICE &amp; TABLE PACK $1.99</button>
74
+ <p class="tiny">Chips are free play chips: a free top-up every day, earned by playing. They can't be bought, sold or cashed out, and there are no prizes. <a href="#" id="b-shop">Extras</a> are looks and crew features only, never better odds.</p>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="ov sheet" id="rules" hidden>
79
+ <div class="card">
80
+ <h2 class="tag">HOW TO PLAY</h2>
81
+ <ol class="rl">
82
+ <li><span class="dz">⚃⚄⚅</span><span><b>4-5-6</b> wins on the spot.</span></li>
83
+ <li><span class="dz">⚀⚁⚂</span><span><b>1-2-3</b> loses on the spot.</span></li>
84
+ <li><span class="dz">⚄⚄⚄</span><span><b>Trips</b> beat any point. Higher trips win.</span></li>
85
+ <li><span class="dz">⚂⚂⚄</span><span><b>A pair + one</b> sets your <b>point</b>: the odd die (here 5). Higher point wins.</span></li>
86
+ <li><span class="dz">⚀⚃⚄</span><span>Anything else: <b>no score, roll again</b>.</span></li>
87
+ </ol>
88
+ <p class="sub">Hold the screen to shake, flick up to throw (or press ROLL). Off the curb or a tilted die? Free re-roll.</p>
89
+ <p class="sub">Ties roll off. In Banker, a tie with the bank is a push: you keep your chips.</p>
90
+ <button class="btn" id="rules-ok" type="button">GOT IT. LET'S ROLL</button>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="ov sheet" id="setup" hidden><div class="card" id="setup-c"></div></div>
95
+ <div class="ov sheet" id="pass" hidden><div class="card"><p class="sub">Pass the phone to</p><h2 class="tag" id="pass-n">PLAYER</h2><button class="btn" id="pass-ok" type="button">I'M READY</button></div></div>
96
+ <div class="ov sheet" id="end" hidden><div class="card" id="end-c"></div></div>
97
+ <div class="ov sheet" id="style" hidden><div class="card" id="style-c"></div></div>
98
+ <div class="ov sheet" id="board" hidden><div class="card" id="board-c"></div></div>
99
+ <div class="ov sheet" id="me" hidden><div class="card" id="me-c"></div></div>
100
+ <div class="ov sheet" id="shop" hidden><div class="card" id="shop-c"></div></div>
101
+ </div>
102
+
103
+ <div class="wrap below">
104
+ <header class="top"><a class="brand" href="https://cybermaxtools.com/games/">CyberMax games</a><nav><a href="https://cybermaxtools.com/games/">All games</a><a href="https://cybermaxtools.com/store/">Store</a></nav></header>
105
+ <main>
106
+ <div class="headline"><h1>Stoopshake <span class="pro-badge">CREW</span> <small class="h1kw">Free 3D street dice game: Cee-lo, 4-5-6</small></h1><p class="muted tag2">Free 3D street dice: Cee-lo, 4-5-6. Flick to throw. No real money. Just for fun.</p></div>
107
+ <section class="buybox" id="extras" aria-labelledby="extras-h">
108
+ <h2 id="extras-h">Stoopshake is free. Extras are optional.</h2>
109
+ <p class="muted">Every mode, the Daily Table, Telegram group tables and the daily free chips are free, with no ads and no sign-up. Extras are paid once and never change the dice or give chips.</p>
110
+ <div class="bx">
111
+ <div class="bo best">
112
+ <p class="bk">Crew features + every look</p>
113
+ <h3>Stoopshake Crew Pass <span class="bp">$2.99 <small>once</small></span></h3>
114
+ <ul><li>Crew tables: save your crew (up to 6) and seat them in one tap, with crew stats</li><li>Your crew name and colours on the dice and every share card</li><li>Full stats history and two more tables: Night Court and Boardwalk</li><li>Also every Dice &amp; Table Pack look</li></ul>
115
+ <a class="btn pink" href="https://data.cybermaxtools.com/buy/st-stoopshake-full-unlock?s=game-stoopshake-page" rel="nofollow" data-cmx-buy>Get the Crew Pass, $2.99</a>
116
+ </div>
117
+ <div class="bo">
118
+ <p class="bk">Looks only</p>
119
+ <h3>Stoopshake Dice &amp; Table Pack <span class="bp">$1.99 <small>once</small></span></h3>
120
+ <ul><li>4 dice: Chrome, Gold, Bone and Neon</li><li>3 tables: Brownstone Stoop, Rooftop and Arcade</li><li>5 win emotes for your big rolls</li><li>Same physics and odds as the free dice</li></ul>
121
+ <a class="btn" href="https://data.cybermaxtools.com/buy/st-stoopshake-skins-pack?s=game-stoopshake-page" rel="nofollow" data-cmx-buy>Get the Dice &amp; Table Pack, $1.99</a>
122
+ </div>
123
+ </div>
124
+ <p class="bsm">Secure Stripe checkout. Prices in USD; tax for your country is added at checkout. Your extras turn on as soon as you pay, and the receipt code turns them on on any device. 14-day fix-or-refund if it doesn't work as described (<a href="https://cybermaxtools.com/terms#payments">terms</a>).</p>
125
+ <p class="bfp"><b>Play more than one CyberMax game?</b> The <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> is a separate pass that covers every CyberMax game, both Stoopshake extras included: $2.99/month, $19/year or $29.99 for life.</p>
126
+ </section>
127
+ <section class="seo" id="guide">
128
+ <h2>Stoopshake: 3d street dice game cee-lo</h2>
129
+ <p>Stoopshake is a free 3D dice game of Cee-lo, the classic three-dice street game, set on a lamp-lit corner at night. The dice are real physics: hold the screen to shake them, flick up to throw, and watch them bounce off the step and settle, with slow motion when a 4-5-6 or a 1-2-3 is about to land. 4-5-6 wins on the spot and 1-2-3 loses on the spot; trips beat any point, higher trips win; a pair plus one sets your point (the odd die); anything else rolls again. Play Quick 1v1, a Table of up to six with a pot, or Banker, where you try to beat the bank&#x27;s point and take the bank. The bots have personalities and friendly trash talk. Play friends on one phone, send a challenge link and take turns from anywhere, or add the bot to a Telegram group and roll with Telegram&#x27;s own animated dice. Chips are free play chips with a free top-up every day: no real money, nothing to cash out and no prizes. Big rolls are recorded as a short clip on your device to share.</p>
130
+ <h2>How to play Stoopshake</h2>
131
+ <ol><li>Hold the screen to shake the dice, then flick up to throw (or press ROLL).</li><li>4-5-6 wins on the spot; 1-2-3 loses on the spot.</li><li>Three of a kind (trips) beats any point; higher trips win.</li><li>A pair plus one sets your point: the odd die. A higher point wins.</li><li>Anything else is no score: roll again. Dice off the curb or a tilted die are a free re-roll.</li></ol>
132
+ <h3>Controls</h3>
133
+ <p><b>Phone and tablet:</b> Hold anywhere on the table to pick up and shake the dice, then flick your thumb up to throw: a harder flick throws harder, a sideways flick angles the throw. Or tap ROLL. The phone buzzes on big rolls inside Telegram.</p>
134
+ <p><b>Desktop, laptop and Chromebook:</b> Click and hold on the table to shake, then drag up and release to throw; or press the ROLL button, or Space or Enter with the table focused.</p>
135
+ <h2>Stoopshake tips and strategy</h2>
136
+ <ul><li>Every throw is real physics, so no throw style is luckier than another: throw how it feels good.</li><li>In Banker, a tie with the bank&#x27;s point is a push: you keep your chips.</li><li>Beat the bank with 4-5-6 or trips and the bank passes to you.</li><li>The Daily Table is the same lineup for everyone today: share your spoiler-free emoji card.</li><li>Out of chips? The free top-up comes back every day, and Practice needs no chips.</li></ul>
137
+ <h2>Game modes</h2>
138
+ <ul><li>Quick 1v1: you against a bot with a personality; best roll takes the stake.</li><li>Table: up to 6 seats, everyone puts the stake in the pot, a 4-5-6 takes it on the spot, ties roll off.</li><li>Banker: beat the bank&#x27;s point; win with 4-5-6 or trips and you take the bank.</li><li>Daily Table: the same three bots and stake for everyone today, 5 rounds, a shareable emoji card.</li><li>Pass and play: 2-6 friends on one phone.</li><li>Challenge a friend: you roll, send the link, they roll on their phone, first to 3; no account, no server.</li><li>Telegram groups: add @CyberMaxTools_bot, type /dice, everyone rolls with Telegram&#x27;s own animated dice; group leaderboard and a weekly crew league.</li><li>Crew tables (Crew Pass): your saved crew, crew colours and crew stats.</li></ul>
139
+ <p>Every mode, the Daily Table, Telegram group tables and the daily free chips are free to play, with no ads and no sign-up. Two optional extras are paid once: the <a href="#extras">Stoopshake Crew Pass</a> ($2.99 once: crew tables, crew colours, stats history, two more tables and every Dice &amp; Table Pack look) and the <a href="#extras">Stoopshake Dice &amp; Table Pack</a> ($1.99 once: 4 dice, 3 tables and 5 win emotes, looks only). The <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life) is a separate pass that covers every CyberMax game, both Stoopshake extras included.</p>
140
+ <h2>Stoopshake FAQ</h2>
141
+ <h3>Is Stoopshake free?</h3><p>Yes. Every mode, the Daily Table, Telegram group tables and the daily free chips are free, with no ads and no sign-up. Optional extras, paid once, are looks (Stoopshake Dice &amp; Table Pack, $1.99) and crew features (Stoopshake Crew Pass, $2.99, which also includes every Dice &amp; Table Pack look). The CyberMax Fun Pass covers both, in every CyberMax game.</p>
142
+ <h3>Is Stoopshake gambling?</h3><p>No. There is no real money anywhere: chips are free play chips that top up every day, they can&#x27;t be bought, sold, traded or cashed out, and there are no prizes. Paid extras never change the dice or give chips.</p>
143
+ <h3>What are the Cee-lo rules?</h3><p>Roll three dice. 4-5-6 wins on the spot and 1-2-3 loses on the spot. Three of a kind beats any point (higher trips win). A pair plus one sets your point, the odd die. Anything else, roll again.</p>
144
+ <h3>Are the dice really random?</h3><p>Each throw is a real physics simulation of three dice bouncing off the step and the ground, started by your own flick, so nobody (including us) picks the result. In Telegram groups the dice are Telegram&#x27;s own animated dice, rolled by Telegram&#x27;s servers.</p>
145
+ <h3>How do I play with friends far away?</h3><p>Tap Challenge a friend: you roll first and send the link; your friend rolls on their phone and sends it back, first to 3. Or add @CyberMaxTools_bot to your group chat and type /dice.</p>
146
+ <h3>How do I play in a Telegram group?</h3><p>Add @CyberMaxTools_bot to the group and type /dice (or /dice 50 for a bigger stake). Players tap Join, the starter taps Start, and each player taps Roll on their turn; the bot rolls three Telegram dice. /dicetop shows the group&#x27;s top shooters and /crews the weekly crew league.</p>
147
+ <h3>What is in the Dice &amp; Table Pack?</h3><p>Chrome, Gold, Bone and Neon dice, three tables (Brownstone Stoop, Rooftop and Arcade) and five win emotes, $1.99 once. Looks only: the dice roll exactly the same.</p>
148
+ <h3>What does the Crew Pass add?</h3><p>Crew tables (save your crew and seat them in one tap, with crew stats), your crew name and colours on the dice and share cards, full stats history and two more tables (Night Court and Boardwalk), plus every Dice &amp; Table Pack look; $2.99 once.</p>
149
+ <h3>Does it work on phones?</h3><p>Yes. It is built phone-first: hold and flick to throw, and it adjusts its graphics to keep the dice smooth. It also installs as an app and runs inside Telegram.</p>
150
+ <h3>Is the board tracking my location?</h3><p>No. The region board is opt-in and uses only the region your device&#x27;s time zone already reports (for example New York), never GPS or your exact location.</p>
151
+ <h3>How much is the Stoopshake Crew Pass?</h3><p>$2.99 once, no subscription: it adds crew tables (save your crew and seat them in one tap, with crew stats), your crew name and colours on the dice and share cards, full stats history, two more tables (Night Court and Boardwalk) and every Dice &amp; Table Pack look, for good, and your receipt code works on any device. Every mode, the Daily Table and the daily free chips stay free for everyone. The Dice &amp; Table Pack alone is $1.99 once, and the CyberMax Fun Pass covers every CyberMax game.</p>
152
+ <h2>Similar free games</h2>
153
+ <div class="more"><a href="https://cybermaxtools.com/games/turfbreak/"><b>Turfbreak</b><span>First-person turf war in a rain-soaked night city: take the zones, chain killstreaks</span></a><a href="https://cybermaxtools.com/games/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/games/soaklark/"><b>Soaklark</b><span>3D water-balloon turf war: paint the block, soak rivals</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
154
+ </section>
155
+ </main>
156
+ <footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
157
+ </div>
158
+ <script type="module" src="game.js"></script>
159
+ </body></html>
manifest.webmanifest ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Stoopshake: Street dice on the stoop: real 3D physics, 4-5-6",
3
+ "short_name": "Stoopshake",
4
+ "description": "Street dice on the stoop: real 3D physics, 4-5-6. Free daily game by CyberMax, works offline.",
5
+ "id": "index.html",
6
+ "start_url": "index.html?utm_source=pwa-hf",
7
+ "scope": "./",
8
+ "display": "standalone",
9
+ "orientation": "portrait",
10
+ "background_color": "#07060C",
11
+ "theme_color": "#0B0912",
12
+ "categories": [
13
+ "games",
14
+ "entertainment"
15
+ ],
16
+ "icons": [
17
+ {
18
+ "src": "icon-192.png",
19
+ "sizes": "192x192",
20
+ "type": "image/png"
21
+ },
22
+ {
23
+ "src": "icon-512.png",
24
+ "sizes": "512x512",
25
+ "type": "image/png"
26
+ },
27
+ {
28
+ "src": "icon-maskable.png",
29
+ "sizes": "512x512",
30
+ "type": "image/png",
31
+ "purpose": "maskable"
32
+ },
33
+ {
34
+ "src": "icon.svg",
35
+ "sizes": "any",
36
+ "type": "image/svg+xml"
37
+ }
38
+ ],
39
+ "screenshots": [
40
+ {
41
+ "src": "shot-phone.png",
42
+ "sizes": "780x1688",
43
+ "type": "image/png",
44
+ "form_factor": "narrow"
45
+ }
46
+ ]
47
+ }
offer.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ // Written by scripts/games/sync_kit.py from products/fun-pass/games.json; do not edit by hand. (c) 2026 CyberMax.
2
+ export const OFFER = {"game": "stoopshake", "name": "Stoopshake", "price": "$2.99", "live": true, "demo": 20, "games": 20, "levels": false};
og.png ADDED

Git LFS Details

  • SHA256: dbac7e7c6e0d243c0a9652d6852e716e8604071a985d22277ab6d5efd19ba18b
  • Pointer size: 131 Bytes
  • Size of remote file: 403 kB
pack.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax game Skin Pack (Stoopshake: the Dice & Table Pack): a one-time cosmetic unlock for ONE game (separate from the Fun Pass, which covers every
2
+ // game's archive + themes). Buy: counted redirect data.cybermaxtools.com/buy/st-<game>-skins-pack?s=game-<game>
3
+ // -> Stripe Payment Link; or the Gumroad listing (licence key). After checkout data.cybermaxtools.com/unlock/<game>-skins
4
+ // checks the order (or the Gumroad licence key) and returns to the game with ?unlock=skins#cmx-pro=<code>.
5
+ // capture() must run BEFORE funpass.init() so the Fun Pass never mistakes a skin-pack code for a pass code.
6
+ // Cosmetic only: skins never change the game, scores or the daily course. (c) 2026 CyberMax.
7
+ import { count, store, toast } from './cmx.js';
8
+
9
+ const DATA = 'https://data.cybermaxtools.com';
10
+ const CODE = /^(cmxk1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i;
11
+ let cfg = { game: '', name: '', price: '$1.99', gumroad: '', skins: 0 };
12
+ let state = null;
13
+ const subs = [];
14
+ const key = () => `cmx-skins:${cfg.game}`;
15
+
16
+ export const owned = () => !!(state && state.code);
17
+ export function onChange(fn) { subs.push(fn); }
18
+ const emit = () => subs.forEach((f) => { try { f(owned()); } catch { /* game hook */ } });
19
+
20
+ // Offline Pro keys (17 Oct 2026): "cmxk1.<payload>.<Ed25519 signature>" from data.cybermaxtools.com/unlock is verified here
21
+ // with WebCrypto and this public key, so an unlock needs no Worker call. Expired, foreign-signed or unverifiable keys fall
22
+ // back to the online check of the order code inside them, as before.
23
+ const CMX_PUB = 'u0FDxbu0d9B9Sz5-yLT6AuL5IagyTlFcbBgEDqZxWtk';
24
+ const b64d = (s) => { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); };
25
+ async function cmxKeyOk(k, app, online) {
26
+ const [tag, body, sig] = k.split('.');
27
+ let p;
28
+ try { p = JSON.parse(new TextDecoder().decode(b64d(body))); } catch { return false; }
29
+ if (!p || p.a !== app) return false;
30
+ try {
31
+ const key = await crypto.subtle.importKey('raw', b64d(CMX_PUB), { name: 'Ed25519' }, false, ['verify']);
32
+ if (await crypto.subtle.verify({ name: 'Ed25519' }, key, b64d(sig), new TextEncoder().encode(tag + '.' + body)) && (!p.x || p.x * 1000 > Date.now())) return true;
33
+ } catch { /* no Ed25519 here: online check below */ }
34
+ return p.c ? online(p.c) : false;
35
+ }
36
+ async function check(code) {
37
+ if (/^cmxk1\./.test(code)) return cmxKeyOk(code, `${cfg.game}-skins`, checkOnline);
38
+ return checkOnline(code);
39
+ }
40
+ async function checkOnline(code) {
41
+ try {
42
+ const r = await fetch(`${DATA}/unlock/${cfg.game}-skins?json=1&session_id=${encodeURIComponent(code)}`);
43
+ const j = await r.json();
44
+ return j && typeof j.pro === 'boolean' ? j.pro : null;
45
+ } catch { return null; }
46
+ }
47
+ export async function unlock(code) {
48
+ const ok = await check(code);
49
+ if (ok) {
50
+ state = store(key(), { code, at: new Date().toISOString() });
51
+ count(cfg.game, 'pro', 'skins-unlocked');
52
+ toast(`${cfg.name} Dice & Table Pack is on. New dice, tables and win emotes are yours!`, 3500);
53
+ emit(); render();
54
+ } else if (ok === false) toast('That code is not a Dice & Table Pack order for this game.', 3500);
55
+ else toast('Could not reach the unlock check. Try again in a minute.', 3500);
56
+ return ok;
57
+ }
58
+
59
+ /** Call first: game id, display name, skin count, Gumroad URL. Reads ?unlock=skins#cmx-pro=<code> after checkout. */
60
+ export function capture(options) {
61
+ cfg = { ...cfg, ...options };
62
+ state = store(key());
63
+ const q = new URLSearchParams(location.search);
64
+ const m = /[#&]cmx-key=([^&]+)/.exec(location.hash || '') || /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
65
+ if (q.get('unlock') === 'skins' && m) {
66
+ const code = decodeURIComponent(m[1]);
67
+ q.delete('unlock');
68
+ history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
69
+ if (CODE.test(code)) unlock(code);
70
+ }
71
+ }
72
+
73
+ const buyUrl = (from) => `${DATA}/buy/st-${cfg.game}-skins-pack?s=game-${cfg.game}${from ? `-${from}` : ''}`;
74
+ function dialog() {
75
+ let d = document.getElementById('skinpack');
76
+ if (!d) {
77
+ d = document.createElement('dialog'); d.id = 'skinpack'; d.className = 'funpass';
78
+ document.body.appendChild(d);
79
+ d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
80
+ }
81
+ return d;
82
+ }
83
+ function render() {
84
+ const d = dialog();
85
+ const prev = (cfg.preview || []).map((s) => `<span class="sp-dot" style="background:${s}"></span>`).join('');
86
+ if (owned()) {
87
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${cfg.name} Dice & Table Pack</p><h2>Your Dice & Table Pack is on. Thank you!</h2>
88
+ <p>Pick dice, tables and emotes under STYLE on the home screen.</p><p class="fp-small">Your code (paste it on another device):</p><code class="fp-code">${state.code}</code></div>`;
89
+ return;
90
+ }
91
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${cfg.name} Dice & Table Pack</p>
92
+ <h2>${cfg.title || 'Dice & Table Pack'}, ${cfg.price} once</h2><p class="sp-row">${prev}</p>
93
+ <ul class="fp-perks">${(cfg.perks || []).map((p) => `<li>${p}</li>`).join('')}<li>Cosmetic only: same dice odds and chips for everyone</li><li>Pay once, keep forever, on any device with your code</li></ul>
94
+ <p class="fp-row"><a class="btn pass" href="${buyUrl('')}" rel="nofollow" data-buy>Get the Dice & Table Pack: ${cfg.price}</a>${cfg.gumroad ? ` <a class="btn ghost sm" href="${cfg.gumroad}" rel="nofollow noopener" target="_blank" data-gum>or on Gumroad</a>` : ''}</p>
95
+ <p class="fp-small">Secure Stripe checkout · the skins turn on as soon as you pay · 14-day promise: if it doesn't work as described, we fix it or refund you (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). The <b>Fun Pass</b> is a separate pass for every game's archive and themes.</p>
96
+ <details><summary>Already bought it?</summary><form class="fp-form"><input aria-label="Order code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details></div>`;
97
+ d.querySelector('.fp-form').addEventListener('submit', (e) => {
98
+ e.preventDefault();
99
+ const c = e.target.querySelector('input').value.trim();
100
+ if (CODE.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
101
+ });
102
+ d.querySelector('[data-buy]').addEventListener('click', () => count(cfg.game, 'cta', 'skins-buy'));
103
+ d.querySelector('[data-gum]')?.addEventListener('click', () => count(cfg.game, 'cta', 'skins-gumroad'));
104
+ }
105
+ export function open(from = 'button') {
106
+ render();
107
+ const d = dialog();
108
+ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
109
+ count(cfg.game, 'cta', `skins-open-${from}`);
110
+ }
111
+
112
+ // Direct checkout (17 Oct 2026, Workers cap fix): a click on a data.cybermaxtools.com/buy/<offer> link goes straight to the
113
+ // Stripe/Polar checkout (static map https://cybermaxtools.com/store/buy-direct.json); the click is counted by a beacon.
114
+ (() => {
115
+ if (typeof document === 'undefined' || window.__cmxBd) return;
116
+ window.__cmxBd = 1;
117
+ let MAP = null;
118
+ try { MAP = JSON.parse(sessionStorage.getItem('cmx-bd') || 'null'); } catch { /* none */ }
119
+ if (!MAP) fetch('https://cybermaxtools.com/store/buy-direct.json').then((r) => r.json()).then((j) => { MAP = j; try { sessionStorage.setItem('cmx-bd', JSON.stringify(j)); } catch { /* full */ } }).catch(() => {});
120
+ document.addEventListener('click', (e) => {
121
+ const a = e.target && e.target.closest && e.target.closest('a[href*="data.cybermaxtools.com/buy/"]');
122
+ if (!a || !MAP || !MAP.offers) return;
123
+ try {
124
+ const u = new URL(a.href);
125
+ const k = /^\/buy\/([a-z0-9-]+)\/?$/.exec(u.pathname);
126
+ const t = k && MAP.offers[k[1]];
127
+ if (!t) return;
128
+ const s = String(u.searchParams.get('s') || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 80) || 'unknown';
129
+ a.href = t.split(MAP.ph).join(s);
130
+ if (!navigator.webdriver) navigator.sendBeacon(u.href + (u.search ? '&' : '?') + 'b=1');
131
+ } catch { /* keep the counted link */ }
132
+ }, true);
133
+ })();
rules.js ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stoopshake rules engine: classic Cee-lo with three dice, plus the free-chip economy, ranks, bots, the daily table and
2
+ // the async challenge link. Pure (no DOM, no clock unless passed, no Math.random unless no rng is passed), so the unit tests
3
+ // cover every outcome and the Telegram table (data Worker src/tgdice.js) scores rolls the same way.
4
+ // Rules (shown on first open):
5
+ // 4-5-6 wins on the spot, 1-2-3 loses on the spot; trips beat any point (higher trips win); a pair plus a single sets the
6
+ // point (the single die is the point, 1-6); anything else is no score: roll again.
7
+ // Chips are free play chips: daily refill, earned by playing, never bought, sold, traded or cashed out. No prizes.
8
+ // (c) 2026 CyberMax. All rights reserved.
9
+
10
+ export const FACES = ['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'];
11
+ export const face = (n) => FACES[n - 1] || '?';
12
+
13
+ /** Score three dice. Returns { kind: '456'|'123'|'trips'|'point'|'none', value, rank, instant, label, short }. */
14
+ export function score(dice) {
15
+ if (!Array.isArray(dice) || dice.length !== 3 || dice.some((d) => !Number.isInteger(d) || d < 1 || d > 6)) throw new Error('three dice 1-6');
16
+ const [a, b, c] = [...dice].sort((x, y) => x - y);
17
+ if (a === 4 && b === 5 && c === 6) return { kind: '456', value: 6, rank: 100, instant: 'win', label: '4-5-6! Instant win', short: '4-5-6' };
18
+ if (a === 1 && b === 2 && c === 3) return { kind: '123', value: 1, rank: -100, instant: 'loss', label: '1-2-3. Instant loss', short: '1-2-3' };
19
+ if (a === b && b === c) return { kind: 'trips', value: a, rank: 50 + a, instant: null, label: `Trip ${a}s`, short: `Trips ${a}` };
20
+ if (a === b || b === c) {
21
+ const single = a === b ? c : a;
22
+ return { kind: 'point', value: single, rank: 10 + single, instant: null, label: `Point ${single}`, short: `Point ${single}` };
23
+ }
24
+ return { kind: 'none', value: 0, rank: null, instant: null, label: 'No score. Roll again', short: 'No score' };
25
+ }
26
+ export const scores = (s) => s.kind !== 'none';
27
+
28
+ /** -1 / 0 / 1: does score a beat score b? (both must be scoring rolls) */
29
+ export function compare(a, b) { return Math.sign(a.rank - b.rank); }
30
+
31
+ /** Table (2-6 seats, everyone antes the same stake). results: [{ seat, score }] in roll order; a 4-5-6 ends the round at once.
32
+ * Returns { winners: [seat], instant: bool, out: [seat] (1-2-3) }. Several winners = a roll-off between them. */
33
+ export function tableWinners(results) {
34
+ const inst = results.find((r) => r.score.kind === '456');
35
+ if (inst) return { winners: [inst.seat], instant: true, out: results.filter((r) => r.score.kind === '123').map((r) => r.seat) };
36
+ const live = results.filter((r) => r.score.kind !== '123' && r.score.kind !== 'none');
37
+ const out = results.filter((r) => r.score.kind === '123').map((r) => r.seat);
38
+ if (!live.length) return { winners: results.map((r) => r.seat), instant: false, out: [] }; // everyone rolled 1-2-3: all roll again
39
+ const best = Math.max(...live.map((r) => r.score.rank));
40
+ return { winners: live.filter((r) => r.score.rank === best).map((r) => r.seat), instant: false, out };
41
+ }
42
+
43
+ /** Banker mode, from the player's side: 'win' | 'lose' | 'push'. bank = banker's scoring roll; player = player's (or null if
44
+ * the banker's roll already settled every bet: 4-5-6 = bank takes all, 1-2-3 = bank pays all). */
45
+ export function bankerSettle(bank, player) {
46
+ if (bank.kind === '456') return 'lose';
47
+ if (bank.kind === '123') return 'win';
48
+ if (!player) throw new Error('player roll needed');
49
+ if (player.kind === '456') return 'win';
50
+ if (player.kind === '123') return 'lose';
51
+ const c = compare(player, bank);
52
+ return c > 0 ? 'win' : c < 0 ? 'lose' : 'push';
53
+ }
54
+ /** The bank passes to a player who beat it with 4-5-6 or trips (classic rule); else the banker keeps it. */
55
+ export const takesBank = (bank, player, outcome) => outcome === 'win' && player && (player.kind === '456' || player.kind === 'trips');
56
+
57
+ /** Quick 1v1: the first roller's instant result ends it; else both roll and compare (tie = shoot-off). */
58
+ export function duel(first, second) {
59
+ if (first.kind === '456') return { winner: 0, instant: true };
60
+ if (first.kind === '123') return { winner: 1, instant: true };
61
+ if (!second) return { winner: null, instant: false };
62
+ if (second.kind === '456') return { winner: 1, instant: true };
63
+ if (second.kind === '123') return { winner: 0, instant: true };
64
+ const c = compare(first, second);
65
+ return { winner: c > 0 ? 0 : c < 0 ? 1 : null, instant: false, tie: c === 0 };
66
+ }
67
+
68
+ // ------------------------------------------------------------------ free chips (never bought, sold or cashed out)
69
+ export const START_CHIPS = 500;
70
+ export const DAILY_FLOOR = 500; // each new day tops you back up to at least this
71
+ export const DAILY_BONUS = 100; // + this for showing up (streak days add a little more, capped)
72
+ export const STAKES = [10, 25, 50, 100, 250];
73
+
74
+ export function newWallet(day) { return { chips: START_CHIPS, day, streakDays: 1, bestStreak: 0, winStreak: 0, rep: 0, played: 0, won: 0, best: null, big: { r456: 0, trips: 0, r123: 0 } }; }
75
+ /** Daily refill: on a new day, top up to DAILY_FLOOR and add the visit bonus. Returns { wallet, gift } (gift = chips added). */
76
+ export function dailyRefill(w, day) {
77
+ if (!w) return { wallet: newWallet(day), gift: START_CHIPS };
78
+ if (w.day === day) return { wallet: w, gift: 0 };
79
+ const streakDays = w.day === day - 1 ? Math.min(30, (w.streakDays || 1) + 1) : 1;
80
+ const bonus = DAILY_BONUS + Math.min(10, streakDays - 1) * 10;
81
+ const chips = Math.max(w.chips, DAILY_FLOOR) + bonus;
82
+ return { wallet: { ...w, day, streakDays, chips }, gift: chips - w.chips };
83
+ }
84
+ /** Settle one round for the local player: delta chips (+ or -), rep earned, streak. */
85
+ export function settle(w, { delta, sc, won }) {
86
+ const out = { ...w, big: { ...w.big } };
87
+ out.chips = Math.max(0, w.chips + delta);
88
+ out.played += 1;
89
+ if (won) { out.won += 1; out.winStreak = (w.winStreak || 0) + 1; out.bestStreak = Math.max(w.bestStreak || 0, out.winStreak); } else if (won === false) out.winStreak = 0;
90
+ let rep = won ? 10 : 2;
91
+ if (sc && sc.kind === '456') { rep += 5; out.big.r456 += 1; }
92
+ if (sc && sc.kind === 'trips') { rep += 3 + sc.value; out.big.trips += 1; }
93
+ if (sc && sc.kind === '123') out.big.r123 += 1;
94
+ out.rep = (w.rep || 0) + rep;
95
+ if (sc && scores(sc) && (!w.best || sc.rank > w.best.rank)) out.best = { kind: sc.kind, value: sc.value, rank: sc.rank, short: sc.short };
96
+ return out;
97
+ }
98
+
99
+ export const RANKS = [
100
+ { id: 'rookie', name: 'Rookie', at: 0 }, { id: 'regular', name: 'Regular', at: 100 }, { id: 'shooter', name: 'Shooter', at: 400 },
101
+ { id: 'captain', name: 'Captain', at: 1200 }, { id: 'champ', name: 'Champ', at: 3000 }, { id: 'legend', name: 'Legend', at: 7500 },
102
+ ];
103
+ export function rankOf(rep = 0) {
104
+ let i = 0;
105
+ for (let k = 0; k < RANKS.length; k++) if (rep >= RANKS[k].at) i = k;
106
+ const next = RANKS[i + 1];
107
+ return { ...RANKS[i], index: i, next: next || null, toNext: next ? next.at - rep : 0, pct: next ? (rep - RANKS[i].at) / (next.at - RANKS[i].at) : 1 };
108
+ }
109
+
110
+ // ------------------------------------------------------------------ bots with personalities (friendly trash talk, 13+)
111
+ export const BOTS = [
112
+ { id: 'dot', name: 'Ms. Dot', vibe: 'the block grandma', power: 0.55, risk: 0.3, color: '#F4B6C2',
113
+ lines: { hi: ['Sit down, baby. Let Ms. Dot show you.', 'I was shaking dice before your phone was born.'], win: ['Mm-hm. Grandma still got it.', 'Go get you some practice, sugar.'], lose: ['Beginner\'s luck. I\'ll allow it.', 'Okay, okay. Run it back.'], big: ['That\'s how we did it back in my day.', 'Now THAT is a roll.'] } },
114
+ { id: 'pip', name: 'Lil Pip', vibe: 'the showboat', power: 0.95, risk: 0.8, color: '#7CF7C8',
115
+ lines: { hi: ['Cameras out. Lil Pip in the building.', 'Hope you stretched.'], win: ['Too easy. Somebody clip that.', 'Light work.'], lose: ['Nah, the curb moved.', 'I let you have that one.'], big: ['SHEEESH. Did you see that?', 'Put it on the big screen.'] } },
116
+ { id: 'prof', name: 'The Professor', vibe: 'calm, does the maths', power: 0.7, risk: 0.45, color: '#9EC5FF',
117
+ lines: { hi: ['Probability is on nobody\'s side. Shall we?', 'Three dice, 216 outcomes. Only one of them is mine.'], win: ['As calculated.', 'The numbers were kind.'], lose: ['Variance. Noted.', 'A fair result. Again?'], big: ['Fascinating.', 'A 1 in 36 shot. Lovely.'] } },
118
+ { id: 'nova', name: 'Nova', vibe: 'cool, quiet, dangerous', power: 0.8, risk: 0.6, color: '#C9A2FF',
119
+ lines: { hi: ['...', 'Your roll.'], win: ['Mm.', 'Again.'], lose: ['Fine.', 'Respect.'], big: ['Ice cold.', 'Yeah.'] } },
120
+ { id: 'ace', name: 'Big Ace', vibe: 'loud, loves the game', power: 1.0, risk: 0.7, color: '#FFC56B',
121
+ lines: { hi: ['WHO\'S NEXT?! Big Ace on the stoop!', 'Bring the whole block!'], win: ['THAT\'S WHAT I\'M TALKIN\' ABOUT!', 'Run it back, run it back!'], lose: ['AW, COME ON!', 'Okay, you got me. ONE more.'], big: ['OHHHH! THE BLOCK IS LOUD TONIGHT!', 'Somebody call the whole street!'] } },
122
+ { id: 'kiko', name: 'Kiko', vibe: 'new on the block, hyped', power: 0.65, risk: 0.5, color: '#FF8FA3',
123
+ lines: { hi: ['Wait, how does the point work again? Kidding. Let\'s go!', 'First time on this stoop. Be nice.'], win: ['I WON? I WON!', 'Ha! Ranked up!'], lose: ['Next one\'s mine.', 'Learning, learning.'], big: ['NO WAY!', 'Did that just happen?!'] } },
124
+ ];
125
+ export const botById = (id) => BOTS.find((b) => b.id === id);
126
+ export function line(bot, mood, rnd = Math.random) { const l = bot.lines[mood] || bot.lines.hi; return l[Math.floor(rnd() * l.length) % l.length]; }
127
+ /** A bot's stake choice for the Table (pot) or as a Banker player. */
128
+ export function botStake(bot, wallet, base, rnd = Math.random) {
129
+ const opts = STAKES.filter((s) => s <= Math.max(10, wallet));
130
+ const pick = Math.min(opts.length - 1, Math.max(0, Math.round((bot.risk + (rnd() - 0.5) * 0.4) * (opts.length - 1))));
131
+ return Math.max(base || 10, opts[pick] || 10);
132
+ }
133
+
134
+ // ------------------------------------------------------------------ the daily table (same lineup and goal for everyone)
135
+ export const EPOCH = Date.UTC(2026, 9, 2); // CyberMax daily #1 = 2 Oct 2026 (shared with every CyberMax game)
136
+ export const dayNumber = (now = Date.now()) => Math.floor((now - EPOCH) / 864e5) + 1;
137
+ 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; }; }
138
+ export const rng = mulberry;
139
+ export const DAILY_ROUNDS = 5;
140
+ export function dailyTable(day) {
141
+ const r = mulberry(day * 7919 + 17);
142
+ const pool = [...BOTS];
143
+ const lineup = [];
144
+ while (lineup.length < 3) lineup.push(pool.splice(Math.floor(r() * pool.length), 1)[0].id);
145
+ const scenes = ['corner', 'corner', 'stoop', 'rooftop', 'arcade', 'court', 'boardwalk'];
146
+ return { day, lineup, stake: [25, 50, 50, 100][Math.floor(r() * 4)], rounds: DAILY_ROUNDS, scene: scenes[day % scenes.length], label: `Daily Table #${day}` };
147
+ }
148
+ /** Spoiler-free share text for a finished daily (Wordle-style): one emoji per round. */
149
+ export function dailyCard(day, rounds, net) {
150
+ const em = rounds.map((x) => (x.sc && x.sc.kind === '456' ? '🔥' : x.sc && x.sc.kind === 'trips' ? '👑' : x.sc && x.sc.kind === '123' ? '💀' : x.won ? '✅' : '❌')).join('');
151
+ return `Stoopshake Daily #${day} 🎲\n${em} ${net >= 0 ? '+' : ''}${net} chips\nNo real money. Just for fun.`;
152
+ }
153
+
154
+ // ------------------------------------------------------------------ async challenge links (state in the URL, no server)
155
+ // { v, n: [nameA, nameB], s: [winsA, winsB], k: round, t: whose turn (0/1), d: last roll [x,y,z] or null, w: first to }
156
+ const b64u = (s) => (typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(s))) : Buffer.from(s, 'utf8').toString('base64')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
157
+ const unb64u = (s) => { const t = s.replace(/-/g, '+').replace(/_/g, '/'); return typeof atob === 'function' ? decodeURIComponent(escape(atob(t))) : Buffer.from(t, 'base64').toString('utf8'); };
158
+ function sum(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36).slice(-4); }
159
+ export const cleanName = (s) => String(s || '').replace(/[^\p{L}\p{N} ._-]/gu, '').replace(/\s+/g, ' ').trim().slice(0, 14) || 'Player';
160
+ export function encodeChallenge(st) {
161
+ const o = { v: 1, n: st.n.map(cleanName), s: st.s.map((x) => Math.max(0, Math.min(9, x | 0))), k: Math.max(1, st.k | 0), t: st.t ? 1 : 0, d: st.d || null, w: st.w || 3 };
162
+ const j = JSON.stringify(o);
163
+ return `${b64u(j)}.${sum(j)}`;
164
+ }
165
+ export function decodeChallenge(code) {
166
+ try {
167
+ const [p, c] = String(code || '').split('.');
168
+ const j = unb64u(p);
169
+ if (sum(j) !== c) return null;
170
+ const o = JSON.parse(j);
171
+ if (o.v !== 1 || !Array.isArray(o.n) || o.n.length !== 2 || !Array.isArray(o.s)) return null;
172
+ if (o.d && (!Array.isArray(o.d) || o.d.length !== 3 || o.d.some((x) => !Number.isInteger(x) || x < 1 || x > 6))) return null;
173
+ return { n: o.n.map(cleanName), s: o.s.map((x) => x | 0), k: o.k | 0, t: o.t ? 1 : 0, d: o.d, w: o.w || 3 };
174
+ } catch { return null; }
175
+ }
176
+ /** Apply one async turn: the player whose turn it is rolled `dice` (a scoring roll). Returns the next state + what happened.
177
+ * Round flow: A rolls (d stored), B rolls and the round is decided against A's stored roll; then the loser of the round
178
+ * starts the next one. A 4-5-6 or 1-2-3 by the first roller decides the round at once. Ties replay the round. */
179
+ export function challengeTurn(st, dice) {
180
+ const me = st.t; const sc = score(dice);
181
+ if (!scores(sc)) throw new Error('scoring roll needed');
182
+ const next = { ...st, s: [...st.s] };
183
+ if (!st.d) { // first roller of this round
184
+ if (sc.instant) { const w = sc.instant === 'win' ? me : 1 - me; next.s[w] += 1; next.k += 1; next.d = null; next.t = 1 - w; return { st: next, sc, round: { winner: w, instant: true } }; }
185
+ next.d = dice; next.t = 1 - me; return { st: next, sc, round: null };
186
+ }
187
+ const first = score(st.d);
188
+ const r = duel(first, sc); // first = the other player
189
+ next.d = null;
190
+ if (r.winner === null) { next.t = 1 - me; return { st: next, sc, round: { winner: null, tie: true } }; } // tie: the other player rolls first again
191
+ const w = r.winner === 0 ? 1 - me : me;
192
+ next.s[w] += 1; next.k += 1; next.t = 1 - w;
193
+ return { st: next, sc, round: { winner: w } };
194
+ }
195
+ export const seriesOver = (st) => (st.s[0] >= st.w ? 0 : st.s[1] >= st.w ? 1 : null);
196
+
197
+ // ------------------------------------------------------------------ region board (opt-in, coarse, never precise)
198
+ /** Coarse region from what the browser already reports (time zone + language). Never GPS. e.g. "New York · US". */
199
+ export function regionOf(tz, lang) {
200
+ const t = String(tz || '');
201
+ const city = (t.split('/').pop() || '').replace(/_/g, ' ').replace(/[^A-Za-z .-]/g, '').slice(0, 24);
202
+ const cc = (/[-_]([A-Za-z]{2})$/.exec(String(lang || '')) || [])[1];
203
+ const area = t.split('/')[0] || '';
204
+ const label = city ? `${city}${cc ? ` · ${cc.toUpperCase()}` : ''}` : (cc ? cc.toUpperCase() : 'Earth');
205
+ return { id: `${area}/${city}`.toLowerCase().replace(/[^a-z/ .-]/g, '').slice(0, 40) || 'earth', label, cc: cc ? cc.toUpperCase() : '' };
206
+ }
shot-phone.png ADDED

Git LFS Details

  • SHA256: d73031905e4fb33908d2ae4feb4d576761185f9aaa08b23342cf1fe339c0a13c
  • Pointer size: 131 Bytes
  • Size of remote file: 476 kB
stage3d.js ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax 3D stage: the shared Three.js (r170, ./vendor, MIT) look for CyberMax games, built from Soaklark 3D's
2
+ // renderer patterns. Toon materials with a rim light, HDR scene target with MSAA, two-level bloom, ACES tone map,
3
+ // colour grading, vignette, hit flash, chromatic kick on impacts, camera shake (trauma), a GPU-light particle pool,
4
+ // gradient sky domes and adaptive resolution. Each game builds its own scene with it and keeps its rules in core.js;
5
+ // the game's 2D canvas renderer stays as the no-WebGL2 fallback. Copied into each 3D game by scripts/games/sync_kit.py.
6
+ // (c) 2026 CyberMax. All rights reserved.
7
+ import * as THREE from 'three';
8
+
9
+ export { THREE };
10
+ export const col = (c) => new THREE.Color(c);
11
+ export const clamp01 = (t) => Math.max(0, Math.min(1, t));
12
+ export const lerp = (a, b, t) => a + (b - a) * t;
13
+ export const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
14
+ export const damp = (a, b, k, dt) => b + (a - b) * Math.exp(-k * dt);
15
+
16
+ /** True when this browser can run the 3D stage (WebGL2). `?2d=1` forces the 2D renderer. */
17
+ export function canRun3d() {
18
+ try {
19
+ if (/[?&]2d=1/.test(location.search)) return false;
20
+ const c = document.createElement('canvas');
21
+ return !!c.getContext('webgl2');
22
+ } catch { return false; }
23
+ }
24
+
25
+ const NOISE = `
26
+ float h21(vec2 p){ p = fract(p*vec2(123.34, 456.21)); p += dot(p, p+45.32); return fract(p.x*p.y); }
27
+ float vnoise(vec2 p){ vec2 i=floor(p); vec2 f=fract(p); f=f*f*(3.-2.*f);
28
+ return mix(mix(h21(i),h21(i+vec2(1,0)),f.x), mix(h21(i+vec2(0,1)),h21(i+vec2(1,1)),f.x), f.y); }`;
29
+ const FSQ_V = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }';
30
+ const BRIGHT_F = `uniform sampler2D t; uniform float uTh; uniform vec2 uPx; varying vec2 vUv;
31
+ void main(){ vec3 c = vec3(0.);
32
+ c += texture2D(t, vUv + uPx*vec2(-1.,-1.)).rgb; c += texture2D(t, vUv + uPx*vec2(1.,-1.)).rgb;
33
+ c += texture2D(t, vUv + uPx*vec2(-1.,1.)).rgb; c += texture2D(t, vUv + uPx*vec2(1.,1.)).rgb; c *= 0.25;
34
+ float l = max(c.r, max(c.g, c.b)); float k = smoothstep(uTh, uTh + 0.8, l); gl_FragColor = vec4(min(c * k, vec3(24.)), 1.); }`;
35
+ const BLUR_F = `uniform sampler2D t; uniform vec2 uDir; varying vec2 vUv;
36
+ void main(){ vec3 c = texture2D(t, vUv).rgb * 0.227027;
37
+ c += texture2D(t, vUv + uDir*1.3846).rgb * 0.3162162; c += texture2D(t, vUv - uDir*1.3846).rgb * 0.3162162;
38
+ c += texture2D(t, vUv + uDir*3.2308).rgb * 0.0702703; c += texture2D(t, vUv - uDir*3.2308).rgb * 0.0702703;
39
+ gl_FragColor = vec4(c, 1.); }`;
40
+ const COMP_F = `uniform sampler2D tScene, tB1, tB2; uniform float uBloom, uExp, uSat, uCon, uVig, uTime, uFlash, uLdr, uAb, uGrain;
41
+ uniform vec3 uLift, uGain, uFlashCol; varying vec2 vUv; ${NOISE}
42
+ vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0., 1.); }
43
+ void main(){
44
+ vec2 uv = vUv; vec2 d = uv - 0.5;
45
+ vec3 c;
46
+ if (uAb > 0.0005) { vec2 o = d * uAb; c = vec3(texture2D(tScene, uv + o).r, texture2D(tScene, uv).g, texture2D(tScene, uv - o).b); }
47
+ else c = texture2D(tScene, uv).rgb;
48
+ c += (texture2D(tB1, uv).rgb * 0.75 + texture2D(tB2, uv).rgb * 1.0) * uBloom;
49
+ c *= uExp;
50
+ c = mix(aces(c), clamp(c, 0., 1.), uLdr);
51
+ c = pow(c, vec3(1./2.2));
52
+ float l = dot(c, vec3(0.299, 0.587, 0.114)); c = mix(vec3(l), c, uSat);
53
+ c = (c - 0.5) * uCon + 0.5; c = c * uGain + uLift * (1. - c);
54
+ c *= 1. - uVig * dot(d, d) * 1.7;
55
+ c += (h21(uv * 913.7 + fract(uTime * 7.31)) - 0.5) * uGrain;
56
+ c = mix(c, uFlashCol, uFlash);
57
+ gl_FragColor = vec4(clamp(c, 0., 1.), 1.);
58
+ }`;
59
+ const SKY_V = 'varying vec3 vP; void main(){ vP = normalize(position); vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.); gl_Position = p.xyww; }';
60
+ const SKY_F = `uniform vec3 uTop, uHor, uLow, uGlowCol; uniform vec3 uGlowDir; uniform float uStars, uTime, uGlow, uCurve; varying vec3 vP; ${NOISE}
61
+ void main(){ vec3 p = normalize(vP); float y = p.y;
62
+ vec3 c = y > 0. ? mix(uHor, uTop, pow(y, uCurve)) : mix(uHor, uLow, pow(-y, uCurve * 0.85));
63
+ float s = max(dot(p, normalize(uGlowDir)), 0.); c += uGlowCol * (pow(s, 8.) * 0.6 + pow(s, 90.) * 2.5) * uGlow;
64
+ if (uStars > 0.) { vec2 g = vec2(atan(p.z, p.x) * 60., p.y * 80.); vec2 id = floor(g); float r = h21(id);
65
+ float st = step(0.975, r) * smoothstep(0.45, 0., length(fract(g) - 0.5)); c += vec3(st) * (0.55 + 0.45 * sin(uTime * 2. + r * 40.)) * uStars * smoothstep(-0.1, 0.3, y) * 2.2; }
66
+ gl_FragColor = vec4(c, 1.); }`;
67
+ const PART_V = `attribute vec3 aCol; attribute float aSize; attribute float aA; varying vec3 vC; varying float vA; uniform float uScale;
68
+ void main(){ vC = aCol; vA = aA; vec4 mv = modelViewMatrix * vec4(position, 1.); gl_Position = projectionMatrix * mv; gl_PointSize = aSize * uScale / max(-mv.z, 0.1); }`;
69
+ const PART_F = `varying vec3 vC; varying float vA; void main(){ vec2 q = gl_PointCoord - 0.5; float d = length(q); if (d > 0.5) discard;
70
+ float k = smoothstep(0.5, 0.0, d); gl_FragColor = vec4(vC * (0.6 + 1.6 * k * k), vA * k); }`;
71
+
72
+ /** A pool of glowing particles (sparks, dust, confetti), updated on the CPU and drawn as one additive point cloud. */
73
+ class Particles {
74
+ constructor(n = 1600) {
75
+ this.n = n; this.i = 0; this.live = new Float32Array(n * 10); // x y z vx vy vz life max size drag
76
+ this.cr = new Float32Array(n * 3);
77
+ const g = new THREE.BufferGeometry();
78
+ this.pos = new THREE.Float32BufferAttribute(new Float32Array(n * 3), 3); this.pos.setUsage(THREE.DynamicDrawUsage);
79
+ this.colA = new THREE.Float32BufferAttribute(new Float32Array(n * 3), 3); this.colA.setUsage(THREE.DynamicDrawUsage);
80
+ this.size = new THREE.Float32BufferAttribute(new Float32Array(n), 1); this.size.setUsage(THREE.DynamicDrawUsage);
81
+ this.al = new THREE.Float32BufferAttribute(new Float32Array(n), 1); this.al.setUsage(THREE.DynamicDrawUsage);
82
+ g.setAttribute('position', this.pos); g.setAttribute('aCol', this.colA); g.setAttribute('aSize', this.size); g.setAttribute('aA', this.al);
83
+ g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
84
+ this.U = { uScale: { value: 300 } };
85
+ this.mesh = new THREE.Points(g, new THREE.ShaderMaterial({ vertexShader: PART_V, fragmentShader: PART_F, uniforms: this.U, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
86
+ this.mesh.frustumCulled = false; this.mesh.renderOrder = 10;
87
+ this.grav = 0;
88
+ }
89
+ /** Burst `n` particles at (x,y,z). colors: CSS colours; speed in world units/s; life in s; up: extra upward kick. */
90
+ burst(x, y, z, { n = 30, colors = ['#FFFFFF'], speed = 30, life = 0.8, size = 3, drag = 2.2, up = 0, spread = 1, hdr = 2.2, dir = null } = {}) {
91
+ const cs = colors.map((c) => col(c).multiplyScalar(hdr));
92
+ for (let k = 0; k < n; k++) {
93
+ const i = this.i; this.i = (this.i + 1) % this.n; const o = i * 10;
94
+ let vx = Math.random() * 2 - 1, vy = Math.random() * 2 - 1, vz = Math.random() * 2 - 1; const l = Math.hypot(vx, vy, vz) || 1;
95
+ const s = speed * (0.35 + Math.random() * 0.65) * spread;
96
+ vx = vx / l * s; vy = vy / l * s + up; vz = vz / l * s;
97
+ if (dir) { vx += dir[0]; vy += dir[1]; vz += dir[2]; }
98
+ const L = life * (0.6 + Math.random() * 0.6);
99
+ this.live.set([x, y, z, vx, vy, vz, L, L, size * (0.6 + Math.random() * 0.8), drag], o);
100
+ const c = cs[k % cs.length]; this.cr[i * 3] = c.r; this.cr[i * 3 + 1] = c.g; this.cr[i * 3 + 2] = c.b;
101
+ }
102
+ }
103
+ update(dt) {
104
+ const P = this.pos.array, C = this.colA.array, S = this.size.array, A = this.al.array, L = this.live;
105
+ for (let i = 0; i < this.n; i++) {
106
+ const o = i * 10;
107
+ if (L[o + 6] <= 0) { A[i] = 0; S[i] = 0; continue; }
108
+ L[o + 6] -= dt; const f = Math.exp(-L[o + 9] * dt);
109
+ L[o + 3] *= f; L[o + 4] = L[o + 4] * f - this.grav * dt; L[o + 5] *= f;
110
+ L[o] += L[o + 3] * dt; L[o + 1] += L[o + 4] * dt; L[o + 2] += L[o + 5] * dt;
111
+ const t = Math.max(0, L[o + 6] / L[o + 7]);
112
+ P[i * 3] = L[o]; P[i * 3 + 1] = L[o + 1]; P[i * 3 + 2] = L[o + 2];
113
+ C[i * 3] = this.cr[i * 3]; C[i * 3 + 1] = this.cr[i * 3 + 1]; C[i * 3 + 2] = this.cr[i * 3 + 2];
114
+ S[i] = L[o + 8] * (0.4 + 0.6 * t); A[i] = Math.min(1, t * 1.6);
115
+ }
116
+ this.pos.needsUpdate = this.colA.needsUpdate = this.size.needsUpdate = this.al.needsUpdate = true;
117
+ }
118
+ clear() { this.live.fill(0); }
119
+ }
120
+
121
+ // A tube bent along an arc in the vertex shader, so one geometry serves every blade/zone/progress length.
122
+ export function arcGeo(seg = 72, around = 8, flat = false) {
123
+ const pos = [], idx = [];
124
+ for (let i = 0; i <= seg; i++) for (let j = 0; j <= around; j++) {
125
+ const v = (j / around) * Math.PI * 2;
126
+ pos.push(i / seg, flat ? (j / around) * 2 - 1 : Math.cos(v), flat ? 0 : Math.sin(v));
127
+ }
128
+ for (let i = 0; i < seg; i++) for (let j = 0; j < around; j++) {
129
+ const a = i * (around + 1) + j, b = a + around + 1;
130
+ idx.push(a, b, a + 1, b, b + 1, a + 1);
131
+ }
132
+ const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx);
133
+ g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 20);
134
+ return g;
135
+ }
136
+ export const TUBE = arcGeo(72, 8), RIBBON = arcGeo(72, 1, true);
137
+ /** Unlit HDR material for TUBE/RIBBON (always transparent: arc materials share one program, and an opaque one would
138
+ * force alpha 1 on every other one): setArc(mesh, startAngle, length, radius, thickness, y) bends it (XZ plane). */
139
+ export function arcMat(color, k = 2.5, { opacity = 1, additive = false, dash = 0 } = {}) {
140
+ const U = { uA0: { value: 0 }, uLen: { value: 1 }, uR: { value: 1 }, uTube: { value: 0.05 }, uY: { value: 0 }, uDash: { value: dash }, uTime: { value: 0 } };
141
+ const m = new THREE.MeshBasicMaterial({ color: col(color).multiplyScalar(k), transparent: true, opacity, depthWrite: !(additive || opacity < 1), blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, side: THREE.DoubleSide });
142
+ m.onBeforeCompile = (s) => {
143
+ Object.assign(s.uniforms, U);
144
+ s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nuniform float uA0, uLen, uR, uTube, uY; varying float vS;')
145
+ .replace('#include <begin_vertex>', 'float th = uA0 + position.x * uLen; float rr = uR + position.y * uTube; vec3 transformed = vec3(cos(th) * rr, uY + position.z * uTube, sin(th) * rr); vS = position.x * uLen;');
146
+ s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform float uDash, uTime; varying float vS;')
147
+ .replace('#include <opaque_fragment>', 'if (uDash > 0. && fract(vS * uDash - uTime * 2.) > 0.5) discard;\n#include <opaque_fragment>');
148
+ };
149
+ m.customProgramCacheKey = () => `arc${dash > 0 ? 'd' : ''}`;
150
+ m.forceSinglePass = true;
151
+ m.U = U; return m;
152
+ }
153
+ export function setArc(m, a0, len, r, tube, y = 0) { const U = m.material.U; U.uA0.value = a0; U.uLen.value = len; U.uR.value = r; U.uTube.value = tube; U.uY.value = y; }
154
+
155
+
156
+ export class Stage {
157
+ /**
158
+ * canvas: the WebGL canvas. opts: { tier: 'high'|'low', record, bloom, threshold, exposure, sat, con, vig, lift, gain, grain, fov }
159
+ */
160
+ constructor(canvas, opts = {}) {
161
+ this.canvas = canvas;
162
+ const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: true, stencil: false, powerPreference: 'high-performance', preserveDrawingBuffer: !!opts.record });
163
+ if (!gl) throw new Error('webgl2');
164
+ this.r = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false });
165
+ this.r.outputColorSpace = THREE.LinearSRGBColorSpace; this.r.toneMapping = THREE.NoToneMapping;
166
+ this.r.shadowMap.enabled = !!opts.shadows; this.r.shadowMap.type = THREE.PCFSoftShadowMap;
167
+ this.r.setPixelRatio(1);
168
+ const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) && !opts.record;
169
+ this.tier = opts.tier || (mobile ? 'low' : 'high');
170
+ this.hdr = this.r.extensions.has('EXT_color_buffer_float') || this.r.extensions.has('EXT_color_buffer_half_float');
171
+ this.q = { scale: 1, msaa: this.tier === 'high' ? 4 : 2, maxPr: this.tier === 'high' ? 2 : 1.75, bloom: true };
172
+ this.look = { bloom: 0.8, threshold: 0.9, exposure: 1, sat: 1.12, con: 1.05, vig: 0.55, lift: [0.01, 0.0, 0.03], gain: [1, 1, 1], grain: 0.025, ...opts };
173
+ this.scene = new THREE.Scene();
174
+ this.cam = new THREE.PerspectiveCamera(opts.fov || 50, 1, 0.1, 4000);
175
+ this.camBase = new THREE.Vector3(); this.camLook = new THREE.Vector3();
176
+ this.gradient = (() => { const d = new Uint8Array([110, 110, 110, 255, 170, 170, 170, 255, 225, 225, 225, 255, 255, 255, 255, 255]); const t = new THREE.DataTexture(d, 4, 1, THREE.RGBAFormat); t.minFilter = t.magFilter = THREE.NearestFilter; t.needsUpdate = true; return t; })();
177
+ this.rimU = { value: col(opts.rim || '#8FA8FF').multiplyScalar(0.5) };
178
+ this.t = 0; this.trauma = 0; this.flashA = 0; this.flashCol = col('#FFFFFF'); this.kick = 0;
179
+ this.parts = new Particles(opts.particles || 1600); this.scene.add(this.parts.mesh);
180
+ this.post();
181
+ this.ft = 16; this.slow = 0; this.fast = 0; this.frames = 0; this.strikes = 0; this.tooSlow = false;
182
+ this.auto = !opts.fixedQuality && !opts.record && !/[?&](fixedq|3d)=1/.test(location.search);
183
+ }
184
+
185
+ // ---------- materials
186
+ /** Cel-shaded material with a rim light (Soaklark look). */
187
+ toon(opts = {}, rim = 1) {
188
+ const m = new THREE.MeshToonMaterial({ gradientMap: this.gradient, ...opts });
189
+ const R = this.rimU;
190
+ m.onBeforeCompile = (s) => {
191
+ s.uniforms.uRim = R;
192
+ s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform vec3 uRim;')
193
+ .replace('#include <opaque_fragment>', `float rimF = 1.0 - max(dot(normalize(vViewPosition), normal), 0.0);\noutgoingLight += uRim * ${rim.toFixed(2)} * smoothstep(0.6, 0.95, rimF);\n#include <opaque_fragment>`);
194
+ };
195
+ return m;
196
+ }
197
+ /** Unlit HDR colour: anything brighter than ~1 blooms. */
198
+ glow(color, k = 2.5, o = {}) { return new THREE.MeshBasicMaterial({ color: col(color).multiplyScalar(k), toneMapped: false, ...o }); }
199
+ /** Back-face outline shell for a mesh (inverted hull). */
200
+ outline(mesh, t = 0.06, color = '#0A0B14') {
201
+ const m = new THREE.Mesh(mesh.geometry, new THREE.MeshBasicMaterial({ color, side: THREE.BackSide }));
202
+ m.scale.setScalar(1 + t); mesh.add(m); return m;
203
+ }
204
+ /** Gradient sky dome (top / horizon / below), optional glow (sun or moon) and twinkling stars. */
205
+ sky({ top = '#0A0B2A', hor = '#3A1E6E', low = '#05060F', glow = '#FF8A5C', glowDir = [0.3, 0.25, -1], glowK = 1, stars = 0, curve = 0.6 } = {}) {
206
+ if (this.skyMesh) { this.scene.remove(this.skyMesh); this.skyMesh.material.dispose(); }
207
+ const U = { uTop: { value: col(top) }, uHor: { value: col(hor) }, uLow: { value: col(low) }, uGlowCol: { value: col(glow) }, uGlowDir: { value: new THREE.Vector3(...glowDir) }, uStars: { value: stars }, uTime: { value: 0 }, uGlow: { value: glowK }, uCurve: { value: curve } };
208
+ const m = new THREE.Mesh(new THREE.SphereGeometry(1000, 32, 16), new THREE.ShaderMaterial({ vertexShader: SKY_V, fragmentShader: SKY_F, uniforms: U, side: THREE.BackSide, depthWrite: false }));
209
+ m.frustumCulled = false; m.renderOrder = -10; this.skyU = U; this.skyMesh = m; this.scene.add(m);
210
+ return m;
211
+ }
212
+
213
+ // ---------- post-processing
214
+ post() {
215
+ const type = this.hdr ? THREE.HalfFloatType : THREE.UnsignedByteType;
216
+ this.rtS = new THREE.WebGLRenderTarget(4, 4, { type, samples: this.q.msaa, depthBuffer: true });
217
+ this.rtB = [0, 1, 2, 3].map(() => new THREE.WebGLRenderTarget(4, 4, { type, depthBuffer: false }));
218
+ const tri = new THREE.BufferGeometry();
219
+ tri.setAttribute('position', new THREE.Float32BufferAttribute([-1, -1, 0, 3, -1, 0, -1, 3, 0], 3));
220
+ tri.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 2, 0, 0, 2], 2));
221
+ const mk = (f, u) => new THREE.ShaderMaterial({ vertexShader: FSQ_V, fragmentShader: f, uniforms: u, depthTest: false, depthWrite: false });
222
+ this.mBright = mk(BRIGHT_F, { t: { value: null }, uTh: { value: 1 }, uPx: { value: new THREE.Vector2() } });
223
+ this.mBlur = mk(BLUR_F, { t: { value: null }, uDir: { value: new THREE.Vector2() } });
224
+ this.mComp = mk(COMP_F, { tScene: { value: null }, tB1: { value: null }, tB2: { value: null }, uBloom: { value: 0.8 }, uExp: { value: 1 }, uSat: { value: 1.1 }, uCon: { value: 1.05 },
225
+ uVig: { value: 0.55 }, uTime: { value: 0 }, uFlash: { value: 0 }, uLdr: { value: this.hdr ? 0 : 1 }, uAb: { value: 0 }, uGrain: { value: 0.02 },
226
+ uLift: { value: new THREE.Vector3() }, uGain: { value: new THREE.Vector3(1, 1, 1) }, uFlashCol: { value: new THREE.Color() } });
227
+ this.fsq = new THREE.Mesh(tri, this.mComp); this.fsq.frustumCulled = false;
228
+ this.fsScene = new THREE.Scene(); this.fsScene.add(this.fsq); this.fsCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
229
+ }
230
+ resize(cssW, cssH, dpr = window.devicePixelRatio || 1) {
231
+ this.cssW = cssW; this.cssH = cssH; this.dpr = dpr;
232
+ const pr = Math.min(dpr, this.q.maxPr) * this.q.scale;
233
+ const w = Math.max(2, Math.round(cssW * pr)), h = Math.max(2, Math.round(cssH * pr));
234
+ if (w === this.w && h === this.h) return;
235
+ this.r.setSize(w, h, false); this.w = w; this.h = h;
236
+ this.rtS.setSize(w, h);
237
+ const bw = Math.max(2, w >> 2), bh = Math.max(2, h >> 2);
238
+ this.rtB[0].setSize(bw, bh); this.rtB[1].setSize(bw, bh); this.rtB[2].setSize(Math.max(2, bw >> 1), Math.max(2, bh >> 1)); this.rtB[3].setSize(Math.max(2, bw >> 1), Math.max(2, bh >> 1));
239
+ this.cam.aspect = w / h; this.cam.updateProjectionMatrix();
240
+ this.parts.U.uScale.value = h * 0.9;
241
+ }
242
+ /** Keep the drawing buffer matched to the canvas's CSS box. */
243
+ fit() { const r = this.canvas.getBoundingClientRect(); if (r.width > 1 && r.height > 1) this.resize(r.width, r.height); }
244
+ setQuality(scale) { this.q.scale = Math.max(0.5, Math.min(1, scale)); const w = this.cssW, h = this.cssH; this.w = 0; this.resize(w, h, this.dpr); }
245
+
246
+ // ---------- juice
247
+ shake(amount = 0.5) { this.trauma = Math.min(1, this.trauma + amount); }
248
+ flash(color = '#FFFFFF', a = 0.5) { this.flashCol.set(color); this.flashA = Math.max(this.flashA, a); }
249
+ punch(k = 0.006) { this.kick = Math.max(this.kick, k); }
250
+ burst(v, o) { this.parts.burst(v.x, v.y, v.z, o); }
251
+
252
+ /**
253
+ * Draw one frame. dt in seconds (fixed when recording). Shake moves the camera a little around where the game put it
254
+ * (call setCam(pos, look) each frame before render, or set stage.cam yourself and leave camBase empty).
255
+ */
256
+ render(dt) {
257
+ dt = Math.min(0.1, Math.max(0, dt));
258
+ this.t += dt;
259
+ this.parts.update(dt);
260
+ if (this.skyU) this.skyU.uTime.value = this.t;
261
+ // camera shake (trauma^2 noise, decays)
262
+ const tr = this.trauma * this.trauma; this.trauma = Math.max(0, this.trauma - dt * 1.6);
263
+ const cam = this.cam;
264
+ if (this.camSet) { cam.position.copy(this.camBase); cam.lookAt(this.camLook); }
265
+ if (tr > 0.0001) {
266
+ const n = (k) => Math.sin(this.t * (37 + k * 11) + k * 7) * 0.6 + Math.sin(this.t * (71 + k * 5) + k) * 0.4;
267
+ const s = this.shakeScale || 1;
268
+ cam.position.x += n(1) * tr * s; cam.position.y += n(2) * tr * s; cam.rotation.z += n(3) * tr * 0.04;
269
+ }
270
+ // adaptive quality from real frame times: lower the resolution, then bloom; a device that still can't keep up gets
271
+ // tooSlow, and the game swaps back to its 2D renderer (same rules, same run) so it always stays playable.
272
+ const nowR = performance.now(), real = this.lastR ? nowR - this.lastR : 16; this.lastR = nowR;
273
+ if (this.auto && real < 1000 && ++this.frames > 8) {
274
+ this.ft = this.ft * 0.85 + real * 0.15;
275
+ if (this.ft > 34) {
276
+ this.fast = 0;
277
+ if (++this.slow > 12) {
278
+ this.slow = 0;
279
+ if (this.q.scale > 0.55) this.setQuality(this.q.scale - 0.2);
280
+ else if (this.q.bloom) this.q.bloom = false;
281
+ else if (++this.strikes > 2) this.tooSlow = true;
282
+ }
283
+ } else if (this.ft < 18 && this.q.scale < 1) { this.slow = 0; if (++this.fast > 300) { this.fast = 0; this.setQuality(this.q.scale + 0.1); } }
284
+ else this.slow = Math.max(0, this.slow - 1);
285
+ }
286
+ const r = this.r, L = this.look, U = this.mComp.uniforms;
287
+ r.setRenderTarget(this.rtS); r.render(this.scene, cam);
288
+ let b1 = this.rtB[0].texture, b2 = this.rtB[2].texture;
289
+ if (this.q.bloom && L.bloom > 0) {
290
+ const pass = (mat, out) => { this.fsq.material = mat; r.setRenderTarget(out); r.render(this.fsScene, this.fsCam); };
291
+ this.mBright.uniforms.t.value = this.rtS.texture; this.mBright.uniforms.uTh.value = L.threshold; this.mBright.uniforms.uPx.value.set(1 / this.w, 1 / this.h);
292
+ pass(this.mBright, this.rtB[0]);
293
+ const blur = (src, tmp, dst, w, h) => {
294
+ this.mBlur.uniforms.t.value = src.texture; this.mBlur.uniforms.uDir.value.set(1 / w, 0); pass(this.mBlur, tmp);
295
+ this.mBlur.uniforms.t.value = tmp.texture; this.mBlur.uniforms.uDir.value.set(0, 1 / h); pass(this.mBlur, dst);
296
+ };
297
+ blur(this.rtB[0], this.rtB[1], this.rtB[0], this.rtB[0].width, this.rtB[0].height);
298
+ this.mBlur.uniforms.t.value = this.rtB[0].texture; this.mBlur.uniforms.uDir.value.set(0, 0); pass(this.mBlur, this.rtB[2]);
299
+ blur(this.rtB[2], this.rtB[3], this.rtB[2], this.rtB[2].width, this.rtB[2].height);
300
+ blur(this.rtB[2], this.rtB[3], this.rtB[2], this.rtB[2].width, this.rtB[2].height);
301
+ b1 = this.rtB[0].texture; b2 = this.rtB[2].texture; U.uBloom.value = L.bloom;
302
+ } else U.uBloom.value = 0;
303
+ U.tScene.value = this.rtS.texture; U.tB1.value = b1; U.tB2.value = b2;
304
+ U.uExp.value = L.exposure; U.uSat.value = L.sat; U.uCon.value = L.con; U.uVig.value = L.vig; U.uGrain.value = L.grain;
305
+ U.uLift.value.set(...L.lift); U.uGain.value.set(...L.gain); U.uTime.value = this.t;
306
+ U.uFlash.value = this.flashA; U.uFlashCol.value.copy(this.flashCol); this.flashA *= Math.exp(-dt * 7); if (this.flashA < 0.004) this.flashA = 0;
307
+ U.uAb.value = this.kick + tr * 0.012; this.kick *= Math.exp(-dt * 8);
308
+ this.fsq.material = this.mComp; r.setRenderTarget(null); r.render(this.fsScene, this.fsCam);
309
+ }
310
+ setCam(pos, look) { this.camSet = true; this.camBase.copy(pos); this.camLook.copy(look); }
311
+ dispose() { this.r.dispose(); }
312
+ }
313
+
314
+ /**
315
+ * Mount a 3D layer behind a game's 2D canvas: a WebGL canvas the same size, in the same box. The 2D canvas stays on
316
+ * top (HUD text, taps, focus, tests) and the game draws it transparent while 3D is on.
317
+ */
318
+ export function mountBehind(cv2d, cls = 'cmx3d') {
319
+ const host = cv2d.parentElement;
320
+ const wrap = document.createElement('div'); wrap.className = `${cls}-wrap`;
321
+ host.insertBefore(wrap, cv2d); wrap.appendChild(cv2d);
322
+ const c = document.createElement('canvas'); c.className = cls; c.setAttribute('aria-hidden', 'true');
323
+ wrap.insertBefore(c, cv2d);
324
+ return c;
325
+ }
style.css CHANGED
@@ -1,28 +1,127 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Stoopshake: night stoop look. Spray-paint display type (Rubik Spray Paint, OFL), Barlow Condensed for UI (OFL). (c) 2026 CyberMax */
2
+ @font-face{font-family:Spray;src:url(fonts/rubik-spray-paint.woff2) format('woff2');font-display:swap}
3
+ @font-face{font-family:Barlow Condensed;font-weight:800;src:url(fonts/barlow-condensed-800.woff2) format('woff2');font-display:swap}
4
+ :root{--ink:#F6F1E7;--mut:#B9B1C9;--bg:#07060C;--amber:#FFC56B;--pink:#FF3D6E;--teal:#2FE6FF;--card:rgba(16,13,26,.92);--line:rgba(255,255,255,.12);--ui:"Barlow Condensed",system-ui,sans-serif;--tgt:0px;--tgb:0px}
5
+ html,body{background:var(--bg);color:var(--ink);margin:0}
6
+ body{font-family:var(--ui);-webkit-tap-highlight-color:transparent}
7
+ .app{position:relative;width:100%;height:100vh;height:100svh;overflow:hidden;background:#07060C;user-select:none;-webkit-user-select:none;touch-action:none}
8
+ body.tg .app{height:var(--tgh,100vh)}
9
+ #c{position:absolute;inset:0;width:100%;height:100%;display:block;outline:none;cursor:grab}
10
+ #c:active{cursor:grabbing}
11
+ .hud{position:absolute;inset:0;pointer-events:none;padding:calc(10px + var(--tgt)) 12px calc(10px + var(--tgb));display:flex;flex-direction:column;gap:8px}
12
+ .hud button,.hud a,.hud .pill{pointer-events:auto}
13
+ .bar{display:flex;align-items:center;gap:8px}
14
+ .logo{font-family:Spray,var(--ui);white-space:nowrap;margin-right:10px;padding-right:2px;font-size:26px;letter-spacing:.02em;color:var(--ink);text-decoration:none;text-shadow:0 0 18px rgba(255,61,110,.45),2px 2px 0 #000}
15
+ .logo i,.biglogo i{font-style:normal;color:var(--amber)}
16
+ .pill{font-weight:800;font-size:15px;letter-spacing:.05em;padding:5px 10px;border-radius:999px;background:rgba(0,0,0,.55);border:1px solid var(--line);backdrop-filter:blur(6px)}
17
+ .pill.chips b{color:var(--amber)}.pill.rank{color:var(--teal)}
18
+ .sp{flex:1}
19
+ .ib{width:38px;height:38px;border-radius:12px;border:1px solid var(--line);background:rgba(0,0,0,.55);color:var(--ink);font-size:18px;font-weight:800;cursor:pointer}
20
+ .ib.off{opacity:.45}
21
+ .seats{display:flex;gap:6px;flex-wrap:wrap;justify-content:center}
22
+ .seat{min-width:86px;max-width:150px;padding:6px 9px;border-radius:12px;background:rgba(8,6,14,.72);border:1px solid var(--line);font-size:14px;line-height:1.15;transition:transform .2s,border-color .2s}
23
+ .seat b{display:block;font-size:15px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
24
+ .seat small{color:var(--mut);display:block}
25
+ .seat .dz{font-size:22px;letter-spacing:1px;line-height:1}
26
+ .seat.on{border-color:var(--amber);transform:translateY(2px);box-shadow:0 0 0 2px rgba(255,197,107,.25),0 8px 24px rgba(0,0,0,.5)}
27
+ .seat.out{opacity:.45}.seat.win{border-color:#6BFFB0;box-shadow:0 0 24px rgba(107,255,176,.35)}
28
+ .seat .bank{color:var(--amber);font-size:11px;letter-spacing:.1em}
29
+ .talk{align-self:center;max-width:min(92%,420px);text-align:center;font-size:17px;padding:7px 12px;border-radius:14px;background:rgba(0,0,0,.6);border:1px solid var(--line);opacity:0;transform:translateY(-6px);transition:opacity .25s,transform .25s}
30
+ .talk.show{opacity:1;transform:none}
31
+ .talk b{color:var(--amber)}
32
+ .banner{position:absolute;left:0;right:0;top:15%;text-align:center;font-family:Spray,var(--ui);font-size:clamp(46px,11vw,104px);line-height:.95;color:#fff;text-shadow:3px 3px 0 #000,0 0 30px rgba(255,61,110,.6);opacity:0;transform:scale(.6) rotate(-4deg);transition:opacity .2s,transform .35s cubic-bezier(.2,1.6,.4,1);pointer-events:none}
33
+ .banner.show{opacity:1;transform:scale(1) rotate(-3deg)}
34
+ .banner small{display:block;font-family:var(--ui);font-size:clamp(18px,4.2vw,30px);letter-spacing:.06em;text-shadow:2px 2px 0 #000;margin-top:6px}
35
+ .banner.k456{color:var(--amber);text-shadow:3px 3px 0 #000,0 0 40px rgba(255,197,107,.9)}
36
+ .banner.ktrips{color:#B98CFF;text-shadow:3px 3px 0 #000,0 0 40px rgba(185,140,255,.9)}
37
+ .banner.k123{color:var(--pink)}.banner.kpoint{color:var(--teal);text-shadow:3px 3px 0 #000,0 0 30px rgba(47,230,255,.6)}
38
+ .banner.knone{color:#ddd;font-size:clamp(34px,8vw,70px)}
39
+ .banner .emote{display:block;font-size:.55em;margin-top:8px}
40
+ .hint{margin-top:auto;align-self:center;font-size:16px;letter-spacing:.08em;color:#fff;background:rgba(0,0,0,.45);padding:6px 12px;border-radius:999px;transition:opacity .3s}
41
+ .hint.hide{opacity:0}
42
+ .actions{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
43
+ .actions button{pointer-events:auto}
44
+ .bottom{display:flex;align-items:center;gap:10px;justify-content:center}
45
+ .pot{font-weight:800;font-size:17px;letter-spacing:.06em;color:var(--amber);min-width:0}
46
+ .roll{font-family:var(--ui);font-weight:800;font-size:24px;letter-spacing:.06em;padding:13px 34px;border-radius:999px;border:0;background:linear-gradient(180deg,#FFD58A,#FFAA3D);color:#1A0E00;box-shadow:0 8px 28px rgba(255,170,61,.45),inset 0 -3px 0 rgba(0,0,0,.2);cursor:pointer}
47
+ .roll:disabled{filter:grayscale(.8) brightness(.6);box-shadow:none}
48
+ .nrm{margin:0;text-align:center;font-size:12px;letter-spacing:.08em;color:var(--mut);opacity:.85}
49
+ .btn,.ghost,.chipb{font-family:var(--ui);font-weight:800;letter-spacing:.05em;border-radius:14px;cursor:pointer;font-size:17px}
50
+ .btn{display:inline-block;padding:12px 20px;border:0;background:linear-gradient(180deg,#FFD58A,#FFAA3D);color:#1A0E00;text-decoration:none;text-align:center}
51
+ .btn.pink{background:linear-gradient(180deg,#FF6D90,#E8285A);color:#fff}.btn.teal{background:linear-gradient(180deg,#7DF3FF,#14C4E0);color:#04161A}
52
+ .ghost{padding:10px 12px;background:rgba(255,255,255,.06);border:1px solid var(--line);color:var(--ink)}
53
+ .chipb{padding:8px 12px;background:rgba(255,255,255,.06);border:1px solid var(--line);color:var(--ink);font-size:16px}
54
+ .chipb.on{background:var(--amber);color:#1A0E00;border-color:var(--amber)}
55
+ .chipb .lock,.mode .lock{font-style:normal;font-size:10px;letter-spacing:.1em;padding:2px 5px;border-radius:6px;background:var(--pink);color:#fff;margin-left:4px;vertical-align:middle}
56
+ .ov{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:calc(12px + var(--tgt)) 12px calc(12px + var(--tgb));z-index:5}
57
+ .ov[hidden]{display:none}
58
+ .ov.load{background:radial-gradient(circle at 50% 40%,#1D1430,#07060C);flex-direction:column;gap:14px}
59
+ .lbar{width:220px;height:6px;border-radius:9px;background:rgba(255,255,255,.1);overflow:hidden}.lbar i{display:block;height:100%;width:10%;background:var(--amber);transition:width .2s}
60
+ .hint2{color:var(--mut);margin:0}
61
+ .biglogo{font-family:Spray,var(--ui);font-size:clamp(46px,12vw,80px);margin:0;line-height:1;text-shadow:3px 3px 0 #000,0 0 30px rgba(255,61,110,.5);text-align:center}
62
+ .biglogo.sm{font-size:clamp(40px,10vw,60px)}
63
+ .sheet{background:rgba(5,4,10,.55);backdrop-filter:blur(3px)}
64
+ .card{width:min(560px,100%);max-height:100%;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:22px;padding:16px;box-shadow:0 30px 80px rgba(0,0,0,.6)}
65
+ .card h2{margin:4px 0 10px}
66
+ .tag{font-family:Spray,var(--ui);font-weight:400;font-size:34px;letter-spacing:.02em;color:var(--amber);text-shadow:2px 2px 0 #000}
67
+ .sub{color:var(--mut);margin:6px 0 10px;font-size:17px;text-align:center}.sub b{color:var(--ink)}
68
+ .tiny{color:var(--mut);font-size:13px;margin:10px 0 0}.tiny a{color:var(--teal)}
69
+ .modes{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0}
70
+ .mode{text-align:left;padding:11px 12px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--ink);cursor:pointer;font-family:var(--ui)}
71
+ .mode b{display:block;font-size:19px;letter-spacing:.04em}.mode span{display:block;color:var(--mut);font-size:14px;line-height:1.2;margin-top:2px}
72
+ .mode.hot{background:linear-gradient(135deg,rgba(255,197,107,.25),rgba(255,61,110,.18));border-color:rgba(255,197,107,.6)}
73
+ .mode.day{border-color:rgba(47,230,255,.5)}.mode.tg{border-color:rgba(64,170,255,.5)}
74
+ .mode:hover{border-color:var(--amber)}
75
+ .row3{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
76
+ .chal{padding:10px 12px;border-radius:14px;background:rgba(47,230,255,.12);border:1px solid rgba(47,230,255,.5);margin:8px 0;font-size:17px}
77
+ .chal b{color:var(--teal)}
78
+ .rl{list-style:none;padding:0;margin:8px 0;display:grid;gap:8px}
79
+ .rl li{display:flex;align-items:center;gap:10px;font-size:19px;padding:8px 10px;background:rgba(255,255,255,.05);border-radius:12px}
80
+ .rl .dz{font-size:32px;letter-spacing:2px;min-width:104px;color:#fff}
81
+ .dzs{font-size:30px;letter-spacing:2px}
82
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
83
+ .lbl{display:block;color:var(--mut);font-size:14px;letter-spacing:.06em;margin:10px 0 4px}
84
+ .inp{width:100%;box-sizing:border-box;font-family:var(--ui);font-size:18px;font-weight:800;padding:9px 11px;border-radius:12px;border:1px solid var(--line);background:rgba(0,0,0,.4);color:var(--ink)}
85
+ .chips-row{display:flex;gap:6px;flex-wrap:wrap}
86
+ .endsum{text-align:center}
87
+ .endsum .big{font-family:Spray,var(--ui);font-size:52px;line-height:1;margin:6px 0}
88
+ .endsum .delta{font-size:26px;font-weight:800}.up{color:#6BFFB0}.down{color:var(--pink)}
89
+ .btnrow{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-top:12px}
90
+ .lb{width:100%;border-collapse:collapse;font-size:17px}.lb td{padding:5px 6px;border-bottom:1px solid var(--line)}.lb td:last-child{text-align:right;color:var(--amber)}
91
+ .lb tr.me td{color:var(--teal)}
92
+ .tabs{display:flex;gap:6px;margin-bottom:8px}
93
+ .sw{display:inline-block;width:18px;height:18px;border-radius:5px;vertical-align:middle;margin-right:4px;border:1px solid rgba(255,255,255,.3)}
94
+ .stylegrid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
95
+ .statgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;text-align:center}.statgrid div{background:rgba(255,255,255,.05);border-radius:12px;padding:8px}.statgrid b{display:block;font-size:24px;color:var(--amber)}
96
+ .hist{display:flex;align-items:flex-end;gap:2px;height:60px;margin:8px 0}.hist i{flex:1;background:var(--teal);min-height:2px;border-radius:2px 2px 0 0}.hist i.l{background:var(--pink)}
97
+ .offer{border:1px solid var(--line);border-radius:16px;padding:12px;margin:8px 0;background:rgba(255,255,255,.04)}
98
+ .offer h3{margin:0 0 4px;font-size:22px}.offer ul{margin:6px 0;padding-left:18px;color:var(--mut)}.offer .pr{color:var(--amber);font-weight:800}
99
+ /* below the fold: the SEO guide on the normal page */
100
+ .below{max-width:880px;margin:0 auto;padding:16px}
101
+ .below .top{display:flex;justify-content:space-between;align-items:center}.below a{color:var(--teal)}
102
+ .below .brand{font-weight:800;text-decoration:none;color:var(--ink)}.below nav a{margin-left:12px}
103
+ .below h1{font-family:Spray,var(--ui);font-weight:400;font-size:44px;margin:10px 0 4px}
104
+ .below .muted{color:var(--mut)}.below .seo{line-height:1.5;font-family:system-ui,sans-serif;font-size:16px}
105
+ .below .seo h2,.below .seo h3{font-family:var(--ui)}
106
+ .below footer{color:var(--mut);padding:24px 0}
107
+ body.tg .below{display:none}
108
+ .pro-badge{display:none;font-size:.35em;vertical-align:middle;background:var(--amber);color:#1A0E00;border-radius:8px;padding:2px 8px;font-family:var(--ui)}
109
+ body.cmx-pro .pro-badge{display:inline-block}
110
+ .app.phone .banner,.coarse .banner{top:19%}
111
+ @media (max-width:520px){.modes{grid-template-columns:1fr 1fr}.mode b{font-size:16px}.mode span{font-size:12.5px}.row3{grid-template-columns:repeat(2,1fr)}.roll{font-size:21px;padding:12px 26px}.seat{min-width:72px;font-size:12px}.seat b{font-size:13px}.seat .dz{font-size:18px}.logo{font-size:22px}.pill{font-size:13px;padding:4px 8px}.card{padding:12px}.tag{font-size:28px}}
112
+ @media (max-width:400px){.logo{font-size:18px}.pill.rank{display:none}}
113
+ @media (max-height:560px){.banner{top:12%}.seats{display:none}}
114
+ /* buy area (17 Oct 2026): the two Stoopshake extras, named as their Stripe products; the Fun Pass apart */
115
+ .xbtn{display:block;width:100%;margin-top:10px;font-size:16px;padding:11px 12px}
116
+ .buybox{margin:18px 0 26px;padding:18px;border:1px solid var(--line);border-radius:18px;background:rgba(255,255,255,.035)}
117
+ .buybox h2{margin:0 0 6px;font-size:26px}.buybox .muted{margin:0 0 12px}
118
+ .buybox .bx{display:grid;grid-template-columns:1fr 1fr;gap:12px}
119
+ .buybox .bo{display:flex;flex-direction:column;padding:14px;border:1px solid var(--line);border-radius:16px;background:rgba(16,13,26,.75)}
120
+ .buybox .bo.best{border-color:rgba(255,61,110,.65);box-shadow:0 0 0 1px rgba(255,61,110,.25)}
121
+ .buybox .bk{margin:0;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--teal)}
122
+ .buybox h3{margin:4px 0 6px;font-size:21px;line-height:1.15}.buybox .bp{display:block;color:var(--amber);font-size:26px;margin-top:2px}.buybox .bp small{font-size:14px;color:var(--mut)}
123
+ .buybox ul{margin:4px 0 12px;padding-left:18px;color:var(--mut);flex:1}.buybox li{margin:3px 0}
124
+ .buybox .bo .btn{display:block}.buybox a.btn{color:#1A0E00}.buybox a.btn.pink{color:#fff}
125
+ .buybox .bsm{font-size:13px;color:var(--mut);margin:12px 0 0}.buybox .bsm a,.buybox .bfp a{color:var(--teal)}
126
+ .buybox .bfp{margin:10px 0 0;padding:10px 12px;border-radius:12px;border:1px dashed var(--line);font-size:15px}
127
+ @media (max-width:640px){.buybox{padding:14px}.buybox .bx{grid-template-columns:1fr}.xbtn{font-size:14px}}
sw.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
2
+ // network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
3
+ const GAME = 'stoopshake';
4
+ const CACHE = `cmx-${GAME}-3a8c2bc765`;
5
+ const FILES = ["index.html", "audio.js", "cmx.js", "dice3d.js", "fonts/barlow-condensed-800.woff2", "fonts/rubik-spray-paint.woff2", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "offer.js", "pack.js", "rules.js", "stage3d.js", "style.css", "tg.js", "vendor/cannon-es.min.js", "vendor/three.module.min.js"];
6
+
7
+ self.addEventListener('install', (e) => {
8
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
9
+ });
10
+ self.addEventListener('activate', (e) => {
11
+ e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(`cmx-${GAME}-`) && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
12
+ });
13
+ self.addEventListener('fetch', (e) => {
14
+ const u = new URL(e.request.url);
15
+ if (e.request.method !== 'GET' || u.origin !== location.origin || !u.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
16
+ if (e.request.mode === 'navigate') {
17
+ e.respondWith(fetch(e.request).then((r) => { const c = r.clone(); caches.open(CACHE).then((x) => x.put(u.pathname, c)); return r; })
18
+ .catch(() => caches.match(u.pathname).then((r) => r || caches.match('index.html'))));
19
+ return;
20
+ }
21
+ e.respondWith(caches.match(e.request, { ignoreSearch: true }).then((r) => r || fetch(e.request).then((res) => {
22
+ if (res.ok) { const c = res.clone(); caches.open(CACHE).then((x) => x.put(e.request, c)); }
23
+ return res;
24
+ })));
25
+ });
tg.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stoopshake as a Telegram Mini App (opened from @CyberMaxTools_bot: menu button, /play, challenge deep links, the
2
+ // pinned channel post). Inside Telegram: telegram-web-app.js, full-height stage with safe areas, Telegram theme colours,
3
+ // haptics, sharing into chats, and purchases with Telegram Stars only (Telegram's rule for digital goods in Mini Apps):
4
+ // Crew Pass (full game) $2.99 = 235 Stars, Dice & Table Pack $1.99 = 155 Stars (same USD price points as the web, 0.013 USD/Star rule).
5
+ // Payment: data Worker /tg/game-invoice -> WebApp.openInvoice; ownership comes back from /tg/game-owned as signed codes
6
+ // that the normal unlock check accepts, so a Telegram purchase also unlocks the web game. (c) 2026 CyberMax.
7
+ const DATA = 'https://data.cybermaxtools.com';
8
+ export const BOT = 'CyberMaxTools_bot';
9
+ export const ITEMS = { 'stoopshake-full': { stars: 235, usd: '$2.99', name: 'the Crew Pass' }, 'stoopshake-skins': { stars: 155, usd: '$1.99', name: 'the Dice & Table Pack' } };
10
+ const q = new URLSearchParams(location.search);
11
+ export const inTG = q.has('tg') || /tgWebAppData=/.test(location.hash);
12
+ let W = null;
13
+ export const app = () => W;
14
+
15
+ function load() {
16
+ return new Promise((res) => {
17
+ if (window.Telegram && window.Telegram.WebApp) { res(window.Telegram.WebApp); return; }
18
+ const s = document.createElement('script'); s.src = 'https://telegram.org/js/telegram-web-app.js';
19
+ s.onload = () => res(window.Telegram && window.Telegram.WebApp); s.onerror = () => res(null); document.head.appendChild(s);
20
+ });
21
+ }
22
+ function insets() {
23
+ if (!W) return;
24
+ const a = W.safeAreaInset || {}; const c = W.contentSafeAreaInset || {};
25
+ const r = document.documentElement.style;
26
+ r.setProperty('--tgt', `${(a.top || 0) + (c.top || 0)}px`); r.setProperty('--tgb', `${(a.bottom || 0) + (c.bottom || 0)}px`);
27
+ r.setProperty('--tgh', `${W.viewportStableHeight || window.innerHeight}px`);
28
+ }
29
+ /** Start the Mini App shell. onResize: re-layout the canvas; onBack: Telegram back button pressed. */
30
+ export async function init({ onResize, onBack } = {}) {
31
+ if (!inTG) return null;
32
+ document.body.classList.add('tg');
33
+ W = await load();
34
+ if (!W || !W.initData) { document.body.classList.remove('tg'); W = null; return null; } // opened with ?tg=1 outside Telegram: plain web game
35
+ try { W.ready(); W.expand(); } catch { /* old client */ }
36
+ try { if (W.disableVerticalSwipes) W.disableVerticalSwipes(); } catch { /* < 7.7 */ }
37
+ try { W.setHeaderColor('#0B0912'); W.setBackgroundColor('#07060C'); if (W.setBottomBarColor) W.setBottomBarColor('#07060C'); } catch { /* < 6.1 */ }
38
+ const tp = W.themeParams || {};
39
+ if (tp.button_color) document.documentElement.style.setProperty('--tg-btn', tp.button_color);
40
+ insets();
41
+ for (const ev of ['viewportChanged', 'safeAreaChanged', 'contentSafeAreaChanged']) { try { W.onEvent(ev, () => { insets(); onResize && onResize(); }); } catch { /* */ } }
42
+ try { W.BackButton.onClick(() => onBack && onBack()); } catch { /* */ }
43
+ onResize && onResize();
44
+ return W;
45
+ }
46
+ export function back(show) { try { if (W) (show ? W.BackButton.show() : W.BackButton.hide()); } catch { /* */ } }
47
+ export function haptic(kind) {
48
+ if (!W || !W.HapticFeedback) return;
49
+ try {
50
+ if (kind === 'select') W.HapticFeedback.selectionChanged();
51
+ else if (kind === 'success' || kind === 'error' || kind === 'warning') W.HapticFeedback.notificationOccurred(kind);
52
+ else W.HapticFeedback.impactOccurred(kind || 'light');
53
+ } catch { /* */ }
54
+ }
55
+ /** Share into a Telegram chat (the native chat picker). */
56
+ export function share(text, url) {
57
+ const link = `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`;
58
+ try { W.openTelegramLink(link); } catch { window.open(link, '_blank'); }
59
+ }
60
+ export const playUrl = () => `https://t.me/${BOT}?start=dice`;
61
+
62
+ async function post(path, body) {
63
+ const r = await fetch(DATA + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, initData: W.initData }) });
64
+ return r.json().catch(() => ({}));
65
+ }
66
+ /** What this Telegram user already bought (codes the normal unlock check accepts). */
67
+ export async function owned() { if (!W) return {}; try { return (await post('/tg/game-owned', {})).codes || {}; } catch { return {}; } }
68
+ /** Buy with Stars: opens Telegram's own payment sheet. Resolves 'paid' | 'cancelled' | 'failed' | 'pending'. */
69
+ export async function buy(item) {
70
+ if (!W) return 'failed';
71
+ let j; try { j = await post('/tg/game-invoice', { item }); } catch { j = {}; }
72
+ if (!j.link) return 'failed';
73
+ return new Promise((res) => { try { W.openInvoice(j.link, (status) => res(status)); } catch { res('failed'); } });
74
+ }
75
+ /** The Stars purchase sheet shown instead of the web checkout inside Telegram. */
76
+ export function sheet(item, { perks = [], onPaid } = {}) {
77
+ const it = ITEMS[item]; let d = document.getElementById('tgbuy');
78
+ if (!d) { d = document.createElement('dialog'); d.id = 'tgbuy'; d.className = 'funpass'; document.body.appendChild(d); d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); }); }
79
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">Stoopshake</p><h2>Get ${it.name}: ${it.stars} ⭐</h2>
80
+ <ul class="fp-perks">${perks.map((p) => `<li>${p}</li>`).join('')}<li>One payment, yours for good (also on the web with the code we send you)</li></ul>
81
+ <p class="fp-row"><button class="btn pass" type="button" data-buy>Pay ${it.stars} ⭐ with Telegram Stars</button></p>
82
+ <p class="fp-small">Paid through Telegram. 14-day promise: if it doesn't work as described, we fix it or refund your Stars (/paysupport in @${BOT}).</p></div>`;
83
+ d.querySelector('[data-buy]').addEventListener('click', async (e) => {
84
+ e.target.disabled = true; e.target.textContent = 'Opening Telegram payment…';
85
+ const st = await buy(item);
86
+ if (st === 'paid') { haptic('success'); d.close(); onPaid && onPaid(); } else { e.target.disabled = false; e.target.textContent = `Pay ${it.stars} ⭐ with Telegram Stars`; if (st === 'failed') haptic('error'); }
87
+ });
88
+ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
89
+ }
90
+
91
+ // ---------- growth loops (all optional for the player; rewards are cosmetic only)
92
+ export const CHANNEL = 'cybermaxtools';
93
+ const startParam = () => { try { return (W && W.initDataUnsafe && W.initDataUnsafe.start_param) || ''; } catch { return ''; } };
94
+ export function refFromLaunch() { const r = q.get('ref') || (/^r_([a-z0-9]{6,16})$/.exec(startParam()) || [])[1] || ''; return /^[a-z0-9]{6,16}$/.test(r) ? r : ''; }
95
+ /** First open: records who invited this player (only for players we have never seen). */
96
+ export async function hello() { if (!W) return {}; try { return await post('/tg/game-hello', { game: 'stoopshake', ref: refFromLaunch() }); } catch { return {}; } }
97
+ /** After the first finished round: records the invite as played (cosmetic rewards only). */
98
+ export async function played() { if (!W) return {}; try { return await post('/tg/game-played', {}); } catch { return {}; } }
99
+ /** Everything this player has: paid codes, free cosmetics, invite link, channel follow. */
100
+ export async function status() { if (!W) return {}; try { return await post('/tg/game-owned', {}); } catch { return {}; } }
101
+ export function invite(link) { share('Shake dice with me on Stoopshake: free 3D street dice (4-5-6). No real money, just bragging rights.', link); }
102
+ export function joinChannel(onBack) {
103
+ try { W.openTelegramLink(`https://t.me/${CHANNEL}`); } catch { window.open(`https://t.me/${CHANNEL}`, '_blank'); }
104
+ const again = () => { try { W.offEvent('activated', again); } catch { /* */ } onBack && onBack(); };
105
+ try { W.onEvent('activated', again); } catch { setTimeout(() => onBack && onBack(), 8000); }
106
+ }
107
+ /** Challenge a friend: the native Telegram share sheet with the game's own challenge link (state lives in the link). */
108
+ export function challenge(text, url) { share(text, url); }
vendor/cannon-es.min.js ADDED
The diff for this file is too large to render. See raw diff
 
vendor/three.module.min.js ADDED
The diff for this file is too large to render. See raw diff