feat: achievement system — badges for visiting portals and chatting
- 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:
189
app.js
189
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<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');
|
||||
});
|
||||
|
||||
14
index.html
14
index.html
@@ -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
161
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user