pawfoil / cmx.js
CyberMax-tools's picture
Deploy pawfoil
434e403 verified
Raw History Blame Contribute Delete
7.6 kB
// CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by
// scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own).
// Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install.
// (c) 2026 CyberMax. All rights reserved.
export const SITE = 'https://cybermaxtools.com';
export const API = 'https://toys.cybermaxtools.com';
export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game
/** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */
export function today(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */
export function dayNumber(iso) {
const [y, m, d] = iso.split('-').map(Number);
return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1;
}
export function dateOf(n) {
const t = new Date(EPOCH + (n - 1) * 864e5);
return t.toISOString().slice(0, 10);
}
/** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */
export function hash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
return h >>> 0;
}
export function rng(seed) {
let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */
export function count(game, ev, ch = '') {
try {
if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return;
const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`;
if (navigator.sendBeacon && navigator.sendBeacon(u)) return;
fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {});
} catch { /* never break the game */ }
}
export function store(key, val) {
try {
if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
localStorage.setItem(key, JSON.stringify(val));
} catch { /* private mode */ }
return val;
}
export function toast(msg, ms = 2400) {
let t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('on');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('on'), ms);
}
/** The link people share: the canonical game page with UTM tags the site view counter reads. */
export function shareLink(path, channel, game) {
const u = new URL(SITE + path);
u.searchParams.set('utm_source', channel);
u.searchParams.set('utm_medium', 'share');
u.searchParams.set('utm_campaign', `game-${game}`);
return u.toString();
}
/**
* Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard.
* text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card.
*/
export async function share(game, text, path, blob, filename) {
const url = shareLink(path, 'share', game);
const full = `${text}\n${url}`;
if (navigator.share) {
try {
const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null;
const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full };
await navigator.share(data);
count(game, 'share', 'native');
return 'shared';
} catch (e) {
if (e && e.name === 'AbortError') return 'cancelled';
}
}
try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); }
count(game, 'share', 'copy');
return 'copied';
}
export function intents(game, text, path) {
const u = (ch) => shareLink(path, ch, game);
return {
x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`,
bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`,
whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`,
};
}
export function download(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
/**
* Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button.
* Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint.
* The button only appears after the first finished round (offer(): never on first load) and never once installed.
*/
export function pwa(game, btn) {
if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) {
navigator.serviceWorker.register('sw.js').catch(() => {});
}
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
if (standalone) { count(game, 'play', 'app'); return { offer() {} }; }
let deferred = null;
let wanted = false;
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn) btn.hidden = false; });
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
if (btn) {
btn.addEventListener('click', async () => {
if (deferred) {
deferred.prompt();
const r = await deferred.userChoice.catch(() => null);
if (r && r.outcome === 'accepted') btn.hidden = true;
deferred = null;
} else if (ios) {
toast('Tap the Share button, then "Add to Home Screen".', 5000);
count(game, 'install', 'ios-hint');
}
});
}
return {
offer() {
wanted = true;
if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
},
};
}
/** Rounded rectangle path for canvas cards. */
export function rr(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
/** Theme toggle shared by every page (light/dark, remembered). */
export function themeToggle(btn) {
if (!btn) return;
btn.addEventListener('click', () => {
const r = document.documentElement;
const c = r.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const n = c === 'dark' ? 'light' : 'dark';
r.dataset.theme = n;
store('cm-theme-v', n);
try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
});
}