// 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); };