/* EMOJESIS β€” game logic and UI. Vanilla JS, no modules. */ (function () { 'use strict'; var DATA = globalThis.EMOJESIS_DATA; var ELEMENTS = DATA.ELEMENTS; var RECIPES = DATA.RECIPES; var CATEGORIES = DATA.CATEGORIES; var STARTERS = DATA.STARTERS; var SAVE_KEY = 'emojesis-save-v1'; var DAY_EPOCH = Date.UTC(2025, 0, 1); var HINT_MAX = 3; var HINT_REGEN_MS = 10 * 60 * 1000; var IDLE_HINT_MS = 90 * 1000; var FAILURE_LINES = [ 'Nothing happened. Probably for the best.', 'These symbols refuse to cooperate.', 'The universe is not ready for that.', 'An interesting idea with no physical consequences.', 'The laboratory politely ignores this.', 'Somewhere, a physicist felt a disturbance.', 'That is not how any of this works.', 'The symbols exchanged a look and moved on.' ]; var CLUE_SUFFIX = ' (Something about this feels almost right.)'; /* ---------- recipe indexes ---------- */ var recipeByPair = {}; // unordered "a|b" -> recipe var recipeByOrdered = {}; // "a>b" -> recipe var recipesByInput = {}; // elementId -> [recipes using it] var recipesByResult = {}; // elementId -> [recipes creating it] RECIPES.forEach(function (r) { if (r.ordered) recipeByOrdered[r.a + '>' + r.b] = r; else recipeByPair[[r.a, r.b].sort().join('|')] = r; (recipesByInput[r.a] = recipesByInput[r.a] || []).push(r); (recipesByInput[r.b] = recipesByInput[r.b] || []).push(r); (recipesByResult[r.result] = recipesByResult[r.result] || []).push(r); }); function findRecipe(a, b) { var ord = recipeByOrdered[a + '>' + b]; if (ord) return ord; return recipeByPair[[a, b].sort().join('|')] || null; } /* ---------- tiny helpers ---------- */ function $(id) { return document.getElementById(id); } function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; } function mulberry32(seed) { var t = seed >>> 0; return function () { t += 0x6D2B79F5; var x = t; x = Math.imul(x ^ (x >>> 15), x | 1); x ^= x + Math.imul(x ^ (x >>> 7), x | 61); return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } function todayInfo() { var d = new Date(); var y = d.getFullYear(), m = d.getMonth(), day = d.getDate(); return { dateStr: y + '-' + String(m + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0'), seed: y * 10000 + (m + 1) * 100 + day, dayNum: Math.floor((Date.UTC(y, m, day) - DAY_EPOCH) / 86400000) }; } function isNight() { var h = new Date().getHours(); return h >= 22 || h < 5; } function reducedMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } /* ---------- state ---------- */ function defaultState() { var order = {}; STARTERS.forEach(function (id, i) { order[id] = i + 1; }); return { elements: STARTERS.slice(), order: order, recipes: [], favorites: [], newItems: [], daily: { date: '', goalId: null, fallbackN: 0, count: 0, done: false, usedHint: false }, hints: { stored: HINT_MAX, lastRegen: Date.now(), used: 0 }, settings: { theme: 'auto', sound: true }, stats: { fails: 0, successes: 0, hintsUsed: 0, secrets: 0, dailyDone: 0, playMs: 0, combos: {} }, lastActive: Date.now(), firstRun: true, eggClicks: 0, milestones: [] }; } var state = load(); function load() { try { var raw = localStorage.getItem(SAVE_KEY); if (!raw) return defaultState(); var s = JSON.parse(raw); var d = defaultState(); // shallow-merge known keys, keep nested objects sane var merged = Object.assign(d, s); merged.daily = Object.assign(d.daily, s.daily || {}); merged.hints = Object.assign(d.hints, s.hints || {}); merged.settings = Object.assign(d.settings, s.settings || {}); merged.stats = Object.assign(d.stats, s.stats || {}); // drop elements that no longer exist in data merged.elements = merged.elements.filter(function (id) { return ELEMENTS[id]; }); STARTERS.forEach(function (id) { if (merged.elements.indexOf(id) === -1) merged.elements.push(id); }); return merged; } catch (e) { return defaultState(); } } function save() { var now = Date.now(); var delta = now - (state.lastActive || now); if (delta > 0) state.stats.playMs += Math.min(delta, 5 * 60 * 1000); state.lastActive = now; try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch (e) { /* storage full/blocked */ } } function has(id) { return state.elements.indexOf(id) !== -1; } /* ---------- audio ---------- */ var actx = null; function audioCtx() { if (!actx) { var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; actx = new AC(); } if (actx.state === 'suspended') actx.resume(); return actx; } function tone(freq, delay, dur, type, vol) { if (!state.settings.sound) return; var ctx = audioCtx(); if (!ctx) return; var t = ctx.currentTime + delay; var osc = ctx.createOscillator(); var gain = ctx.createGain(); osc.type = type || 'sine'; osc.frequency.setValueAtTime(freq, t); gain.gain.setValueAtTime(0.0001, t); gain.gain.exponentialRampToValueAtTime(vol || 0.08, t + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); osc.connect(gain).connect(ctx.destination); osc.start(t); osc.stop(t + dur + 0.05); } var SOUNDS = { select: function () { tone(660, 0, 0.06, 'triangle', 0.05); }, combine: function () { tone(440, 0, 0.08, 'triangle', 0.06); tone(587, 0.07, 0.1, 'triangle', 0.06); }, discover: function () { tone(523, 0, 0.09, 'sine', 0.07); tone(659, 0.08, 0.09, 'sine', 0.07); tone(784, 0.16, 0.14, 'sine', 0.08); }, invalid: function () { tone(160, 0, 0.14, 'sawtooth', 0.04); }, secret: function () { tone(880, 0, 0.08, 'sine', 0.06); tone(1108, 0.09, 0.08, 'sine', 0.06); tone(1318, 0.18, 0.2, 'sine', 0.07); } }; function playSound(name) { if (SOUNDS[name]) SOUNDS[name](); } // WebAudio needs a gesture; warm up on first interaction. document.addEventListener('pointerdown', function () { if (state.settings.sound) audioCtx(); }, { once: true }); /* ---------- toasts & announcements ---------- */ function toast(msg) { var box = $('toasts'); var t = el('div', 'toast', msg); box.appendChild(t); setTimeout(function () { t.remove(); }, 3200); } function announce(msg) { $('live').textContent = ''; // force re-announcement setTimeout(function () { $('live').textContent = msg; }, 30); } function setMessage(msg, isAnnounce) { var m = $('message'); m.textContent = msg; m.classList.toggle('announce', !!isAnnounce); if (isAnnounce) announce(msg); } /* ---------- slots & combining ---------- */ var slots = [null, null]; var lastCombo = null; // {a, b} of last successful combo var lastAction = Date.now(); function slotEls() { return [$('slotA'), $('slotB')]; } function renderSlots() { slotEls().forEach(function (s, i) { var id = slots[i]; var emoji = s.querySelector('.slot-emoji'); var name = s.querySelector('.slot-name'); if (id) { emoji.textContent = ELEMENTS[id].emoji; name.textContent = ELEMENTS[id].name; s.dataset.filled = 'true'; s.setAttribute('aria-label', (i === 0 ? 'First' : 'Second') + ' ingredient: ' + ELEMENTS[id].name + '. Activate to remove.'); } else { emoji.textContent = 'Β·'; name.textContent = i === 0 ? 'first' : 'second'; s.dataset.filled = 'false'; s.setAttribute('aria-label', (i === 0 ? 'First' : 'Second') + ' ingredient slot. Empty.'); } }); var both = slots[0] && slots[1]; $('btnCombine').disabled = !both; $('btnSwap').disabled = !(slots[0] || slots[1]); $('btnClear').disabled = !(slots[0] || slots[1]); $('btnRepeat').disabled = !(lastCombo && has(lastCombo.a) && has(lastCombo.b)); } function fillNextSlot(id) { if (!has(id)) return; if (slots[0] === null) slots[0] = id; else if (slots[1] === null) slots[1] = id; else { slots[1] = id; } // both filled: replace second playSound('select'); markSeen(id); renderSlots(); touch(); } function clearSlot(i) { if (slots[i] !== null) { slots[i] = null; playSound('select'); renderSlots(); touch(); } } /* ---------- recent combinations (session-only) ---------- */ var comboHistory = []; // [{a, b}], most recent first function pushHistory(a, b) { comboHistory = comboHistory.filter(function (c) { return !(c.a === a && c.b === b); }); comboHistory.unshift({ a: a, b: b }); if (comboHistory.length > 5) comboHistory.pop(); renderHistory(); } function renderHistory() { var box = $('comboHistory'); box.innerHTML = ''; comboHistory.forEach(function (c) { if (!has(c.a) || !has(c.b)) return; var b = el('button', 'hist-chip', ELEMENTS[c.a].emoji + ' + ' + ELEMENTS[c.b].emoji); b.title = ELEMENTS[c.a].name + ' + ' + ELEMENTS[c.b].name; b.setAttribute('aria-label', 'Refill slots with ' + ELEMENTS[c.a].name + ' and ' + ELEMENTS[c.b].name); b.addEventListener('click', function () { slots[0] = c.a; slots[1] = c.b; playSound('select'); renderSlots(); touch(); }); box.appendChild(b); }); } function combine() { if (!slots[0] || !slots[1]) return; var a = slots[0], b = slots[1]; touch(); var r = findRecipe(a, b); if (r && r.nightOnly && !isNight()) { state.stats.fails++; save(); playSound('invalid'); setMessage('Something about this feels nocturnal.'); renderSlots(); return; } if (!r) { state.stats.fails++; bumpCombo(a); bumpCombo(b); save(); playSound('invalid'); var line = FAILURE_LINES[Math.floor(Math.random() * FAILURE_LINES.length)]; if (nearMiss(a, b) && Math.random() < 0.5) line += CLUE_SUFFIX; setMessage(line); renderSlots(); return; } lastCombo = { a: a, b: b }; pushHistory(a, b); var resultId = r.result; var already = has(resultId); state.stats.successes++; bumpCombo(a); bumpCombo(b); // charming fuse animation, then reveal var sEls = slotEls(); if (!reducedMotion()) { sEls[0].classList.add('fusing'); sEls[1].classList.add('fusing'); } var revealDelay = reducedMotion() ? 0 : 420; setTimeout(function () { sEls[0].classList.remove('fusing'); sEls[1].classList.remove('fusing'); revealResult(resultId, r); if (already) { setMessage('You already know this one: ' + ELEMENTS[resultId].name + '.', false); playSound('combine'); } else { discoverElement(resultId, r); } save(); renderSlots(); }, revealDelay); } function revealResult(resultId, r) { var box = $('result'); $('resultEmoji').textContent = ELEMENTS[resultId].emoji; $('resultName').textContent = ELEMENTS[resultId].name; box.setAttribute('aria-hidden', 'false'); box.className = 'result'; if (!reducedMotion()) { void box.offsetWidth; // restart animation box.classList.add('reveal'); if (r.fx) box.classList.add('fx-' + r.fx); } } function discoverElement(id, r) { state.elements.push(id); state.order[id] = state.elements.length; state.newItems.push(id); var key = r.ordered ? r.a + '>' + r.b : [r.a, r.b].sort().join('|'); if (state.recipes.indexOf(key) === -1) state.recipes.push(key); var isSecret = !!(r.secret || ELEMENTS[id].secret); if (isSecret) { state.stats.secrets++; playSound('secret'); secretFlash(r.fx); } else playSound('discover'); var msg = r.msg; if (r.msgs && r.msgs.length) { msg = Math.random() < 0.5 ? r.msg : r.msgs[Math.floor(Math.random() * r.msgs.length)]; } setMessage(msg, true); updateDaily(id); checkMilestones(); renderInventory(); updateWorld(); updateDailyLine(); } /* full-screen flash ceremony for secret discoveries */ function secretFlash(fx) { if (reducedMotion()) return; var f = $('secretFlash'); f.className = 'secret-flash'; void f.offsetWidth; // restart animation f.classList.add('active'); if (fx) f.classList.add('fx-' + fx); setTimeout(function () { f.className = 'secret-flash'; }, 1000); } /* ---------- progress milestones ---------- */ var MILESTONES = [0.25, 0.5, 0.75, 1]; function checkMilestones() { var total = Object.keys(ELEMENTS).length; var pct = state.elements.length / total; MILESTONES.forEach(function (m) { if (pct >= m && state.milestones.indexOf(m) === -1) { state.milestones.push(m); var label = m === 1 ? '100% β€” every symbol discovered. The universe is yours. 🌌' : Math.round(m * 100) + '% of all symbols discovered.'; toast('✨ ' + label); playSound('secret'); } }); } function bumpCombo(id) { state.stats.combos[id] = (state.stats.combos[id] || 0) + 1; } // vague failure clue: an undiscovered recipe exists where one input is `a` // and the other input shares a category with `b` (or vice versa) function nearMiss(a, b) { var catB = ELEMENTS[b].category, catA = ELEMENTS[a].category; return RECIPES.some(function (r) { if (has(r.result) || r.secret || r.nightOnly) return false; var inputs = [r.a, r.b]; var ia = inputs.indexOf(a), ib = inputs.indexOf(b); if (ia !== -1) { var other = inputs[1 - ia]; return other !== b && ELEMENTS[other].category === catB; } if (ib !== -1) { var other2 = inputs[1 - ib]; return other2 !== a && ELEMENTS[other2].category === catA; } return false; }); } function touch() { lastAction = Date.now(); } /* ---------- daily challenge ---------- */ function rollDaily() { var info = todayInfo(); if (state.daily.date === info.dateStr) return; var rng = mulberry32(info.seed); var undiscovered = Object.keys(ELEMENTS).filter(function (id) { return STARTERS.indexOf(id) === -1 && !has(id); }); // prefer goals that are craftable right now var craftable = undiscovered.filter(function (id) { return (recipesByResult[id] || []).some(function (r) { return has(r.a) && has(r.b) && !r.nightOnly; }); }); var goalId = null, fallbackN = 0; if (craftable.length) { goalId = craftable[Math.floor(rng() * craftable.length)]; } else if (undiscovered.length) { goalId = undiscovered[Math.floor(rng() * undiscovered.length)]; } else { fallbackN = 3 + Math.floor(rng() * 3); } state.daily = { date: info.dateStr, goalId: goalId, fallbackN: fallbackN, count: 0, done: false, usedHint: false }; save(); } function updateDaily(newId) { var d = state.daily; if (d.done) return; d.count++; if (d.goalId && newId === d.goalId) completeDaily(); else if (!d.goalId && d.fallbackN && d.count >= d.fallbackN) completeDaily(); } function completeDaily() { state.daily.done = true; state.stats.dailyDone++; toast('Daily challenge complete! ✨'); playSound('secret'); updateDailyLine(); } function dailyShareText() { var info = todayInfo(); var d = state.daily; var goal = d.goalId ? ELEMENTS[d.goalId].emoji + ' ' + ELEMENTS[d.goalId].name : d.fallbackN + ' new discoveries'; var lines = [ 'EMOJESIS #' + info.dayNum, 'Goal: ' + goal, d.count + ' discoveries Β· ' + (d.usedHint ? 'with hints πŸ’‘' : 'no hints ✨'), d.done ? 'Completed βœ…' : 'In progress…' ]; return lines.join('\n'); } function updateDailyLine() { var d = state.daily; var line = $('dailyLine'); if (!d.date) { line.hidden = true; return; } line.hidden = false; var goal = d.goalId ? 'Create ' + ELEMENTS[d.goalId].emoji + ' today' : 'Make ' + d.fallbackN + ' new discoveries today'; line.textContent = (d.done ? 'βœ… ' : 'πŸ“… ') + goal + ' Β· ' + d.count + ' so far'; $('dailyDot').hidden = !!d.done; } /* ---------- hints ---------- */ function regenHints() { var now = Date.now(); var elapsed = now - (state.hints.lastRegen || now); if (elapsed >= HINT_REGEN_MS && state.hints.stored < HINT_MAX) { var gained = Math.floor(elapsed / HINT_REGEN_MS); state.hints.stored = Math.min(HINT_MAX, state.hints.stored + gained); state.hints.lastRegen = now; save(); } $('hintCount').textContent = state.hints.stored; } function useHint() { touch(); if (state.hints.stored <= 0) { toast('No hints stored. One drifts in every 10 minutes.'); playSound('invalid'); return; } var candidates = RECIPES.filter(function (r) { return !has(r.result) && !r.secret && !r.nightOnly && has(r.a) && has(r.b); }); if (!candidates.length) { toast('No hint available right now β€” try combining things you haven\u2019t paired yet.'); return; } var r = candidates[Math.floor(Math.random() * candidates.length)]; state.hints.stored--; state.hints.used++; state.stats.hintsUsed++; state.daily.usedHint = true; save(); regenHints(); playSound('select'); var eA = ELEMENTS[r.a], eB = ELEMENTS[r.b]; setMessage('πŸ’‘ ' + eA.emoji + ' ' + eA.name + ' still has something to teach ' + eB.emoji + ' ' + eB.name + '.', true); } // free hint after 90s of inactivity with 0 stored setInterval(function () { if (state.hints.stored === 0 && Date.now() - lastAction >= IDLE_HINT_MS) { state.hints.stored = 1; state.hints.lastRegen = Date.now(); save(); regenHints(); toast('A thought drifted in. (+1 hint)'); } regenHints(); }, 30000); /* ---------- inventory ---------- */ var invFilter = { search: '', category: 'all', favOnly: false, showUnknown: false, sort: 'order' }; var suppressNextClick = false; // set after a touch-drag so the trailing click doesn't also fire var ptrDrag = null; // active pointer drag: { pointerId, x, y, dragging, ghost, id } function unlockedCategories() { var cats = {}; state.elements.forEach(function (id) { cats[ELEMENTS[id].category] = true; }); return CATEGORIES.filter(function (c) { return cats[c]; }); } function renderCategoryChips() { var box = $('invCats'); box.innerHTML = ''; var mk = function (value, label, pressed) { var b = el('button', 'chip', label); b.setAttribute('aria-pressed', pressed ? 'true' : 'false'); if (value !== 'all') b.dataset.cat = value; b.addEventListener('click', function () { invFilter.category = value; renderInventory(); }); return b; }; box.appendChild(mk('all', 'all ' + state.elements.length, invFilter.category === 'all')); unlockedCategories().forEach(function (c) { var n = state.elements.filter(function (id) { return ELEMENTS[id].category === c; }).length; box.appendChild(mk(c, c + ' ' + n, invFilter.category === c)); }); } function markSeen(id) { var i = state.newItems.indexOf(id); if (i !== -1) { state.newItems.splice(i, 1); save(); var card = document.querySelector('.inv-item[data-id="' + id + '"]'); if (card) card.classList.remove('is-new'); } } function renderInventory() { renderCategoryChips(); var grid = $('invGrid'); grid.innerHTML = ''; $('invCount').textContent = state.elements.length + '/' + Object.keys(ELEMENTS).length; var ids = state.elements.slice(); if (invFilter.search) { var q = invFilter.search.toLowerCase(); ids = ids.filter(function (id) { var e = ELEMENTS[id]; return e.name.toLowerCase().indexOf(q) !== -1 || e.description.toLowerCase().indexOf(q) !== -1 || e.category.toLowerCase().indexOf(q) !== -1; }); } if (invFilter.category !== 'all') { ids = ids.filter(function (id) { return ELEMENTS[id].category === invFilter.category; }); } if (invFilter.favOnly) { ids = ids.filter(function (id) { return state.favorites.indexOf(id) !== -1; }); } ids.sort(function (x, y) { var fx = state.favorites.indexOf(x) !== -1 ? 0 : 1; var fy = state.favorites.indexOf(y) !== -1 ? 0 : 1; if (fx !== fy) return fx - fy; if (invFilter.sort === 'alpha') return ELEMENTS[x].name.localeCompare(ELEMENTS[y].name); return (state.order[x] || 0) - (state.order[y] || 0); }); ids.forEach(function (id) { grid.appendChild(buildCard(id)); }); if (invFilter.showUnknown && !invFilter.search && invFilter.favOnly === false) { Object.keys(ELEMENTS).forEach(function (id) { var e = ELEMENTS[id]; if (has(id) || e.secret) return; if (invFilter.category !== 'all' && e.category !== invFilter.category) return; grid.appendChild(buildUnknownCard(e)); }); } if (!grid.children.length) { grid.appendChild(el('p', 'inv-empty', invFilter.favOnly ? 'No favorites yet. Star something you love.' : 'Nothing here yet.')); } } function buildCard(id) { var e = ELEMENTS[id]; var card = el('div', 'inv-item'); card.dataset.id = id; card.dataset.cat = e.category; if (e.secret) card.classList.add('secret'); if (state.newItems.indexOf(id) !== -1) card.classList.add('is-new'); if (state.firstRun && (id === 'fire' || id === 'water')) card.classList.add('ob-pulse'); var main = el('button', 'inv-main'); main.draggable = true; main.setAttribute('aria-label', e.name + ' (' + e.category + '). Activate to place in a combination slot.'); var em = el('span', 'inv-emoji', e.emoji); em.setAttribute('aria-hidden', 'true'); main.appendChild(em); main.appendChild(el('span', 'inv-name', e.name)); main.addEventListener('click', function () { if (suppressNextClick) { suppressNextClick = false; return; } if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); } fillNextSlot(id); }); main.addEventListener('dragstart', function (ev) { ev.dataTransfer.setData('text/plain', id); ev.dataTransfer.effectAllowed = 'copy'; }); wireTouchDrag(main, id, e); var fav = el('button', 'inv-fav', state.favorites.indexOf(id) !== -1 ? '⭐' : 'β˜†'); if (state.favorites.indexOf(id) !== -1) fav.classList.add('on'); fav.setAttribute('aria-label', 'Toggle favorite for ' + e.name); fav.addEventListener('click', function () { toggleFavorite(id); }); var info = el('button', 'inv-info', 'β“˜'); info.setAttribute('aria-label', 'Open journal entry for ' + e.name); info.addEventListener('click', function () { openJournal(id); }); card.appendChild(main); card.appendChild(fav); card.appendChild(info); return card; } /* touch/pen drag: HTML5 DnD is mouse-only, so mirror it with pointer events */ function wireTouchDrag(main, id, e) { main.addEventListener('pointerdown', function (ev) { if (ev.pointerType === 'mouse') return; ptrDrag = { pointerId: ev.pointerId, x: ev.clientX, y: ev.clientY, dragging: false, ghost: null }; }); main.addEventListener('pointermove', function (ev) { if (!ptrDrag || ev.pointerId !== ptrDrag.pointerId) return; var dx = ev.clientX - ptrDrag.x, dy = ev.clientY - ptrDrag.y; if (!ptrDrag.dragging && Math.sqrt(dx * dx + dy * dy) > 10) { ptrDrag.dragging = true; ptrDrag.ghost = el('div', 'drag-ghost', e.emoji); ptrDrag.ghost.setAttribute('aria-hidden', 'true'); document.body.appendChild(ptrDrag.ghost); } if (ptrDrag.dragging) { ptrDrag.ghost.style.left = ev.clientX + 'px'; ptrDrag.ghost.style.top = ev.clientY + 'px'; var over = document.elementFromPoint(ev.clientX, ev.clientY); slotEls().forEach(function (s) { s.classList.toggle('drop-hover', !!(over && s.contains(over))); }); } }); var endDrag = function (ev, cancelled) { if (!ptrDrag || ev.pointerId !== ptrDrag.pointerId) return; var wasDragging = ptrDrag.dragging; if (ptrDrag.ghost) ptrDrag.ghost.remove(); slotEls().forEach(function (s) { s.classList.remove('drop-hover'); }); ptrDrag = null; if (!wasDragging || cancelled) return; suppressNextClick = true; var over = document.elementFromPoint(ev.clientX, ev.clientY); var slot = over && over.closest ? over.closest('.slot') : null; var i = slot ? slotEls().indexOf(slot) : -1; if (i !== -1 && has(id)) { slots[i] = id; playSound('select'); markSeen(id); if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); } renderSlots(); touch(); } }; main.addEventListener('pointerup', function (ev) { endDrag(ev, false); }); main.addEventListener('pointercancel', function (ev) { endDrag(ev, true); }); } function buildUnknownCard(e) { var card = el('div', 'inv-item'); card.dataset.cat = e.category; var main = el('button', 'inv-main'); main.setAttribute('aria-label', 'Undiscovered element in category ' + e.category); var em = el('span', 'inv-emoji', '❓'); em.setAttribute('aria-hidden', 'true'); main.appendChild(em); main.appendChild(el('span', 'inv-name', e.category)); main.addEventListener('click', function () { setMessage('Something undiscovered in ' + e.category + '. The laboratory waits.', false); }); card.appendChild(main); return card; } function toggleFavorite(id) { var i = state.favorites.indexOf(id); if (i === -1) state.favorites.push(id); else state.favorites.splice(i, 1); save(); playSound('select'); renderInventory(); } function clearOnboardingPulse() { document.querySelectorAll('.ob-pulse').forEach(function (n) { n.classList.remove('ob-pulse'); }); } /* ---------- journal ---------- */ var journalId = null; function openJournal(id) { journalId = id; markSeen(id); var e = ELEMENTS[id]; var body = $('journalBody'); body.innerHTML = ''; var head = el('div', 'journal-head'); head.appendChild(el('span', 'journal-emoji', e.emoji)); var titleWrap = el('div'); var h2 = el('h2', null, e.name); h2.id = 'journalTitle'; titleWrap.appendChild(h2); titleWrap.appendChild(el('div', 'journal-meta', e.category + ' Β· discovery #' + (state.order[id] || '?'))); head.appendChild(titleWrap); body.appendChild(head); body.appendChild(el('p', null, e.description)); var madeBy = (recipesByResult[id] || []).filter(function (r) { return recipeDiscovered(r); }); if (madeBy.length) { body.appendChild(el('h3', 'journal-sec', 'Created by')); body.appendChild(recipeList(madeBy)); } var usedIn = (recipesByInput[id] || []).filter(function (r) { return recipeDiscovered(r) && has(r.result); }); if (usedIn.length) { body.appendChild(el('h3', 'journal-sec', 'Used in')); body.appendChild(recipeList(usedIn)); } // vague hint about undiscovered relationships var unknownCats = {}; (recipesByInput[id] || []).forEach(function (r) { if (!has(r.result)) { var other = r.a === id ? r.b : r.a; if (has(other)) unknownCats[ELEMENTS[r.result].category] = true; } }); var cats = Object.keys(unknownCats); if (cats.length) { body.appendChild(el('p', 'journal-hint', 'It still reacts with something in ' + cats.join(' and ') + '.')); } openModal('modalJournal'); } function recipeDiscovered(r) { var key = r.ordered ? r.a + '>' + r.b : [r.a, r.b].sort().join('|'); return state.recipes.indexOf(key) !== -1; } function recipeList(recipes) { var ul = el('ul', 'journal-recipes'); recipes.forEach(function (r) { var li = el('li', null, ELEMENTS[r.a].emoji + ' ' + ELEMENTS[r.a].name + ' + ' + ELEMENTS[r.b].emoji + ' ' + ELEMENTS[r.b].name + ' β†’ ' + ELEMENTS[r.result].emoji + ' ' + ELEMENTS[r.result].name); ul.appendChild(li); }); return ul; } /* ---------- world view ---------- */ function updateWorld() { document.querySelectorAll('.w-slot').forEach(function (slot) { slot.classList.toggle('found', has(slot.dataset.el)); }); $('world').classList.toggle('night', isNight()); } /* ---------- stats ---------- */ function renderStats() { var dl = $('statsList'); dl.innerHTML = ''; var totalEl = Object.keys(ELEMENTS).length; var catDone = unlockedCategories().map(function (c) { var total = Object.keys(ELEMENTS).filter(function (id) { return ELEMENTS[id].category === c; }).length; var got = state.elements.filter(function (id) { return ELEMENTS[id].category === c; }).length; return c + ' ' + got + '/' + total; }); var favId = null, favN = 0; Object.keys(state.stats.combos).forEach(function (id) { if (state.stats.combos[id] > favN) { favN = state.stats.combos[id]; favId = id; } }); var mins = Math.floor(state.stats.playMs / 60000); var rows = [ ['elements', state.elements.length + ' / ' + totalEl], ['recipes', state.recipes.length + ' / ' + RECIPES.length], ['categories', catDone.join(' Β· ')], ['experiments βœ“', String(state.stats.successes)], ['experiments βœ—', String(state.stats.fails)], ['hints used', String(state.stats.hintsUsed)], ['secrets found', String(state.stats.secrets)], ['daily challenges', String(state.stats.dailyDone)], ['favorites', String(state.favorites.length)], ['most combined', favId ? ELEMENTS[favId].emoji + ' ' + ELEMENTS[favId].name + ' (' + favN + ')' : 'β€”'], ['time in lab', mins + ' min'] ]; rows.forEach(function (row) { dl.appendChild(el('dt', null, row[0])); dl.appendChild(el('dd', null, row[1])); }); } /* ---------- modals ---------- */ function openModal(id) { var m = $(id); m.hidden = false; var btn = m.querySelector('button, input, select'); if (btn) btn.focus(); } function closeModal(id) { $(id).hidden = true; } document.addEventListener('click', function (ev) { var closer = ev.target.closest('.modal-close'); if (closer) closeModal(closer.dataset.close); var modal = ev.target.classList && ev.target.classList.contains('modal') ? ev.target : null; if (modal) modal.hidden = true; }); document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape') { document.querySelectorAll('.modal:not([hidden])').forEach(function (m) { m.hidden = true; }); } }); /* ---------- theme & sound ---------- */ function applyTheme() { var t = state.settings.theme; if (t === 'auto') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t); $('btnTheme').textContent = t === 'dark' ? 'πŸŒ™' : t === 'light' ? 'β˜€οΈ' : 'πŸŒ—'; $('setTheme').value = t; } function cycleTheme() { var order = ['auto', 'light', 'dark']; var i = order.indexOf(state.settings.theme); state.settings.theme = order[(i + 1) % order.length]; save(); applyTheme(); } function applySound() { $('btnSound').textContent = state.settings.sound ? 'πŸ”Š' : 'πŸ”‡'; $('btnSound').setAttribute('aria-label', state.settings.sound ? 'Mute sound' : 'Unmute sound'); $('setSound').checked = state.settings.sound; } /* ---------- save export / import / reset ---------- */ function exportSave() { var blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'emojesis-save.json'; a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); } function importSave(file) { var reader = new FileReader(); reader.onload = function () { try { var s = JSON.parse(reader.result); if (!s || !Array.isArray(s.elements)) throw new Error('bad save'); localStorage.setItem(SAVE_KEY, JSON.stringify(s)); location.reload(); } catch (e) { toast('That file is not an EMOJESIS save.'); } }; reader.readAsText(file); } /* ---------- easter egg: title clicking ---------- */ var eggTimer = null; function titleClick() { state.eggClicks++; save(); clearTimeout(eggTimer); eggTimer = setTimeout(function () { if (state.eggClicks < 7) { state.eggClicks = 0; save(); } }, 2500); if (state.eggClicks === 7) { toast('Stop that.'); playSound('invalid'); } else if (state.eggClicks >= 10) { state.eggClicks = 0; if (!has('egg')) { state.elements.push('egg'); state.order.egg = state.elements.length; state.newItems.push('egg'); state.stats.secrets++; save(); renderInventory(); updateWorld(); playSound('secret'); toast('…fine. πŸ₯š'); } else { toast('You already have the egg. There is no second egg.'); } } else { playSound('select'); } } /* ---------- easter egg: terminal ---------- */ function termPrint(text) { var out = $('termOut'); out.textContent += text + '\n'; out.scrollTop = out.scrollHeight; } function termRun(cmdRaw) { var cmd = cmdRaw.trim().toLowerCase(); if (!cmd) return; termPrint('> ' + cmdRaw); if (cmd === 'help') { termPrint('available commands:\n help β€” this\n about β€” what is this place\n sudo β€” escalate\n netuno β€” listen to the outer dark\n feed β€” subscribe to the signal\n clear β€” wipe the glass\n exit β€” leave'); } else if (cmd === 'about') { termPrint('EMOJESIS v1.0 β€” a tiny universe made of symbols.\nPablo Murad, 2026.\nno accounts. no tracking. no ads. only combinations.'); } else if (cmd === 'sudo') { termPrint('permission granted. you were always the admin of this universe.'); } else if (cmd === 'netuno') { if (!has('netuno')) { state.elements.push('netuno'); state.order.netuno = state.elements.length; state.newItems.push('netuno'); state.stats.secrets++; save(); renderInventory(); updateWorld(); playSound('secret'); termPrint('🜲 NETUNO: signal received from the outer dark.\nπŸͺ Netuno has been added to your collection.'); toast('πŸͺ Netuno discovered.'); } else { termPrint('🜲 NETUNO: the signal is already within you.'); } } else if (cmd === 'feed') { termPrint('the signal broadcasts at ./feed.xml β€” bring your own reader.'); } else if (cmd === 'clear') { $('termOut').textContent = ''; } else if (cmd === 'exit') { closeModal('modalTerm'); } else if (cmd === 'ls') { termPrint('fire water earth air … ' + state.elements.length + ' symbols and counting'); } else if (cmd === 'whoami') { termPrint('a person combining symbols at ' + new Date().toLocaleTimeString() + '.'); } else { termPrint('command not found: ' + cmd + ' β€” try \u2019help\u2019'); } } /* ---------- onboarding ---------- */ function showOnboarding() { $('onboarding').hidden = false; $('btnBegin').focus(); } /* ---------- wiring ---------- */ function init() { regenHints(); rollDaily(); applyTheme(); applySound(); renderSlots(); renderInventory(); updateWorld(); updateDailyLine(); // slots: click to clear slotEls().forEach(function (s, i) { s.addEventListener('click', function () { clearSlot(i); }); s.addEventListener('dragover', function (ev) { ev.preventDefault(); s.classList.add('drop-hover'); }); s.addEventListener('dragleave', function () { s.classList.remove('drop-hover'); }); s.addEventListener('drop', function (ev) { ev.preventDefault(); s.classList.remove('drop-hover'); var id = ev.dataTransfer.getData('text/plain'); if (id && has(id)) { slots[i] = id; playSound('select'); markSeen(id); if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); } renderSlots(); touch(); } }); }); $('btnCombine').addEventListener('click', combine); $('btnSwap').addEventListener('click', function () { var t = slots[0]; slots[0] = slots[1]; slots[1] = t; playSound('select'); renderSlots(); touch(); }); $('btnClear').addEventListener('click', function () { slots[0] = null; slots[1] = null; playSound('select'); renderSlots(); touch(); }); // Enter combines when both slots are filled and focus isn't on a control document.addEventListener('keydown', function (ev) { if (ev.key !== 'Enter') return; if (ev.target.closest && ev.target.closest('button, input, select, textarea, a, .modal, .onboarding')) return; if (slots[0] && slots[1]) combine(); }); $('btnRepeat').addEventListener('click', function () { if (lastCombo && has(lastCombo.a) && has(lastCombo.b)) { slots[0] = lastCombo.a; slots[1] = lastCombo.b; renderSlots(); combine(); } }); // inventory tools $('invSearch').addEventListener('input', function (ev) { invFilter.search = ev.target.value; renderInventory(); }); $('invSort').addEventListener('change', function (ev) { invFilter.sort = ev.target.value; renderInventory(); }); $('invFav').addEventListener('click', function () { invFilter.favOnly = !invFilter.favOnly; $('invFav').setAttribute('aria-pressed', invFilter.favOnly ? 'true' : 'false'); renderInventory(); }); var unknownChip = el('button', 'chip', '❓'); unknownChip.setAttribute('aria-pressed', 'false'); unknownChip.title = 'Show undiscovered silhouettes'; unknownChip.setAttribute('aria-label', 'Show undiscovered elements'); unknownChip.addEventListener('click', function () { invFilter.showUnknown = !invFilter.showUnknown; unknownChip.setAttribute('aria-pressed', invFilter.showUnknown ? 'true' : 'false'); renderInventory(); }); document.querySelector('.inv-tools').appendChild(unknownChip); // inventory drawer (mobile) / collapse (desktop) $('invToggle').addEventListener('click', function () { var inv = $('inventory'); if (window.matchMedia('(max-width: 860px)').matches) { // three snap points: closed β†’ mid β†’ open β†’ closed if (inv.classList.contains('open')) { inv.classList.remove('open'); } else if (inv.classList.contains('mid')) { inv.classList.remove('mid'); inv.classList.add('open'); } else { inv.classList.add('mid'); } var expanded = inv.classList.contains('mid') || inv.classList.contains('open'); $('invToggle').setAttribute('aria-expanded', expanded ? 'true' : 'false'); } else { inv.classList.toggle('collapsed'); $('invToggle').setAttribute('aria-expanded', inv.classList.contains('collapsed') ? 'false' : 'true'); } }); // world toggle $('worldToggle').addEventListener('click', function () { var w = $('world'); w.classList.toggle('collapsed'); $('worldToggle').setAttribute('aria-expanded', w.classList.contains('collapsed') ? 'false' : 'true'); }); // header buttons $('btnHint').addEventListener('click', useHint); $('btnTheme').addEventListener('click', cycleTheme); $('btnSound').addEventListener('click', function () { state.settings.sound = !state.settings.sound; save(); applySound(); if (state.settings.sound) playSound('select'); }); $('btnStats').addEventListener('click', function () { renderStats(); openModal('modalStats'); }); $('btnAbout').addEventListener('click', function () { openModal('modalAbout'); }); $('footAbout').addEventListener('click', function () { openModal('modalAbout'); }); $('btnDaily').addEventListener('click', function () { rollDaily(); var d = state.daily; $('dailyGoal').textContent = d.goalId ? 'Create ' + ELEMENTS[d.goalId].emoji + ' ' + ELEMENTS[d.goalId].name + ' today.' : 'Make ' + d.fallbackN + ' new discoveries today.'; $('dailyProgress').textContent = d.count + ' discoveries so far' + (d.done ? ' β€” completed βœ…' : '.'); $('dailyShare').textContent = dailyShareText(); openModal('modalDaily'); }); $('btnCopyDaily').addEventListener('click', function () { var text = dailyShareText(); var doneOk = function () { toast('Copied. Share it somewhere small and personal.'); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(doneOk, function () { fallbackCopy(text); doneOk(); }); } else { fallbackCopy(text); doneOk(); } }); // journal "send to slot" $('journalUse').addEventListener('click', function () { if (journalId) fillNextSlot(journalId); closeModal('modalJournal'); }); // settings $('setTheme').addEventListener('change', function (ev) { state.settings.theme = ev.target.value; save(); applyTheme(); }); $('setSound').addEventListener('change', function (ev) { state.settings.sound = ev.target.checked; save(); applySound(); }); $('btnExport').addEventListener('click', exportSave); $('btnImport').addEventListener('click', function () { $('importFile').click(); }); $('importFile').addEventListener('change', function (ev) { if (ev.target.files && ev.target.files[0]) importSave(ev.target.files[0]); }); $('btnReset').addEventListener('click', function () { $('resetConfirm').hidden = false; }); $('btnResetNo').addEventListener('click', function () { $('resetConfirm').hidden = true; }); $('btnResetYes').addEventListener('click', function () { localStorage.removeItem(SAVE_KEY); location.reload(); }); // easter eggs $('titleEmoji').addEventListener('click', titleClick); $('footTerminal').addEventListener('click', function () { openModal('modalTerm'); if (!$('termOut').textContent) { termPrint('EMOJESIS shell v1.0 β€” type \u2019help\u2019.'); } $('termInput').focus(); }); $('termForm').addEventListener('submit', function (ev) { ev.preventDefault(); var v = $('termInput').value; $('termInput').value = ''; termRun(v); }); // onboarding $('btnBegin').addEventListener('click', function () { $('onboarding').hidden = true; touch(); }); if (state.firstRun && state.elements.length <= STARTERS.length) { showOnboarding(); } // activity tracking for the idle hint ['pointerdown', 'keydown'].forEach(function (evt) { document.addEventListener(evt, touch, { passive: true }); }); // re-roll daily if the date changes while open; keep the world's day/night current setInterval(function () { var info = todayInfo(); if (state.daily.date !== info.dateStr) { rollDaily(); updateDailyLine(); toast('A new day, a new challenge. πŸ“…'); } updateWorld(); }, 60000); } function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) { /* best effort */ } ta.remove(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // optional offline cache; never registered on file:// if ('serviceWorker' in navigator && location.protocol.indexOf('http') === 0) { navigator.serviceWorker.register('sw.js').catch(function () { /* offline cache unavailable */ }); } })();