342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
// ── Achievements: invisible until the first one lands ─────────
|
|
(function () {
|
|
const KEY = 'achievements';
|
|
const ALL = [
|
|
{ id: 'cat', name: 'Cat lover', how: 'Saw every picture of Pepsi' },
|
|
{ id: 'natural', name: 'Natural', how: 'Rolled a natural 20' },
|
|
{ id: 'investigator', name: 'Investigator', how: 'Clicked everything there is to click' },
|
|
{ id: 'adventurer', name: 'Adventurer', how: 'Completed the Quest for the Deleted Website' },
|
|
{ id: 'hacker', name: 'Hacker', how: 'Became root' },
|
|
{ id: 'completionist', name: 'Completionist', how: 'Earned every other achievement' },
|
|
];
|
|
// Investigator's checklist: six named interactions plus every round
|
|
// icon — same selector as js/icon-spin.js, which reports 'icon-<index>'
|
|
const ICONS = document.querySelectorAll(
|
|
'.skill-card-icon, .project-icon, .edu-icon, .nav-logo'
|
|
).length;
|
|
const MARKS = ['anagram', 'slot', 'pronouns', 'avatar', 'd20', 'pepsi'];
|
|
for (let i = 0; i < ICONS; i++) MARKS.push('icon-' + i);
|
|
|
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
|
|
// Medal art for earned achievements; ids without an entry render as
|
|
// blank white shields until their design lands
|
|
const MEDALS = {
|
|
cat:
|
|
'<svg class="m-cat" viewBox="0 0 80 95" aria-hidden="true">' +
|
|
'<path d="M40,28 C38,27 35,26 33,26 L14,10 L17,37 C13,42 12,50 14,56 ' +
|
|
'C18,67 28,73 40,73 C52,73 62,67 66,56 C68,50 67,42 63,37 L66,10 ' +
|
|
'L47,26 C45,26 42,27 40,28 Z" fill="var(--ink)"/>' +
|
|
'<ellipse cx="29" cy="48" rx="5" ry="6" fill="var(--yellow)"/>' +
|
|
'<ellipse cx="51" cy="48" rx="5" ry="6" fill="var(--yellow)"/>' +
|
|
'<ellipse cx="29" cy="48" rx="1.8" ry="5" fill="var(--ink)"/>' +
|
|
'<ellipse cx="51" cy="48" rx="1.8" ry="5" fill="var(--ink)"/>' +
|
|
'<path d="M36,58 h8 l-4,5 z" fill="var(--pink)" stroke="var(--pink)" ' +
|
|
'stroke-width="2" stroke-linejoin="round"/>' +
|
|
'<g fill="none" stroke="var(--white)" stroke-width="2" stroke-linecap="round">' +
|
|
'<path d="M31,57 Q20,55 12,53"/><path d="M31,60 Q20,60 11,60"/>' +
|
|
'<path d="M31,63 Q20,65 12,67"/><path d="M49,57 Q60,55 68,53"/>' +
|
|
'<path d="M49,60 Q60,60 69,60"/><path d="M49,63 Q60,65 68,67"/>' +
|
|
'</g></svg>',
|
|
investigator:
|
|
'<svg class="m-inv" viewBox="0 0 80 95" aria-hidden="true">' +
|
|
'<defs><pattern id="invSeme" width="16" height="28" ' +
|
|
'patternUnits="userSpaceOnUse" fill="var(--yellow)">' +
|
|
'<rect x="-1.5" y="-4.5" width="3" height="9" rx="1.5" transform="translate(8 7) rotate(45)"/>' +
|
|
'<rect x="-1.5" y="-4.5" width="3" height="9" rx="1.5" transform="translate(0 21) rotate(45)"/>' +
|
|
'<rect x="-1.5" y="-4.5" width="3" height="9" rx="1.5" transform="translate(16 21) rotate(45)"/>' +
|
|
'</pattern></defs>' +
|
|
'<path d="M3 3 H77 V37.5 C77 68 55 84 40 92 C25 84 3 68 3 37.5 Z" fill="url(#invSeme)"/>' +
|
|
'<circle cx="32" cy="38" r="15" fill="#177245" stroke="var(--white)" stroke-width="5"/>' +
|
|
'<line x1="43" y1="49" x2="56" y2="64" stroke="var(--white)" stroke-width="7" stroke-linecap="round"/>' +
|
|
'</svg>',
|
|
natural:
|
|
'<svg class="m-nat" viewBox="0 0 80 95" aria-hidden="true">' +
|
|
'<defs><clipPath id="natFace">' +
|
|
'<path d="M3 3 H77 V37.5 C77 68 55 84 40 92 C25 84 3 68 3 37.5 Z"/>' +
|
|
'</clipPath></defs>' +
|
|
'<g clip-path="url(#natFace)" fill="var(--yellow)">' +
|
|
'<path d="M40 42 L26.5 -26.7 L53.5 -26.7 Z"/>' +
|
|
'<path d="M40 42 L100.7 7 L114.2 30.4 Z"/>' +
|
|
'<path d="M40 42 L114.2 53.6 L100.7 77 Z"/>' +
|
|
'<path d="M40 42 L53.5 110.7 L26.5 110.7 Z"/>' +
|
|
'<path d="M40 42 L-20.7 77 L-34.2 53.6 Z"/>' +
|
|
'<path d="M40 42 L-34.2 30.4 L-20.7 7 Z"/>' +
|
|
'</g>' +
|
|
'<g stroke="var(--ink)" stroke-width="2" stroke-linejoin="round">' +
|
|
'<polygon points="40,18 60.8,30 60.8,54 40,66 19.2,54 19.2,30" fill="var(--white)"/>' +
|
|
'<g fill="none">' +
|
|
'<polygon points="40,28 52.12,49 27.88,49"/>' +
|
|
'<line x1="40" y1="28" x2="40" y2="18"/>' +
|
|
'<line x1="27.88" y1="49" x2="19.2" y2="54"/>' +
|
|
'<line x1="52.12" y1="49" x2="60.8" y2="54"/>' +
|
|
'<line x1="40" y1="28" x2="60.8" y2="30"/>' +
|
|
'<line x1="40" y1="28" x2="19.2" y2="30"/>' +
|
|
'<line x1="27.88" y1="49" x2="19.2" y2="30"/>' +
|
|
'<line x1="27.88" y1="49" x2="40" y2="66"/>' +
|
|
'<line x1="52.12" y1="49" x2="40" y2="66"/>' +
|
|
'<line x1="52.12" y1="49" x2="60.8" y2="30"/>' +
|
|
'</g></g>' +
|
|
'<text x="40" y="46" text-anchor="middle" ' +
|
|
'font-family="\'Space Grotesk\', sans-serif" font-weight="700" ' +
|
|
'font-size="9.5" fill="var(--ink)">20</text>' +
|
|
'</svg>',
|
|
adventurer:
|
|
'<svg class="m-adv" viewBox="0 0 80 95" aria-hidden="true">' +
|
|
'<path d="M40,9 L44.5,21 L43.5,58 L36.5,58 L35.5,21 Z" fill="var(--white)"/>' +
|
|
'<rect x="24" y="58" width="32" height="6" rx="2" fill="var(--yellow)"/>' +
|
|
'<rect x="36.5" y="64" width="7" height="14" fill="var(--ink)"/>' +
|
|
'<circle cx="40" cy="82" r="5" fill="var(--yellow)"/>' +
|
|
'</svg>',
|
|
completionist:
|
|
'<svg class="m-comp" viewBox="0 0 80 95" aria-hidden="true">' +
|
|
'<g stroke="var(--ink)" stroke-width="2" stroke-linejoin="round" fill="var(--yellow)">' +
|
|
'<path d="M19.2,54 L21.2,21 L30.9,44 L40,13 L49.1,44 L58.8,21 L60.8,54 Z"/>' +
|
|
'<rect x="17.9" y="54" width="44.2" height="10.4" rx="2.6"/>' +
|
|
'</g></svg>',
|
|
hacker:
|
|
'<svg class="m-hk" viewBox="0 0 80 95" aria-hidden="true">' +
|
|
'<defs><filter id="hkGlow" x="-60%" y="-60%" width="220%" height="220%">' +
|
|
'<feGaussianBlur stdDeviation="2.4" result="b"/>' +
|
|
'<feMerge><feMergeNode in="b"/><feMergeNode in="b"/>' +
|
|
'<feMergeNode in="SourceGraphic"/></feMerge>' +
|
|
'</filter></defs>' +
|
|
'<g filter="url(#hkGlow)">' +
|
|
'<path d="M25,31 L40,42 L25,53" fill="none" stroke="var(--green)" ' +
|
|
'stroke-width="5.5" stroke-linecap="square"/>' +
|
|
'<rect class="m-hk-cursor" x="45" y="50" width="13" height="5.5" fill="var(--green)"/>' +
|
|
'</g></svg>',
|
|
};
|
|
|
|
const state = { unlocked: [], announced: [], pepsi: [], marks: [] };
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(KEY));
|
|
if (saved && typeof saved === 'object') {
|
|
for (const k of Object.keys(state)) {
|
|
if (Array.isArray(saved[k])) state[k] = saved[k];
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
const save = () => {
|
|
try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {}
|
|
};
|
|
|
|
// ── Badge: absent from the DOM until at least one unlock ────
|
|
let badge = null;
|
|
let button = null;
|
|
let panel = null;
|
|
|
|
function buildBadge() {
|
|
badge = document.createElement('div');
|
|
badge.className = 'achv';
|
|
button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'achv-badge';
|
|
button.setAttribute('aria-expanded', 'false');
|
|
button.innerHTML =
|
|
'<i class="fas fa-trophy" aria-hidden="true"></i><span class="achv-count"></span>';
|
|
panel = document.createElement('ul');
|
|
panel.className = 'achv-panel';
|
|
panel.hidden = true;
|
|
// Closing plays the slide-out first; hidden flips when it's done
|
|
// (0.3s slide + 225ms reverse stagger)
|
|
let closing = null;
|
|
const openPanel = () => {
|
|
if (closing) { clearTimeout(closing); closing = null; }
|
|
panel.classList.remove('achv-panel--closing');
|
|
panel.hidden = false;
|
|
button.setAttribute('aria-expanded', 'true');
|
|
};
|
|
const closePanel = () => {
|
|
if (panel.hidden || closing) return;
|
|
button.setAttribute('aria-expanded', 'false');
|
|
if (reduceMotion.matches) { panel.hidden = true; return; }
|
|
panel.classList.add('achv-panel--closing');
|
|
closing = setTimeout(() => {
|
|
closing = null;
|
|
panel.classList.remove('achv-panel--closing');
|
|
panel.hidden = true;
|
|
}, 560);
|
|
};
|
|
button.addEventListener('click', () => {
|
|
if (panel.hidden || closing) openPanel(); else closePanel();
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
if (badge.contains(e.target)) return;
|
|
closePanel();
|
|
});
|
|
badge.appendChild(button);
|
|
badge.appendChild(panel);
|
|
// Body-level: keeps it a later sibling of #header, so the
|
|
// `#header.scrolled ~ .achv` rule can slide it below the nav
|
|
document.body.appendChild(badge);
|
|
}
|
|
|
|
function renderBadge() {
|
|
if (!state.unlocked.length) return;
|
|
if (!badge) buildBadge();
|
|
if (!badge) return;
|
|
const n = state.unlocked.length;
|
|
button.querySelector('.achv-count').textContent = n + '/' + ALL.length;
|
|
button.setAttribute(
|
|
'aria-label',
|
|
'Achievements: ' + n + ' of ' + ALL.length + ' unlocked'
|
|
);
|
|
panel.textContent = '';
|
|
ALL.forEach((a) => {
|
|
const li = document.createElement('li');
|
|
const got = state.unlocked.includes(a.id);
|
|
li.className = 'achv-medal' + (got ? ' achv-medal--got' : '');
|
|
if (got && MEDALS[a.id]) {
|
|
li.classList.add('achv-medal--' + a.id);
|
|
li.innerHTML = MEDALS[a.id];
|
|
}
|
|
const tip = document.createElement('span');
|
|
tip.className = 'achv-tip';
|
|
if (got) {
|
|
const title = document.createElement('b');
|
|
title.textContent = a.name;
|
|
tip.appendChild(title);
|
|
tip.appendChild(document.createTextNode(a.how));
|
|
} else {
|
|
tip.textContent = '????';
|
|
}
|
|
li.appendChild(tip);
|
|
const sr = document.createElement('span');
|
|
sr.className = 'achv-sr';
|
|
sr.textContent = got ? a.name + ' — ' + a.how : '????';
|
|
li.appendChild(sr);
|
|
panel.appendChild(li);
|
|
});
|
|
}
|
|
|
|
// ── Toasts: queued, one at a time. Shown as manual popovers so
|
|
// they join the top layer and render above any open <dialog>
|
|
// (the terminal); without popover support they hold back until
|
|
// the dialog closes instead ──────────────────────────────────
|
|
const queue = [];
|
|
let showing = false;
|
|
|
|
function next() {
|
|
if (!queue.length) { showing = false; return; }
|
|
if (!HTMLElement.prototype.showPopover &&
|
|
document.querySelector('dialog[open]')) {
|
|
setTimeout(next, 600);
|
|
return;
|
|
}
|
|
const a = queue.shift();
|
|
const el = document.createElement('div');
|
|
el.className = 'achv-toast';
|
|
el.setAttribute('role', 'status');
|
|
el.setAttribute('popover', 'manual');
|
|
// Tiny medal: a real shield scaled down, so the art stays shared
|
|
const slot = document.createElement('span');
|
|
slot.className = 'achv-toast-medal';
|
|
const shield = document.createElement('span');
|
|
shield.className = 'achv-medal achv-medal--got' +
|
|
(MEDALS[a.id] ? ' achv-medal--' + a.id : '');
|
|
if (MEDALS[a.id]) shield.innerHTML = MEDALS[a.id];
|
|
slot.appendChild(shield);
|
|
const text = document.createElement('span');
|
|
text.className = 'achv-toast-text';
|
|
const kicker = document.createElement('span');
|
|
kicker.className = 'achv-toast-kicker';
|
|
kicker.textContent = 'Achievement unlocked';
|
|
const label = document.createElement('span');
|
|
label.className = 'achv-toast-name';
|
|
label.textContent = a.name;
|
|
text.appendChild(kicker);
|
|
text.appendChild(label);
|
|
el.appendChild(slot);
|
|
el.appendChild(text);
|
|
document.body.appendChild(el);
|
|
if (el.showPopover) el.showPopover();
|
|
|
|
const remove = () => { el.remove(); next(); };
|
|
const hide = () => {
|
|
if (reduceMotion.matches) { remove(); return; }
|
|
el.animate(
|
|
[
|
|
{ transform: 'translate(-50%, 0)' },
|
|
{ transform: 'translate(-50%, -140%)' },
|
|
],
|
|
{ duration: 300, easing: 'cubic-bezier(0.4, 0, 0.7, 0.2)', fill: 'forwards' }
|
|
).finished.then(remove);
|
|
};
|
|
if (reduceMotion.matches) {
|
|
el.style.transform = 'translate(-50%, 0)';
|
|
setTimeout(hide, 3500);
|
|
return;
|
|
}
|
|
el.animate(
|
|
[
|
|
{ transform: 'translate(-50%, -140%)' },
|
|
{ transform: 'translate(-50%, 0)' },
|
|
],
|
|
{ duration: 350, easing: 'cubic-bezier(0.2, 0.9, 0.3, 1.1)', fill: 'forwards' }
|
|
).finished.then((anim) => {
|
|
el.style.transform = 'translate(-50%, 0)';
|
|
anim.cancel();
|
|
setTimeout(hide, 3500);
|
|
});
|
|
}
|
|
|
|
function toast(a) {
|
|
queue.push(a);
|
|
if (!showing) { showing = true; next(); }
|
|
}
|
|
|
|
function announce() {
|
|
state.unlocked.forEach((id) => {
|
|
if (state.announced.includes(id)) return;
|
|
state.announced.push(id);
|
|
const a = ALL.find((x) => x.id === id);
|
|
if (a) toast(a);
|
|
});
|
|
save();
|
|
renderBadge();
|
|
}
|
|
|
|
// ── The API the feature files call (always guarded:
|
|
// window.achv && window.achv.…) ─────────────────────────────
|
|
function unlock(id) {
|
|
if (state.unlocked.includes(id)) return;
|
|
if (!ALL.some((a) => a.id === id)) return;
|
|
state.unlocked.push(id);
|
|
if (
|
|
id !== 'completionist' &&
|
|
ALL.every((a) => a.id === 'completionist' || state.unlocked.includes(a.id))
|
|
) {
|
|
state.unlocked.push('completionist');
|
|
}
|
|
save();
|
|
announce();
|
|
}
|
|
|
|
function mark(id) {
|
|
if (!MARKS.includes(id) || state.marks.includes(id)) return;
|
|
state.marks.push(id);
|
|
save();
|
|
if (MARKS.every((m) => state.marks.includes(m))) unlock('investigator');
|
|
}
|
|
|
|
function pepsi(n, total) {
|
|
if (state.pepsi.includes(n)) return;
|
|
state.pepsi.push(n);
|
|
save();
|
|
if (state.pepsi.length >= total) unlock('cat');
|
|
}
|
|
|
|
window.achv = { unlock, mark, pepsi };
|
|
|
|
// Unlocks earned off-page (the quest) or before this system existed
|
|
// (a rooted terminal) surface on the next index load
|
|
setTimeout(() => {
|
|
try {
|
|
if (localStorage.getItem('questWon') === '1') unlock('adventurer');
|
|
if (localStorage.getItem('termRoot') === '1') unlock('hacker');
|
|
} catch (e) {}
|
|
announce();
|
|
}, 800);
|
|
})();
|