← back to Abrams
public/js/gamify.js
248 lines
// gamify.js — XP / levels / badges / streaks / confetti for the Abrams Panel.
// Drop-in: include this script and call window.Gamify.event('action', xp) anywhere.
// Persists per-player in localStorage + posts events to /api/gamify/event for the leaderboard.
(function () {
const STORE_KEY = 'abrams.gamify.v1';
const SOUND_KEY = 'abrams.gamify.sound';
const PLAYER_KEY = 'abrams.gamify.player';
// ── load or init player ────────────────────────────────────────────
let player = localStorage.getItem(PLAYER_KEY);
if (!player) {
player = 'agent-' + Math.random().toString(36).slice(2, 8);
localStorage.setItem(PLAYER_KEY, player);
}
let state = {
player,
xp: 0,
level: 1,
streak_days: 0,
last_seen_day: null,
badges: [],
events_seen: 0,
};
try { Object.assign(state, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); state.player = player; } catch {}
const soundOn = () => localStorage.getItem(SOUND_KEY) !== 'off';
const setSound = (on) => localStorage.setItem(SOUND_KEY, on ? 'on' : 'off');
// ── levels (Fibonacci-style growth) ────────────────────────────────
const LEVELS = [0, 50, 150, 350, 750, 1500, 3000, 6000, 12000, 24000, 50000];
const levelFromXp = (xp) => { for (let i = LEVELS.length - 1; i >= 0; i--) if (xp >= LEVELS[i]) return i + 1; return 1; };
const xpNeededForNext = (lvl) => LEVELS[lvl] || (LEVELS[LEVELS.length - 1] * 2);
// ── badge catalog ──────────────────────────────────────────────────
const BADGES = {
'first-step': { name: 'First Step', icon: '◐', desc: 'Visited the panel', trigger: s => s.events_seen >= 1 },
'curious-mind': { name: 'Curious Mind', icon: '✦', desc: 'Viewed 5+ projects', trigger: s => (s._actions?.project_view || 0) >= 5 },
'thesis-reader': { name: 'Thesis Reader', icon: '※', desc: 'Scrolled through the brand thesis', trigger: s => (s._actions?.thesis_view || 0) >= 1 },
'mag-20-auditor': { name: 'Mag-20 Auditor', icon: '⚐', desc: 'Opened the Mag-20 audit page', trigger: s => (s._actions?.mag20_view || 0) >= 1 },
'swot-scholar': { name: 'SWOT Scholar', icon: '⚖', desc: 'Opened the SWOT ledger', trigger: s => (s._actions?.swot_view || 0) >= 1 },
'first-blood': { name: 'First Blood', icon: '★', desc: 'Submitted the first lead', trigger: s => (s._actions?.lead_submit || 0) >= 1 },
'wall-poster': { name: 'Wall Poster', icon: '✎', desc: 'Posted to the Wall', trigger: s => (s._actions?.wall_post || 0) >= 1 },
'chatter': { name: 'Chatter', icon: '◗', desc: 'Talked to Abrams 3+ times', trigger: s => (s._actions?.chat_msg || 0) >= 3 },
'tm-investigator': { name: 'TM Investigator', icon: '®', desc: 'Studied the Trademark hub', trigger: s => (s._actions?.trademark_view || 0) >= 1 },
'tick-witness': { name: 'Tick Witness', icon: '⟳', desc: 'Watched 3+ live build events', trigger: s => (s._actions?.tick_witnessed || 0) >= 3 },
'streak-3': { name: '3-Day Streak', icon: '✶✶✶', desc: 'Visited 3 days in a row', trigger: s => s.streak_days >= 3 },
'streak-7': { name: 'Week Streak', icon: '✸', desc: 'Visited 7 days in a row', trigger: s => s.streak_days >= 7 },
'high-roller': { name: 'High Roller', icon: '✤', desc: 'Reached 1,500 XP', trigger: s => s.xp >= 1500 },
'panel-master': { name: 'Panel Master', icon: '✜', desc: 'Reached 6,000 XP', trigger: s => s.xp >= 6000 },
};
// ── tone/beep (uses WebAudio so no asset loading) ──────────────────
let audioCtx = null;
function beep(freq = 660, dur = 0.08, type = 'sine', vol = 0.06) {
if (!soundOn()) return;
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.type = type; o.frequency.value = freq;
g.gain.value = vol;
o.connect(g); g.connect(audioCtx.destination);
o.start();
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
o.stop(audioCtx.currentTime + dur);
} catch {}
}
function chord() { beep(523, 0.1); setTimeout(() => beep(659, 0.1), 90); setTimeout(() => beep(784, 0.18), 180); }
// ── confetti (canvas-free, span-based, lightweight) ────────────────
function confetti(n = 80) {
const colors = ['#c9a96e', '#fff', '#4ade80', '#f59e0b', '#94a3b8'];
for (let i = 0; i < n; i++) {
const el = document.createElement('span');
el.style.cssText = `position:fixed;top:-12px;left:${Math.random()*100}vw;width:8px;height:14px;background:${colors[i%colors.length]};opacity:.9;z-index:9999;border-radius:2px;pointer-events:none;transform:rotate(${Math.random()*360}deg);transition:transform 2.5s linear, top 2.5s linear, opacity 2.5s ease-out;`;
document.body.appendChild(el);
requestAnimationFrame(() => {
el.style.top = (60 + Math.random()*40) + 'vh';
el.style.transform = `rotate(${360 + Math.random()*720}deg) translateX(${(Math.random()-0.5)*200}px)`;
el.style.opacity = '0';
});
setTimeout(() => el.remove(), 2700);
}
}
// ── HUD widget ────────────────────────────────────────────────────
let hud;
function buildHud() {
if (hud) return;
hud = document.createElement('div');
hud.id = 'abrams-gamify-hud';
hud.style.cssText = 'position:fixed;bottom:18px;right:18px;z-index:90;background:rgba(20,20,20,.92);backdrop-filter:blur(6px);border:1px solid #2a2620;padding:12px 16px;font-family:JetBrains Mono,monospace;font-size:11px;color:#f1ece4;letter-spacing:.1em;border-radius:14px;min-width:220px;box-shadow:0 8px 32px rgba(0,0,0,.5);cursor:pointer;user-select:none;';
hud.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="color:#c9a96e;font-weight:600;letter-spacing:.2em;text-transform:uppercase;">LVL <span id="ag-level">1</span></span>
<span id="ag-sound" style="cursor:pointer;font-size:14px;opacity:.6;" title="toggle sound">♪</span>
</div>
<div style="display:flex;justify-content:space-between;color:#b8b1a4;margin-bottom:6px;">
<span><span id="ag-xp">0</span> XP</span>
<span><span id="ag-streak">0</span>🔥</span>
</div>
<div style="height:4px;background:#1d1d1d;border-radius:2px;overflow:hidden;margin-bottom:8px;">
<div id="ag-bar" style="height:100%;background:linear-gradient(90deg,#c9a96e,#fff);width:0%;transition:width .5s;"></div>
</div>
<div id="ag-badges" style="display:flex;gap:4px;flex-wrap:wrap;font-size:14px;line-height:1;"></div>
<div id="ag-popup" style="position:absolute;bottom:100%;right:0;margin-bottom:8px;background:#c9a96e;color:#111;padding:8px 14px;border-radius:8px;font-weight:600;letter-spacing:.15em;opacity:0;transform:translateY(8px);transition:all .3s;pointer-events:none;white-space:nowrap;text-transform:uppercase;font-size:10px;"></div>
`;
document.body.appendChild(hud);
document.getElementById('ag-sound').addEventListener('click', e => {
e.stopPropagation();
const on = !soundOn(); setSound(on);
document.getElementById('ag-sound').style.opacity = on ? '1' : '.3';
});
document.getElementById('ag-sound').style.opacity = soundOn() ? '1' : '.3';
hud.addEventListener('click', () => {
// expand to show full badges + leaderboard preview
window.location.href = '/#leaderboard';
});
}
function renderHud() {
if (!hud) return;
document.getElementById('ag-level').textContent = state.level;
document.getElementById('ag-xp').textContent = state.xp;
document.getElementById('ag-streak').textContent = state.streak_days;
const cur = state.xp - (LEVELS[state.level - 1] || 0);
const need = xpNeededForNext(state.level) - (LEVELS[state.level - 1] || 0);
document.getElementById('ag-bar').style.width = Math.min(100, (cur / Math.max(1, need)) * 100) + '%';
document.getElementById('ag-badges').innerHTML = state.badges.slice(-8).map(b => {
const info = BADGES[b] || { icon: '◌', name: b };
return `<span title="${info.name}: ${info.desc||''}" style="cursor:help;">${info.icon}</span>`;
}).join('');
}
function popup(text) {
const el = document.getElementById('ag-popup'); if (!el) return;
el.textContent = text;
el.style.opacity = '1'; el.style.transform = 'translateY(0)';
setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(8px)'; }, 1800);
}
// ── streak tracker ────────────────────────────────────────────────
function checkStreak() {
const today = new Date().toISOString().slice(0, 10);
if (state.last_seen_day === today) return;
if (state.last_seen_day) {
const d1 = new Date(state.last_seen_day).getTime(), d2 = new Date(today).getTime();
const diff = Math.round((d2 - d1) / 86400000);
if (diff === 1) state.streak_days += 1;
else if (diff > 1) state.streak_days = 1;
} else state.streak_days = 1;
state.last_seen_day = today;
}
// ── core event API ────────────────────────────────────────────────
function event(action, xp = 10) {
state._actions = state._actions || {};
state._actions[action] = (state._actions[action] || 0) + 1;
state.xp += xp;
state.events_seen += 1;
const oldLevel = state.level;
state.level = levelFromXp(state.xp);
const newBadges = [];
for (const [id, b] of Object.entries(BADGES)) {
if (!state.badges.includes(id) && b.trigger(state)) {
state.badges.push(id);
newBadges.push(b);
}
}
persist();
renderHud();
// animate
if (xp > 0) { beep(660, 0.05); popup('+' + xp + ' XP'); }
if (state.level > oldLevel) { chord(); confetti(); setTimeout(() => popup('LEVEL UP! LVL ' + state.level), 200); }
for (const b of newBadges) { setTimeout(() => { beep(880, 0.12); popup('🏅 ' + b.name); }, 1200); }
// post to server (fire-and-forget)
try {
fetch('/api/gamify/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player: state.player, action, xp, level: state.level, badge: newBadges[0]?.name }) }).catch(()=>{});
} catch {}
}
function persist() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch {} }
// ── auto-event detectors based on current page ────────────────────
function autoDetect() {
const path = location.pathname;
if (path === '/' || path === '/index.html') event('panel_view', 5);
if (path.startsWith('/trademark')) event('trademark_view', 15);
if (path.startsWith('/ideas')) event('swot_view', 15);
if (path.startsWith('/wall')) event('wall_view', 10);
if (path.startsWith('/chat')) event('chat_view', 10);
if (path.startsWith('/mag-20')) event('mag20_view', 25);
if (path.startsWith('/press')) event('press_view', 15);
// thesis section view (IntersectionObserver)
setTimeout(() => {
const thesis = document.getElementById('thesis');
if (thesis && 'IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { event('thesis_view', 10); io.disconnect(); } });
}, { threshold: 0.3 });
io.observe(thesis);
}
}, 800);
// detect SSE build events being received → tick_witnessed (debounced)
let lastTick = 0;
document.addEventListener('abrams:build-event', () => {
const now = Date.now();
if (now - lastTick > 8000) { event('tick_witnessed', 2); lastTick = now; }
});
// detect lead-form submission
const leadForm = document.querySelector('form.lead-form');
if (leadForm) leadForm.addEventListener('submit', () => setTimeout(() => event('lead_submit', 100), 400));
// detect wall-post button click
const wallBtn = document.querySelector('.composer button');
if (wallBtn && /POST/i.test(wallBtn.textContent || '')) wallBtn.addEventListener('click', () => event('wall_post', 30));
// detect chat send
const chatBtn = document.querySelector('button[onclick*="send()"]');
if (chatBtn) chatBtn.addEventListener('click', () => event('chat_msg', 15));
// detect project tile click on home
document.querySelectorAll('.tile').forEach(t => t.addEventListener('click', () => event('project_view', 8), { once: true }));
}
// ── boot ──────────────────────────────────────────────────────────
function boot() {
buildHud();
checkStreak();
state.level = levelFromXp(state.xp);
persist();
renderHud();
autoDetect();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
// public surface
window.Gamify = { event, state: () => ({ ...state }), confetti, beep };
})();