[claude] Visitor presence — live count + Timmy greeting (#9) #22
110
app.js
110
app.js
@@ -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);
|
||||
|
||||
10
index.html
10
index.html
@@ -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">
|
||||
|
||||
38
style.css
38
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user