From ee0d34715290b2cbe9723cd1636a24098bfdcaf5 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:51:24 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20achievement=20system=20=E2=80=94=20badg?= =?UTF-8?q?es=20for=20visiting=20portals=20and=20chatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 9 achievements tracked via localStorage: First Contact, Eagle Eye, Chronicler, First Words, Chatterbox, Sovereign, Gate Watcher, Wayseeker, Nexus Master - Add 4 portal stub meshes (glowing torus rings) at cardinal positions; clicking a stub registers a portal visit and awards portal achievements - Show toast notification (bottom-right) on achievement unlock with slide-in animation - Add trophy button (πŸ†) in HUD toggling a full achievement panel overlay - Hook into existing sovereignty easter egg, overview mode, photo mode, and chat-message events to trigger relevant achievements - Portal stubs pulse and slowly rotate in the animation loop Fixes #268 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 14 ++++ style.css | 161 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) diff --git a/app.js b/app.js index b396094..03d2df0 100644 --- a/app.js +++ b/app.js @@ -402,6 +402,7 @@ document.addEventListener('keydown', (e) => { overviewMode = !overviewMode; if (overviewMode) { overviewIndicator.classList.add('visible'); + unlockAchievement('eagle_eye'); } else { overviewIndicator.classList.remove('visible'); } @@ -449,6 +450,7 @@ document.addEventListener('keydown', (e) => { photoIndicator.classList.toggle('visible', photoMode); } if (photoMode) { + unlockAchievement('chronicler'); // Enhanced DoF in photo mode bokehPass.uniforms['aperture'].value = 0.0003; bokehPass.uniforms['maxblur'].value = 0.008; @@ -770,6 +772,12 @@ function animate() { rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2; } + // Pulse portal stub rings + for (let i = 0; i < portalStubMeshes.length; i++) { + portalStubMeshes[i].material.opacity = 0.4 + Math.sin(elapsed * 1.6 + i * 1.4) * 0.28; + portalStubMeshes[i].rotation.y = elapsed * 0.35 + i * (Math.PI / 2); + } + composer.render(); } @@ -812,6 +820,10 @@ window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => { window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => { console.log('Chat message:', event.detail); + achState.chatCount = (achState.chatCount || 0) + 1; + saveAchievementState(); + if (achState.chatCount >= 1) unlockAchievement('first_words'); + if (achState.chatCount >= 5) unlockAchievement('chatterbox'); if (typeof event.detail?.text === 'string') { showTimmySpeech(event.detail.text); if (event.detail.text.toLowerCase().includes('sovereignty')) { @@ -839,6 +851,8 @@ const sovereigntyMsg = document.getElementById('sovereignty-msg'); * Triggers the sovereignty Easter egg: stars pulse gold, message flashes. */ function triggerSovereigntyEasterEgg() { + unlockAchievement('sovereign'); + // Flash constellation lines gold const originalLineColor = constellationLines.material.color.getHex(); constellationLines.material.color.setHex(0xffd700); @@ -935,6 +949,10 @@ const commitBanners = []; /** @type {THREE.Sprite[]} */ const agentPanelSprites = []; +// === PORTAL STUB MESHES (declared early β€” populated by achievement system) === +/** @type {THREE.Mesh[]} */ +const portalStubMeshes = []; + /** * Creates a canvas texture for a commit banner. * @param {string} hash - Short commit hash @@ -1290,3 +1308,174 @@ function showTimmySpeech(text) { timmySpeechSprite = sprite; timmySpeechState = { startTime: clock.getElapsedTime(), sprite }; } + +// === ACHIEVEMENT SYSTEM === + +const ACHIEVEMENT_DEFS = [ + { id: 'first_contact', icon: '🌌', title: 'First Contact', desc: 'Entered The Nexus for the first time' }, + { id: 'eagle_eye', icon: 'πŸ¦…', title: 'Eagle Eye', desc: 'Surveyed the Nexus from overview mode [Tab]' }, + { id: 'chronicler', icon: 'πŸ“·', title: 'Chronicler', desc: 'Captured a moment in photo mode [P]' }, + { id: 'first_words', icon: 'πŸ’¬', title: 'First Words', desc: 'Witnessed a chat message in the Nexus' }, + { id: 'chatterbox', icon: 'πŸ“‘', title: 'Chatterbox', desc: 'Witnessed 5 chat messages' }, + { id: 'sovereign', icon: '⚑', title: 'Sovereign', desc: 'Activated the sovereignty Easter egg' }, + { id: 'portal_1', icon: 'πŸšͺ', title: 'Gate Watcher', desc: 'Discovered your first portal' }, + { id: 'portal_3', icon: 'πŸ—ΊοΈ', title: 'Wayseeker', desc: 'Discovered 3 portals' }, + { id: 'portal_all', icon: 'πŸ‘‘', title: 'Nexus Master', desc: 'Discovered all portals' }, +]; + +const ACHIEVEMENT_KEY = 'nexus_achievements_v1'; + +/** + * @returns {{ unlocked: string[], chatCount: number, portalsVisited: string[] }} + */ +function loadAchievementState() { + try { + const raw = localStorage.getItem(ACHIEVEMENT_KEY); + return raw ? JSON.parse(raw) : { unlocked: [], chatCount: 0, portalsVisited: [] }; + } catch { + return { unlocked: [], chatCount: 0, portalsVisited: [] }; + } +} + +function saveAchievementState() { + try { + localStorage.setItem(ACHIEVEMENT_KEY, JSON.stringify(achState)); + } catch { /* quota */ } +} + +const achState = loadAchievementState(); + +/** @type {ReturnType|null} */ +let achToastTimer = null; + +/** + * @param {{ id: string, icon: string, title: string, desc: string }} def + */ +function showAchievementToast(def) { + const toast = document.getElementById('achievement-toast'); + if (!toast) return; + toast.innerHTML = + `${def.icon}` + + `
` + + `
Achievement Unlocked
` + + `
${def.title}
` + + `
${def.desc}
` + + `
`; + toast.classList.remove('ach-exit'); + toast.classList.add('ach-visible'); + if (achToastTimer) clearTimeout(achToastTimer); + achToastTimer = setTimeout(() => { + toast.classList.add('ach-exit'); + setTimeout(() => toast.classList.remove('ach-visible', 'ach-exit'), 450); + }, 3500); +} + +function renderAchievementPanel() { + const list = document.getElementById('ach-panel-list'); + const progress = document.getElementById('ach-panel-progress'); + if (!list) return; + const earned = achState.unlocked.length; + const total = ACHIEVEMENT_DEFS.length; + list.innerHTML = ACHIEVEMENT_DEFS.map(def => { + const isEarned = achState.unlocked.includes(def.id); + return `
` + + `${def.icon}` + + `
${def.title}
` + + `
${def.desc}
` + + `
`; + }).join(''); + if (progress) { + progress.textContent = `${earned} / ${total} unlocked`; + } +} + +/** + * Unlocks an achievement by id; shows toast if newly unlocked. + * @param {string} id + */ +function unlockAchievement(id) { + if (achState.unlocked.includes(id)) return; + const def = ACHIEVEMENT_DEFS.find(a => a.id === id); + if (!def) return; + achState.unlocked.push(id); + saveAchievementState(); + showAchievementToast(def); + renderAchievementPanel(); +} + +// Award on first page load +unlockAchievement('first_contact'); +renderAchievementPanel(); + +// Achievement panel toggle +document.getElementById('ach-toggle').addEventListener('click', () => { + const panel = document.getElementById('achievement-panel'); + if (panel) panel.classList.toggle('ach-panel-visible'); +}); + +document.getElementById('ach-panel-close').addEventListener('click', () => { + const panel = document.getElementById('achievement-panel'); + if (panel) panel.classList.remove('ach-panel-visible'); +}); + +// === PORTAL STUBS === +// Placeholder portals arranged at cardinal points; future portal system (#5) +// will replace these. Clicking a stub counts as a portal visit. + +const PORTAL_STUB_RADIUS = 10.5; +const PORTAL_STUB_DEFS = [ + { id: 'portal_north', name: 'Northern Gate', angle: 0, color: 0x44ffcc }, + { id: 'portal_east', name: 'Eastern Gate', angle: Math.PI / 2, color: 0x4488ff }, + { id: 'portal_south', name: 'Southern Gate', angle: Math.PI, color: 0xff44aa }, + { id: 'portal_west', name: 'Western Gate', angle: -Math.PI / 2, color: 0xffaa44 }, +]; + +for (const stub of PORTAL_STUB_DEFS) { + const x = Math.sin(stub.angle) * PORTAL_STUB_RADIUS; + const z = Math.cos(stub.angle) * PORTAL_STUB_RADIUS; + + const geo = new THREE.TorusGeometry(1.3, 0.07, 8, 48); + const mat = new THREE.MeshBasicMaterial({ color: stub.color, transparent: true, opacity: 0.55 }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(x, 2.2, z); + mesh.userData = { portalId: stub.id }; + scene.add(mesh); + portalStubMeshes.push(mesh); + + // Faint label sprite + const lc = document.createElement('canvas'); + lc.width = 256; lc.height = 44; + const lx = lc.getContext('2d'); + lx.font = '12px "Courier New", monospace'; + lx.fillStyle = '#' + stub.color.toString(16).padStart(6, '0'); + lx.textAlign = 'center'; + lx.fillText(stub.name, 128, 28); + const lTex = new THREE.CanvasTexture(lc); + const lMat = new THREE.SpriteMaterial({ map: lTex, transparent: true, opacity: 0.6, depthWrite: false }); + const lSprite = new THREE.Sprite(lMat); + lSprite.scale.set(3.4, 0.55, 1); + lSprite.position.set(x, 3.8, z); + scene.add(lSprite); +} + +// Raycaster for portal stub click detection +const portalRaycaster = new THREE.Raycaster(); +const portalPointer = new THREE.Vector2(); + +renderer.domElement.addEventListener('click', (e) => { + if (photoMode) return; + portalPointer.x = (e.clientX / window.innerWidth) * 2 - 1; + portalPointer.y = -(e.clientY / window.innerHeight) * 2 + 1; + portalRaycaster.setFromCamera(portalPointer, camera); + const hits = portalRaycaster.intersectObjects(portalStubMeshes); + if (hits.length === 0) return; + const portalId = hits[0].object.userData.portalId; + if (!achState.portalsVisited.includes(portalId)) { + achState.portalsVisited.push(portalId); + saveAchievementState(); + } + const n = achState.portalsVisited.length; + if (n >= 1) unlockAchievement('portal_1'); + if (n >= 3) unlockAchievement('portal_3'); + if (n >= PORTAL_STUB_DEFS.length) unlockAchievement('portal_all'); +}); diff --git a/index.html b/index.html index 69d6b65..47d0dfb 100644 --- a/index.html +++ b/index.html @@ -33,9 +33,23 @@ + +
+ + +
MAP VIEW [Tab] to exit diff --git a/style.css b/style.css index 8ccbc2d..770a230 100644 --- a/style.css +++ b/style.css @@ -224,3 +224,164 @@ body.photo-mode #overview-indicator { 50% { opacity: 0.15; } 100% { opacity: 0.05; } } + +/* === ACHIEVEMENT SYSTEM === */ +#achievement-toast { + display: none; + position: fixed; + bottom: 24px; + right: 20px; + z-index: 200; + background: rgba(0, 6, 20, 0.92); + border: 1px solid var(--color-primary); + padding: 10px 14px; + font-family: var(--font-body); + color: var(--color-text); + min-width: 240px; + max-width: 300px; + pointer-events: none; + box-shadow: 0 0 18px rgba(68, 136, 255, 0.35); +} + +#achievement-toast.ach-visible { + display: flex; + align-items: center; + gap: 12px; + animation: ach-slide-in 0.35s ease forwards; +} + +#achievement-toast.ach-exit { + animation: ach-slide-out 0.4s ease forwards; +} + +@keyframes ach-slide-in { + from { opacity: 0; transform: translateX(40px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes ach-slide-out { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(40px); } +} + +.ach-icon { + font-size: 22px; + flex-shrink: 0; +} + +.ach-label { + font-size: 8px; + letter-spacing: 0.18em; + color: var(--color-primary); + text-transform: uppercase; + margin-bottom: 2px; +} + +.ach-title { + font-size: 13px; + font-weight: bold; + color: #ffffff; + margin-bottom: 1px; +} + +.ach-desc { + font-size: 10px; + color: var(--color-text-muted); + line-height: 1.3; +} + +/* Achievement panel overlay */ +#achievement-panel { + display: none; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 150; + background: rgba(0, 4, 16, 0.96); + border: 1px solid var(--color-secondary); + padding: 20px 24px; + font-family: var(--font-body); + color: var(--color-text); + width: 340px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 0 40px rgba(68, 136, 255, 0.2); +} + +#achievement-panel.ach-panel-visible { + display: block; + animation: ach-slide-in 0.3s ease forwards; +} + +.ach-panel-header { + font-size: 11px; + letter-spacing: 0.2em; + color: var(--color-primary); + text-transform: uppercase; + margin-bottom: 16px; + border-bottom: 1px solid var(--color-secondary); + padding-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.ach-panel-close { + background: none; + border: none; + color: var(--color-text-muted); + font-family: var(--font-body); + font-size: 11px; + cursor: pointer; + letter-spacing: 0.1em; + padding: 2px 6px; +} + +.ach-panel-close:hover { + color: var(--color-text); +} + +.ach-badge { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(51, 68, 136, 0.3); + opacity: 0.35; +} + +.ach-badge.ach-earned { + opacity: 1; +} + +.ach-badge-icon { + font-size: 18px; + flex-shrink: 0; + width: 24px; + text-align: center; +} + +.ach-badge-title { + font-size: 12px; + font-weight: bold; + color: #ccd6f6; + margin-bottom: 2px; +} + +.ach-badge.ach-earned .ach-badge-title { + color: #ffffff; +} + +.ach-badge-desc { + font-size: 10px; + color: var(--color-text-muted); + line-height: 1.3; +} + +.ach-progress { + font-size: 9px; + color: var(--color-primary); + margin-top: 12px; + letter-spacing: 0.12em; +} -- 2.43.0