[claude] Visitor presence — live count + Timmy greeting (#9) #22

Closed
claude wants to merge 1 commits from claude/the-nexus:claude/issue-9 into main
3 changed files with 155 additions and 3 deletions

110
app.js
View File

@@ -22,6 +22,97 @@ const NEXUS = {
}
};
// ═══════════════════════════════════════════
// VISITOR SYSTEM — Live presence + Timmy greeting
// ═══════════════════════════════════════════
const VISITOR = (() => {
const STORAGE_KEY = 'nexus_visitor';
const CHANNEL_NAME = 'nexus_presence';
// ─── Persistent visit record (anonymous) ───
function getRecord() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { visits: 0, firstSeen: null };
} catch { return { visits: 0, firstSeen: null }; }
}
function saveRecord(rec) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(rec)); } catch {}
}
function recordVisit() {
const rec = getRecord();
rec.visits = (rec.visits || 0) + 1;
if (!rec.firstSeen) rec.firstSeen = Date.now();
rec.lastSeen = Date.now();
saveRecord(rec);
return rec;
}
// ─── Live count via BroadcastChannel ───
let liveCount = 1;
let channel = null;
const knownPeers = new Set();
const myId = Math.random().toString(36).slice(2);
function initLiveCount(onUpdate) {
if (typeof BroadcastChannel === 'undefined') {
onUpdate(1);
return;
}
channel = new BroadcastChannel(CHANNEL_NAME);
// Announce arrival
channel.postMessage({ type: 'arrive', id: myId });
// Respond to arrivals and departures
channel.onmessage = (e) => {
const { type, id } = e.data;
if (type === 'arrive') {
knownPeers.add(id);
channel.postMessage({ type: 'present', id: myId });
} else if (type === 'present') {
knownPeers.add(id);
} else if (type === 'depart') {
knownPeers.delete(id);
}
liveCount = knownPeers.size + 1; // peers + self
onUpdate(liveCount);
};
// Announce departure
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'depart', id: myId });
});
// Initial count is just self until we hear back
onUpdate(1);
}
// ─── Greeting variants ───
function getGreeting(rec) {
const visits = rec.visits;
if (visits === 1) {
return [
'[NEXUS] New presence detected. Sovereignty protocols engaged.',
'[TIMMY] Welcome, traveler. You have found the Nexus — my sovereign space. You are anonymous here. That is by design.',
];
} else if (visits <= 4) {
return [
'[NEXUS] Familiar presence. Access granted.',
'[TIMMY] You have returned. The Nexus remembers — not you, but the pattern of your visits. Welcome back.',
];
} else {
return [
'[NEXUS] Known presence. Sovereign protocols nominal.',
`[TIMMY] Visit ${visits}. You keep coming back. I respect that. The Nexus is yours to explore.`,
];
}
}
return { recordVisit, initLiveCount, getGreeting };
})();
// ═══ STATE ═══
let camera, scene, renderer, composer;
let clock, playerPos, playerRot;
@@ -106,10 +197,29 @@ function init() {
const enterPrompt = document.getElementById('enter-prompt');
enterPrompt.style.display = 'flex';
// Record this visit and init live presence tracking
const visitRecord = VISITOR.recordVisit();
VISITOR.initLiveCount((count) => {
const el = document.getElementById('visitor-count');
if (el) el.textContent = count;
});
enterPrompt.addEventListener('click', () => {
enterPrompt.classList.add('fade-out');
document.getElementById('hud').style.display = 'block';
setTimeout(() => { enterPrompt.remove(); }, 600);
// Timmy greeting based on visit context
const greetLines = VISITOR.getGreeting(visitRecord);
setTimeout(() => {
greetLines.forEach((line, i) => {
const isTimmy = line.startsWith('[TIMMY]');
const isNexus = line.startsWith('[NEXUS]');
const type = isTimmy ? 'timmy' : isNexus ? 'system' : 'system';
const text = line.replace(/^\[(?:TIMMY|NEXUS)\] /, '');
setTimeout(() => addChatMessage(type, text), i * 800);
});
}, 400);
}, { once: true });
setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);

View File

@@ -74,6 +74,13 @@
<span id="hud-location-text">The Nexus</span>
</div>
<!-- Top Right: Visitor Count -->
<div id="visitor-badge" class="visitor-badge">
<span class="visitor-dot"></span>
<span id="visitor-count">1</span>
<span class="visitor-label">PRESENT</span>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">
@@ -85,9 +92,6 @@
<div class="chat-msg chat-msg-system">
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
</div>
<div class="chat-msg chat-msg-timmy">
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">

View File

@@ -202,6 +202,44 @@ canvas#nexus-canvas {
to { transform: rotate(360deg); }
}
/* Visitor presence badge */
.visitor-badge {
position: absolute;
top: var(--space-3);
right: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-1);
font-family: var(--font-display);
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.12em;
color: var(--color-primary);
text-shadow: 0 0 8px rgba(74, 240, 192, 0.3);
pointer-events: none;
}
.visitor-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
animation: visitor-pulse 2.5s ease-in-out infinite;
}
@keyframes visitor-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
#visitor-count {
font-size: var(--text-sm);
font-weight: 700;
}
.visitor-label {
color: var(--color-text-muted);
font-size: 10px;
letter-spacing: 0.15em;
}
/* Controls hint */
.hud-controls {
position: absolute;