Spaces:
Running
Running
Deploy sightfern
Browse files- .gitattributes +3 -0
- README.md +27 -5
- cmx.js +181 -0
- core.js +178 -0
- detect.js +36 -0
- fun.css +54 -0
- funpass.js +227 -0
- game.js +282 -0
- icon-192.png +0 -0
- icon-512.png +3 -0
- icon-maskable.png +0 -0
- icon.svg +1 -0
- index.html +98 -18
- manifest.webmanifest +47 -0
- offer.js +2 -0
- og.png +3 -0
- shot-phone.png +3 -0
- style.css +33 -28
- sw.js +25 -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 |
+
icon-512.png 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
|
README.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title: Sightfern
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "Sightfern: Real-world spotting game (camera)"
|
| 3 |
+
emoji: 🎮
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
license: other
|
| 9 |
+
thumbnail: https://cybermaxtools.com/games/sightfern/og.png
|
| 10 |
+
short_description: "Spot real cats, dogs and birds with your camera and fill y"
|
| 11 |
+
tags:
|
| 12 |
+
- game
|
| 13 |
+
- html5
|
| 14 |
+
- browser-game
|
| 15 |
+
- pwa
|
| 16 |
+
- cybermax
|
| 17 |
---
|
| 18 |
+
**Sightfern**: Spot real cats, dogs and birds with your camera and fill your field album. 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/sightfern/?utm_source=huggingface
|
| 21 |
+
|
| 22 |
+

|
| 23 |
+
|
| 24 |
+
**Embed it free on your site** (works on phones, keeps a small link to CyberMax):
|
| 25 |
+
|
| 26 |
+
```html
|
| 27 |
+
<iframe src="https://cybermaxtools.com/embed/sightfern/" width="480" height="720" style="border:0;max-width:100%;aspect-ratio:2/3;height:auto" title="Sightfern: free game by CyberMax" loading="lazy" allowfullscreen></iframe>
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
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
|
| 31 |
+
|
| 32 |
+
More free games: https://cybermaxtools.com/games/ · © 2026 CyberMax. All rights reserved.
|
cmx.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 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 |
+
}
|
core.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Sightfern core: the field-journal rules, pure functions (no DOM, no camera), shared by the page and the tests.
|
| 2 |
+
// A sighting = an animal the on-device detector found in the player's own photo (or one they tagged by hand), its
|
| 3 |
+
// coat/plumage colour read from the pixels inside the detection box, and the local time of day.
|
| 4 |
+
// Rarity is a game rule (how hard a colour is to find in everyday streets and parks), not a biological claim.
|
| 5 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 6 |
+
|
| 7 |
+
/** Albums. Free: Cats, Dogs, Birds. Fun Pass: Farm and Wild (the other animals the detector knows). */
|
| 8 |
+
export const ALBUMS = {
|
| 9 |
+
cats: { name: 'Cats', icon: '🐈', species: ['cat'], pro: false },
|
| 10 |
+
dogs: { name: 'Dogs', icon: '🐕', species: ['dog'], pro: false },
|
| 11 |
+
birds: { name: 'Birds', icon: '🐦', species: ['bird'], pro: false },
|
| 12 |
+
farm: { name: 'Farm', icon: '🐄', species: ['horse', 'sheep', 'cow'], pro: true },
|
| 13 |
+
wild: { name: 'Wild & zoo', icon: '🦒', species: ['bear', 'elephant', 'zebra', 'giraffe'], pro: true },
|
| 14 |
+
};
|
| 15 |
+
export const SPECIES = Object.fromEntries(Object.entries(ALBUMS).flatMap(([album, a]) => a.species.map((s) => [s, album])));
|
| 16 |
+
export const LABEL = { cat: 'Cat', dog: 'Dog', bird: 'Bird', horse: 'Horse', sheep: 'Sheep', cow: 'Cow', bear: 'Bear', elephant: 'Elephant', zebra: 'Zebra', giraffe: 'Giraffe' };
|
| 17 |
+
|
| 18 |
+
export const COLOURS = {
|
| 19 |
+
black: { name: 'Black', hex: '#1c1c1f' },
|
| 20 |
+
white: { name: 'White', hex: '#f2f0ea' },
|
| 21 |
+
grey: { name: 'Grey', hex: '#8d9097' },
|
| 22 |
+
brown: { name: 'Brown', hex: '#7a4e2d' },
|
| 23 |
+
ginger: { name: 'Ginger', hex: '#d9792b' },
|
| 24 |
+
golden: { name: 'Golden', hex: '#e2b55a' },
|
| 25 |
+
patched: { name: 'Patched', hex: 'linear-gradient(135deg,#1c1c1f 0 33%,#f2f0ea 33% 66%,#d9792b 66%)' },
|
| 26 |
+
red: { name: 'Red', hex: '#c8352e' },
|
| 27 |
+
yellow: { name: 'Yellow', hex: '#e8c931' },
|
| 28 |
+
green: { name: 'Green', hex: '#4f9a4a' },
|
| 29 |
+
blue: { name: 'Blue', hex: '#3d6fd1' },
|
| 30 |
+
};
|
| 31 |
+
/** Slots per species: which colours can fill the page, and how rare each is (0 common … 3 epic). */
|
| 32 |
+
const MAMMAL = { black: 1, white: 1, grey: 0, brown: 0, ginger: 1, golden: 2, patched: 2 };
|
| 33 |
+
export const SLOTS = {
|
| 34 |
+
cat: { ...MAMMAL, golden: 3, patched: 2 },
|
| 35 |
+
dog: { ...MAMMAL, ginger: 2, grey: 1 },
|
| 36 |
+
bird: { black: 0, grey: 0, brown: 0, white: 1, red: 2, yellow: 2, green: 3, blue: 3 },
|
| 37 |
+
horse: { brown: 0, black: 1, white: 2, grey: 1, golden: 2, patched: 2 },
|
| 38 |
+
sheep: { white: 0, grey: 1, brown: 1, black: 2 },
|
| 39 |
+
cow: { patched: 0, black: 0, brown: 0, white: 1, golden: 2 },
|
| 40 |
+
bear: { brown: 0, black: 1, white: 3 }, elephant: { grey: 0 }, zebra: { patched: 0 }, giraffe: { patched: 0, golden: 1 },
|
| 41 |
+
};
|
| 42 |
+
export const RARITY = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary'];
|
| 43 |
+
export const TIMES = [
|
| 44 |
+
{ id: 'dawn', name: 'Dawn', from: 5, to: 8, icon: '🌅' },
|
| 45 |
+
{ id: 'day', name: 'Day', from: 8, to: 17, icon: '☀️' },
|
| 46 |
+
{ id: 'dusk', name: 'Dusk', from: 17, to: 21, icon: '🌇' },
|
| 47 |
+
{ id: 'night', name: 'Night', from: 21, to: 29, icon: '🌙' }, // 21:00-04:59
|
| 48 |
+
];
|
| 49 |
+
export function timeBand(hour) {
|
| 50 |
+
const h = hour < 5 ? hour + 24 : hour;
|
| 51 |
+
return (TIMES.find((t) => h >= t.from && h < t.to) || TIMES[1]).id;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// ------------------------------------------------------------------ colour from pixels
|
| 55 |
+
/** RGB 0-255 -> { h 0-360, s 0-1, l 0-1 } */
|
| 56 |
+
export function hsl(r, g, b) {
|
| 57 |
+
r /= 255; g /= 255; b /= 255;
|
| 58 |
+
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, d = mx - mn;
|
| 59 |
+
if (!d) return { h: 0, s: 0, l };
|
| 60 |
+
const s = d / (1 - Math.abs(2 * l - 1));
|
| 61 |
+
let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
| 62 |
+
h = (h * 60 + 360) % 360;
|
| 63 |
+
return { h, s, l };
|
| 64 |
+
}
|
| 65 |
+
/** One pixel -> colour bucket. */
|
| 66 |
+
export function bucket(r, g, b) {
|
| 67 |
+
const { h, s, l } = hsl(r, g, b);
|
| 68 |
+
if (l < 0.16) return 'black';
|
| 69 |
+
if (l > 0.82 && s < 0.35) return 'white';
|
| 70 |
+
if (s < 0.16) return l > 0.62 ? 'white' : l < 0.25 ? 'black' : 'grey';
|
| 71 |
+
if (h < 12 || h >= 340) return s > 0.45 && l > 0.25 ? 'red' : 'brown';
|
| 72 |
+
if (h < 40) return l < 0.38 || s < 0.38 ? 'brown' : 'ginger';
|
| 73 |
+
if (h < 58) return l > 0.55 ? 'golden' : s > 0.55 ? 'yellow' : 'brown';
|
| 74 |
+
if (h < 70) return 'yellow';
|
| 75 |
+
if (h < 170) return 'green';
|
| 76 |
+
if (h < 260) return 'blue';
|
| 77 |
+
return 'grey';
|
| 78 |
+
}
|
| 79 |
+
/**
|
| 80 |
+
* The animal's colour from RGBA pixels of the detection box (centre 60%, every 4th pixel): the biggest bucket, or
|
| 81 |
+
* "patched" when two contrasting buckets each cover 22%+ (calico, piebald, tuxedo). Only colours the species' page
|
| 82 |
+
* has are chosen; the rest fall back to the nearest neutral.
|
| 83 |
+
*/
|
| 84 |
+
export function coatColour(rgba, w, h, species) {
|
| 85 |
+
const slots = SLOTS[species] || MAMMAL;
|
| 86 |
+
const n = {};
|
| 87 |
+
let total = 0;
|
| 88 |
+
const x0 = Math.floor(w * 0.2), x1 = Math.ceil(w * 0.8), y0 = Math.floor(h * 0.2), y1 = Math.ceil(h * 0.8);
|
| 89 |
+
for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) {
|
| 90 |
+
const i = (y * w + x) * 4;
|
| 91 |
+
const k = bucket(rgba[i], rgba[i + 1], rgba[i + 2]);
|
| 92 |
+
n[k] = (n[k] || 0) + 1; total++;
|
| 93 |
+
}
|
| 94 |
+
if (!total) return Object.keys(slots)[0];
|
| 95 |
+
const ranked = Object.entries(n).sort((a, b) => b[1] - a[1]);
|
| 96 |
+
const share = (k) => (n[k] || 0) / total;
|
| 97 |
+
const CONTRAST = { black: ['white', 'ginger', 'golden'], white: ['black', 'ginger', 'brown'], ginger: ['black', 'white'], brown: ['white'], golden: ['black', 'white'] };
|
| 98 |
+
if ('patched' in slots) {
|
| 99 |
+
const [a, b] = ranked;
|
| 100 |
+
if (a && b && share(a[0]) >= 0.22 && share(b[0]) >= 0.22 && (CONTRAST[a[0]] || []).includes(b[0])) return 'patched';
|
| 101 |
+
}
|
| 102 |
+
for (const [k] of ranked) if (k in slots) return k;
|
| 103 |
+
const fallback = { red: 'brown', yellow: 'golden', green: 'grey', blue: 'grey', ginger: 'brown', golden: 'brown', patched: 'brown' };
|
| 104 |
+
for (const [k] of ranked) { const f = fallback[k] || k; if (f in slots) return f; }
|
| 105 |
+
return Object.keys(slots)[0];
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/** Rarity of a sighting: the slot's rarity, +1 at night (night spotters get a moon stamp), capped at Legendary. */
|
| 109 |
+
export function rarityOf(species, colour, time) {
|
| 110 |
+
const base = (SLOTS[species] || {})[colour] ?? 0;
|
| 111 |
+
return Math.min(4, base + (time === 'night' ? 1 : 0));
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/** A new sighting record (pure; the page adds the photo blob id). */
|
| 115 |
+
export function sighting({ species, colour, at = new Date(), manual = false, score = null, coarse = null }) {
|
| 116 |
+
const time = timeBand(at.getHours());
|
| 117 |
+
return {
|
| 118 |
+
id: `${at.getTime().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`,
|
| 119 |
+
species, album: SPECIES[species], colour, time, rarity: rarityOf(species, colour, time),
|
| 120 |
+
day: localDay(at), at: at.toISOString(), manual, score: score == null ? null : Math.round(score * 100) / 100,
|
| 121 |
+
...(coarse ? { where: coarse } : {}),
|
| 122 |
+
};
|
| 123 |
+
}
|
| 124 |
+
export const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
| 125 |
+
|
| 126 |
+
/** Opt-in location is rounded to 1 decimal place (about 11 km) before it is ever stored. */
|
| 127 |
+
export const coarsen = (lat, lon) => ({ lat: Math.round(lat * 10) / 10, lon: Math.round(lon * 10) / 10 });
|
| 128 |
+
|
| 129 |
+
// ------------------------------------------------------------------ album progress, streak, badges
|
| 130 |
+
export function slotsOf(album) {
|
| 131 |
+
return ALBUMS[album].species.flatMap((sp) => Object.keys(SLOTS[sp]).map((c) => `${sp}:${c}`));
|
| 132 |
+
}
|
| 133 |
+
export function progress(sightings, album) {
|
| 134 |
+
const have = new Set(sightings.filter((s) => s.album === album).map((s) => `${s.species}:${s.colour}`));
|
| 135 |
+
const all = slotsOf(album);
|
| 136 |
+
return { found: all.filter((k) => have.has(k)).length, total: all.length, have };
|
| 137 |
+
}
|
| 138 |
+
export function streak(sightings, today) {
|
| 139 |
+
const days = new Set(sightings.map((s) => s.day));
|
| 140 |
+
const d = new Date(`${today}T12:00:00`);
|
| 141 |
+
if (!days.has(today)) d.setDate(d.getDate() - 1);
|
| 142 |
+
let k = 0;
|
| 143 |
+
while (days.has(localDay(d))) { k++; d.setDate(d.getDate() - 1); }
|
| 144 |
+
return k;
|
| 145 |
+
}
|
| 146 |
+
export const BADGES = [
|
| 147 |
+
{ id: 'first', icon: '👀', name: 'First sighting', test: (s) => s.length >= 1 },
|
| 148 |
+
{ id: 'ten', icon: '📒', name: '10 sightings', test: (s) => s.length >= 10 },
|
| 149 |
+
{ id: 'fifty', icon: '📚', name: '50 sightings', test: (s) => s.length >= 50 },
|
| 150 |
+
{ id: 'trio', icon: '🐾', name: 'A cat, a dog and a bird', test: (s) => ['cat', 'dog', 'bird'].every((sp) => s.some((x) => x.species === sp)) },
|
| 151 |
+
{ id: 'night', icon: '🌙', name: 'Night spotter', test: (s) => s.some((x) => x.time === 'night') },
|
| 152 |
+
{ id: 'dawn', icon: '🌅', name: 'Early bird', test: (s) => s.some((x) => x.time === 'dawn') },
|
| 153 |
+
{ id: 'rare', icon: '💎', name: 'Rare find', test: (s) => s.some((x) => x.rarity >= 2) },
|
| 154 |
+
{ id: 'epic', icon: '🌟', name: 'Epic find', test: (s) => s.some((x) => x.rarity >= 3) },
|
| 155 |
+
{ id: 'streak3', icon: '🔥', name: '3-day streak', test: (s, st) => st >= 3 },
|
| 156 |
+
{ id: 'streak7', icon: '🏅', name: '7-day streak', test: (s, st) => st >= 7 },
|
| 157 |
+
{ id: 'catpage', icon: '🐈', name: 'Full cat page', test: (s) => progress(s, 'cats').found === progress(s, 'cats').total },
|
| 158 |
+
{ id: 'birdpage', icon: '🐦', name: 'Full bird page', test: (s) => progress(s, 'birds').found === progress(s, 'birds').total },
|
| 159 |
+
];
|
| 160 |
+
export function badges(sightings, today) {
|
| 161 |
+
const st = streak(sightings, today);
|
| 162 |
+
return BADGES.map((b) => ({ ...b, got: !!b.test(sightings, st) }));
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/** Today's quest, the same for everyone on a date: spot a given animal (or colour) today. */
|
| 166 |
+
export function quest(day) {
|
| 167 |
+
let h = 0x811c9dc5;
|
| 168 |
+
for (let i = 0; i < day.length; i++) { h ^= day.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
| 169 |
+
h >>>= 0;
|
| 170 |
+
const Q = [
|
| 171 |
+
{ species: 'bird', text: 'Spot any bird' }, { species: 'cat', text: 'Spot any cat' }, { species: 'dog', text: 'Spot any dog' },
|
| 172 |
+
{ species: 'cat', colour: 'black', text: 'Spot a black cat' }, { species: 'dog', colour: 'white', text: 'Spot a white dog' },
|
| 173 |
+
{ species: 'bird', colour: 'black', text: 'Spot a black bird' }, { species: 'cat', colour: 'ginger', text: 'Spot a ginger cat' },
|
| 174 |
+
{ species: 'dog', colour: 'brown', text: 'Spot a brown dog' }, { species: 'bird', colour: 'grey', text: 'Spot a grey bird' },
|
| 175 |
+
];
|
| 176 |
+
return Q[h % Q.length];
|
| 177 |
+
}
|
| 178 |
+
export const questDone = (q, sightings, day) => sightings.some((s) => s.day === day && s.species === q.species && (!q.colour || s.colour === q.colour));
|
detect.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Sightfern on-device detector: Google MediaPipe Object Detector (EfficientDet-Lite0, COCO classes, Apache-2.0),
|
| 2 |
+
// loaded only when the player opens the camera or picks a photo. The library and model files are downloaded once
|
| 3 |
+
// (then cached for offline use by sw.js); every image is analysed inside the browser and never uploaded.
|
| 4 |
+
// (c) 2026 CyberMax. All rights reserved. MediaPipe (c) Google LLC, Apache License 2.0.
|
| 5 |
+
import { SPECIES } from './core.js';
|
| 6 |
+
|
| 7 |
+
export const MP_VERSION = '0.10.14';
|
| 8 |
+
export const MP_BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}`;
|
| 9 |
+
export const MODEL = 'https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/int8/1/efficientdet_lite0.tflite';
|
| 10 |
+
export const MIN_SCORE = 0.4;
|
| 11 |
+
|
| 12 |
+
let pending = null;
|
| 13 |
+
/** Load once. Returns { detect(source) -> { ms, items }, delegate }. Rejects when the device cannot run it. */
|
| 14 |
+
export function loadDetector(prefer = 'CPU') { // CPU (WASM) by default: measured fast and exact (tests/detect-speed.mjs); GPU is opt-in
|
| 15 |
+
if (pending) return pending;
|
| 16 |
+
pending = (async () => {
|
| 17 |
+
const vision = await import(/* @vite-ignore */ `${MP_BASE}/vision_bundle.mjs`);
|
| 18 |
+
const files = await vision.FilesetResolver.forVisionTasks(`${MP_BASE}/wasm`);
|
| 19 |
+
const opts = (delegate) => ({ baseOptions: { modelAssetPath: MODEL, delegate }, runningMode: 'IMAGE', scoreThreshold: 0.3, maxResults: 5, categoryAllowlist: Object.keys(SPECIES) });
|
| 20 |
+
let det, delegate = prefer;
|
| 21 |
+
try { det = await vision.ObjectDetector.createFromOptions(files, opts(prefer)); } catch { delegate = 'CPU'; det = await vision.ObjectDetector.createFromOptions(files, opts('CPU')); }
|
| 22 |
+
return {
|
| 23 |
+
delegate,
|
| 24 |
+
/** source: <canvas>, <img> or ImageBitmap. Boxes are in the source's pixel space. */
|
| 25 |
+
detect(source) {
|
| 26 |
+
const t = performance.now();
|
| 27 |
+
const r = det.detect(source);
|
| 28 |
+
const items = (r.detections || []).map((d) => ({ species: d.categories[0].categoryName, score: d.categories[0].score, box: { x: d.boundingBox.originX, y: d.boundingBox.originY, w: d.boundingBox.width, h: d.boundingBox.height } }))
|
| 29 |
+
.filter((d) => d.species in SPECIES).sort((a, b) => b.score - a.score);
|
| 30 |
+
return { ms: performance.now() - t, items };
|
| 31 |
+
},
|
| 32 |
+
};
|
| 33 |
+
})();
|
| 34 |
+
pending.catch(() => { pending = null; });
|
| 35 |
+
return pending;
|
| 36 |
+
}
|
fun.css
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 53 |
+
.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}
|
| 54 |
+
.fp-or{margin:12px 0 8px;font-size:14px}
|
funpass.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = /^(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 |
+
|
| 37 |
+
const passOn = () => !!(state && state.code);
|
| 38 |
+
export const ownsGame = () => !!(full && full.code);
|
| 39 |
+
const rewardOn = () => { const r = store(REWARD_KEY); return !!(r && Date.now() - r.at < REWARD_MS); };
|
| 40 |
+
/** True when this player may use everything in this game: Fun Pass, this game's full unlock, or a portal reward hour. */
|
| 41 |
+
export const isPro = () => passOn() || ownsGame() || rewardOn();
|
| 42 |
+
/** Free levels in this game's demo: OFFER.demo once the per-game unlock is live, else the game's current number. */
|
| 43 |
+
export const freeLevels = (current = 20) => (OFFER.live && OFFER.demo ? Math.min(current, OFFER.demo) : current);
|
| 44 |
+
const portal = () => (typeof window !== 'undefined' && window.CMXPortal && window.CMXPortal.portal) || '';
|
| 45 |
+
export function onChange(fn) { listeners.push(fn); fn(isPro()); }
|
| 46 |
+
function emit() {
|
| 47 |
+
document.documentElement.classList.toggle('cmx-pro', isPro());
|
| 48 |
+
listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function check(code, what = 'fun-pass') {
|
| 52 |
+
try {
|
| 53 |
+
const r = await fetch(`${DATA}/unlock/${what}?json=1&session_id=${encodeURIComponent(code)}`);
|
| 54 |
+
const j = await r.json();
|
| 55 |
+
return j && typeof j.pro === 'boolean' ? j.pro : null;
|
| 56 |
+
} catch { return null; }
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** Turn on a full-game code for this game (receipt code of the <game>-full Payment Link). */
|
| 60 |
+
export async function unlockGame(code) {
|
| 61 |
+
const ok = await check(code, `${OFFER.game}-full`);
|
| 62 |
+
if (ok) {
|
| 63 |
+
full = store(FULL_KEY, { code, at: new Date().toISOString() });
|
| 64 |
+
count(cfg.game, 'pro', 'full-unlocked');
|
| 65 |
+
toast(`${OFFER.name}: the full game is on. Thank you for supporting CyberMax games!`, 3500);
|
| 66 |
+
emit(); render();
|
| 67 |
+
} else if (ok === false) toast('That code is not a full-game order for this game.', 3500);
|
| 68 |
+
else toast('Could not reach the unlock check. Try again in a minute.', 3500);
|
| 69 |
+
return ok;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Turn on a pasted or returned code: a Fun Pass code, or (where this game sells its own unlock) a full-game code. */
|
| 73 |
+
export async function unlock(code) {
|
| 74 |
+
const ok = await check(code);
|
| 75 |
+
if (ok) {
|
| 76 |
+
state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
|
| 77 |
+
count(cfg.game, 'pro', 'unlocked');
|
| 78 |
+
toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
|
| 79 |
+
emit();
|
| 80 |
+
render();
|
| 81 |
+
return ok;
|
| 82 |
+
}
|
| 83 |
+
if (sellsGame() && (await check(code, `${OFFER.game}-full`))) return unlockGame(code);
|
| 84 |
+
if (ok === false) toast('That code is not an active Fun Pass.', 3500);
|
| 85 |
+
else toast('Could not reach the pass check. Try again in a minute.', 3500);
|
| 86 |
+
return ok;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function recheck() {
|
| 90 |
+
if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
|
| 91 |
+
const ok = await check(state.code);
|
| 92 |
+
if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
|
| 93 |
+
else if (ok) state = store(KEY, { ...state, checked: Date.now() });
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 97 |
+
const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
|
| 98 |
+
const gameUrl = (from = '') => `${DATA}/buy/st-${OFFER.game}-full-unlock?s=game-${cfg.game}${from ? `-${from}` : ''}`;
|
| 99 |
+
const sellsGame = () => !!(OFFER.live && OFFER.price && OFFER.game !== 'games');
|
| 100 |
+
|
| 101 |
+
function dialog() {
|
| 102 |
+
let d = document.getElementById('funpass');
|
| 103 |
+
if (!d) {
|
| 104 |
+
d = document.createElement('dialog');
|
| 105 |
+
d.id = 'funpass';
|
| 106 |
+
d.className = 'funpass';
|
| 107 |
+
document.body.appendChild(d);
|
| 108 |
+
d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
|
| 109 |
+
}
|
| 110 |
+
return d;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
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('');
|
| 114 |
+
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>`;
|
| 115 |
+
|
| 116 |
+
function render() {
|
| 117 |
+
const d = dialog();
|
| 118 |
+
const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
|
| 119 |
+
if (isPro()) {
|
| 120 |
+
const what = passOn() ? 'The Fun Pass is on in this browser for every CyberMax game' : ownsGame() ? `You own the full ${esc(OFFER.name)}` : 'The full game is open for the next hour (rewarded ad)';
|
| 121 |
+
const code = passOn() ? state.code : ownsGame() ? full.code : '';
|
| 122 |
+
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>
|
| 123 |
+
<p>${what}: ${perks.map(esc).join(' · ')}.</p>
|
| 124 |
+
${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>` : ''}
|
| 125 |
+
${passOn() ? '<p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p>' : ''}</div>`;
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
if (portal()) { // portal build: no outside checkout; a rewarded ad (when the portal offers one) opens the game for an hour
|
| 129 |
+
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>
|
| 130 |
+
<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>
|
| 131 |
+
<p><button class="btn" type="button" data-reward>▶ Watch a short ad</button></p>
|
| 132 |
+
<p class="fp-small">Optional. The daily challenge and the free levels stay free without ads.</p></div>`;
|
| 133 |
+
d.querySelector('[data-reward]').addEventListener('click', async () => {
|
| 134 |
+
count(cfg.game, 'cta', 'reward-ad');
|
| 135 |
+
const ok = await window.CMXPortal.rewarded().catch(() => false);
|
| 136 |
+
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(); }
|
| 137 |
+
else toast('No ad is available right now. Try again later.', 3000);
|
| 138 |
+
});
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
const allGames = `${OFFER.games || 'every'} CyberMax games`;
|
| 142 |
+
const own = cfg.perks || [];
|
| 143 |
+
const gamePerks = [...own, ...(own.some((p) => /archive/i.test(p)) ? [] : ['The full archive of past dailies']), 'Every bonus mode in this game'].slice(0, 6);
|
| 144 |
+
d.innerHTML = sellsGame()
|
| 145 |
+
? `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name)} · full game</p>
|
| 146 |
+
<h2>Unlock the full ${esc(OFFER.name)}</h2>
|
| 147 |
+
<ul class="fp-perks">${gamePerks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 148 |
+
<a class="fp-plan fp-game" href="${gameUrl()}" rel="nofollow" data-game><i>This game</i><b>Full ${esc(OFFER.name)}</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>
|
| 149 |
+
<p class="fp-or"><b>Or get all ${esc(allGames)}</b> with the Fun Pass:</p>
|
| 150 |
+
<div class="fp-plans">${planHtml()}</div>
|
| 151 |
+
<p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · your code works on any device · 14-day promise: if it doesn't work as described, we fix it or refund you. The daily challenge and the first ${OFFER.demo || ''} levels stay free for everyone.</p>
|
| 152 |
+
${codeForm}</div>`
|
| 153 |
+
: `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
|
| 154 |
+
<h2>One pass. Every game. More to play.</h2>
|
| 155 |
+
<ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 156 |
+
<div class="fp-plans">${planHtml()}</div>
|
| 157 |
+
<p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The daily challenges and the free levels stay free for everyone.</p>
|
| 158 |
+
${codeForm}</div>`;
|
| 159 |
+
d.querySelector('.fp-form').addEventListener('submit', (e) => {
|
| 160 |
+
e.preventDefault();
|
| 161 |
+
const c = e.target.querySelector('input').value.trim();
|
| 162 |
+
if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
|
| 163 |
+
});
|
| 164 |
+
d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
|
| 165 |
+
d.querySelector('[data-game]')?.addEventListener('click', () => count(cfg.game, 'cta', 'full-game'));
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/** Open the pass sheet; `from` names the button that opened it (counted as cta). */
|
| 169 |
+
export function open(from = 'button') {
|
| 170 |
+
render();
|
| 171 |
+
const d = dialog();
|
| 172 |
+
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
| 173 |
+
count(cfg.game, 'cta', `pass-open-${from}`);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/** After a finished round (the value moment): one small inline card under the result, never a pop-up. Shows the
|
| 177 |
+
* pass price, the honest trust line and an optional "email me" weekly reminder (double opt-in).
|
| 178 |
+
* `host` is the result panel; the card is added once and removed for pass holders. */
|
| 179 |
+
export function afterWin(host, from = 'win') {
|
| 180 |
+
if (!host) return;
|
| 181 |
+
if (isPro() || portal()) { host.querySelector('.fp-after')?.remove(); return; }
|
| 182 |
+
let el = host.querySelector('.fp-after');
|
| 183 |
+
if (!el) { el = document.createElement('div'); el.className = 'fp-after'; host.appendChild(el); }
|
| 184 |
+
const best = PLANS.find((p) => p.best) || PLANS[0];
|
| 185 |
+
const game = sellsGame();
|
| 186 |
+
const href = game ? gameUrl('after') : `${buyUrl(best.id)}-after`;
|
| 187 |
+
const label = game ? `Get the full ${OFFER.name}: ${OFFER.price} once` : `Get the Fun Pass: ${best.price}${best.per}`;
|
| 188 |
+
const line = game ? (cfg.extra || `The full game opens every level, mode, the daily archive and themes in ${OFFER.name}.`)
|
| 189 |
+
: (cfg.extra || 'The Fun Pass opens every level, mode, the full archive and themes in every CyberMax game.');
|
| 190 |
+
el.innerHTML = `<p><b>Want more today?</b> ${esc(line)}</p>
|
| 191 |
+
<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>
|
| 192 |
+
<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 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 daily challenge stays free.</p>
|
| 193 |
+
<div class="fp-cap"></div>`;
|
| 194 |
+
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)); });
|
| 195 |
+
el.querySelector('[data-plans]').addEventListener('click', () => open(`after-${from}`));
|
| 196 |
+
kit((K) => {
|
| 197 |
+
K.event('result', cfg.game); K.event('upgrade-view', cfg.game);
|
| 198 |
+
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' });
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// Convert kit (funnel beacons + email capture) from the CyberMax site; optional, the game works without it.
|
| 203 |
+
let kitQ = null;
|
| 204 |
+
function kit(fn) {
|
| 205 |
+
if (window.CMXConvert) return fn(window.CMXConvert);
|
| 206 |
+
if (kitQ) return kitQ.push(fn);
|
| 207 |
+
kitQ = [fn];
|
| 208 |
+
const sc = document.createElement('script');
|
| 209 |
+
sc.src = 'https://cybermaxtools.com/store/convert.js'; sc.async = true;
|
| 210 |
+
sc.onload = () => kitQ.splice(0).forEach((f) => { try { f(window.CMXConvert); } catch { /* optional */ } });
|
| 211 |
+
document.head.appendChild(sc);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/** Call once per page: game id, display name and the game's own perks (listed first). Reads #cmx-pro=<code> after
|
| 215 |
+
* checkout (?unlock=full marks this game's full-game return). Skin-pack codes are taken first by skins.js capture(). */
|
| 216 |
+
export function init(options) {
|
| 217 |
+
cfg = { ...cfg, ...options };
|
| 218 |
+
const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
|
| 219 |
+
if (m && SID.test(decodeURIComponent(m[1]))) {
|
| 220 |
+
const q = new URLSearchParams(location.search);
|
| 221 |
+
const fullReturn = q.get('unlock') === 'full';
|
| 222 |
+
q.delete('unlock');
|
| 223 |
+
history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
|
| 224 |
+
if (fullReturn) unlockGame(decodeURIComponent(m[1])); else unlock(decodeURIComponent(m[1]));
|
| 225 |
+
} else recheck();
|
| 226 |
+
emit();
|
| 227 |
+
}
|
game.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Sightfern: camera, photo and manual spotting, the field album (IndexedDB, this device only), badges, streak,
|
| 2 |
+
// quest and sighting cards. Rules: core.js. Recogniser: detect.js (on-device). (c) 2026 CyberMax. All rights reserved.
|
| 3 |
+
import { ALBUMS, SPECIES, LABEL, COLOURS, SLOTS, RARITY, TIMES, coatColour, sighting, coarsen, progress, slotsOf, streak, badges, quest, questDone, localDay } from './core.js';
|
| 4 |
+
import { loadDetector, MIN_SCORE } from './detect.js';
|
| 5 |
+
import { count, store, toast, share, download, pwa, themeToggle } from './cmx.js';
|
| 6 |
+
import * as pass from './funpass.js';
|
| 7 |
+
|
| 8 |
+
const GAME = 'sightfern', PATH = '/games/sightfern/';
|
| 9 |
+
const $ = (id) => document.getElementById(id);
|
| 10 |
+
pass.init({ game: GAME, name: 'Sightfern', perks: ['Farm and Wild & zoo albums (horses, sheep, cows, bears, elephants, zebras, giraffes)', 'Gold, Film and Holo card frames', 'Extras in every CyberMax game'] });
|
| 11 |
+
pwa(GAME, $('install'));
|
| 12 |
+
themeToggle($('theme'));
|
| 13 |
+
|
| 14 |
+
// ------------------------------------------------------------------ storage: IndexedDB on this device only
|
| 15 |
+
const DB = new Promise((ok, bad) => {
|
| 16 |
+
const r = indexedDB.open('sightfern', 1);
|
| 17 |
+
r.onupgradeneeded = () => { r.result.createObjectStore('sightings', { keyPath: 'id' }); r.result.createObjectStore('photos'); };
|
| 18 |
+
r.onsuccess = () => ok(r.result); r.onerror = () => bad(r.error);
|
| 19 |
+
});
|
| 20 |
+
const tx = async (st, mode, fn) => { const db = await DB; return new Promise((ok, bad) => { const t = db.transaction(st, mode); const out = fn(t); t.oncomplete = () => ok(out && 'result' in out ? out.result : out); t.onerror = () => bad(t.error); }); };
|
| 21 |
+
const allSightings = () => tx('sightings', 'readonly', (t) => t.objectStore('sightings').getAll());
|
| 22 |
+
const putSighting = (s, blob) => tx(['sightings', 'photos'], 'readwrite', (t) => { t.objectStore('sightings').put(s); if (blob) t.objectStore('photos').put(blob, s.id); });
|
| 23 |
+
const getPhoto = (id) => tx('photos', 'readonly', (t) => t.objectStore('photos').get(id));
|
| 24 |
+
const wipeAll = () => tx(['sightings', 'photos'], 'readwrite', (t) => { t.objectStore('sightings').clear(); t.objectStore('photos').clear(); });
|
| 25 |
+
|
| 26 |
+
let S = [];
|
| 27 |
+
let tab = 'cats';
|
| 28 |
+
let frame = store('sightfern:frame') || 'field';
|
| 29 |
+
const FRAMES = { field: { name: 'Field journal', pro: false }, plain: { name: 'Plain', pro: false }, gold: { name: 'Gold', pro: true }, film: { name: 'Film', pro: true }, holo: { name: 'Holo', pro: true } };
|
| 30 |
+
const today = () => localDay(new Date());
|
| 31 |
+
|
| 32 |
+
// ------------------------------------------------------------------ header: quest, streak, totals
|
| 33 |
+
function header() {
|
| 34 |
+
const q = quest(today());
|
| 35 |
+
$('quest').textContent = q.text;
|
| 36 |
+
$('quest-ok').hidden = !questDone(q, S, today());
|
| 37 |
+
$('streak').textContent = `🔥 ${streak(S, today())}`;
|
| 38 |
+
$('total').textContent = `📒 ${S.length}`;
|
| 39 |
+
const free = Object.keys(ALBUMS).filter((a) => !ALBUMS[a].pro || pass.isPro());
|
| 40 |
+
const f = free.reduce((n, a) => n + progress(S, a).found, 0), t = free.reduce((n, a) => n + progress(S, a).total, 0);
|
| 41 |
+
$('found').textContent = `${f} / ${t} album slots`;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// ------------------------------------------------------------------ album view
|
| 45 |
+
const urls = new Map();
|
| 46 |
+
async function thumb(id) {
|
| 47 |
+
if (urls.has(id)) return urls.get(id);
|
| 48 |
+
const b = await getPhoto(id);
|
| 49 |
+
const u = b ? URL.createObjectURL(b) : null;
|
| 50 |
+
urls.set(id, u);
|
| 51 |
+
return u;
|
| 52 |
+
}
|
| 53 |
+
async function album() {
|
| 54 |
+
$('tabs').innerHTML = Object.entries(ALBUMS).map(([k, a]) => `<button role="tab" type="button" class="tab${k === tab ? ' on' : ''}" aria-selected="${k === tab}" data-tab="${k}">${a.icon} ${a.name}${a.pro && !pass.isPro() ? ' 🔒' : ''} <small>${progress(S, k).found}/${progress(S, k).total}</small></button>`).join('');
|
| 55 |
+
const locked = ALBUMS[tab].pro && !pass.isPro();
|
| 56 |
+
const cells = [];
|
| 57 |
+
for (const key of slotsOf(tab)) {
|
| 58 |
+
const [sp, c] = key.split(':');
|
| 59 |
+
const mine = S.filter((s) => s.species === sp && s.colour === c).sort((a, b) => b.at.localeCompare(a.at));
|
| 60 |
+
const r = SLOTS[sp][c];
|
| 61 |
+
const sw = COLOURS[c].hex;
|
| 62 |
+
if (mine.length && !locked) {
|
| 63 |
+
const u = await thumb(mine[0].id);
|
| 64 |
+
cells.push(`<button class="slot got r${r}" type="button" data-id="${mine[0].id}" title="${COLOURS[c].name} ${LABEL[sp]}: ${mine.length} sighting(s)">${u ? `<img src="${u}" alt="">` : `<i style="background:${sw}"></i>`}<span>${COLOURS[c].name} ${LABEL[sp]}</span><em>${RARITY[r]}${mine.length > 1 ? ` · ×${mine.length}` : ''}</em></button>`);
|
| 65 |
+
} else cells.push(`<div class="slot r${r}"><i style="background:${sw}"></i><span>${COLOURS[c].name} ${LABEL[sp]}</span><em>${locked ? '🔒 Fun Pass' : RARITY[r]}</em></div>`);
|
| 66 |
+
}
|
| 67 |
+
$('slots').innerHTML = cells.join('');
|
| 68 |
+
$('badges').innerHTML = badges(S, today()).map((b) => `<span class="bdg${b.got ? ' got' : ''}" title="${b.name}">${b.icon}<small>${b.name}</small></span>`).join('');
|
| 69 |
+
$('frames').innerHTML = Object.entries(FRAMES).map(([k, f]) => `<button class="chip${k === frame ? ' on' : ''}" type="button" data-frame="${k}" aria-pressed="${k === frame}">${f.name}${f.pro && !pass.isPro() ? ' 🔒' : ''}</button>`).join('');
|
| 70 |
+
}
|
| 71 |
+
$('tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-tab]'); if (!b) return; tab = b.dataset.tab; if (ALBUMS[tab].pro && !pass.isPro()) pass.open('album'); album(); });
|
| 72 |
+
$('slots').addEventListener('click', async (e) => { const b = e.target.closest('[data-id]'); if (!b) return; const s = S.find((x) => x.id === b.dataset.id); if (s) openCard(s, false); });
|
| 73 |
+
$('frames').addEventListener('click', (e) => { const b = e.target.closest('[data-frame]'); if (!b) return; if (FRAMES[b.dataset.frame].pro && !pass.isPro()) { pass.open('frame'); return; } frame = b.dataset.frame; store('sightfern:frame', frame); album(); });
|
| 74 |
+
async function refresh() { S = await allSightings(); header(); await album(); }
|
| 75 |
+
|
| 76 |
+
// ------------------------------------------------------------------ the sighting card (canvas)
|
| 77 |
+
const RCOL = ['#6b7a70', '#2f7d4f', '#2f66c9', '#8a3fd1', '#d79a10'];
|
| 78 |
+
async function drawCard(s) {
|
| 79 |
+
const c = $('card'), x = c.getContext('2d'), W = 1080, H = 1350;
|
| 80 |
+
const bg = { field: '#f3ead6', plain: '#ffffff', gold: '#2a2214', film: '#121212', holo: '#1b1730' }[frame] || '#f3ead6';
|
| 81 |
+
const ink = ['gold', 'film', 'holo'].includes(frame) ? '#f6f1e4' : '#1d2a22';
|
| 82 |
+
x.fillStyle = bg; x.fillRect(0, 0, W, H);
|
| 83 |
+
if (frame === 'holo') { const g = x.createLinearGradient(0, 0, W, H); ['#ff6ad5', '#c774e8', '#ad8cff', '#8795e8', '#94d0ff'].forEach((col, i) => g.addColorStop(i / 4, col)); x.globalAlpha = 0.35; x.fillStyle = g; x.fillRect(0, 0, W, H); x.globalAlpha = 1; }
|
| 84 |
+
if (frame === 'gold') { x.strokeStyle = '#e2b55a'; x.lineWidth = 18; x.strokeRect(24, 24, W - 48, H - 48); }
|
| 85 |
+
if (frame === 'film') { x.fillStyle = '#2b2b2b'; for (let i = 0; i < 14; i++) { x.fillRect(18, 40 + i * 92, 34, 50); x.fillRect(W - 52, 40 + i * 92, 34, 50); } }
|
| 86 |
+
const blob = await getPhoto(s.id);
|
| 87 |
+
const px = 90, py = 150, pw = W - 180, ph = 760;
|
| 88 |
+
x.fillStyle = '#d9d1bf'; x.fillRect(px, py, pw, ph);
|
| 89 |
+
if (blob) {
|
| 90 |
+
const im = await createImageBitmap(blob);
|
| 91 |
+
const sc = Math.max(pw / im.width, ph / im.height), dw = im.width * sc, dh = im.height * sc;
|
| 92 |
+
x.save(); x.beginPath(); x.rect(px, py, pw, ph); x.clip(); x.drawImage(im, px + (pw - dw) / 2, py + (ph - dh) / 2, dw, dh); x.restore();
|
| 93 |
+
} else { x.font = '220px system-ui, "Apple Color Emoji", "Segoe UI Emoji"'; x.textAlign = 'center'; x.fillText(ALBUMS[s.album].icon, W / 2, py + ph / 2 + 80); x.textAlign = 'left'; }
|
| 94 |
+
x.fillStyle = ink; x.font = '900 46px system-ui, sans-serif'; x.fillText('SIGHTFERN', 90, 108);
|
| 95 |
+
x.font = '600 32px system-ui, sans-serif'; x.globalAlpha = 0.7; x.textAlign = 'right'; x.fillText(`#${S.findIndex((y) => y.id === s.id) + 1 || S.length} in my journal`, W - 90, 108); x.textAlign = 'left'; x.globalAlpha = 1;
|
| 96 |
+
x.font = '900 82px system-ui, sans-serif'; x.fillText(`${COLOURS[s.colour].name} ${LABEL[s.species]}`, 90, 1020);
|
| 97 |
+
x.fillStyle = RCOL[s.rarity]; x.beginPath(); x.roundRect(90, 1050, 330, 74, 37); x.fill();
|
| 98 |
+
x.fillStyle = '#fff'; x.font = '800 40px system-ui, sans-serif'; x.fillText(RARITY[s.rarity], 120, 1100);
|
| 99 |
+
const t = TIMES.find((y) => y.id === s.time);
|
| 100 |
+
x.fillStyle = ink; x.font = '600 38px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
|
| 101 |
+
x.fillText(`${t.icon} ${t.name} · ${new Date(s.at).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}${s.manual ? ' · ✍️ self-tagged' : ''}`, 90, 1190);
|
| 102 |
+
x.globalAlpha = 0.65; x.font = '600 30px system-ui, sans-serif'; x.fillText('Spot your own: cybermaxtools.com/games/sightfern', 90, 1280); x.globalAlpha = 1;
|
| 103 |
+
}
|
| 104 |
+
let shown = null;
|
| 105 |
+
async function openCard(s, fresh) {
|
| 106 |
+
shown = s;
|
| 107 |
+
$('sheet-title').textContent = fresh ? (s.rarity >= 2 ? `${RARITY[s.rarity]} find! ✨` : 'New sighting!') : `${COLOURS[s.colour].name} ${LABEL[s.species]}`;
|
| 108 |
+
await drawCard(s);
|
| 109 |
+
const where = s.where ? ` · noted near ${s.where.lat}, ${s.where.lon} (on this phone only)` : '';
|
| 110 |
+
$('sheet-info').textContent = `${s.manual ? 'Tagged by you' : `Recognised on this device (${Math.round((s.score || 0) * 100)}% sure)`}${where}.`;
|
| 111 |
+
$('sheet').showModal();
|
| 112 |
+
if (fresh && !pass.isPro() && S.length === 3) pass.afterWin($('sheet-after'), 'win');
|
| 113 |
+
}
|
| 114 |
+
$('sheet-close').onclick = () => $('sheet').close();
|
| 115 |
+
const cardBlob = () => new Promise((ok) => $('card').toBlob(ok, 'image/png'));
|
| 116 |
+
$('share').onclick = async () => { const s = shown; share(GAME, `I spotted a ${COLOURS[s.colour].name.toLowerCase()} ${LABEL[s.species].toLowerCase()} (${RARITY[s.rarity]}) for my Sightfern field album 📒`, PATH, await cardBlob(), `sightfern-${s.species}.png`); };
|
| 117 |
+
$('dl').onclick = async () => { download(await cardBlob(), `sightfern-${shown.species}-${shown.day}.png`); count(GAME, 'share', 'png'); };
|
| 118 |
+
|
| 119 |
+
// ------------------------------------------------------------------ saving a sighting
|
| 120 |
+
async function where() {
|
| 121 |
+
if (!store('sightfern:loc') || !navigator.geolocation) return null;
|
| 122 |
+
return new Promise((ok) => navigator.geolocation.getCurrentPosition((p) => ok(coarsen(p.coords.latitude, p.coords.longitude)), () => ok(null), { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }));
|
| 123 |
+
}
|
| 124 |
+
/** Crop around the box (15% margin), at most 720 px, as a JPEG blob. */
|
| 125 |
+
function cropBlob(src, box) {
|
| 126 |
+
const m = 0.15, x = Math.max(0, box.x - box.w * m), y = Math.max(0, box.y - box.h * m);
|
| 127 |
+
const w = Math.min(src.width - x, box.w * (1 + 2 * m)), h = Math.min(src.height - y, box.h * (1 + 2 * m));
|
| 128 |
+
const s = Math.min(1, 720 / Math.max(w, h));
|
| 129 |
+
const c = document.createElement('canvas'); c.width = Math.round(w * s); c.height = Math.round(h * s);
|
| 130 |
+
c.getContext('2d').drawImage(src, x, y, w, h, 0, 0, c.width, c.height);
|
| 131 |
+
return new Promise((ok) => c.toBlob(ok, 'image/jpeg', 0.85));
|
| 132 |
+
}
|
| 133 |
+
async function save(rec, blob) {
|
| 134 |
+
const loc = await where();
|
| 135 |
+
if (loc) rec.where = loc;
|
| 136 |
+
const before = new Set(S.map((s) => `${s.species}:${s.colour}`));
|
| 137 |
+
await putSighting(rec, blob);
|
| 138 |
+
await refresh();
|
| 139 |
+
count(GAME, 'win', `${rec.species}-${RARITY[rec.rarity].toLowerCase()}${rec.manual ? '-manual' : ''}`);
|
| 140 |
+
if (!before.has(`${rec.species}:${rec.colour}`)) toast(`New album slot: ${COLOURS[rec.colour].name} ${LABEL[rec.species]}!`, 3000);
|
| 141 |
+
const q = quest(today());
|
| 142 |
+
if (questDone(q, S, today()) && !questDone(q, S.filter((s) => s.id !== rec.id), today())) toast(`Quest done: ${q.text} ✅`, 3000);
|
| 143 |
+
await openCard(rec, true);
|
| 144 |
+
}
|
| 145 |
+
let det = null, perf = [];
|
| 146 |
+
async function detector() {
|
| 147 |
+
if (det) return det;
|
| 148 |
+
$('cam-note').textContent = 'Loading the on-device recogniser (once, about 14 MB)…';
|
| 149 |
+
try { det = await loadDetector(); $('cam-note').textContent = 'Recogniser ready. It runs on this device; nothing is uploaded.'; }
|
| 150 |
+
catch (e) { $('cam-note').textContent = 'This browser could not run the recogniser. Use “Tag it myself” to add sightings by hand.'; throw e; }
|
| 151 |
+
return det;
|
| 152 |
+
}
|
| 153 |
+
/** Find the best animal in a canvas; returns { item, colour } or null. Detection runs on a 512 px copy. */
|
| 154 |
+
async function analyse(src) {
|
| 155 |
+
const d = await detector();
|
| 156 |
+
const s = Math.min(1, 512 / Math.max(src.width, src.height));
|
| 157 |
+
const small = document.createElement('canvas'); small.width = Math.round(src.width * s); small.height = Math.round(src.height * s);
|
| 158 |
+
small.getContext('2d').drawImage(src, 0, 0, small.width, small.height);
|
| 159 |
+
const r = d.detect(small);
|
| 160 |
+
perf.push(r.ms);
|
| 161 |
+
const it = r.items.find((i) => i.score >= MIN_SCORE);
|
| 162 |
+
if (!it) return null;
|
| 163 |
+
const box = { x: it.box.x / s, y: it.box.y / s, w: it.box.w / s, h: it.box.h / s };
|
| 164 |
+
const cw = Math.max(1, Math.round(box.w)), ch = Math.max(1, Math.round(box.h));
|
| 165 |
+
const cc = document.createElement('canvas'); cc.width = Math.min(cw, 256); cc.height = Math.round(ch * (cc.width / cw)) || 1;
|
| 166 |
+
cc.getContext('2d').drawImage(src, box.x, box.y, box.w, box.h, 0, 0, cc.width, cc.height);
|
| 167 |
+
const px = cc.getContext('2d').getImageData(0, 0, cc.width, cc.height).data;
|
| 168 |
+
return { item: { ...it, box }, colour: coatColour(px, cc.width, cc.height, it.species) };
|
| 169 |
+
}
|
| 170 |
+
async function spotFrom(src, how) {
|
| 171 |
+
let a;
|
| 172 |
+
try { a = await analyse(src); } catch { openTagger(src); return; }
|
| 173 |
+
if (!a) { toast('No cat, dog or bird found in that shot. Get a bit closer, or tag it yourself.', 3800); lastShot = src; return; }
|
| 174 |
+
count(GAME, 'play', how);
|
| 175 |
+
const rec = sighting({ species: a.item.species, colour: a.colour, score: a.item.score });
|
| 176 |
+
await save(rec, await cropBlob(src, a.item.box));
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// ------------------------------------------------------------------ camera
|
| 180 |
+
let stream = null, looking = false, lastShot = null, lastSeen = null;
|
| 181 |
+
const video = $('video'), boxes = $('boxes');
|
| 182 |
+
async function startCam() {
|
| 183 |
+
if (stream) { stopCam(); return; }
|
| 184 |
+
try {
|
| 185 |
+
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false });
|
| 186 |
+
} catch { toast('Camera not available. Use a photo or tag it yourself.', 3500); return; }
|
| 187 |
+
video.srcObject = stream; await video.play();
|
| 188 |
+
$('viewer').hidden = false; $('shutter').hidden = false; $('cam-btn').textContent = '⏹ Stop camera';
|
| 189 |
+
count(GAME, 'play', 'camera-open');
|
| 190 |
+
detector().then(() => { looking = true; look(); }).catch(() => {});
|
| 191 |
+
}
|
| 192 |
+
function stopCam() {
|
| 193 |
+
looking = false;
|
| 194 |
+
if (stream) stream.getTracks().forEach((t) => t.stop());
|
| 195 |
+
stream = null; $('viewer').hidden = true; $('shutter').hidden = true; $('cam-btn').textContent = '📷 Start camera';
|
| 196 |
+
}
|
| 197 |
+
function frameCanvas(maxSide) {
|
| 198 |
+
const s = Math.min(1, maxSide / Math.max(video.videoWidth, video.videoHeight));
|
| 199 |
+
const c = document.createElement('canvas'); c.width = Math.round(video.videoWidth * s); c.height = Math.round(video.videoHeight * s);
|
| 200 |
+
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
|
| 201 |
+
return c;
|
| 202 |
+
}
|
| 203 |
+
async function look() {
|
| 204 |
+
if (!looking || !video.videoWidth) { if (looking) setTimeout(look, 300); return; }
|
| 205 |
+
const c = frameCanvas(384);
|
| 206 |
+
const r = det.detect(c);
|
| 207 |
+
perf.push(r.ms); if (perf.length > 20) perf.shift();
|
| 208 |
+
const it = r.items.find((i) => i.score >= MIN_SCORE);
|
| 209 |
+
lastSeen = it || null;
|
| 210 |
+
const vw = boxes.clientWidth, vh = boxes.clientHeight;
|
| 211 |
+
boxes.width = vw * devicePixelRatio; boxes.height = vh * devicePixelRatio;
|
| 212 |
+
const x = boxes.getContext('2d'); x.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); x.clearRect(0, 0, vw, vh);
|
| 213 |
+
// video is shown with object-fit: cover; map the detection box onto it
|
| 214 |
+
const sc = Math.max(vw / c.width, vh / c.height), ox = (vw - c.width * sc) / 2, oy = (vh - c.height * sc) / 2;
|
| 215 |
+
if (it) { x.strokeStyle = '#7CFFB2'; x.lineWidth = 3; x.setLineDash([10, 6]); x.strokeRect(ox + it.box.x * sc, oy + it.box.y * sc, it.box.w * sc, it.box.h * sc); }
|
| 216 |
+
$('seen').textContent = it ? `${LABEL[it.species]} in sight (${Math.round(it.score * 100)}%). Tap Spot it!` : 'Looking for a cat, dog or bird…';
|
| 217 |
+
$('shutter').disabled = !it;
|
| 218 |
+
const avg = perf.reduce((a, b) => a + b, 0) / perf.length;
|
| 219 |
+
setTimeout(look, Math.max(250, avg * 2)); // leave the phone breathing room between looks
|
| 220 |
+
}
|
| 221 |
+
$('cam-btn').onclick = startCam;
|
| 222 |
+
$('shutter').onclick = async () => { if (!stream) return; const c = frameCanvas(1280); lastShot = c; await spotFrom(c, 'camera'); };
|
| 223 |
+
$('file').onchange = async (e) => {
|
| 224 |
+
const f = e.target.files[0]; e.target.value = ''; if (!f) return;
|
| 225 |
+
const im = await createImageBitmap(f);
|
| 226 |
+
const s = Math.min(1, 1600 / Math.max(im.width, im.height));
|
| 227 |
+
const c = document.createElement('canvas'); c.width = Math.round(im.width * s); c.height = Math.round(im.height * s);
|
| 228 |
+
c.getContext('2d').drawImage(im, 0, 0, c.width, c.height);
|
| 229 |
+
lastShot = c;
|
| 230 |
+
$('cam-note').textContent = 'Checking your photo on this device…';
|
| 231 |
+
await spotFrom(c, 'photo');
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
// ------------------------------------------------------------------ tag it yourself (manual fallback)
|
| 235 |
+
let tagSp = null, tagCol = null, tagSrc = null;
|
| 236 |
+
function openTagger(src = lastShot) {
|
| 237 |
+
tagSrc = src; tagSp = null; tagCol = null;
|
| 238 |
+
$('tag-species').innerHTML = Object.entries(SPECIES).map(([sp, al]) => `<button class="chip" type="button" data-sp="${sp}">${ALBUMS[al].icon} ${LABEL[sp]}${ALBUMS[al].pro && !pass.isPro() ? ' 🔒' : ''}</button>`).join('');
|
| 239 |
+
$('tag-colour').innerHTML = ''; $('tag-save').disabled = true;
|
| 240 |
+
$('tag-why').textContent = src ? 'Pick what is in your last photo. Self-tagged sightings get a ✍️ stamp.' : 'Pick what you saw. Self-tagged sightings get a ✍️ stamp.';
|
| 241 |
+
$('tagger').showModal();
|
| 242 |
+
}
|
| 243 |
+
$('manual-btn').onclick = () => openTagger(lastShot);
|
| 244 |
+
$('tag-species').addEventListener('click', (e) => {
|
| 245 |
+
const b = e.target.closest('[data-sp]'); if (!b) return;
|
| 246 |
+
if (ALBUMS[SPECIES[b.dataset.sp]].pro && !pass.isPro()) { pass.open('album'); return; }
|
| 247 |
+
tagSp = b.dataset.sp; tagCol = null; $('tag-save').disabled = true;
|
| 248 |
+
$('tag-species').querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === b));
|
| 249 |
+
$('tag-colour').innerHTML = Object.keys(SLOTS[tagSp]).map((c) => `<button class="chip" type="button" data-col="${c}"><i style="background:${COLOURS[c].hex}"></i>${COLOURS[c].name}</button>`).join('');
|
| 250 |
+
});
|
| 251 |
+
$('tag-colour').addEventListener('click', (e) => { const b = e.target.closest('[data-col]'); if (!b) return; tagCol = b.dataset.col; $('tag-colour').querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === b)); $('tag-save').disabled = false; });
|
| 252 |
+
$('tag-cancel').onclick = () => $('tagger').close();
|
| 253 |
+
$('tag-save').onclick = async () => {
|
| 254 |
+
$('tagger').close();
|
| 255 |
+
const rec = sighting({ species: tagSp, colour: tagCol, manual: true });
|
| 256 |
+
const blob = tagSrc ? await cropBlob(tagSrc, { x: 0, y: 0, w: tagSrc.width, h: tagSrc.height }) : null;
|
| 257 |
+
count(GAME, 'play', 'manual');
|
| 258 |
+
await save(rec, blob);
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
// ------------------------------------------------------------------ privacy settings
|
| 262 |
+
$('loc').checked = !!store('sightfern:loc');
|
| 263 |
+
$('loc').onchange = (e) => {
|
| 264 |
+
if (e.target.checked && navigator.geolocation) navigator.geolocation.getCurrentPosition(() => { store('sightfern:loc', true); toast('Rough location on: rounded to about 11 km, kept on this phone only.', 3500); }, () => { e.target.checked = false; store('sightfern:loc', false); toast('Location stays off.'); }, { enableHighAccuracy: false, timeout: 10000 });
|
| 265 |
+
else store('sightfern:loc', false);
|
| 266 |
+
};
|
| 267 |
+
$('wipe').onclick = async () => {
|
| 268 |
+
if (!confirm('Delete every sighting and photo in Sightfern on this device? This cannot be undone.')) return;
|
| 269 |
+
await wipeAll(); urls.forEach((u) => u && URL.revokeObjectURL(u)); urls.clear(); await refresh(); toast('Everything deleted.');
|
| 270 |
+
};
|
| 271 |
+
$('pass-btn').onclick = () => pass.open('button');
|
| 272 |
+
pass.onChange(() => { if (FRAMES[frame].pro && !pass.isPro()) frame = 'field'; refresh(); });
|
| 273 |
+
document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stopCam(); });
|
| 274 |
+
refresh();
|
| 275 |
+
|
| 276 |
+
// Test + store-screenshot hooks (tests/e2e.mjs, scripts/games/render_assets.mjs)
|
| 277 |
+
window.__sightfern = { spotFrom, openTagger, refresh, perf: () => perf, add: async (recs) => { for (const r of recs) await putSighting(r, null); await refresh(); } };
|
| 278 |
+
window.__cmxShot = async (stage = 1) => {
|
| 279 |
+
const now = new Date(); const mk = (species, colour, h, d = 0) => { const at = new Date(now); at.setDate(at.getDate() - d); at.setHours(h); return sighting({ species, colour, at, score: 0.9 }); };
|
| 280 |
+
await window.__sightfern.add([mk('cat', 'ginger', 9), mk('cat', 'black', 22, 1), mk('dog', 'golden', 12, 2), mk('bird', 'blue', 7), mk('cat', 'grey', 15, 3), mk('dog', 'white', 18)]);
|
| 281 |
+
if (stage === 2) await openCard(S.find((s) => s.species === 'bird') || S[0], true);
|
| 282 |
+
};
|
icon-192.png
ADDED
|
|
icon-512.png
ADDED
|
|
Git LFS Details
|
icon-maskable.png
ADDED
|
|
icon.svg
ADDED
|
|
index.html
CHANGED
|
@@ -1,19 +1,99 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>Sightfern: Spot Real Cats, Dogs and Birds With Your Camera, Free Album Game</title>
|
| 4 |
+
<meta name="description" content="Point your phone at a real cat, dog or bird: Sightfern recognises it on your device and adds it to your field album. Rarity, badges, daily streak. Free.">
|
| 5 |
+
<link rel="canonical" href="https://cybermaxtools.com/games/sightfern/">
|
| 6 |
+
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
+
<meta property="og:title" content="Sightfern: Spot Real Cats">
|
| 8 |
+
<meta property="og:description" content="Point your phone at a real cat, dog or bird: Sightfern recognises it on your device and adds it to your field album. Rarity, badges, daily streak. Free.">
|
| 9 |
+
<meta property="og:url" content="https://cybermaxtools.com/games/sightfern/">
|
| 10 |
+
<meta property="og:image" content="https://cybermaxtools.com/games/sightfern/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/sightfern/og.png">
|
| 12 |
+
<meta name="theme-color" content="#14231B">
|
| 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="Sightfern">
|
| 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 |
+
<meta name="twitter:title" content="Sightfern: Spot Real Cats">
|
| 19 |
+
<meta name="twitter:description" content="Point your phone at a real cat, dog or bird: Sightfern recognises it on your device and adds it to your field album. Rarity, badges, daily streak. Free.">
|
| 20 |
+
<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>
|
| 21 |
+
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/sightfern/#game", "name": "Sightfern", "alternateName": "Spot real animals camera game", "url": "https://cybermaxtools.com/games/sightfern/", "image": "https://cybermaxtools.com/games/sightfern/og.png", "description": "Sightfern turns everyday walks into a collection game. Point your phone's camera at a real cat, dog or bird and an on-device recogniser identifies it, reads its coat or plumage colour and stamps the time of day. Each sighting fills a slot in your field album, with rarity, badges, a daily quest and a streak. Photos, the album and the optional rough location stay on your phone: nothing is uploaded.", "genre": ["Casual", "Collection", "Outdoor", "Photography"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": [{"@type": "Offer", "name": "Free demo (daily challenge + levels 1-20)", "price": "0", "priceCurrency": "USD"}], "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-09", "keywords": "Spot real animals camera game, free real-world animal spotting game, unblocked, no download, browser game"}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/sightfern/#faq", "mainEntity": [{"@type": "Question", "name": "Is Sightfern free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. The camera recogniser, the Cats, Dogs and Birds albums, badges, quests and streaks are free, with no ads and no sign-up."}}, {"@type": "Question", "name": "Are my photos uploaded?", "acceptedAnswer": {"@type": "Answer", "text": "No. Recognition runs on your device and the album is stored in your browser. Nothing is sent to us or anyone else."}}, {"@type": "Question", "name": "Does it track my location?", "acceptedAnswer": {"@type": "Answer", "text": "No. Location is off unless you switch it on, and then only a rough position (about 11 km) is kept, on your phone only."}}, {"@type": "Question", "name": "Which animals does it recognise?", "acceptedAnswer": {"@type": "Answer", "text": "Cats, dogs and birds in the free albums; the Fun Pass adds horses, sheep, cows, bears, elephants, zebras and giraffes."}}, {"@type": "Question", "name": "Does it work offline?", "acceptedAnswer": {"@type": "Answer", "text": "After the first load, the albums and cards work offline; the recogniser needs your browser's cached copy from the first use."}}]}]}</script>
|
| 22 |
+
</head><body>
|
| 23 |
+
<div class="wrap wide">
|
| 24 |
+
<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>
|
| 25 |
+
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 26 |
+
<main>
|
| 27 |
+
<h1>Spot real cats, dogs and birds <small class="h1kw">Free real-world animal spotting game</small></h1>
|
| 28 |
+
<p class="muted lede">Point your phone at an animal you meet. Sightfern recognises it on your device and adds it to your field album, with its colour, rarity and the time you saw it. Your photos never leave this phone.</p>
|
| 29 |
+
|
| 30 |
+
<section class="today panel" aria-label="Today">
|
| 31 |
+
<div><span class="lbl">Today's quest</span><b id="quest"></b> <span id="quest-ok" hidden>✅</span></div>
|
| 32 |
+
<div class="tstats"><span class="badge" id="streak" title="Daily streak">🔥 0</span><span class="badge" id="total">📒 0</span><span class="badge" id="found">0 / 0 pages</span></div>
|
| 33 |
+
</section>
|
| 34 |
+
|
| 35 |
+
<section class="cam panel" id="cam-panel" aria-label="Camera">
|
| 36 |
+
<div class="viewer" id="viewer" hidden><video id="video" playsinline muted></video><canvas id="boxes" aria-hidden="true"></canvas><p class="seen" id="seen" aria-live="polite">Looking…</p></div>
|
| 37 |
+
<div class="row center"><button class="btn big" id="cam-btn" type="button">📷 Start camera</button><button class="btn shutter" id="shutter" type="button" hidden disabled>📸 Spot it</button>
|
| 38 |
+
<label class="btn ghost"><input type="file" id="file" accept="image/*" hidden>🖼️ Use a photo</label><button class="btn ghost" id="manual-btn" type="button">✍️ Tag it myself</button></div>
|
| 39 |
+
<p class="muted small" id="cam-note">The recogniser downloads the first time you use it (about 14 MB; your browser keeps a copy) and runs on this device. No photo, video or location is uploaded.</p>
|
| 40 |
+
</section>
|
| 41 |
+
|
| 42 |
+
<dialog id="sheet" class="sheet" aria-labelledby="sheet-title">
|
| 43 |
+
<h2 id="sheet-title">New sighting!</h2>
|
| 44 |
+
<canvas id="card" width="1080" height="1350" aria-label="Your sighting card"></canvas>
|
| 45 |
+
<p id="sheet-info" class="muted small"></p>
|
| 46 |
+
<div class="row center"><button class="btn" id="share" type="button">Share card</button><button class="btn ghost" id="dl" type="button">⬇ Save card</button><button class="btn ghost" id="sheet-close" type="button">Done</button></div>
|
| 47 |
+
<div id="sheet-after"></div>
|
| 48 |
+
</dialog>
|
| 49 |
+
|
| 50 |
+
<dialog id="tagger" class="sheet" aria-labelledby="tag-title">
|
| 51 |
+
<h2 id="tag-title">Tag it yourself</h2>
|
| 52 |
+
<p class="muted small" id="tag-why">Pick what you saw. Self-tagged sightings get a ✍️ stamp.</p>
|
| 53 |
+
<div class="chips" id="tag-species" role="group" aria-label="Animal"></div>
|
| 54 |
+
<div class="chips" id="tag-colour" role="group" aria-label="Colour"></div>
|
| 55 |
+
<div class="row center"><button class="btn" id="tag-save" type="button" disabled>Add to album</button><button class="btn ghost" id="tag-cancel" type="button">Cancel</button></div>
|
| 56 |
+
</dialog>
|
| 57 |
+
|
| 58 |
+
<section class="album panel" aria-label="Your field album">
|
| 59 |
+
<div class="row between"><h2>Your field album</h2><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
|
| 60 |
+
<div class="tabs" role="tablist" id="tabs"></div>
|
| 61 |
+
<div class="slots" id="slots"></div>
|
| 62 |
+
<h3>Badges</h3><div class="badges" id="badges"></div>
|
| 63 |
+
<h3>Card frame</h3><div class="chips" id="frames" role="group" aria-label="Card frame"></div>
|
| 64 |
+
</section>
|
| 65 |
+
|
| 66 |
+
<section class="panel settings" aria-label="Privacy settings">
|
| 67 |
+
<h2>Privacy</h2>
|
| 68 |
+
<label class="toggle"><input type="checkbox" id="loc"> Note roughly where I saw each animal (about 11 km precision, kept only on this phone). Off unless you switch it on.</label>
|
| 69 |
+
<p class="muted small">Sightfern has no account and no server: photos, the album and the optional rough location are stored in this browser only. Clearing the site's data or the button below deletes them for good.</p>
|
| 70 |
+
<div class="row"><button class="btn ghost sm" id="wipe" type="button">🗑️ Delete all my sightings</button><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button></div>
|
| 71 |
+
</section>
|
| 72 |
+
|
| 73 |
+
<section class="seo" id="guide">
|
| 74 |
+
<h2>Sightfern: spot real animals camera game</h2>
|
| 75 |
+
<p>Sightfern turns everyday walks into a collection game. Point your phone's camera at a real cat, dog or bird and an on-device recogniser identifies it, reads its coat or plumage colour and stamps the time of day. Each sighting fills a slot in your field album, with rarity, badges, a daily quest and a streak. Photos, the album and the optional rough location stay on your phone: nothing is uploaded.</p>
|
| 76 |
+
<h2>How to play Sightfern</h2>
|
| 77 |
+
<ol><li>Tap Start camera and point it at a cat, dog or bird you meet (or pick a photo from your gallery).</li><li>When a dashed box appears around the animal, tap Spot it.</li><li>Sightfern reads the animal's colour and the time of day and adds a sighting card to your album.</li><li>Fill every colour slot on the Cats, Dogs and Birds pages and collect badges.</li><li>Spot at least one animal a day to keep your streak; complete the daily quest.</li></ol>
|
| 78 |
+
<h3>Controls</h3>
|
| 79 |
+
<p><b>Phone and tablet:</b> Use the back camera: hold the animal in the frame until the dashed box appears, then tap Spot it.</p>
|
| 80 |
+
<p><b>Desktop, laptop and Chromebook:</b> Use a webcam or choose a photo file; the same recogniser runs in your browser.</p>
|
| 81 |
+
<h2>Sightfern tips and strategy</h2>
|
| 82 |
+
<ul><li>Get close enough that the animal fills about a third of the frame.</li><li>Night sightings (after 9 pm) rank one rarity higher.</li><li>Patched (two-colour) cats and dogs are rare: look for tuxedo, calico and piebald coats.</li><li>No animal around? Tag a past photo yourself; it gets a ✍️ stamp.</li></ul>
|
| 83 |
+
<h2>Game modes</h2>
|
| 84 |
+
<ul><li>Cats, Dogs and Birds albums (free): colour slots, rarity, badges, daily quest and streak.</li><li>Tag it myself (free): add a sighting by hand when the camera can't see it.</li><li>Fun Pass: Farm and Wild & zoo albums (horses, sheep, cows, bears, elephants, zebras, giraffes) and Gold, Film and Holo card frames.</li></ul>
|
| 85 |
+
<p>Spotting, the Cats, Dogs and Birds albums, badges, quests and streaks are free, with no ads and no sign-up. The Farm and Wild & zoo albums and extra card frames come with the optional <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life), which also opens extras in every CyberMax game.</p>
|
| 86 |
+
<h2>Sightfern FAQ</h2>
|
| 87 |
+
<h3>Is Sightfern free?</h3><p>Yes. The camera recogniser, the Cats, Dogs and Birds albums, badges, quests and streaks are free, with no ads and no sign-up.</p>
|
| 88 |
+
<h3>Are my photos uploaded?</h3><p>No. Recognition runs on your device and the album is stored in your browser. Nothing is sent to us or anyone else.</p>
|
| 89 |
+
<h3>Does it track my location?</h3><p>No. Location is off unless you switch it on, and then only a rough position (about 11 km) is kept, on your phone only.</p>
|
| 90 |
+
<h3>Which animals does it recognise?</h3><p>Cats, dogs and birds in the free albums; the Fun Pass adds horses, sheep, cows, bears, elephants, zebras and giraffes.</p>
|
| 91 |
+
<h3>Does it work offline?</h3><p>After the first load, the albums and cards work offline; the recogniser needs your browser's cached copy from the first use.</p>
|
| 92 |
+
<h2>Similar free games</h2>
|
| 93 |
+
<div class="more"><a href="https://cybermaxtools.com/games/pawfoil/"><b>Pawfoil</b><span>Make your pet a holo trading card</span></a><a href="https://cybermaxtools.com/games/wreckwren/"><b>Wreckwren</b><span>Wreck any website with a stickman</span></a><a href="https://cybermaxtools.com/games/kenwren/"><b>Kenwren</b><span>How well do your friends know you?</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
|
| 94 |
+
</section>
|
| 95 |
+
</main>
|
| 96 |
+
<footer class="foot muted small"><a href="https://cybermaxtools.com/games/">More free games</a> · <a href="https://cybermaxtools.com/privacy.html">Privacy</a> · <a href="https://cybermaxtools.com/terms.html">Terms</a> · Animal recognition by Google MediaPipe (Apache-2.0) · © 2026 CyberMax</footer>
|
| 97 |
+
</div>
|
| 98 |
+
<script type="module" src="game.js"></script>
|
| 99 |
+
</body></html>
|
manifest.webmanifest
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Sightfern",
|
| 3 |
+
"short_name": "Sightfern",
|
| 4 |
+
"description": "Spot real cats, dogs and birds with your camera and fill your field album. 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": "#14231B",
|
| 11 |
+
"theme_color": "#14231B",
|
| 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": "sightfern", "name": "Sightfern", "price": null, "live": false, "demo": 20, "games": 12};
|
og.png
ADDED
|
Git LFS Details
|
shot-phone.png
ADDED
|
Git LFS Details
|
style.css
CHANGED
|
@@ -1,28 +1,33 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.
|
| 27 |
-
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Sightfern page styles (fun.css = shared CyberMax game kit). (c) 2026 CyberMax. */
|
| 2 |
+
.today{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}
|
| 3 |
+
.today .lbl{display:block;font-size:.78em;opacity:.7;text-transform:uppercase;letter-spacing:.05em}
|
| 4 |
+
.tstats{display:flex;gap:6px;flex-wrap:wrap}
|
| 5 |
+
.cam .row{gap:8px;flex-wrap:wrap}.center{justify-content:center}
|
| 6 |
+
.btn.big{font-size:1.15em;padding:14px 22px}
|
| 7 |
+
.btn.shutter{background:#2f9e5b;color:#fff;font-size:1.15em;padding:14px 22px}.btn.shutter:disabled{opacity:.5}
|
| 8 |
+
.viewer{position:relative;width:100%;max-width:720px;margin:0 auto 10px;aspect-ratio:3/4;max-height:70vh;border-radius:16px;overflow:hidden;background:#0b120e}
|
| 9 |
+
.viewer video,.viewer canvas{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
| 10 |
+
.viewer .seen{position:absolute;left:10px;right:10px;bottom:10px;margin:0;background:rgba(10,20,14,.78);color:#e9fff1;padding:8px 12px;border-radius:12px;font-weight:700;text-align:center}
|
| 11 |
+
@media (min-width:760px){.viewer{aspect-ratio:16/9}}
|
| 12 |
+
.sheet{border:0;border-radius:18px;padding:18px;max-width:min(520px,calc(100vw - 24px));width:100%;background:var(--panel,#1c2420);color:inherit}
|
| 13 |
+
.sheet::backdrop{background:rgba(0,0,0,.6)}
|
| 14 |
+
.sheet canvas{width:100%;height:auto;border-radius:12px;display:block;margin:6px 0}
|
| 15 |
+
.sheet h2{margin:0 0 4px}
|
| 16 |
+
.tabs{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px}
|
| 17 |
+
.tab{font:inherit;font-weight:700;border:0;border-radius:999px;padding:8px 12px;background:var(--chip,#ffffff14);color:inherit;cursor:pointer;white-space:nowrap}
|
| 18 |
+
.tab.on{background:#2f9e5b;color:#fff}.tab small{opacity:.75;margin-left:4px}
|
| 19 |
+
.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:10px;margin:12px 0}
|
| 20 |
+
.slot{position:relative;display:flex;flex-direction:column;gap:4px;align-items:stretch;padding:8px;border-radius:14px;background:var(--chip,#ffffff10);border:2px dashed #ffffff22;font:inherit;color:inherit;text-align:left}
|
| 21 |
+
.slot.got{border-style:solid;cursor:pointer}
|
| 22 |
+
.slot i,.slot img{display:block;width:100%;aspect-ratio:1;border-radius:10px;object-fit:cover}
|
| 23 |
+
.slot:not(.got) i{opacity:.25;filter:grayscale(.3)}
|
| 24 |
+
.slot span{font-weight:700;font-size:.9em}.slot em{font-style:normal;font-size:.78em;opacity:.8}
|
| 25 |
+
.slot.r1.got{border-color:#2f7d4f}.slot.r2.got{border-color:#2f66c9}.slot.r3.got{border-color:#8a3fd1}.slot.r4.got{border-color:#d79a10}
|
| 26 |
+
.badges{display:flex;flex-wrap:wrap;gap:8px}
|
| 27 |
+
.bdg{display:flex;flex-direction:column;align-items:center;width:84px;font-size:1.6em;opacity:.3;filter:grayscale(1);text-align:center}
|
| 28 |
+
.bdg.got{opacity:1;filter:none}.bdg small{font-size:.42em;line-height:1.2}
|
| 29 |
+
.chip i{display:inline-block;width:12px;height:12px;border-radius:50%;margin-right:6px;vertical-align:-1px;border:1px solid #0004}
|
| 30 |
+
.chip.on{outline:2px solid #2f9e5b}
|
| 31 |
+
.toggle{display:flex;gap:10px;align-items:flex-start}.toggle input{margin-top:4px;transform:scale(1.2)}
|
| 32 |
+
.between{justify-content:space-between;align-items:center}.foot{margin:30px 0 20px}
|
| 33 |
+
@media (max-width:560px){.slots{grid-template-columns:repeat(2,1fr)}.btn.big,.btn.shutter{width:100%}}
|
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 = 'sightfern';
|
| 4 |
+
const CACHE = `cmx-${GAME}-85ca465435`;
|
| 5 |
+
const FILES = ["index.html", "cmx.js", "core.js", "detect.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "offer.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 |
+
});
|