feat: achievement system — badges for visiting portals and chatting
Some checks failed
CI / validate (pull_request) Failing after 11s
CI / auto-merge (pull_request) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 00:51:24 -04:00
parent 39e0eecb9e
commit ee0d347152
3 changed files with 364 additions and 0 deletions

189
app.js
View File

@@ -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<typeof setTimeout>|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 =
`<span class="ach-icon">${def.icon}</span>` +
`<div class="ach-body">` +
`<div class="ach-label">Achievement Unlocked</div>` +
`<div class="ach-title">${def.title}</div>` +
`<div class="ach-desc">${def.desc}</div>` +
`</div>`;
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 `<div class="ach-badge${isEarned ? ' ach-earned' : ''}">` +
`<span class="ach-badge-icon">${def.icon}</span>` +
`<div><div class="ach-badge-title">${def.title}</div>` +
`<div class="ach-badge-desc">${def.desc}</div></div>` +
`</div>`;
}).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');
});

View File

@@ -33,9 +33,23 @@
<button id="debug-toggle" class="chat-toggle-btn" aria-label="Toggle debug mode" style="background-color: var(--color-secondary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔍
</button>
<button id="ach-toggle" class="chat-toggle-btn" aria-label="Achievements" style="background-color: var(--color-secondary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🏆
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
<div id="achievement-toast" aria-live="polite"></div>
<div id="achievement-panel" role="dialog" aria-label="Achievements">
<div class="ach-panel-header">
<span>Achievements</span>
<button class="ach-panel-close" id="ach-panel-close">[close]</button>
</div>
<div id="ach-panel-list"></div>
<div class="ach-progress" id="ach-panel-progress"></div>
</div>
<div id="overview-indicator">
<span>MAP VIEW</span>
<span class="overview-hint">[Tab] to exit</span>

161
style.css
View File

@@ -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;
}