Spaces:
Running
Running
Deploy brimbloom to the hub
Browse files- .gitattributes +2 -0
- brimbloom/cmx.js +179 -0
- brimbloom/core.js +81 -0
- brimbloom/fun.css +52 -0
- brimbloom/funpass.js +150 -0
- brimbloom/game.js +296 -0
- brimbloom/icon-192.png +0 -0
- brimbloom/icon-512.png +0 -0
- brimbloom/icon-maskable.png +0 -0
- brimbloom/icon.svg +1 -0
- brimbloom/index.html +56 -0
- brimbloom/manifest.webmanifest +47 -0
- brimbloom/og.png +3 -0
- brimbloom/shot-phone.png +3 -0
- brimbloom/skins.js +90 -0
- brimbloom/style.css +14 -0
- brimbloom/sw.js +25 -0
.gitattributes
CHANGED
|
@@ -987,3 +987,5 @@ brand/game-assets-bundle/image-2-inside.png filter=lfs diff=lfs merge=lfs -text
|
|
| 987 |
brand/game-assets-bundle/image-3-output.png filter=lfs diff=lfs merge=lfs -text
|
| 988 |
brand/game-assets-bundle/image-4-savings.png filter=lfs diff=lfs merge=lfs -text
|
| 989 |
latchkite/shot-phone.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 987 |
brand/game-assets-bundle/image-3-output.png filter=lfs diff=lfs merge=lfs -text
|
| 988 |
brand/game-assets-bundle/image-4-savings.png filter=lfs diff=lfs merge=lfs -text
|
| 989 |
latchkite/shot-phone.png filter=lfs diff=lfs merge=lfs -text
|
| 990 |
+
brimbloom/og.png filter=lfs diff=lfs merge=lfs -text
|
| 991 |
+
brimbloom/shot-phone.png filter=lfs diff=lfs merge=lfs -text
|
brimbloom/cmx.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) 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 |
+
deferred = null;
|
| 141 |
+
} else if (ios) {
|
| 142 |
+
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 143 |
+
count(game, 'install', 'ios-hint');
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
return {
|
| 148 |
+
offer() {
|
| 149 |
+
wanted = true;
|
| 150 |
+
if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
|
| 151 |
+
},
|
| 152 |
+
};
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/** Rounded rectangle path for canvas cards. */
|
| 156 |
+
export function rr(ctx, x, y, w, h, r) {
|
| 157 |
+
ctx.beginPath();
|
| 158 |
+
ctx.moveTo(x + r, y);
|
| 159 |
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
| 160 |
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
| 161 |
+
ctx.arcTo(x, y + h, x, y, r);
|
| 162 |
+
ctx.arcTo(x, y, x + w, y, r);
|
| 163 |
+
ctx.closePath();
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
|
| 167 |
+
|
| 168 |
+
/** Theme toggle shared by every page (light/dark, remembered). */
|
| 169 |
+
export function themeToggle(btn) {
|
| 170 |
+
if (!btn) return;
|
| 171 |
+
btn.addEventListener('click', () => {
|
| 172 |
+
const r = document.documentElement;
|
| 173 |
+
const c = r.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 174 |
+
const n = c === 'dark' ? 'light' : 'dark';
|
| 175 |
+
r.dataset.theme = n;
|
| 176 |
+
store('cm-theme-v', n);
|
| 177 |
+
try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
|
| 178 |
+
});
|
| 179 |
+
}
|
brimbloom/core.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Brimbloom core: deterministic simulation (no DOM). A flower blooms outward; one tap stops it. Stop inside the ring
|
| 2 |
+
// (the brim) to clear the bloom; dead centre of the ring is a perfect. Miss once and the run ends. Blooms get faster,
|
| 3 |
+
// rings thinner, and later blooms pulse or their ring breathes. Same blooms for everyone each UTC day (seed = date).
|
| 4 |
+
// Fixed 60 Hz ticks. (c) 2026 CyberMax.
|
| 5 |
+
|
| 6 |
+
export const HZ = 60;
|
| 7 |
+
export const GAP = 24; // ticks between blooms (the result flash)
|
| 8 |
+
export const LIMIT = 1.06; // a bloom that grows past the frame is a miss
|
| 9 |
+
|
| 10 |
+
export function rng(seed) {
|
| 11 |
+
let h = 0x811c9dc5;
|
| 12 |
+
for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
| 13 |
+
let a = h >>> 0;
|
| 14 |
+
return () => {
|
| 15 |
+
a = (a + 0x6d2b79f5) >>> 0;
|
| 16 |
+
let t = a;
|
| 17 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 18 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 19 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 20 |
+
};
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/** Bloom i of a seed: ring centre c and width w (radius 1 = the frame), growth speed s (radius/second),
|
| 24 |
+
* pulse amplitude p (the bloom surges and eases), breathe b (the ring itself drifts in and out), petals n. */
|
| 25 |
+
export function bloom(seed, i) {
|
| 26 |
+
const r = rng(`brimbloom:${seed}:${i}`);
|
| 27 |
+
const w = Math.max(0.075, 0.17 - i * 0.0065 - r() * 0.02);
|
| 28 |
+
return {
|
| 29 |
+
c: 0.46 + r() * 0.4, w,
|
| 30 |
+
s: Math.min(0.95, 0.42 + i * 0.04 + r() * 0.1),
|
| 31 |
+
p: i < 4 ? 0 : Math.min(0.5, 0.15 + r() * 0.35),
|
| 32 |
+
b: i < 7 || r() < 0.4 ? 0 : 0.02 + r() * 0.025,
|
| 33 |
+
n: 5 + Math.floor(r() * 4), hue: Math.floor(r() * 360),
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** Radius after t ticks. With a pulse the bloom surges and eases but never shrinks (p <= 0.5 keeps it opening). */
|
| 38 |
+
export function radius(b, t) {
|
| 39 |
+
const x = t / HZ;
|
| 40 |
+
return b.s * (x + b.p * Math.sin(x * 4.4) / 4.4);
|
| 41 |
+
}
|
| 42 |
+
/** The ring centre after t ticks (it breathes on later blooms). */
|
| 43 |
+
export const ringC = (b, t) => b.c + (b.b ? b.b * Math.sin(t / HZ * 2.2) : 0);
|
| 44 |
+
|
| 45 |
+
/** Judge a stop at radius rr against the ring: 'perfect' (middle half of the band), 'good', or 'miss'. */
|
| 46 |
+
export function judge(b, rr, t) {
|
| 47 |
+
const d = Math.abs(rr - ringC(b, t));
|
| 48 |
+
return d <= b.w * 0.25 ? 'perfect' : d <= b.w / 2 ? 'good' : 'miss';
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** A run: step(tap) once per tick; returns 'perfect' | 'good' | 'miss' | ''. */
|
| 52 |
+
export function makeSim(seed) {
|
| 53 |
+
const s = {
|
| 54 |
+
seed, i: 0, t: 0, wait: 0, cur: bloom(seed, 0), r: 0, marks: [], score: 0, perfect: 0, dead: false, last: '',
|
| 55 |
+
step(tap) {
|
| 56 |
+
if (this.dead) return '';
|
| 57 |
+
if (this.wait > 0) { if (--this.wait === 0) { this.i++; this.cur = bloom(this.seed, this.i); this.t = 0; this.r = 0; } return ''; }
|
| 58 |
+
this.t++;
|
| 59 |
+
const nr = radius(this.cur, this.t);
|
| 60 |
+
this.r = nr > this.r ? nr : this.r;
|
| 61 |
+
let res = '';
|
| 62 |
+
if (tap) res = judge(this.cur, this.r, this.t);
|
| 63 |
+
else if (this.r > LIMIT) res = 'miss';
|
| 64 |
+
if (!res) return '';
|
| 65 |
+
this.marks.push(res); this.last = res;
|
| 66 |
+
if (res === 'miss') { this.dead = true; return res; }
|
| 67 |
+
this.score++; if (res === 'perfect') this.perfect++;
|
| 68 |
+
this.wait = GAP;
|
| 69 |
+
return res;
|
| 70 |
+
},
|
| 71 |
+
};
|
| 72 |
+
return s;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** Emoji row for the share text: 🟩 perfect, 🟨 good, 🟥 the miss that ended the run (rows of 10). */
|
| 76 |
+
export function grid(marks) {
|
| 77 |
+
const e = marks.map((m) => (m === 'perfect' ? '🟩' : m === 'good' ? '🟨' : '🟥'));
|
| 78 |
+
const rows = [];
|
| 79 |
+
for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
|
| 80 |
+
return rows.join('\n');
|
| 81 |
+
}
|
brimbloom/fun.css
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* CyberMax Fun kit: shared look for CyberMax games (copied into each game). Mobile first, light + dark. (c) 2026 CyberMax. */
|
| 2 |
+
:root{--bg:#F6F3EC;--card:#fff;--ink:#11141B;--muted:#565C6B;--line:#E2DCD0;--chip:#ECE6DA;--acc:#FF6A3D;--acc-ink:#11141B;--ok:#0B7F5E;--bad:#C2361B;--shadow:0 1px 2px rgba(17,20,27,.06),0 10px 30px rgba(17,20,27,.08);color-scheme:light}
|
| 3 |
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}}
|
| 4 |
+
:root[data-theme=dark]{--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}
|
| 5 |
+
*{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}
|
| 6 |
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
|
| 7 |
+
a{color:inherit}img,canvas{max-width:100%}
|
| 8 |
+
.wrap{max-width:760px;margin:0 auto;padding:0 16px}
|
| 9 |
+
.top{display:flex;align-items:center;gap:6px;padding:12px 0}
|
| 10 |
+
.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}
|
| 11 |
+
.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}
|
| 12 |
+
.top nav a:hover,.top .icon:hover{color:var(--ink);background:var(--chip)}
|
| 13 |
+
.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}
|
| 14 |
+
.btn:hover{filter:brightness(1.06)}.btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)}.btn.sm{min-height:40px;padding:6px 14px;font-size:15px}
|
| 15 |
+
.btn.pass{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B}
|
| 16 |
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 17 |
+
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
| 18 |
+
.chip{border:1.5px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:6px 12px;font:inherit;font-size:14px;font-weight:700;cursor:pointer;min-height:36px}
|
| 19 |
+
.chip[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}.chip .lock{opacity:.7;font-size:12px}
|
| 20 |
+
.panel{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:var(--shadow)}
|
| 21 |
+
.muted{color:var(--muted)}.small{font-size:14px}
|
| 22 |
+
h1{font-size:clamp(26px,6vw,40px);line-height:1.08;letter-spacing:-.02em;margin:0 0 6px;text-wrap:balance}
|
| 23 |
+
h2{font-size:21px;letter-spacing:-.01em;margin:30px 0 8px}
|
| 24 |
+
.seo h3{font-size:17px;margin:18px 0 4px}.seo p,.seo li{color:var(--muted)}
|
| 25 |
+
.more{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:12px 0}
|
| 26 |
+
.more a{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:var(--card);text-decoration:none}.more a b{display:block}.more a span{color:var(--muted);font-size:14px}
|
| 27 |
+
footer{color:var(--muted);font-size:14px;padding:26px 0 40px;border-top:1px solid var(--line);margin-top:36px}
|
| 28 |
+
#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}
|
| 29 |
+
#toast.on{transform:translate(-50%,0);opacity:1}
|
| 30 |
+
.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}
|
| 31 |
+
html.cmx-pro .nudge{display:none}
|
| 32 |
+
.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}
|
| 33 |
+
html.cmx-pro .pro-badge{display:inline-block}
|
| 34 |
+
dialog.funpass{border:0;padding:0;border-radius:20px;max-width:min(520px,calc(100% - 24px));width:100%;background:var(--card);color:var(--ink);box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
| 35 |
+
dialog.funpass::backdrop{background:rgba(10,12,18,.55)}
|
| 36 |
+
.fp{padding:22px 20px 18px;position:relative}.fp h2{margin:2px 0 10px;font-size:24px}
|
| 37 |
+
.fp-x{position:absolute;right:10px;top:8px;border:0;background:none;color:var(--muted);font-size:28px;cursor:pointer;width:44px;height:44px}
|
| 38 |
+
.fp-kick{margin:0;color:var(--acc);font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
|
| 39 |
+
.fp-perks{margin:0 0 14px;padding-left:20px}.fp-perks li{margin:2px 0}
|
| 40 |
+
.fp-plans{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
| 41 |
+
.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(--bg)}
|
| 42 |
+
.fp-plan:hover{border-color:var(--acc)}.fp-plan.best{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
|
| 43 |
+
.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}
|
| 44 |
+
.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)}
|
| 45 |
+
.fp-small{font-size:13px;color:var(--muted);margin:10px 0 4px}
|
| 46 |
+
.fp-code{display:block;user-select:all;word-break:break-all;background:var(--chip);padding:8px;border-radius:8px;font-size:12px}
|
| 47 |
+
.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)}
|
| 48 |
+
@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}}
|
| 49 |
+
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
| 50 |
+
@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}}
|
| 51 |
+
@media (min-width:760px){dialog.funpass{max-width:720px}.fp-perks{columns:2;column-gap:28px}}
|
| 52 |
+
.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}
|
brimbloom/funpass.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
|
| 2 |
+
// Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
|
| 3 |
+
// Monthly $2.99 · Yearly $19 · Lifetime $29.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
|
| 4 |
+
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
+
// After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
|
| 6 |
+
// the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
|
| 7 |
+
// Every game on the same site reads the same browser key, so one pass covers them all. The code on the receipt page
|
| 8 |
+
// unlocks any other device or browser. No account, no cookies, nothing personal stored. (c) 2026 CyberMax.
|
| 9 |
+
import { count, store, toast } from './cmx.js';
|
| 10 |
+
|
| 11 |
+
const DATA = 'https://data.cybermaxtools.com';
|
| 12 |
+
const KEY = 'cmx-pro:fun-pass';
|
| 13 |
+
const SID = /^(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
|
| 14 |
+
const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
|
| 15 |
+
export const PLANS = [
|
| 16 |
+
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 17 |
+
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 18 |
+
{ id: 'unlock', label: 'Lifetime', price: '$29.99', per: ' once', note: 'Pay once, keep forever' },
|
| 19 |
+
];
|
| 20 |
+
export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
|
| 21 |
+
|
| 22 |
+
let state = store(KEY);
|
| 23 |
+
const listeners = [];
|
| 24 |
+
let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
|
| 25 |
+
|
| 26 |
+
export const isPro = () => !!(state && state.code);
|
| 27 |
+
export function onChange(fn) { listeners.push(fn); fn(isPro()); }
|
| 28 |
+
function emit() {
|
| 29 |
+
document.documentElement.classList.toggle('cmx-pro', isPro());
|
| 30 |
+
listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async function check(code) {
|
| 34 |
+
try {
|
| 35 |
+
const r = await fetch(`${DATA}/unlock/fun-pass?json=1&session_id=${encodeURIComponent(code)}`);
|
| 36 |
+
const j = await r.json();
|
| 37 |
+
return j && typeof j.pro === 'boolean' ? j.pro : null;
|
| 38 |
+
} catch { return null; }
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export async function unlock(code) {
|
| 42 |
+
const ok = await check(code);
|
| 43 |
+
if (ok) {
|
| 44 |
+
state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
|
| 45 |
+
count(cfg.game, 'pro', 'unlocked');
|
| 46 |
+
toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
|
| 47 |
+
emit();
|
| 48 |
+
render();
|
| 49 |
+
} else if (ok === false) toast('That code is not an active Fun Pass.', 3500);
|
| 50 |
+
else toast('Could not reach the pass check. Try again in a minute.', 3500);
|
| 51 |
+
return ok;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function recheck() {
|
| 55 |
+
if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
|
| 56 |
+
const ok = await check(state.code);
|
| 57 |
+
if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
|
| 58 |
+
else if (ok) state = store(KEY, { ...state, checked: Date.now() });
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 62 |
+
const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
|
| 63 |
+
|
| 64 |
+
function dialog() {
|
| 65 |
+
let d = document.getElementById('funpass');
|
| 66 |
+
if (!d) {
|
| 67 |
+
d = document.createElement('dialog');
|
| 68 |
+
d.id = 'funpass';
|
| 69 |
+
d.className = 'funpass';
|
| 70 |
+
document.body.appendChild(d);
|
| 71 |
+
d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
|
| 72 |
+
}
|
| 73 |
+
return d;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function render() {
|
| 77 |
+
const d = dialog();
|
| 78 |
+
const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
|
| 79 |
+
if (isPro()) {
|
| 80 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p><h2>You're a supporter. Thank you!</h2>
|
| 81 |
+
<p>The pass is on in this browser for every CyberMax game: ${perks.map(esc).join(' · ')}.</p>
|
| 82 |
+
<p class="fp-small">Your code (paste it on another device to turn the pass on there):</p><code class="fp-code">${esc(state.code)}</code>
|
| 83 |
+
<p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p></div>`;
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
|
| 87 |
+
<h2>One pass. Every game. More to play.</h2>
|
| 88 |
+
<ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 89 |
+
<div class="fp-plans">${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('')}</div>
|
| 90 |
+
<p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The free daily games stay free for everyone.</p>
|
| 91 |
+
<details><summary>Already have a pass?</summary><form class="fp-form"><input aria-label="Pass code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details></div>`;
|
| 92 |
+
d.querySelector('.fp-form').addEventListener('submit', (e) => {
|
| 93 |
+
e.preventDefault();
|
| 94 |
+
const c = e.target.querySelector('input').value.trim();
|
| 95 |
+
if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
|
| 96 |
+
});
|
| 97 |
+
d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** Open the pass sheet; `from` names the button that opened it (counted as cta). */
|
| 101 |
+
export function open(from = 'button') {
|
| 102 |
+
render();
|
| 103 |
+
const d = dialog();
|
| 104 |
+
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
| 105 |
+
count(cfg.game, 'cta', `pass-open-${from}`);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/** After a finished round (the value moment): one small inline card under the result, never a pop-up. Shows the
|
| 109 |
+
* pass price, the honest trust line and an optional "email me" weekly reminder (double opt-in).
|
| 110 |
+
* `host` is the result panel; the card is added once and removed for pass holders. */
|
| 111 |
+
export function afterWin(host, from = 'win') {
|
| 112 |
+
if (!host) return;
|
| 113 |
+
if (isPro()) { host.querySelector('.fp-after')?.remove(); return; }
|
| 114 |
+
let el = host.querySelector('.fp-after');
|
| 115 |
+
if (!el) { el = document.createElement('div'); el.className = 'fp-after'; host.appendChild(el); }
|
| 116 |
+
const best = PLANS.find((p) => p.best) || PLANS[0];
|
| 117 |
+
el.innerHTML = `<p><b>Want more today?</b> ${esc(cfg.extra || 'The Fun Pass unlocks extra packs, the full archive and themes in every CyberMax game.')}</p>
|
| 118 |
+
<p class="fp-row"><a class="btn sm" href="${buyUrl(best.id)}-after" rel="nofollow" data-after>Get the Fun Pass: ${esc(best.price)}${esc(best.per)}</a> <button class="btn sm ghost" type="button" data-plans>See all plans</button></p>
|
| 119 |
+
<p class="fp-small">Secure Stripe checkout · the pass turns on as soon as you pay · cancel any time from your receipt email · 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>). Today's free game stays free.</p>
|
| 120 |
+
<div class="fp-cap"></div>`;
|
| 121 |
+
el.querySelector('[data-after]').addEventListener('click', () => { count(cfg.game, 'cta', `pass-${best.id}-after`); kit((K) => K.event('upgrade-click', cfg.game)); });
|
| 122 |
+
el.querySelector('[data-plans]').addEventListener('click', () => open(`after-${from}`));
|
| 123 |
+
kit((K) => {
|
| 124 |
+
K.event('result', cfg.game); K.event('upgrade-view', cfg.game);
|
| 125 |
+
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' });
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Convert kit (funnel beacons + email capture) from the CyberMax site; optional, the game works without it.
|
| 130 |
+
let kitQ = null;
|
| 131 |
+
function kit(fn) {
|
| 132 |
+
if (window.CMXConvert) return fn(window.CMXConvert);
|
| 133 |
+
if (kitQ) return kitQ.push(fn);
|
| 134 |
+
kitQ = [fn];
|
| 135 |
+
const sc = document.createElement('script');
|
| 136 |
+
sc.src = 'https://cybermaxtools.com/store/convert.js'; sc.async = true;
|
| 137 |
+
sc.onload = () => kitQ.splice(0).forEach((f) => { try { f(window.CMXConvert); } catch { /* optional */ } });
|
| 138 |
+
document.head.appendChild(sc);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/** Call once per page: game id, display name and the game's own Pro perks (listed first). */
|
| 142 |
+
export function init(options) {
|
| 143 |
+
cfg = { ...cfg, ...options };
|
| 144 |
+
const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
|
| 145 |
+
if (m && SID.test(decodeURIComponent(m[1]))) {
|
| 146 |
+
history.replaceState(null, '', location.pathname + location.search);
|
| 147 |
+
unlock(decodeURIComponent(m[1]));
|
| 148 |
+
} else recheck();
|
| 149 |
+
emit();
|
| 150 |
+
}
|
brimbloom/game.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Brimbloom: game screen (fixed-tick loop, canvas, sound, daily/practice/archive, share grid, Fun Pass, Skin Pack). (c) 2026 CyberMax.
|
| 2 |
+
import { makeSim, grid, ringC, judge, radius, HZ, LIMIT } from './core.js';
|
| 3 |
+
import { dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js';
|
| 4 |
+
import * as skins from './skins.js';
|
| 5 |
+
import * as pass from './funpass.js';
|
| 6 |
+
|
| 7 |
+
const GAME = 'brimbloom';
|
| 8 |
+
const PATH = '/games/brimbloom/';
|
| 9 |
+
const GUMROAD = 'https://cybermax1.gumroad.com/l/cybermax-brimbloom-skins';
|
| 10 |
+
const $ = (id) => document.getElementById(id);
|
| 11 |
+
const cv = $('c');
|
| 12 |
+
const g = cv.getContext('2d');
|
| 13 |
+
// tier: free | pass (CyberMax Fun Pass) | pack (Brimbloom Skin Pack, one-time). petals: list of colours or 'hue' (each bloom's own)
|
| 14 |
+
const SKINS = {
|
| 15 |
+
pond: { name: 'Pond', tier: 'free', bg: '#0E2A2A', band: 'rgba(255,255,255,.14)', sweet: 'rgba(255,255,255,.32)', edge: '#F7F1E3', petals: ['#FF8FB1', '#FFB3C8'], core: '#FFD84D', shape: 'round', text: '#F7F1E3' },
|
| 16 |
+
night: { name: 'Night', tier: 'pass', bg: '#0A0A1F', band: 'rgba(160,140,255,.16)', sweet: 'rgba(160,140,255,.38)', edge: '#C9C0FF', petals: ['#8F7CFF', '#C3B8FF'], core: '#FFF3B0', shape: 'pointed', text: '#ECE9FF' },
|
| 17 |
+
linen: { name: 'Linen', tier: 'pass', bg: '#F5EFE3', band: 'rgba(17,20,27,.08)', sweet: 'rgba(17,20,27,.18)', edge: '#11141B', petals: ['#E4572E', '#F3A712'], core: '#11141B', shape: 'round', text: '#11141B' },
|
| 18 |
+
sakura: { name: 'Sakura', tier: 'pack', bg: '#2B1B2E', band: 'rgba(255,214,228,.14)', sweet: 'rgba(255,214,228,.34)', edge: '#FFE4EE', petals: ['#FFC2D6', '#FFE4EE'], core: '#FF6B9A', shape: 'heart', text: '#FFE4EE' },
|
| 19 |
+
lotus: { name: 'Lotus', tier: 'pack', bg: '#06242E', band: 'rgba(130,240,220,.14)', sweet: 'rgba(130,240,220,.34)', edge: '#BFFFF2', petals: ['#F7A8D8', '#FFFFFF'], core: '#FFE066', shape: 'pointed', text: '#E6FFFA' },
|
| 20 |
+
sunflower: { name: 'Sunflower', tier: 'pack', bg: '#1E3A1A', band: 'rgba(255,230,120,.15)', sweet: 'rgba(255,230,120,.35)', edge: '#FFF2B3', petals: ['#FFC300', '#FFD84D'], core: '#5A3A1A', shape: 'pointed', text: '#FFF8DC' },
|
| 21 |
+
prism: { name: 'Prism', tier: 'pack', bg: '#111111', band: 'rgba(255,255,255,.12)', sweet: 'rgba(255,255,255,.3)', edge: '#FFFFFF', petals: 'hue', core: '#FFFFFF', shape: 'round', text: '#FFFFFF' },
|
| 22 |
+
frost: { name: 'Frost', tier: 'pack', bg: '#0B1D33', band: 'rgba(180,225,255,.15)', sweet: 'rgba(180,225,255,.36)', edge: '#E3F4FF', petals: ['#9AD8FF', '#E3F4FF'], core: '#FFFFFF', shape: 'star', text: '#E3F4FF' },
|
| 23 |
+
ember: { name: 'Ember', tier: 'pack', bg: '#1A0A05', band: 'rgba(255,140,60,.15)', sweet: 'rgba(255,140,60,.36)', edge: '#FFD0A8', petals: ['#FF5A1F', '#FFB03B'], core: '#FFE9A8', shape: 'star', text: '#FFE9D6' },
|
| 24 |
+
};
|
| 25 |
+
const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack');
|
| 26 |
+
const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same blooms for everyone on Earth
|
| 27 |
+
const todayNum = dayNumber(todayIso);
|
| 28 |
+
|
| 29 |
+
skins.capture({ game: GAME, name: 'Brimbloom', skins: PACK.length, price: '$1.99', gumroad: GUMROAD, preview: PACK.map((s) => (s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : s.petals[0])),
|
| 30 |
+
perks: [`${PACK.length} flowers with their own pond and petals: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] });
|
| 31 |
+
pass.init({ game: GAME, name: 'Brimbloom', perks: ['Every past daily garden in the archive', 'Night and Linen themes'] });
|
| 32 |
+
const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned());
|
| 33 |
+
let skin = SKINS[store('brimbloom:skin')] && canUse(SKINS[store('brimbloom:skin')]) ? store('brimbloom:skin') : 'pond';
|
| 34 |
+
let muted = !!store('brimbloom:mute');
|
| 35 |
+
let mode = { kind: 'daily', date: todayIso };
|
| 36 |
+
let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shake = 0, burst = 0, lastRun = null;
|
| 37 |
+
const install = pwa(GAME, $('install'));
|
| 38 |
+
themeToggle($('theme'));
|
| 39 |
+
|
| 40 |
+
// ------------------------------------------------------------------ sound
|
| 41 |
+
let ac;
|
| 42 |
+
function blip(f, d = 0.06, type = 'sine', vol = 0.08) {
|
| 43 |
+
if (muted) return;
|
| 44 |
+
try {
|
| 45 |
+
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
|
| 46 |
+
const o = ac.createOscillator(), v = ac.createGain();
|
| 47 |
+
o.type = type; o.frequency.setValueAtTime(f, ac.currentTime);
|
| 48 |
+
o.frequency.exponentialRampToValueAtTime(f * (type === 'sawtooth' ? 0.3 : 1.5), ac.currentTime + d);
|
| 49 |
+
v.gain.setValueAtTime(vol, ac.currentTime); v.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + d);
|
| 50 |
+
o.connect(v); v.connect(ac.destination); o.start(); o.stop(ac.currentTime + d + 0.02);
|
| 51 |
+
} catch { /* no audio */ }
|
| 52 |
+
}
|
| 53 |
+
$('mute').textContent = muted ? '🔇' : '🔊';
|
| 54 |
+
$('mute').onclick = () => { muted = !muted; store('brimbloom:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; };
|
| 55 |
+
|
| 56 |
+
// ------------------------------------------------------------------ modes + stats
|
| 57 |
+
const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date);
|
| 58 |
+
const dayKey = (m) => `brimbloom:day:${m.date}`;
|
| 59 |
+
const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0, marks: [] };
|
| 60 |
+
function renderModes() {
|
| 61 |
+
const el = $('modes'); el.innerHTML = '';
|
| 62 |
+
const items = [
|
| 63 |
+
{ t: `Daily #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => setMode({ kind: 'daily', date: todayIso }) },
|
| 64 |
+
{ t: 'Practice', on: mode.kind === 'practice', go: () => setMode({ kind: 'practice', seed: `p${Date.now()}` }) },
|
| 65 |
+
{ t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive },
|
| 66 |
+
];
|
| 67 |
+
for (const it of items) {
|
| 68 |
+
const b = document.createElement('button');
|
| 69 |
+
b.className = 'chip'; b.type = 'button'; b.setAttribute('aria-pressed', String(it.on));
|
| 70 |
+
b.innerHTML = `${it.t}${it.lock ? ' <span class="lock">🔒</span>' : ''}`;
|
| 71 |
+
b.onclick = it.go; el.appendChild(b);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
function archive() {
|
| 75 |
+
if (!pass.isPro()) { pass.open('archive'); return; }
|
| 76 |
+
const n = todayNum - 1;
|
| 77 |
+
if (n < 1) { toast('The archive fills up day by day; the first archived garden arrives tomorrow.', 4000); return; }
|
| 78 |
+
const k = Number(prompt(`Play a past daily garden: enter a number from 1 to ${n}`, String(n)));
|
| 79 |
+
if (k >= 1 && k <= n) setMode({ kind: 'daily', date: dateOf(k) });
|
| 80 |
+
}
|
| 81 |
+
function setMode(m) {
|
| 82 |
+
mode = m;
|
| 83 |
+
$('num').textContent = m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`;
|
| 84 |
+
$('over').hidden = true;
|
| 85 |
+
renderModes(); reset(); hud();
|
| 86 |
+
}
|
| 87 |
+
function hud() {
|
| 88 |
+
const s = mode.kind === 'daily' ? dayStats(mode) : { best: store('brimbloom:practice-best') || 0, tries: 0 };
|
| 89 |
+
$('best').textContent = s.best; $('tries').textContent = s.tries || 0;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ------------------------------------------------------------------ loop
|
| 93 |
+
function reset() { sim = makeSim(seedOf(mode)); state = 'ready'; pending = false; acc = 0; flash = 0; shake = 0; burst = 0; }
|
| 94 |
+
function start() {
|
| 95 |
+
if (mode.kind === 'practice') mode.seed = `p${Date.now()}`;
|
| 96 |
+
reset(); state = 'run'; last = performance.now();
|
| 97 |
+
count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice');
|
| 98 |
+
$('over').hidden = true;
|
| 99 |
+
}
|
| 100 |
+
function input() {
|
| 101 |
+
if (state === 'ready') { start(); return; }
|
| 102 |
+
if (state === 'run') { pending = true; return; }
|
| 103 |
+
if (state === 'dead' && performance.now() - deadAt > 300) start();
|
| 104 |
+
}
|
| 105 |
+
function die() {
|
| 106 |
+
state = 'dead'; deadAt = performance.now(); flash = 1; shake = 12;
|
| 107 |
+
lastRun = { score: sim.score, perfect: sim.perfect, marks: sim.marks.slice() };
|
| 108 |
+
blip(150, 0.32, 'sawtooth', 0.11);
|
| 109 |
+
if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
|
| 110 |
+
let newBest = false;
|
| 111 |
+
if (mode.kind === 'daily') {
|
| 112 |
+
const s = dayStats(mode); s.tries = (s.tries || 0) + 1;
|
| 113 |
+
if (sim.score > (s.best || 0) || (sim.score === s.best && sim.perfect > (s.perfect || 0))) { s.best = sim.score; s.perfect = sim.perfect; s.marks = lastRun.marks; newBest = sim.score > 0; }
|
| 114 |
+
store(dayKey(mode), s);
|
| 115 |
+
} else if (sim.score > (store('brimbloom:practice-best') || 0)) { store('brimbloom:practice-best', sim.score); newBest = true; }
|
| 116 |
+
hud(); showOver(newBest);
|
| 117 |
+
if (sim.score >= 5) install.offer();
|
| 118 |
+
}
|
| 119 |
+
function frame(now) {
|
| 120 |
+
requestAnimationFrame(frame);
|
| 121 |
+
if (state === 'run') {
|
| 122 |
+
acc += Math.min(0.25, (now - last) / 1000); last = now;
|
| 123 |
+
let n = 0;
|
| 124 |
+
while (acc >= 1 / HZ && n++ < 8) {
|
| 125 |
+
acc -= 1 / HZ;
|
| 126 |
+
const tap = pending && sim.wait === 0; pending = false;
|
| 127 |
+
const ev = sim.step(tap);
|
| 128 |
+
if (ev === 'perfect') { blip(880 + Math.min(sim.score, 30) * 20, 0.09, 'sine', 0.08); burst = 1; }
|
| 129 |
+
else if (ev === 'good') { blip(560 + Math.min(sim.score, 30) * 14, 0.07, 'triangle', 0.07); burst = 0.6; }
|
| 130 |
+
if (sim.dead) { die(); break; }
|
| 131 |
+
}
|
| 132 |
+
} else last = now;
|
| 133 |
+
draw(now);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// ------------------------------------------------------------------ drawing
|
| 137 |
+
const TAU = Math.PI * 2;
|
| 138 |
+
function petal(ctx, len, wid, shape) {
|
| 139 |
+
ctx.beginPath();
|
| 140 |
+
if (shape === 'pointed') { ctx.moveTo(0, 0); ctx.quadraticCurveTo(wid, -len * 0.5, 0, -len); ctx.quadraticCurveTo(-wid, -len * 0.5, 0, 0); }
|
| 141 |
+
else if (shape === 'heart') { ctx.moveTo(0, 0); ctx.bezierCurveTo(wid * 1.3, -len * 0.4, wid * 0.8, -len * 1.05, 0, -len * 0.82); ctx.bezierCurveTo(-wid * 0.8, -len * 1.05, -wid * 1.3, -len * 0.4, 0, 0); }
|
| 142 |
+
else if (shape === 'star') { ctx.moveTo(0, 0); ctx.lineTo(wid * 0.55, -len * 0.45); ctx.lineTo(0, -len); ctx.lineTo(-wid * 0.55, -len * 0.45); ctx.closePath(); }
|
| 143 |
+
else ctx.ellipse(0, -len / 2, wid * 0.7, len / 2, 0, 0, TAU);
|
| 144 |
+
ctx.fill();
|
| 145 |
+
}
|
| 146 |
+
function flower(ctx, x, y, R, n, S, hue, spinA) {
|
| 147 |
+
if (R <= 0.5) return;
|
| 148 |
+
const cols = S.petals === 'hue' ? [`hsl(${hue},90%,62%)`, `hsl(${(hue + 40) % 360},90%,72%)`] : S.petals;
|
| 149 |
+
ctx.save(); ctx.translate(x, y); ctx.rotate(spinA);
|
| 150 |
+
for (let layer = 0; layer < 2; layer++) {
|
| 151 |
+
ctx.fillStyle = cols[layer];
|
| 152 |
+
const L = R * (layer ? 0.72 : 1), Wd = Math.max(4, R * (layer ? 0.3 : 0.42) * 6 / n);
|
| 153 |
+
for (let k = 0; k < n; k++) { ctx.save(); ctx.rotate(k * TAU / n + layer * Math.PI / n); petal(ctx, L, Wd, S.shape); ctx.restore(); }
|
| 154 |
+
}
|
| 155 |
+
ctx.fillStyle = S.core; ctx.beginPath(); ctx.arc(0, 0, Math.max(5, R * 0.16), 0, TAU); ctx.fill();
|
| 156 |
+
ctx.restore();
|
| 157 |
+
}
|
| 158 |
+
function ring(ctx, x, y, Rmax, c, w, S) {
|
| 159 |
+
ctx.fillStyle = S.band; ctx.beginPath(); ctx.arc(x, y, (c + w / 2) * Rmax, 0, TAU); ctx.arc(x, y, Math.max(0, c - w / 2) * Rmax, 0, TAU, true); ctx.fill();
|
| 160 |
+
ctx.fillStyle = S.sweet; ctx.beginPath(); ctx.arc(x, y, (c + w * 0.25) * Rmax, 0, TAU); ctx.arc(x, y, (c - w * 0.25) * Rmax, 0, TAU, true); ctx.fill();
|
| 161 |
+
ctx.strokeStyle = S.edge; ctx.lineWidth = 3; ctx.globalAlpha = 0.7;
|
| 162 |
+
for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, r * Rmax, 0, TAU); ctx.stroke(); }
|
| 163 |
+
ctx.globalAlpha = 1;
|
| 164 |
+
}
|
| 165 |
+
function draw(now) {
|
| 166 |
+
const S = SKINS[skin], Wc = cv.width, cx = Wc / 2, cy = Wc / 2 + 20, Rmax = Wc * 0.43;
|
| 167 |
+
g.save();
|
| 168 |
+
if (shake > 0) { g.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); shake *= 0.85; if (shake < 0.5) shake = 0; }
|
| 169 |
+
g.fillStyle = S.bg; g.fillRect(-20, -20, Wc + 40, Wc + 40);
|
| 170 |
+
// ripples
|
| 171 |
+
g.strokeStyle = S.band; g.lineWidth = 2;
|
| 172 |
+
for (let i = 0; i < 3; i++) { const r = ((now / 30 + i * 120) % 360); g.globalAlpha = 1 - r / 360; g.beginPath(); g.arc(cx, cy, r + 20, 0, TAU); g.stroke(); }
|
| 173 |
+
g.globalAlpha = 1;
|
| 174 |
+
const b = sim.cur;
|
| 175 |
+
ring(g, cx, cy, Rmax, ringC(b, sim.t), b.w, S);
|
| 176 |
+
const R = (state === 'ready' ? 0.18 + Math.sin(now / 300) * 0.02 : Math.min(sim.r, LIMIT)) * Rmax;
|
| 177 |
+
flower(g, cx, cy, R * (1 + burst * 0.06), b.n, S, b.hue, now / 4000);
|
| 178 |
+
burst *= 0.88;
|
| 179 |
+
// marks row (this run)
|
| 180 |
+
const marks = sim.marks.slice(-14);
|
| 181 |
+
marks.forEach((m, i) => { g.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; g.beginPath(); g.arc(cx - (marks.length - 1) * 13 + i * 26, Wc - 26, 8, 0, TAU); g.fill(); });
|
| 182 |
+
g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle';
|
| 183 |
+
if (state === 'ready') {
|
| 184 |
+
g.font = '900 60px system-ui, sans-serif'; g.fillText('TAP', cx, 64);
|
| 185 |
+
g.font = '700 24px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('then tap again when the bloom fills the ring', cx, 112); g.globalAlpha = 1;
|
| 186 |
+
} else {
|
| 187 |
+
g.font = '900 72px system-ui, sans-serif'; g.fillText(String(sim.score), cx, 56);
|
| 188 |
+
if (sim.wait > 0) { g.font = '900 34px system-ui, sans-serif'; g.fillStyle = sim.last === 'perfect' ? '#3DDC84' : '#FFC93C'; g.fillText(sim.last === 'perfect' ? 'PERFECT' : 'GOOD', cx, 112); }
|
| 189 |
+
if (state === 'dead') { g.font = '800 28px system-ui, sans-serif'; g.fillStyle = S.text; g.fillText(sim.r > LIMIT ? 'it overflowed · tap to retry' : 'off the brim · tap to retry', cx, 112); }
|
| 190 |
+
}
|
| 191 |
+
if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(0, 0, Wc, Wc); flash *= 0.88; if (flash < 0.02) flash = 0; }
|
| 192 |
+
g.restore();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// ------------------------------------------------------------------ results, share, card
|
| 196 |
+
function result() {
|
| 197 |
+
if (mode.kind === 'daily') { const s = dayStats(mode); return { best: s.best, perfect: s.perfect || 0, marks: s.marks || [], tries: s.tries }; }
|
| 198 |
+
return { best: lastRun ? lastRun.score : 0, perfect: lastRun ? lastRun.perfect : 0, marks: lastRun ? lastRun.marks : [] };
|
| 199 |
+
}
|
| 200 |
+
function shareText() {
|
| 201 |
+
const r = result();
|
| 202 |
+
const head = mode.kind === 'practice' ? 'Brimbloom practice 🌸' : `Brimbloom #${dayNumber(mode.date)} 🌸`;
|
| 203 |
+
return `${head} ${r.best} blooms, ${r.perfect} perfect${r.tries ? ` (${r.tries} tries)` : ''}\n${grid(r.marks)}\nCan you stop on the brim?`;
|
| 204 |
+
}
|
| 205 |
+
function showOver(newBest) {
|
| 206 |
+
const s = mode.kind === 'daily' ? dayStats(mode) : null;
|
| 207 |
+
$('over-title').textContent = newBest ? `New best: ${lastRun.score}!` : `${lastRun.score} bloom${lastRun.score === 1 ? '' : 's'}, ${lastRun.perfect} perfect`;
|
| 208 |
+
$('over-line').textContent = s ? `Today's best ${s.best} after ${s.tries} tries. Tap the game to go again.` : 'Practice garden. Tap the game to go again.';
|
| 209 |
+
$('over-grid').textContent = grid(lastRun.marks);
|
| 210 |
+
const it = intents(GAME, shareText(), PATH);
|
| 211 |
+
$('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
|
| 212 |
+
$('over').hidden = false;
|
| 213 |
+
pass.afterWin($('over'), 'over');
|
| 214 |
+
}
|
| 215 |
+
function card() {
|
| 216 |
+
const S = SKINS[skin], c = document.createElement('canvas');
|
| 217 |
+
c.width = 1080; c.height = 1350;
|
| 218 |
+
const x = c.getContext('2d');
|
| 219 |
+
x.fillStyle = S.bg; x.fillRect(0, 0, 1080, 1350);
|
| 220 |
+
ring(x, 540, 700, 330, 0.7, 0.16, S);
|
| 221 |
+
flower(x, 540, 700, 0.7 * 330, 7, S, 330, 0.2);
|
| 222 |
+
const r = result();
|
| 223 |
+
x.fillStyle = S.text; x.textAlign = 'left';
|
| 224 |
+
x.font = '900 66px system-ui, sans-serif'; x.fillText('BRIMBLOOM', 80, 130);
|
| 225 |
+
x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif';
|
| 226 |
+
x.fillText(mode.kind === 'daily' ? `Daily garden #${dayNumber(mode.date)} · ${mode.date}` : 'Practice garden', 80, 190);
|
| 227 |
+
x.globalAlpha = 1; x.textAlign = 'right'; x.font = '900 120px system-ui, sans-serif'; x.fillText(String(r.best), 1000, 150);
|
| 228 |
+
x.font = '700 34px system-ui, sans-serif'; x.fillText(`blooms · ${r.perfect} perfect`, 1000, 195);
|
| 229 |
+
x.textAlign = 'left';
|
| 230 |
+
const ms = r.marks.slice(0, 30);
|
| 231 |
+
ms.forEach((m, i) => { x.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; x.fillRect(80 + (i % 10) * 92, 990 + Math.floor(i / 10) * 52, 76, 40); });
|
| 232 |
+
x.fillStyle = S.text; x.font = '800 48px system-ui, sans-serif'; x.fillText('Can you stop on the brim?', 80, 1200);
|
| 233 |
+
x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/brimbloom', 80, 1290);
|
| 234 |
+
return c;
|
| 235 |
+
}
|
| 236 |
+
$('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `brimbloom-${mode.date || 'practice'}.png`);
|
| 237 |
+
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
|
| 238 |
+
$('card').onclick = async () => { download(await canvasBlob(card()), `brimbloom-${mode.date || 'practice'}.png`); count(GAME, 'share', 'png'); };
|
| 239 |
+
|
| 240 |
+
// ------------------------------------------------------------------ input, skins, start
|
| 241 |
+
cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); });
|
| 242 |
+
cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); input(); } });
|
| 243 |
+
document.addEventListener('keydown', (e) => { if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); } });
|
| 244 |
+
$('pass-btn').onclick = () => pass.open('header');
|
| 245 |
+
$('skins-btn').onclick = () => skins.open('header');
|
| 246 |
+
document.addEventListener('click', (e) => {
|
| 247 |
+
const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass);
|
| 248 |
+
const k = e.target.closest('[data-skins]'); if (k) skins.open(k.dataset.skins);
|
| 249 |
+
});
|
| 250 |
+
document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'run') last = performance.now(); });
|
| 251 |
+
function renderSkins() {
|
| 252 |
+
const el = $('skins'); el.innerHTML = '';
|
| 253 |
+
for (const [id, s] of Object.entries(SKINS)) {
|
| 254 |
+
const b = document.createElement('button');
|
| 255 |
+
const lock = !canUse(s);
|
| 256 |
+
b.type = 'button'; b.title = `${s.name}${s.tier === 'pass' ? ' (Fun Pass)' : s.tier === 'pack' ? ' (Skin Pack)' : ''}`; b.setAttribute('aria-label', b.title);
|
| 257 |
+
b.style.background = s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : `linear-gradient(135deg, ${s.bg} 50%, ${s.petals[0]} 50%)`;
|
| 258 |
+
b.setAttribute('aria-pressed', String(id === skin));
|
| 259 |
+
if (lock) b.classList.add('locked');
|
| 260 |
+
b.onclick = () => {
|
| 261 |
+
if (lock) { if (s.tier === 'pass') pass.open('theme'); else skins.open('dot'); return; }
|
| 262 |
+
skin = id; store('brimbloom:skin', id); renderSkins();
|
| 263 |
+
};
|
| 264 |
+
el.appendChild(b);
|
| 265 |
+
}
|
| 266 |
+
$('skins-btn').textContent = skins.owned() ? '🌸 Skins' : '🌸 Skin Pack $1.99';
|
| 267 |
+
}
|
| 268 |
+
const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'pond'; store('brimbloom:skin', 'pond'); } renderSkins(); renderModes(); };
|
| 269 |
+
pass.onChange(refresh);
|
| 270 |
+
skins.onChange(refresh);
|
| 271 |
+
|
| 272 |
+
// Screenshot helper for scripts/games/render_assets.mjs: a mid-run frame on today's garden (no effect on normal play).
|
| 273 |
+
window.__cmxShot = async (stage = 1) => {
|
| 274 |
+
start();
|
| 275 |
+
const s = makeSim(todayIso);
|
| 276 |
+
while (s.score < 4 && !s.dead) { const t = s.t + 1; s.step(s.wait === 0 && judge(s.cur, Math.max(s.r, radius(s.cur, t)), t) === 'perfect'); }
|
| 277 |
+
while (s.wait > 0) s.step(false);
|
| 278 |
+
for (let i = 0; i < 28; i++) s.step(false);
|
| 279 |
+
sim = s; state = 'shot';
|
| 280 |
+
if (stage === 2) { s.marks.push('miss'); s.dead = true; die(); }
|
| 281 |
+
};
|
| 282 |
+
|
| 283 |
+
// Demo recorder helper (scripts/studio/demo_video.mjs): real taps (pointer events) on perfect moments for n blooms,
|
| 284 |
+
// then one early tap so the run ends on screen. Cosmetic games only; there is no leaderboard to game.
|
| 285 |
+
window.__cmxDemo = (n = 6) => {
|
| 286 |
+
const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
| 287 |
+
if (state !== 'run') tap();
|
| 288 |
+
const iv = setInterval(() => {
|
| 289 |
+
if (state === 'dead') { clearInterval(iv); return; }
|
| 290 |
+
if (state !== 'run' || sim.wait > 0 || pending) return;
|
| 291 |
+
const t = sim.t + 1;
|
| 292 |
+
if (sim.score >= n ? sim.t > 12 : judge(sim.cur, Math.max(sim.r, radius(sim.cur, t)), t) === 'perfect') tap();
|
| 293 |
+
}, 4);
|
| 294 |
+
};
|
| 295 |
+
setMode(mode);
|
| 296 |
+
requestAnimationFrame(frame);
|
brimbloom/icon-192.png
ADDED
|
|
brimbloom/icon-512.png
ADDED
|
|
brimbloom/icon-maskable.png
ADDED
|
|
brimbloom/icon.svg
ADDED
|
|
brimbloom/index.html
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>Brimbloom: one-tap timing game, stop the bloom on the brim | CyberMax</title>
|
| 4 |
+
<meta name="description" content="Brimbloom is a free one-tap timing game: a flower blooms outward, tap to stop it inside the ring. Same blooms for everyone each day, rounds of seconds and a Wordle-style share grid. Works offline, installs on your phone.">
|
| 5 |
+
<link rel="canonical" href="https://cybermaxtools.com/games/brimbloom/">
|
| 6 |
+
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
+
<meta property="og:title" content="Brimbloom: stop on the brim">
|
| 8 |
+
<meta property="og:description" content="One tap stops the bloom. Inside the ring and it scores; miss once and it's over. Everyone gets the same garden today.">
|
| 9 |
+
<meta property="og:url" content="https://cybermaxtools.com/games/brimbloom/">
|
| 10 |
+
<meta property="og:image" content="https://cybermaxtools.com/games/brimbloom/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
|
| 11 |
+
<meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/brimbloom/og.png">
|
| 12 |
+
<meta name="theme-color" content="#0E2A2A">
|
| 13 |
+
<link rel="manifest" href="manifest.webmanifest">
|
| 14 |
+
<link rel="icon" href="icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icon-192.png">
|
| 15 |
+
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Brimbloom">
|
| 16 |
+
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 17 |
+
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
| 18 |
+
<script type="application/ld+json">{"@context":"https://schema.org","@type":"VideoGame","name":"Brimbloom","url":"https://cybermaxtools.com/games/brimbloom/","image":"https://cybermaxtools.com/games/brimbloom/og.png","description":"A free one-tap timing game: stop the growing flower inside the ring. Same blooms for everyone each day.","genre":["Arcade","Casual","Timing"],"gamePlatform":["Web browser","Android","iOS"],"applicationCategory":"Game","operatingSystem":"Any","playMode":"SinglePlayer","publisher":{"@type":"Organization","name":"CyberMax","url":"https://cybermaxtools.com/"},"offers":[{"@type":"Offer","price":"0","priceCurrency":"USD","description":"Free to play"},{"@type":"Offer","price":"1.99","priceCurrency":"USD","description":"Brimbloom Skin Pack (6 flowers, one-time)"},{"@type":"Offer","price":"2.99","priceCurrency":"USD","description":"CyberMax Fun Pass, monthly"}]}</script>
|
| 19 |
+
</head><body>
|
| 20 |
+
<div class="wrap">
|
| 21 |
+
<header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 22 |
+
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mute" type="button" aria-label="Sound on or off">🔊</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 23 |
+
<main>
|
| 24 |
+
<section class="game">
|
| 25 |
+
<div class="headline"><h1>Brimbloom <span id="num" class="num"></span> <span class="pro-badge">PASS</span></h1>
|
| 26 |
+
<p class="muted tag">Tap to stop the bloom inside the ring. One miss ends the run. Same garden for everyone today.</p></div>
|
| 27 |
+
<div class="chips" id="modes" role="group" aria-label="Mode"></div>
|
| 28 |
+
<div class="stage"><canvas id="c" width="720" height="720" aria-label="Brimbloom game. Tap, click or press Space to stop the bloom." tabindex="0"></canvas></div>
|
| 29 |
+
<div class="row under"><span class="muted small">Today's best <b id="best">0</b> · tries <b id="tries">0</b></span><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn ghost sm" id="skins-btn" type="button">🌸 Skin Pack $1.99</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
|
| 30 |
+
<section class="panel over" id="over" hidden aria-live="polite">
|
| 31 |
+
<h2 id="over-title"></h2>
|
| 32 |
+
<p id="over-line" class="muted"></p>
|
| 33 |
+
<p id="over-grid" class="grid" aria-label="Your run"></p>
|
| 34 |
+
<div class="row"><button class="btn" id="share" type="button">Share score</button><a class="btn ghost sm" id="sx" target="_blank" rel="noopener">X</a><a class="btn ghost sm" id="sb" target="_blank" rel="noopener">Bluesky</a><a class="btn ghost sm" id="sw" target="_blank" rel="noopener">WhatsApp</a><button class="btn ghost sm" id="card" type="button">Save card</button></div>
|
| 35 |
+
<div class="nudge"><span>New look? The <b>Skin Pack</b> adds 6 flowers with their own ponds and petals, $1.99 once.</span><button class="btn ghost sm" data-skins="over" type="button">See the skins</button></div>
|
| 36 |
+
</section>
|
| 37 |
+
<div class="row skins" id="skins" aria-label="Flower skin"></div>
|
| 38 |
+
</section>
|
| 39 |
+
|
| 40 |
+
<section class="seo">
|
| 41 |
+
<h2>How to play Brimbloom</h2>
|
| 42 |
+
<p>A flower blooms outward from the middle of the pond. <b>Tap anywhere (or press Space) to stop it.</b> If the tips of the petals stop inside the ring (the brim), the bloom counts; stop in the brighter middle half of the ring for a <b>perfect</b>. Stop short, or let it grow past the ring, and the run is over. Each bloom is faster and its ring thinner; later blooms surge and ease, and some rings breathe in and out.</p>
|
| 43 |
+
<h3>One garden a day</h3>
|
| 44 |
+
<p>Everyone gets the same blooms each UTC day, so scores are directly comparable. Your best run is shared as a grid: 🟩 perfect, 🟨 good, 🟥 the bloom that ended it. Practice gives a fresh random garden every try.</p>
|
| 45 |
+
<h3>Free, with optional extras</h3>
|
| 46 |
+
<p>Brimbloom is free to play, including the daily garden and unlimited practice. The <b>Brimbloom Skin Pack</b> ($1.99 once) adds 6 flowers (Sakura, Lotus, Sunflower, Prism, Frost, Ember), each with its own pond and petal shape; skins are cosmetic only. The <b>CyberMax Fun Pass</b> ($2.99/month, $19/year or $29.99 for life) unlocks every past daily garden and the Night and Linen themes, in every CyberMax game.</p>
|
| 47 |
+
<h3>FAQ</h3>
|
| 48 |
+
<p><b>Is it fair?</b> The game runs on fixed 60 Hz ticks, and every bloom is checked so its ring opens at least 0.3 s in and stays open for at least 4 frames. <b>Does it work offline?</b> Yes, after the first visit; install it from your browser menu or the Add to Home Screen button. <b>Do skins change the game?</b> No, they only change how it looks.</p>
|
| 49 |
+
<h2>More free games from CyberMax</h2>
|
| 50 |
+
<div class="more"><a href="https://cybermaxtools.com/games/latchkite/"><b>Latchkite</b><span>Let go at the right moment, catch the next peg</span></a><a href="https://cybermaxtools.com/games/whirlane/"><b>Whirlane</b><span>One-tap orbit dodger with a daily leaderboard</span></a><a href="https://cybermaxtools.com/games/pathmoor/"><b>Pathmoor</b><span>Daily path puzzle: light every stone</span></a></div>
|
| 51 |
+
</section>
|
| 52 |
+
</main>
|
| 53 |
+
<footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/toys/">Toys</a> · <a href="https://cybermaxtools.com/store/">Store</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 54 |
+
</div>
|
| 55 |
+
<script type="module" src="game.js"></script>
|
| 56 |
+
</body></html>
|
brimbloom/manifest.webmanifest
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Brimbloom: One-tap timing: stop the bloom on the brim",
|
| 3 |
+
"short_name": "Brimbloom",
|
| 4 |
+
"description": "One-tap timing: stop the bloom on the brim. 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": "#0E2A2A",
|
| 11 |
+
"theme_color": "#0E2A2A",
|
| 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 |
+
}
|
brimbloom/og.png
ADDED
|
Git LFS Details
|
brimbloom/shot-phone.png
ADDED
|
Git LFS Details
|
brimbloom/skins.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax game Skin 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 = /^(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 |
+
async function check(code) {
|
| 21 |
+
try {
|
| 22 |
+
const r = await fetch(`${DATA}/unlock/${cfg.game}-skins?json=1&session_id=${encodeURIComponent(code)}`);
|
| 23 |
+
const j = await r.json();
|
| 24 |
+
return j && typeof j.pro === 'boolean' ? j.pro : null;
|
| 25 |
+
} catch { return null; }
|
| 26 |
+
}
|
| 27 |
+
export async function unlock(code) {
|
| 28 |
+
const ok = await check(code);
|
| 29 |
+
if (ok) {
|
| 30 |
+
state = store(key(), { code, at: new Date().toISOString() });
|
| 31 |
+
count(cfg.game, 'pro', 'skins-unlocked');
|
| 32 |
+
toast(`${cfg.name} Skin Pack is on. Enjoy the new looks!`, 3500);
|
| 33 |
+
emit(); render();
|
| 34 |
+
} else if (ok === false) toast('That code is not a Skin Pack order for this game.', 3500);
|
| 35 |
+
else toast('Could not reach the unlock check. Try again in a minute.', 3500);
|
| 36 |
+
return ok;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** Call first: game id, display name, skin count, Gumroad URL. Reads ?unlock=skins#cmx-pro=<code> after checkout. */
|
| 40 |
+
export function capture(options) {
|
| 41 |
+
cfg = { ...cfg, ...options };
|
| 42 |
+
state = store(key());
|
| 43 |
+
const q = new URLSearchParams(location.search);
|
| 44 |
+
const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
|
| 45 |
+
if (q.get('unlock') === 'skins' && m) {
|
| 46 |
+
const code = decodeURIComponent(m[1]);
|
| 47 |
+
q.delete('unlock');
|
| 48 |
+
history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
|
| 49 |
+
if (CODE.test(code)) unlock(code);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const buyUrl = (from) => `${DATA}/buy/st-${cfg.game}-skins-pack?s=game-${cfg.game}${from ? `-${from}` : ''}`;
|
| 54 |
+
function dialog() {
|
| 55 |
+
let d = document.getElementById('skinpack');
|
| 56 |
+
if (!d) {
|
| 57 |
+
d = document.createElement('dialog'); d.id = 'skinpack'; d.className = 'funpass';
|
| 58 |
+
document.body.appendChild(d);
|
| 59 |
+
d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
|
| 60 |
+
}
|
| 61 |
+
return d;
|
| 62 |
+
}
|
| 63 |
+
function render() {
|
| 64 |
+
const d = dialog();
|
| 65 |
+
const prev = (cfg.preview || []).map((s) => `<span class="sp-dot" style="background:${s}"></span>`).join('');
|
| 66 |
+
if (owned()) {
|
| 67 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${cfg.name} Skin Pack</p><h2>All ${cfg.skins} skins are yours. Thank you!</h2>
|
| 68 |
+
<p>Pick one from the colour dots under the game.</p><p class="fp-small">Your code (paste it on another device):</p><code class="fp-code">${state.code}</code></div>`;
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${cfg.name} Skin Pack</p>
|
| 72 |
+
<h2>${cfg.skins} extra looks, ${cfg.price} once</h2><p class="sp-row">${prev}</p>
|
| 73 |
+
<ul class="fp-perks">${(cfg.perks || []).map((p) => `<li>${p}</li>`).join('')}<li>Cosmetic only: same course, same scores for everyone</li><li>Pay once, keep forever, on any device with your code</li></ul>
|
| 74 |
+
<p class="fp-row"><a class="btn pass" href="${buyUrl('')}" rel="nofollow" data-buy>Get the Skin 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>
|
| 75 |
+
<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>
|
| 76 |
+
<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>`;
|
| 77 |
+
d.querySelector('.fp-form').addEventListener('submit', (e) => {
|
| 78 |
+
e.preventDefault();
|
| 79 |
+
const c = e.target.querySelector('input').value.trim();
|
| 80 |
+
if (CODE.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
|
| 81 |
+
});
|
| 82 |
+
d.querySelector('[data-buy]').addEventListener('click', () => count(cfg.game, 'cta', 'skins-buy'));
|
| 83 |
+
d.querySelector('[data-gum]')?.addEventListener('click', () => count(cfg.game, 'cta', 'skins-gumroad'));
|
| 84 |
+
}
|
| 85 |
+
export function open(from = 'button') {
|
| 86 |
+
render();
|
| 87 |
+
const d = dialog();
|
| 88 |
+
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
| 89 |
+
count(cfg.game, 'cta', `skins-open-${from}`);
|
| 90 |
+
}
|
brimbloom/style.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Brimbloom layout. Game colours live in game.js (SKINS). (c) 2026 CyberMax. */
|
| 2 |
+
.headline h1 .num{color:var(--muted);font-weight:700;font-size:.62em}
|
| 3 |
+
.tag{margin:0 0 12px}
|
| 4 |
+
.stage{display:flex;justify-content:center;margin-top:12px}
|
| 5 |
+
#c{width:100%;max-width:min(560px,calc(100vh - 220px));min-width:260px;aspect-ratio:1;border-radius:24px;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer;box-shadow:var(--shadow)}
|
| 6 |
+
.under{justify-content:center;margin:12px 0}
|
| 7 |
+
.over{margin:12px 0}.over h2{margin:0 0 4px}
|
| 8 |
+
.skins{justify-content:center;margin:8px 0}
|
| 9 |
+
.skins button{width:36px;height:36px;border-radius:50%;border:3px solid var(--line);cursor:pointer;position:relative}
|
| 10 |
+
.skins button[aria-pressed=true]{border-color:var(--ink)}
|
| 11 |
+
.skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
|
| 12 |
+
.sp-row{display:flex;gap:6px;margin:0 0 10px}.sp-dot{width:26px;height:26px;border-radius:50%;display:inline-block;border:2px solid var(--line)}
|
| 13 |
+
.fp-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 14 |
+
.grid{font-size:22px;letter-spacing:2px;margin:4px 0 10px;white-space:pre-line;line-height:1.3}
|
brimbloom/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 = 'brimbloom';
|
| 4 |
+
const CACHE = `cmx-${GAME}-8716f0739d`;
|
| 5 |
+
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "skins.js", "style.css"];
|
| 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 |
+
});
|