// Sightfern: camera, photo and manual spotting, the field album (IndexedDB, this device only), badges, streak,
// quest and sighting cards. Rules: core.js. Recogniser: detect.js (on-device). (c) 2026 CyberMax. All rights reserved.
import { ALBUMS, SPECIES, LABEL, COLOURS, SLOTS, RARITY, TIMES, coatColour, sighting, coarsen, progress, slotsOf, streak, badges, quest, questDone, localDay } from './core.js';
import { loadDetector, MIN_SCORE } from './detect.js';
import { count, store, toast, share, download, pwa, themeToggle } from './cmx.js';
import * as pass from './funpass.js';
const GAME = 'sightfern', PATH = '/games/sightfern/';
const $ = (id) => document.getElementById(id);
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'] });
pwa(GAME, $('install'));
themeToggle($('theme'));
// ------------------------------------------------------------------ storage: IndexedDB on this device only
const DB = new Promise((ok, bad) => {
const r = indexedDB.open('sightfern', 1);
r.onupgradeneeded = () => { r.result.createObjectStore('sightings', { keyPath: 'id' }); r.result.createObjectStore('photos'); };
r.onsuccess = () => ok(r.result); r.onerror = () => bad(r.error);
});
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); }); };
const allSightings = () => tx('sightings', 'readonly', (t) => t.objectStore('sightings').getAll());
const putSighting = (s, blob) => tx(['sightings', 'photos'], 'readwrite', (t) => { t.objectStore('sightings').put(s); if (blob) t.objectStore('photos').put(blob, s.id); });
const getPhoto = (id) => tx('photos', 'readonly', (t) => t.objectStore('photos').get(id));
const wipeAll = () => tx(['sightings', 'photos'], 'readwrite', (t) => { t.objectStore('sightings').clear(); t.objectStore('photos').clear(); });
let S = [];
let tab = 'cats';
let frame = store('sightfern:frame') || 'field';
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 } };
const today = () => localDay(new Date());
// ------------------------------------------------------------------ header: quest, streak, totals
function header() {
const q = quest(today());
$('quest').textContent = q.text;
$('quest-ok').hidden = !questDone(q, S, today());
$('streak').textContent = `🔥 ${streak(S, today())}`;
$('total').textContent = `📒 ${S.length}`;
const free = Object.keys(ALBUMS).filter((a) => !ALBUMS[a].pro || pass.isPro());
const f = free.reduce((n, a) => n + progress(S, a).found, 0), t = free.reduce((n, a) => n + progress(S, a).total, 0);
$('found').textContent = `${f} / ${t} album slots`;
}
// ------------------------------------------------------------------ album view
const urls = new Map();
async function thumb(id) {
if (urls.has(id)) return urls.get(id);
const b = await getPhoto(id);
const u = b ? URL.createObjectURL(b) : null;
urls.set(id, u);
return u;
}
async function album() {
$('tabs').innerHTML = Object.entries(ALBUMS).map(([k, a]) => ``).join('');
const locked = ALBUMS[tab].pro && !pass.isPro();
const cells = [];
for (const key of slotsOf(tab)) {
const [sp, c] = key.split(':');
const mine = S.filter((s) => s.species === sp && s.colour === c).sort((a, b) => b.at.localeCompare(a.at));
const r = SLOTS[sp][c];
const sw = COLOURS[c].hex;
if (mine.length && !locked) {
const u = await thumb(mine[0].id);
cells.push(``);
} else cells.push(`
${COLOURS[c].name} ${LABEL[sp]}${locked ? '🔒 Fun Pass' : RARITY[r]}
`);
}
$('slots').innerHTML = cells.join('');
$('badges').innerHTML = badges(S, today()).map((b) => `${b.icon}${b.name}`).join('');
$('frames').innerHTML = Object.entries(FRAMES).map(([k, f]) => ``).join('');
}
$('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(); });
$('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); });
$('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(); });
async function refresh() { S = await allSightings(); header(); await album(); }
// ------------------------------------------------------------------ the sighting card (canvas)
const RCOL = ['#6b7a70', '#2f7d4f', '#2f66c9', '#8a3fd1', '#d79a10'];
async function drawCard(s) {
const c = $('card'), x = c.getContext('2d'), W = 1080, H = 1350;
const bg = { field: '#f3ead6', plain: '#ffffff', gold: '#2a2214', film: '#121212', holo: '#1b1730' }[frame] || '#f3ead6';
const ink = ['gold', 'film', 'holo'].includes(frame) ? '#f6f1e4' : '#1d2a22';
x.fillStyle = bg; x.fillRect(0, 0, W, H);
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; }
if (frame === 'gold') { x.strokeStyle = '#e2b55a'; x.lineWidth = 18; x.strokeRect(24, 24, W - 48, H - 48); }
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); } }
const blob = await getPhoto(s.id);
const px = 90, py = 150, pw = W - 180, ph = 760;
x.fillStyle = '#d9d1bf'; x.fillRect(px, py, pw, ph);
if (blob) {
const im = await createImageBitmap(blob);
const sc = Math.max(pw / im.width, ph / im.height), dw = im.width * sc, dh = im.height * sc;
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();
} 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'; }
x.fillStyle = ink; x.font = '900 46px system-ui, sans-serif'; x.fillText('SIGHTFERN', 90, 108);
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;
x.font = '900 82px system-ui, sans-serif'; x.fillText(`${COLOURS[s.colour].name} ${LABEL[s.species]}`, 90, 1020);
x.fillStyle = RCOL[s.rarity]; x.beginPath(); x.roundRect(90, 1050, 330, 74, 37); x.fill();
x.fillStyle = '#fff'; x.font = '800 40px system-ui, sans-serif'; x.fillText(RARITY[s.rarity], 120, 1100);
const t = TIMES.find((y) => y.id === s.time);
x.fillStyle = ink; x.font = '600 38px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
x.fillText(`${t.icon} ${t.name} · ${new Date(s.at).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}${s.manual ? ' · ✍️ self-tagged' : ''}`, 90, 1190);
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;
}
let shown = null;
async function openCard(s, fresh) {
shown = s;
$('sheet-title').textContent = fresh ? (s.rarity >= 2 ? `${RARITY[s.rarity]} find! ✨` : 'New sighting!') : `${COLOURS[s.colour].name} ${LABEL[s.species]}`;
await drawCard(s);
const where = s.where ? ` · noted near ${s.where.lat}, ${s.where.lon} (on this phone only)` : '';
$('sheet-info').textContent = `${s.manual ? 'Tagged by you' : `Recognised on this device (${Math.round((s.score || 0) * 100)}% sure)`}${where}.`;
$('sheet').showModal();
if (fresh && !pass.isPro() && S.length === 3) pass.afterWin($('sheet-after'), 'win');
}
$('sheet-close').onclick = () => $('sheet').close();
const cardBlob = () => new Promise((ok) => $('card').toBlob(ok, 'image/png'));
$('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`); };
$('dl').onclick = async () => { download(await cardBlob(), `sightfern-${shown.species}-${shown.day}.png`); count(GAME, 'share', 'png'); };
// ------------------------------------------------------------------ saving a sighting
async function where() {
if (!store('sightfern:loc') || !navigator.geolocation) return null;
return new Promise((ok) => navigator.geolocation.getCurrentPosition((p) => ok(coarsen(p.coords.latitude, p.coords.longitude)), () => ok(null), { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }));
}
/** Crop around the box (15% margin), at most 720 px, as a JPEG blob. */
function cropBlob(src, box) {
const m = 0.15, x = Math.max(0, box.x - box.w * m), y = Math.max(0, box.y - box.h * m);
const w = Math.min(src.width - x, box.w * (1 + 2 * m)), h = Math.min(src.height - y, box.h * (1 + 2 * m));
const s = Math.min(1, 720 / Math.max(w, h));
const c = document.createElement('canvas'); c.width = Math.round(w * s); c.height = Math.round(h * s);
c.getContext('2d').drawImage(src, x, y, w, h, 0, 0, c.width, c.height);
return new Promise((ok) => c.toBlob(ok, 'image/jpeg', 0.85));
}
async function save(rec, blob) {
const loc = await where();
if (loc) rec.where = loc;
const before = new Set(S.map((s) => `${s.species}:${s.colour}`));
await putSighting(rec, blob);
await refresh();
count(GAME, 'win', `${rec.species}-${RARITY[rec.rarity].toLowerCase()}${rec.manual ? '-manual' : ''}`);
if (!before.has(`${rec.species}:${rec.colour}`)) toast(`New album slot: ${COLOURS[rec.colour].name} ${LABEL[rec.species]}!`, 3000);
const q = quest(today());
if (questDone(q, S, today()) && !questDone(q, S.filter((s) => s.id !== rec.id), today())) toast(`Quest done: ${q.text} ✅`, 3000);
await openCard(rec, true);
}
let det = null, perf = [];
async function detector() {
if (det) return det;
$('cam-note').textContent = 'Loading the on-device recogniser (once, about 14 MB)…';
try { det = await loadDetector(); $('cam-note').textContent = 'Recogniser ready. It runs on this device; nothing is uploaded.'; }
catch (e) { $('cam-note').textContent = 'This browser could not run the recogniser. Use “Tag it myself” to add sightings by hand.'; throw e; }
return det;
}
/** Find the best animal in a canvas; returns { item, colour } or null. Detection runs on a 512 px copy. */
async function analyse(src) {
const d = await detector();
const s = Math.min(1, 512 / Math.max(src.width, src.height));
const small = document.createElement('canvas'); small.width = Math.round(src.width * s); small.height = Math.round(src.height * s);
small.getContext('2d').drawImage(src, 0, 0, small.width, small.height);
const r = d.detect(small);
perf.push(r.ms);
const it = r.items.find((i) => i.score >= MIN_SCORE);
if (!it) return null;
const box = { x: it.box.x / s, y: it.box.y / s, w: it.box.w / s, h: it.box.h / s };
const cw = Math.max(1, Math.round(box.w)), ch = Math.max(1, Math.round(box.h));
const cc = document.createElement('canvas'); cc.width = Math.min(cw, 256); cc.height = Math.round(ch * (cc.width / cw)) || 1;
cc.getContext('2d').drawImage(src, box.x, box.y, box.w, box.h, 0, 0, cc.width, cc.height);
const px = cc.getContext('2d').getImageData(0, 0, cc.width, cc.height).data;
return { item: { ...it, box }, colour: coatColour(px, cc.width, cc.height, it.species) };
}
async function spotFrom(src, how) {
let a;
try { a = await analyse(src); } catch { openTagger(src); return; }
if (!a) { toast('No cat, dog or bird found in that shot. Get a bit closer, or tag it yourself.', 3800); lastShot = src; return; }
count(GAME, 'play', how);
const rec = sighting({ species: a.item.species, colour: a.colour, score: a.item.score });
await save(rec, await cropBlob(src, a.item.box));
}
// ------------------------------------------------------------------ camera
let stream = null, looking = false, lastShot = null, lastSeen = null;
const video = $('video'), boxes = $('boxes');
async function startCam() {
if (stream) { stopCam(); return; }
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false });
} catch { toast('Camera not available. Use a photo or tag it yourself.', 3500); return; }
video.srcObject = stream; await video.play();
$('viewer').hidden = false; $('shutter').hidden = false; $('cam-btn').textContent = '⏹ Stop camera';
count(GAME, 'play', 'camera-open');
detector().then(() => { looking = true; look(); }).catch(() => {});
}
function stopCam() {
looking = false;
if (stream) stream.getTracks().forEach((t) => t.stop());
stream = null; $('viewer').hidden = true; $('shutter').hidden = true; $('cam-btn').textContent = '📷 Start camera';
}
function frameCanvas(maxSide) {
const s = Math.min(1, maxSide / Math.max(video.videoWidth, video.videoHeight));
const c = document.createElement('canvas'); c.width = Math.round(video.videoWidth * s); c.height = Math.round(video.videoHeight * s);
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
return c;
}
async function look() {
if (!looking || !video.videoWidth) { if (looking) setTimeout(look, 300); return; }
const c = frameCanvas(384);
const r = det.detect(c);
perf.push(r.ms); if (perf.length > 20) perf.shift();
const it = r.items.find((i) => i.score >= MIN_SCORE);
lastSeen = it || null;
const vw = boxes.clientWidth, vh = boxes.clientHeight;
boxes.width = vw * devicePixelRatio; boxes.height = vh * devicePixelRatio;
const x = boxes.getContext('2d'); x.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); x.clearRect(0, 0, vw, vh);
// video is shown with object-fit: cover; map the detection box onto it
const sc = Math.max(vw / c.width, vh / c.height), ox = (vw - c.width * sc) / 2, oy = (vh - c.height * sc) / 2;
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); }
$('seen').textContent = it ? `${LABEL[it.species]} in sight (${Math.round(it.score * 100)}%). Tap Spot it!` : 'Looking for a cat, dog or bird…';
$('shutter').disabled = !it;
const avg = perf.reduce((a, b) => a + b, 0) / perf.length;
setTimeout(look, Math.max(250, avg * 2)); // leave the phone breathing room between looks
}
$('cam-btn').onclick = startCam;
$('shutter').onclick = async () => { if (!stream) return; const c = frameCanvas(1280); lastShot = c; await spotFrom(c, 'camera'); };
$('file').onchange = async (e) => {
const f = e.target.files[0]; e.target.value = ''; if (!f) return;
const im = await createImageBitmap(f);
const s = Math.min(1, 1600 / Math.max(im.width, im.height));
const c = document.createElement('canvas'); c.width = Math.round(im.width * s); c.height = Math.round(im.height * s);
c.getContext('2d').drawImage(im, 0, 0, c.width, c.height);
lastShot = c;
$('cam-note').textContent = 'Checking your photo on this device…';
await spotFrom(c, 'photo');
};
// ------------------------------------------------------------------ tag it yourself (manual fallback)
let tagSp = null, tagCol = null, tagSrc = null;
function openTagger(src = lastShot) {
tagSrc = src; tagSp = null; tagCol = null;
$('tag-species').innerHTML = Object.entries(SPECIES).map(([sp, al]) => ``).join('');
$('tag-colour').innerHTML = ''; $('tag-save').disabled = true;
$('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.';
$('tagger').showModal();
}
$('manual-btn').onclick = () => openTagger(lastShot);
$('tag-species').addEventListener('click', (e) => {
const b = e.target.closest('[data-sp]'); if (!b) return;
if (ALBUMS[SPECIES[b.dataset.sp]].pro && !pass.isPro()) { pass.open('album'); return; }
tagSp = b.dataset.sp; tagCol = null; $('tag-save').disabled = true;
$('tag-species').querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === b));
$('tag-colour').innerHTML = Object.keys(SLOTS[tagSp]).map((c) => ``).join('');
});
$('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; });
$('tag-cancel').onclick = () => $('tagger').close();
$('tag-save').onclick = async () => {
$('tagger').close();
const rec = sighting({ species: tagSp, colour: tagCol, manual: true });
const blob = tagSrc ? await cropBlob(tagSrc, { x: 0, y: 0, w: tagSrc.width, h: tagSrc.height }) : null;
count(GAME, 'play', 'manual');
await save(rec, blob);
};
// ------------------------------------------------------------------ privacy settings
$('loc').checked = !!store('sightfern:loc');
$('loc').onchange = (e) => {
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 });
else store('sightfern:loc', false);
};
$('wipe').onclick = async () => {
if (!confirm('Delete every sighting and photo in Sightfern on this device? This cannot be undone.')) return;
await wipeAll(); urls.forEach((u) => u && URL.revokeObjectURL(u)); urls.clear(); await refresh(); toast('Everything deleted.');
};
$('pass-btn').onclick = () => pass.open('button');
pass.onChange(() => { if (FRAMES[frame].pro && !pass.isPro()) frame = 'field'; refresh(); });
document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stopCam(); });
refresh();
// Test + store-screenshot hooks (tests/e2e.mjs, scripts/games/render_assets.mjs)
window.__sightfern = { spotFrom, openTagger, refresh, perf: () => perf, add: async (recs) => { for (const r of recs) await putSighting(r, null); await refresh(); } };
window.__cmxShot = async (stage = 1) => {
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 }); };
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)]);
if (stage === 2) await openCard(S.find((s) => s.species === 'bird') || S[0], true);
};