Compare commits
4 Commits
perplexity
...
feat/mnemo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
112ab16441 | ||
|
|
2aac7df086 | ||
|
|
cec0781d95 | ||
| 182a1148eb |
509
frontend/index.html
Normal file
509
frontend/index.html
Normal file
@@ -0,0 +1,509 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="3D visualization of the Timmy agent network" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Tower World" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
||||
<title>Timmy Tower World</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
|
||||
canvas { display: block; }
|
||||
|
||||
/* Loading screen — hidden by main.js after init */
|
||||
#loading-screen {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #000;
|
||||
color: #00ff41; font-size: 14px; letter-spacing: 4px;
|
||||
text-shadow: 0 0 12px #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
#loading-screen.hidden { display: none; }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
#loading-screen span { animation: blink 1.2s ease-in-out infinite; }
|
||||
|
||||
#ui-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 10;
|
||||
}
|
||||
#hud {
|
||||
position: fixed; top: 16px; left: 16px;
|
||||
color: #00ff41; font-size: clamp(10px, 1.5vw, 14px); line-height: 1.6;
|
||||
text-shadow: 0 0 8px #00ff41;
|
||||
pointer-events: none;
|
||||
}
|
||||
#hud h1 { font-size: clamp(12px, 2vw, 18px); letter-spacing: clamp(2px, 0.4vw, 4px); margin-bottom: 8px; color: #00ff88; }
|
||||
#status-panel {
|
||||
position: fixed; top: 16px; right: 16px;
|
||||
color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.8;
|
||||
text-shadow: 0 0 6px #00ff41; max-width: 240px;
|
||||
}
|
||||
#status-panel .label { color: #007722; }
|
||||
#chat-panel {
|
||||
position: fixed; bottom: 52px; left: 16px; right: 16px;
|
||||
max-height: 150px; overflow-y: auto;
|
||||
color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.6;
|
||||
text-shadow: 0 0 4px #00ff41;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-entry { opacity: 0.8; }
|
||||
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
|
||||
.chat-entry.visitor { opacity: 1; }
|
||||
.chat-entry.visitor .agent-name { color: #888; }
|
||||
|
||||
/* ── Chat input (#40) ── */
|
||||
#chat-input-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-top: 1px solid #003300;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#chat-input {
|
||||
flex: 1;
|
||||
background: rgba(0, 20, 0, 0.6);
|
||||
border: 1px solid #003300;
|
||||
color: #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(12px, 1.5vw, 14px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
caret-color: #00ff41;
|
||||
}
|
||||
#chat-input::placeholder { color: #004400; }
|
||||
#chat-input:focus { border-color: #00ff41; box-shadow: 0 0 8px rgba(0, 255, 65, 0.2); }
|
||||
#chat-send {
|
||||
background: transparent;
|
||||
border: 1px solid #003300;
|
||||
color: #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
pointer-events: auto;
|
||||
text-shadow: 0 0 6px #00ff41;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
#chat-send:hover, #chat-send:active { background: rgba(0, 255, 65, 0.1); border-color: #00ff41; }
|
||||
|
||||
/* ── Bark display (#42) ── */
|
||||
#bark-container {
|
||||
position: fixed;
|
||||
top: 20%; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 600px; width: 90%;
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
}
|
||||
.bark {
|
||||
background: rgba(0, 10, 0, 0.85);
|
||||
border: 1px solid #003300;
|
||||
border-left: 3px solid #00ff41;
|
||||
padding: 12px 20px;
|
||||
color: #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(13px, 1.8vw, 16px);
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 0 8px #00ff41;
|
||||
opacity: 0;
|
||||
animation: barkIn 0.4s ease-out forwards;
|
||||
max-width: 100%;
|
||||
}
|
||||
.bark .bark-agent {
|
||||
font-size: clamp(9px, 1vw, 11px);
|
||||
color: #007722;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.bark.fade-out {
|
||||
animation: barkOut 0.6s ease-in forwards;
|
||||
}
|
||||
@keyframes barkIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes barkOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
#connection-status {
|
||||
position: fixed; bottom: 52px; right: 16px;
|
||||
font-size: clamp(9px, 1.2vw, 12px); color: #555;
|
||||
}
|
||||
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
|
||||
|
||||
/* ── Presence HUD (#53) ── */
|
||||
#presence-hud {
|
||||
position: fixed; bottom: 180px; right: 16px;
|
||||
background: rgba(0, 5, 0, 0.75);
|
||||
border: 1px solid #002200;
|
||||
border-radius: 2px;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(9px, 1.1vw, 11px);
|
||||
color: #00ff41;
|
||||
text-shadow: 0 0 4px rgba(0, 255, 65, 0.3);
|
||||
min-width: 180px;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
}
|
||||
.presence-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid #002200;
|
||||
font-size: clamp(8px, 1vw, 10px);
|
||||
letter-spacing: 2px; color: #007722;
|
||||
}
|
||||
.presence-count { color: #00ff41; letter-spacing: 0; }
|
||||
.presence-mode { letter-spacing: 1px; }
|
||||
.presence-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.presence-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.presence-dot.online {
|
||||
background: var(--agent-color, #00ff41);
|
||||
box-shadow: 0 0 6px var(--agent-color, #00ff41);
|
||||
animation: presencePulse 2s ease-in-out infinite;
|
||||
}
|
||||
.presence-dot.offline {
|
||||
background: #333;
|
||||
box-shadow: none;
|
||||
}
|
||||
@keyframes presencePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.presence-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.presence-state { font-size: clamp(7px, 0.9vw, 9px); min-width: 40px; text-align: center; }
|
||||
.presence-uptime { color: #005500; min-width: 48px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Transcript controls (#54) ── */
|
||||
#transcript-controls {
|
||||
position: fixed; top: 16px; right: 260px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(8px, 1vw, 10px);
|
||||
z-index: 15;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.transcript-label { color: #005500; letter-spacing: 2px; }
|
||||
.transcript-badge {
|
||||
color: #00ff41; background: rgba(0, 20, 0, 0.6);
|
||||
border: 1px solid #003300; border-radius: 2px;
|
||||
padding: 1px 5px; font-variant-numeric: tabular-nums;
|
||||
min-width: 28px; text-align: center;
|
||||
}
|
||||
.transcript-btn {
|
||||
background: transparent; border: 1px solid #003300;
|
||||
color: #00aa44; font-family: 'Courier New', monospace;
|
||||
font-size: clamp(7px, 0.9vw, 9px); padding: 2px 6px;
|
||||
cursor: pointer; border-radius: 2px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.transcript-btn:hover { color: #00ff41; border-color: #00ff41; background: rgba(0, 255, 65, 0.08); }
|
||||
.transcript-btn-clear { color: #553300; border-color: #332200; }
|
||||
.transcript-btn-clear:hover { color: #ff6600; border-color: #ff6600; background: rgba(255, 102, 0, 0.08); }
|
||||
|
||||
@media (max-width: 500px) {
|
||||
#presence-hud { bottom: 180px; right: 8px; left: auto; min-width: 150px; padding: 6px 8px; }
|
||||
#transcript-controls { top: auto; bottom: 180px; right: auto; left: 8px; }
|
||||
}
|
||||
|
||||
/* Safe area padding for notched devices */
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
|
||||
#status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#chat-panel { bottom: calc(52px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#connection-status { bottom: calc(52px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#presence-hud { bottom: calc(180px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
}
|
||||
|
||||
/* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */
|
||||
@media (max-width: 500px) {
|
||||
#status-panel { top: 100px !important; left: 16px; right: auto; }
|
||||
}
|
||||
|
||||
/* ── Agent info popup (#44) ── */
|
||||
#agent-popup {
|
||||
position: fixed;
|
||||
z-index: 25;
|
||||
background: rgba(0, 8, 0, 0.92);
|
||||
border: 1px solid #003300;
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(10px, 1.3vw, 13px);
|
||||
color: #00ff41;
|
||||
text-shadow: 0 0 6px rgba(0, 255, 65, 0.3);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 65, 0.1);
|
||||
}
|
||||
.agent-popup-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid #002200;
|
||||
}
|
||||
.agent-popup-name {
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
font-size: clamp(11px, 1.5vw, 14px);
|
||||
}
|
||||
.agent-popup-close {
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
font-size: 16px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.agent-popup-close:hover { color: #00ff41; }
|
||||
.agent-popup-role {
|
||||
padding: 4px 12px;
|
||||
color: #007722;
|
||||
font-size: clamp(9px, 1.1vw, 11px);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.agent-popup-state {
|
||||
padding: 2px 12px 8px;
|
||||
font-size: clamp(9px, 1.1vw, 11px);
|
||||
}
|
||||
.agent-popup-talk {
|
||||
display: block; width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid #002200;
|
||||
color: #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(10px, 1.2vw, 12px);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
letter-spacing: 2px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.agent-popup-talk:hover { background: rgba(0, 255, 65, 0.08); }
|
||||
|
||||
/* ── Streaming cursor (#16) ── */
|
||||
.chat-entry.streaming .stream-cursor {
|
||||
color: #00ff41;
|
||||
animation: cursorBlink 0.7s step-end infinite;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.chat-entry.streaming .stream-text {
|
||||
color: #00ff41;
|
||||
}
|
||||
.chat-ts { color: #004400; font-size: 0.9em; }
|
||||
|
||||
/* ── Economy / Treasury panel (#17) ── */
|
||||
#economy-panel {
|
||||
position: fixed; bottom: 180px; left: 16px;
|
||||
background: rgba(0, 5, 0, 0.75);
|
||||
border: 1px solid #002200;
|
||||
border-radius: 2px;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(9px, 1.1vw, 11px);
|
||||
color: #00ff41;
|
||||
text-shadow: 0 0 4px rgba(0, 255, 65, 0.3);
|
||||
min-width: 170px;
|
||||
max-width: 220px;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
}
|
||||
.econ-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid #002200;
|
||||
font-size: clamp(8px, 1vw, 10px);
|
||||
letter-spacing: 2px; color: #007722;
|
||||
}
|
||||
.econ-total { color: #ffcc00; letter-spacing: 0; font-variant-numeric: tabular-nums; }
|
||||
.econ-waiting { color: #004400; font-style: italic; font-size: clamp(8px, 0.9vw, 10px); }
|
||||
.econ-agents { margin-bottom: 6px; }
|
||||
.econ-agent-row {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.econ-dot {
|
||||
width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.econ-agent-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: #00aa44; }
|
||||
.econ-agent-bal { color: #ffcc00; font-variant-numeric: tabular-nums; min-width: 50px; text-align: right; }
|
||||
.econ-agent-spent { color: #664400; font-variant-numeric: tabular-nums; min-width: 50px; text-align: right; }
|
||||
.econ-txns { border-top: 1px solid #002200; padding-top: 4px; }
|
||||
.econ-txns-label { color: #004400; letter-spacing: 2px; font-size: clamp(7px, 0.8vw, 9px); margin-bottom: 2px; }
|
||||
.econ-tx { color: #007722; padding: 1px 0; }
|
||||
.econ-tx-amt { color: #ffcc00; }
|
||||
|
||||
@media (max-width: 500px) {
|
||||
#economy-panel { bottom: 180px; left: 8px; min-width: 150px; padding: 6px 8px; }
|
||||
}
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
#economy-panel { bottom: calc(180px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); }
|
||||
}
|
||||
|
||||
/* ── Help overlay ── */
|
||||
#help-hint {
|
||||
position: fixed; top: 12px; right: 12px;
|
||||
font-family: 'Courier New', monospace; font-size: 0.65rem;
|
||||
color: #005500; background: rgba(0, 10, 0, 0.6);
|
||||
border: 1px solid #003300; padding: 2px 8px;
|
||||
cursor: pointer; z-index: 30; letter-spacing: 0.05em;
|
||||
transition: color 0.3s, border-color 0.3s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#help-hint:hover { color: #00ff41; border-color: #00ff41; }
|
||||
|
||||
#help-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
align-items: center; justify-content: center;
|
||||
font-family: 'Courier New', monospace; color: #00ff41;
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.help-content {
|
||||
position: relative; max-width: 420px; width: 90%;
|
||||
padding: 24px 28px; border: 1px solid #003300;
|
||||
background: rgba(0, 10, 0, 0.7);
|
||||
}
|
||||
.help-title {
|
||||
font-size: 1rem; letter-spacing: 0.15em; margin-bottom: 20px;
|
||||
color: #00ff41; text-shadow: 0 0 8px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
.help-close {
|
||||
position: absolute; top: 12px; right: 16px;
|
||||
font-size: 1.2rem; cursor: pointer; color: #005500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.help-close:hover { color: #00ff41; }
|
||||
.help-section { margin-bottom: 16px; }
|
||||
.help-heading {
|
||||
font-size: 0.65rem; color: #007700; letter-spacing: 0.1em;
|
||||
margin-bottom: 6px; border-bottom: 1px solid #002200; padding-bottom: 3px;
|
||||
}
|
||||
.help-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 3px 0; font-size: 0.72rem;
|
||||
}
|
||||
.help-row span:last-child { margin-left: auto; color: #009900; text-align: right; }
|
||||
.help-row kbd {
|
||||
display: inline-block; font-family: 'Courier New', monospace;
|
||||
font-size: 0.65rem; background: rgba(0, 30, 0, 0.6);
|
||||
border: 1px solid #004400; border-radius: 3px;
|
||||
padding: 1px 5px; min-width: 18px; text-align: center; color: #00cc33;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-screen"><span>INITIALIZING...</span></div>
|
||||
<!-- WebGL context loss overlay (iPad PWA, GPU resets) -->
|
||||
<div id="webgl-recovery-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.9);color:#00ff41;font-family:monospace;align-items:center;justify-content:center;flex-direction:column">
|
||||
<p style="font-size:1.4rem">RECOVERING WebGL CONTEXT…</p>
|
||||
<p style="font-size:.85rem;opacity:.6">GPU was reset. Rebuilding world.</p>
|
||||
</div>
|
||||
<div id="ui-overlay">
|
||||
<div id="hud">
|
||||
<h1>TIMMY TOWER WORLD</h1>
|
||||
<div id="agent-count">AGENTS: 0</div>
|
||||
<div id="active-jobs">JOBS: 0</div>
|
||||
<div id="fps">FPS: --</div>
|
||||
</div>
|
||||
<div id="status-panel">
|
||||
<div id="agent-list"></div>
|
||||
</div>
|
||||
<div id="chat-panel"></div>
|
||||
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
|
||||
<div id="bark-container"></div>
|
||||
<div id="transcript-controls"></div>
|
||||
<div id="economy-panel"></div>
|
||||
<div id="presence-hud"></div>
|
||||
<div id="connection-status">OFFLINE</div>
|
||||
<div id="help-hint">? HELP</div>
|
||||
<div id="help-overlay" style="display:none">
|
||||
<div class="help-content">
|
||||
<div class="help-title">CONTROLS</div>
|
||||
<div class="help-close">×</div>
|
||||
<div class="help-section">
|
||||
<div class="help-heading">MOVEMENT</div>
|
||||
<div class="help-row"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd><span>Move avatar</span></div>
|
||||
<div class="help-row"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd><span>Move avatar</span></div>
|
||||
<div class="help-row"><kbd>Right-click + drag</kbd><span>Look around</span></div>
|
||||
</div>
|
||||
<div class="help-section">
|
||||
<div class="help-heading">CAMERA</div>
|
||||
<div class="help-row"><span>Click PiP window</span><span>Toggle 1st / 3rd person</span></div>
|
||||
<div class="help-row"><span>Scroll wheel</span><span>Zoom in / out</span></div>
|
||||
<div class="help-row"><span>Left-click + drag</span><span>Orbit camera</span></div>
|
||||
</div>
|
||||
<div class="help-section">
|
||||
<div class="help-heading">INTERACTION</div>
|
||||
<div class="help-row"><span>Click an agent</span><span>View agent info</span></div>
|
||||
<div class="help-row"><kbd>Enter</kbd><span>Focus chat input</span></div>
|
||||
<div class="help-row"><kbd>?</kbd><span>Toggle this overlay</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat-input-bar">
|
||||
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
|
||||
<button id="chat-send">></button>
|
||||
</div>
|
||||
<script type="module" src="./js/main.js"></script>
|
||||
<script>
|
||||
// Help overlay toggle
|
||||
(function() {
|
||||
const overlay = document.getElementById('help-overlay');
|
||||
const hint = document.getElementById('help-hint');
|
||||
const close = overlay ? overlay.querySelector('.help-close') : null;
|
||||
function toggle() {
|
||||
if (!overlay) return;
|
||||
overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none';
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
|
||||
if (document.activeElement?.tagName === 'INPUT' ||
|
||||
document.activeElement?.tagName === 'TEXTAREA') return;
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
if (e.key === 'Escape' && overlay && overlay.style.display !== 'none') {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
if (hint) hint.addEventListener('click', toggle);
|
||||
if (close) close.addEventListener('click', toggle);
|
||||
if (overlay) overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) overlay.style.display = 'none';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<!-- SW registration is handled by main.js in production builds only -->
|
||||
</body>
|
||||
</html>
|
||||
30
frontend/js/agent-defs.js
Normal file
30
frontend/js/agent-defs.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* agent-defs.js — Single source of truth for all agent definitions.
|
||||
*
|
||||
* These are the REAL agents of the Timmy Tower ecosystem.
|
||||
* Additional agents can join at runtime via the `agent_joined` WS event
|
||||
* (handled by addAgent() in agents.js).
|
||||
*
|
||||
* Fields:
|
||||
* id — unique string key used in WebSocket messages and state maps
|
||||
* label — display name shown in the 3D HUD and chat panel
|
||||
* color — hex integer (0xRRGGBB) used for Three.js materials and lights
|
||||
* role — human-readable role string shown under the label sprite
|
||||
* direction — cardinal facing direction (for future mesh orientation use)
|
||||
* x, z — world-space position on the horizontal plane (y is always 0)
|
||||
*/
|
||||
export const AGENT_DEFS = [
|
||||
{ id: 'timmy', label: 'TIMMY', color: 0x00ff41, role: 'sovereign agent', direction: 'north', x: 0, z: 0 },
|
||||
{ id: 'perplexity', label: 'PERPLEXITY', color: 0x20b8cd, role: 'integration architect', direction: 'east', x: 5, z: 3 },
|
||||
{ id: 'replit', label: 'REPLIT', color: 0xff6622, role: 'lead architect', direction: 'south', x: -5, z: 3 },
|
||||
{ id: 'kimi', label: 'KIMI', color: 0xcc44ff, role: 'scout', direction: 'west', x: -5, z: -3 },
|
||||
{ id: 'claude', label: 'CLAUDE', color: 0xd4a574, role: 'senior engineer', direction: 'north', x: 5, z: -3 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert an integer color (e.g. 0x00ff88) to a CSS hex string ('#00ff88').
|
||||
* Useful for DOM styling and canvas rendering.
|
||||
*/
|
||||
export function colorToCss(intColor) {
|
||||
return '#' + intColor.toString(16).padStart(6, '0');
|
||||
}
|
||||
523
frontend/js/agents.js
Normal file
523
frontend/js/agents.js
Normal file
@@ -0,0 +1,523 @@
|
||||
import * as THREE from 'three';
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
|
||||
const agents = new Map();
|
||||
let scene;
|
||||
let connectionLines = [];
|
||||
|
||||
/* ── Shared geometries (created once, reused by all agents) ── */
|
||||
const SHARED_GEO = {
|
||||
core: new THREE.IcosahedronGeometry(0.7, 1),
|
||||
ring: new THREE.TorusGeometry(1.1, 0.04, 8, 32),
|
||||
glow: new THREE.SphereGeometry(1.3, 16, 16),
|
||||
};
|
||||
|
||||
/* ── Shared connection line material (one instance for all lines) ── */
|
||||
const CONNECTION_MAT = new THREE.LineBasicMaterial({
|
||||
color: 0x00aa44,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
/* ── Active-conversation highlight material ── */
|
||||
const ACTIVE_CONNECTION_MAT = new THREE.LineBasicMaterial({
|
||||
color: 0x00ff41,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
|
||||
/** Map of active pulse timers: `${idA}-${idB}` → timeoutId */
|
||||
const pulseTimers = new Map();
|
||||
|
||||
class Agent {
|
||||
constructor(def) {
|
||||
this.id = def.id;
|
||||
this.label = def.label;
|
||||
this.color = def.color;
|
||||
this.role = def.role;
|
||||
this.position = new THREE.Vector3(def.x, 0, def.z);
|
||||
this.homePosition = this.position.clone(); // remember spawn point
|
||||
this.state = 'idle';
|
||||
this.walletHealth = 1.0; // 0.0–1.0, 1.0 = healthy (#15)
|
||||
this.pulsePhase = Math.random() * Math.PI * 2;
|
||||
|
||||
// Movement system
|
||||
this._moveTarget = null; // THREE.Vector3 or null
|
||||
this._moveSpeed = 2.0; // units/sec (adjustable per moveTo call)
|
||||
this._moveCallback = null; // called when arrival reached
|
||||
|
||||
// Stress glow color targets (#15)
|
||||
this._baseColor = new THREE.Color(def.color);
|
||||
this._stressColor = new THREE.Color(0xff4400); // amber-red for low health
|
||||
this._currentGlowColor = new THREE.Color(def.color);
|
||||
|
||||
this.group = new THREE.Group();
|
||||
this.group.position.copy(this.position);
|
||||
|
||||
this._buildMeshes();
|
||||
this._buildLabel();
|
||||
}
|
||||
|
||||
_buildMeshes() {
|
||||
// Per-agent materials (need unique color + mutable emissiveIntensity)
|
||||
const coreMat = new THREE.MeshStandardMaterial({
|
||||
color: this.color,
|
||||
emissive: this.color,
|
||||
emissiveIntensity: 0.4,
|
||||
roughness: 0.3,
|
||||
metalness: 0.8,
|
||||
});
|
||||
|
||||
this.core = new THREE.Mesh(SHARED_GEO.core, coreMat);
|
||||
this.group.add(this.core);
|
||||
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 });
|
||||
this.ring = new THREE.Mesh(SHARED_GEO.ring, ringMat);
|
||||
this.ring.rotation.x = Math.PI / 2;
|
||||
this.group.add(this.ring);
|
||||
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: this.color,
|
||||
transparent: true,
|
||||
opacity: 0.05,
|
||||
side: THREE.BackSide,
|
||||
});
|
||||
this.glow = new THREE.Mesh(SHARED_GEO.glow, glowMat);
|
||||
this.group.add(this.glow);
|
||||
|
||||
const light = new THREE.PointLight(this.color, 1.5, 10);
|
||||
this.group.add(light);
|
||||
this.light = light;
|
||||
}
|
||||
|
||||
_buildLabel() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256; canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||||
ctx.fillRect(0, 0, 256, 64);
|
||||
ctx.font = 'bold 22px Courier New';
|
||||
ctx.fillStyle = colorToCss(this.color);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.label, 128, 28);
|
||||
ctx.font = '14px Courier New';
|
||||
ctx.fillStyle = '#007722';
|
||||
ctx.fillText(this.role.toUpperCase(), 128, 50);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
|
||||
this.sprite = new THREE.Sprite(spriteMat);
|
||||
this.sprite.scale.set(2.4, 0.6, 1);
|
||||
this.sprite.position.y = 2;
|
||||
this.group.add(this.sprite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move agent toward a target position over time.
|
||||
* @param {THREE.Vector3|{x,z}} target — destination (y ignored, stays 0)
|
||||
* @param {number} [speed=2.0] — units per second
|
||||
* @param {Function} [onArrive] — callback when agent reaches target
|
||||
*/
|
||||
moveTo(target, speed = 2.0, onArrive = null) {
|
||||
this._moveTarget = new THREE.Vector3(
|
||||
target.x ?? target.getComponent?.(0) ?? 0,
|
||||
0,
|
||||
target.z ?? target.getComponent?.(2) ?? 0
|
||||
);
|
||||
this._moveSpeed = speed;
|
||||
this._moveCallback = onArrive;
|
||||
}
|
||||
|
||||
/** Cancel in-progress movement. */
|
||||
stopMoving() {
|
||||
this._moveTarget = null;
|
||||
this._moveCallback = null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} true if agent is currently moving toward a target */
|
||||
get isMoving() {
|
||||
return this._moveTarget !== null;
|
||||
}
|
||||
|
||||
update(time, delta) {
|
||||
// ── Movement interpolation ──
|
||||
if (this._moveTarget) {
|
||||
const step = this._moveSpeed * delta;
|
||||
const dist = this.position.distanceTo(this._moveTarget);
|
||||
if (dist <= step + 0.05) {
|
||||
// Arrived
|
||||
this.position.copy(this._moveTarget);
|
||||
this.position.y = 0;
|
||||
this.group.position.x = this.position.x;
|
||||
this.group.position.z = this.position.z;
|
||||
const cb = this._moveCallback;
|
||||
this._moveTarget = null;
|
||||
this._moveCallback = null;
|
||||
if (cb) cb();
|
||||
} else {
|
||||
// Lerp toward target
|
||||
const dir = new THREE.Vector3().subVectors(this._moveTarget, this.position).normalize();
|
||||
this.position.addScaledVector(dir, step);
|
||||
this.position.y = 0;
|
||||
this.group.position.x = this.position.x;
|
||||
this.group.position.z = this.position.z;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visual effects ──
|
||||
const pulse = Math.sin(time * 0.002 + this.pulsePhase);
|
||||
const active = this.state === 'active';
|
||||
const moving = this.isMoving;
|
||||
const wh = this.walletHealth;
|
||||
|
||||
// Budget stress glow (#15): blend base color toward stress color as wallet drops
|
||||
const stressT = 1 - Math.max(0, Math.min(1, wh));
|
||||
this._currentGlowColor.copy(this._baseColor).lerp(this._stressColor, stressT * stressT);
|
||||
|
||||
// Stress breathing: faster + wider pulse when wallet is low
|
||||
const stressPulseSpeed = 0.002 + stressT * 0.006;
|
||||
const stressPulse = Math.sin(time * stressPulseSpeed + this.pulsePhase);
|
||||
const breathingAmp = stressT > 0.5 ? 0.15 + stressT * 0.15 : 0;
|
||||
const stressBreathe = breathingAmp * stressPulse;
|
||||
|
||||
const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1 + stressBreathe;
|
||||
this.core.material.emissiveIntensity = intensity;
|
||||
this.core.material.emissive.copy(this._currentGlowColor);
|
||||
this.light.color.copy(this._currentGlowColor);
|
||||
this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3;
|
||||
|
||||
// Glow sphere shows stress color
|
||||
this.glow.material.color.copy(this._currentGlowColor);
|
||||
this.glow.material.opacity = 0.05 + stressT * 0.08;
|
||||
|
||||
const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03;
|
||||
this.core.scale.setScalar(scale);
|
||||
|
||||
// Ring spins faster when moving
|
||||
this.ring.rotation.y += moving ? 0.05 : (active ? 0.03 : 0.008);
|
||||
this.ring.material.opacity = 0.3 + pulse * 0.2;
|
||||
|
||||
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set wallet health (0.0–1.0). Affects glow color and pulse. (#15)
|
||||
*/
|
||||
setWalletHealth(health) {
|
||||
this.walletHealth = Math.max(0, Math.min(1, health));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose per-agent GPU resources (materials + textures).
|
||||
* Shared geometries are NOT disposed here — they outlive individual agents.
|
||||
*/
|
||||
dispose() {
|
||||
this.core.material.dispose();
|
||||
this.ring.material.dispose();
|
||||
this.glow.material.dispose();
|
||||
this.sprite.material.map.dispose();
|
||||
this.sprite.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function initAgents(sceneRef) {
|
||||
scene = sceneRef;
|
||||
|
||||
AGENT_DEFS.forEach(def => {
|
||||
const agent = new Agent(def);
|
||||
agents.set(def.id, agent);
|
||||
scene.add(agent.group);
|
||||
});
|
||||
|
||||
buildConnectionLines();
|
||||
}
|
||||
|
||||
function buildConnectionLines() {
|
||||
// Dispose old line geometries before removing
|
||||
connectionLines.forEach(l => {
|
||||
scene.remove(l);
|
||||
l.geometry.dispose();
|
||||
// Material is shared — do NOT dispose here
|
||||
});
|
||||
connectionLines = [];
|
||||
|
||||
const agentList = [...agents.values()];
|
||||
|
||||
for (let i = 0; i < agentList.length; i++) {
|
||||
for (let j = i + 1; j < agentList.length; j++) {
|
||||
const a = agentList[i];
|
||||
const b = agentList[j];
|
||||
if (a.position.distanceTo(b.position) <= 14) {
|
||||
const points = [a.position.clone(), b.position.clone()];
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geo, CONNECTION_MAT);
|
||||
connectionLines.push(line);
|
||||
scene.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAgents(time, delta) {
|
||||
agents.forEach(agent => agent.update(time, delta));
|
||||
// Update connection lines to follow agents as they move
|
||||
updateConnectionLines();
|
||||
}
|
||||
|
||||
/** Update connection line endpoints to track moving agents. */
|
||||
function updateConnectionLines() {
|
||||
const agentList = [...agents.values()];
|
||||
let lineIdx = 0;
|
||||
for (let i = 0; i < agentList.length; i++) {
|
||||
for (let j = i + 1; j < agentList.length; j++) {
|
||||
if (lineIdx >= connectionLines.length) return;
|
||||
const a = agentList[i];
|
||||
const b = agentList[j];
|
||||
if (a.position.distanceTo(b.position) <= 20) {
|
||||
const line = connectionLines[lineIdx];
|
||||
const pos = line.geometry.attributes.position;
|
||||
pos.setXYZ(0, a.position.x, a.position.y, a.position.z);
|
||||
pos.setXYZ(1, b.position.x, b.position.y, b.position.z);
|
||||
pos.needsUpdate = true;
|
||||
line.visible = true;
|
||||
lineIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide any excess lines (agents moved apart)
|
||||
for (; lineIdx < connectionLines.length; lineIdx++) {
|
||||
connectionLines[lineIdx].visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an agent toward a position. Used by behavior system and WS commands.
|
||||
* @param {string} agentId
|
||||
* @param {{x: number, z: number}} target
|
||||
* @param {number} [speed=2.0]
|
||||
* @param {Function} [onArrive]
|
||||
*/
|
||||
export function moveAgentTo(agentId, target, speed = 2.0, onArrive = null) {
|
||||
const agent = agents.get(agentId);
|
||||
if (agent) agent.moveTo(target, speed, onArrive);
|
||||
}
|
||||
|
||||
/** Stop an agent's movement. */
|
||||
export function stopAgentMovement(agentId) {
|
||||
const agent = agents.get(agentId);
|
||||
if (agent) agent.stopMoving();
|
||||
}
|
||||
|
||||
/** Check if an agent is currently in motion. */
|
||||
export function isAgentMoving(agentId) {
|
||||
const agent = agents.get(agentId);
|
||||
return agent ? agent.isMoving : false;
|
||||
}
|
||||
|
||||
export function getAgentCount() {
|
||||
return agents.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily highlight the connection line between two agents.
|
||||
* Used during agent-to-agent conversations (interview, collaboration).
|
||||
*
|
||||
* @param {string} idA — first agent
|
||||
* @param {string} idB — second agent
|
||||
* @param {number} durationMs — how long to keep the line bright (default 4000)
|
||||
*/
|
||||
export function pulseConnection(idA, idB, durationMs = 4000) {
|
||||
// Find the connection line between these two agents
|
||||
const a = agents.get(idA);
|
||||
const b = agents.get(idB);
|
||||
if (!a || !b) return;
|
||||
|
||||
const key = [idA, idB].sort().join('-');
|
||||
|
||||
// Find the line connecting them
|
||||
for (const line of connectionLines) {
|
||||
const pos = line.geometry.attributes.position;
|
||||
if (!pos || pos.count < 2) continue;
|
||||
const p0 = new THREE.Vector3(pos.getX(0), pos.getY(0), pos.getZ(0));
|
||||
const p1 = new THREE.Vector3(pos.getX(1), pos.getY(1), pos.getZ(1));
|
||||
|
||||
const matchesAB = (p0.distanceTo(a.position) < 0.5 && p1.distanceTo(b.position) < 0.5);
|
||||
const matchesBA = (p0.distanceTo(b.position) < 0.5 && p1.distanceTo(a.position) < 0.5);
|
||||
|
||||
if (matchesAB || matchesBA) {
|
||||
// Swap to highlight material
|
||||
line.material = ACTIVE_CONNECTION_MAT;
|
||||
|
||||
// Clear any existing timer for this pair
|
||||
if (pulseTimers.has(key)) {
|
||||
clearTimeout(pulseTimers.get(key));
|
||||
}
|
||||
|
||||
// Reset after duration
|
||||
const timer = setTimeout(() => {
|
||||
line.material = CONNECTION_MAT;
|
||||
pulseTimers.delete(key);
|
||||
}, durationMs);
|
||||
pulseTimers.set(key, timer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setAgentState(agentId, state) {
|
||||
const agent = agents.get(agentId);
|
||||
if (agent) agent.setState(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set wallet health for an agent (Issue #15).
|
||||
* @param {string} agentId
|
||||
* @param {number} health — 0.0 (broke) to 1.0 (full)
|
||||
*/
|
||||
export function setAgentWalletHealth(agentId, health) {
|
||||
const agent = agents.get(agentId);
|
||||
if (agent) agent.setWalletHealth(health);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an agent's world position (for satflow particle targeting).
|
||||
* @param {string} agentId
|
||||
* @returns {THREE.Vector3|null}
|
||||
*/
|
||||
export function getAgentPosition(agentId) {
|
||||
const agent = agents.get(agentId);
|
||||
return agent ? agent.position.clone() : null;
|
||||
}
|
||||
|
||||
export function getAgentDefs() {
|
||||
return [...agents.values()].map(a => ({
|
||||
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic agent hot-add (Issue #12).
|
||||
*
|
||||
* Spawns a new 3D agent at runtime when the backend sends an agent_joined event.
|
||||
* If x/z are not provided, the agent is auto-placed in the next available slot
|
||||
* on a circle around the origin (radius 8) to avoid overlapping existing agents.
|
||||
*
|
||||
* @param {object} def — Agent definition { id, label, color, role, direction, x, z }
|
||||
* @returns {boolean} true if added, false if agent with that id already exists
|
||||
*/
|
||||
export function addAgent(def) {
|
||||
if (agents.has(def.id)) {
|
||||
console.warn('[Agents] Agent', def.id, 'already exists — skipping hot-add');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-place if no position given
|
||||
if (def.x == null || def.z == null) {
|
||||
const placed = autoPlace();
|
||||
def.x = placed.x;
|
||||
def.z = placed.z;
|
||||
}
|
||||
|
||||
const agent = new Agent(def);
|
||||
agents.set(def.id, agent);
|
||||
scene.add(agent.group);
|
||||
|
||||
// Rebuild connection lines to include the new agent
|
||||
buildConnectionLines();
|
||||
|
||||
console.info('[Agents] Hot-added agent:', def.id, 'at', def.x, def.z);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an unoccupied position on a circle around the origin.
|
||||
* Tries radius 8 first (same ring as the original 4), then expands.
|
||||
*/
|
||||
function autoPlace() {
|
||||
const existing = [...agents.values()].map(a => a.position);
|
||||
const RADIUS_START = 8;
|
||||
const RADIUS_STEP = 4;
|
||||
const ANGLE_STEP = Math.PI / 6; // 30° increments = 12 slots per ring
|
||||
const MIN_DISTANCE = 3; // minimum gap between agents
|
||||
|
||||
for (let r = RADIUS_START; r <= RADIUS_START + RADIUS_STEP * 3; r += RADIUS_STEP) {
|
||||
for (let angle = 0; angle < Math.PI * 2; angle += ANGLE_STEP) {
|
||||
const x = Math.round(r * Math.sin(angle) * 10) / 10;
|
||||
const z = Math.round(r * Math.cos(angle) * 10) / 10;
|
||||
const candidate = new THREE.Vector3(x, 0, z);
|
||||
const tooClose = existing.some(p => p.distanceTo(candidate) < MIN_DISTANCE);
|
||||
if (!tooClose) {
|
||||
return { x, z };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: random offset if all slots taken (very unlikely)
|
||||
return { x: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an agent from the scene and dispose its resources.
|
||||
* Useful for agent_left events.
|
||||
*
|
||||
* @param {string} agentId
|
||||
* @returns {boolean} true if removed
|
||||
*/
|
||||
export function removeAgent(agentId) {
|
||||
const agent = agents.get(agentId);
|
||||
if (!agent) return false;
|
||||
|
||||
scene.remove(agent.group);
|
||||
agent.dispose();
|
||||
agents.delete(agentId);
|
||||
buildConnectionLines();
|
||||
|
||||
console.info('[Agents] Removed agent:', agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot current agent states for preservation across WebGL context loss.
|
||||
* @returns {Object.<string,string>} agentId → state string
|
||||
*/
|
||||
export function getAgentStates() {
|
||||
const snapshot = {};
|
||||
for (const [id, agent] of agents) {
|
||||
snapshot[id] = agent.state || 'idle';
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reapply a state snapshot after world rebuild.
|
||||
* @param {Object.<string,string>} snapshot
|
||||
*/
|
||||
export function applyAgentStates(snapshot) {
|
||||
if (!snapshot) return;
|
||||
for (const [id, state] of Object.entries(snapshot)) {
|
||||
const agent = agents.get(id);
|
||||
if (agent) agent.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all agent resources (used on world teardown).
|
||||
*/
|
||||
export function disposeAgents() {
|
||||
// Dispose connection line geometries first
|
||||
connectionLines.forEach(l => {
|
||||
scene.remove(l);
|
||||
l.geometry.dispose();
|
||||
});
|
||||
connectionLines = [];
|
||||
|
||||
for (const [id, agent] of agents) {
|
||||
scene.remove(agent.group);
|
||||
agent.dispose();
|
||||
}
|
||||
agents.clear();
|
||||
}
|
||||
212
frontend/js/ambient.js
Normal file
212
frontend/js/ambient.js
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* ambient.js — Mood-driven scene atmosphere.
|
||||
*
|
||||
* Timmy's mood (calm, focused, excited, contemplative, stressed)
|
||||
* smoothly transitions the scene's lighting color temperature,
|
||||
* fog density, rain intensity, and ambient sound cues.
|
||||
*
|
||||
* Resolves Issue #43 — Ambient state system
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
/* ── Mood definitions ── */
|
||||
|
||||
const MOODS = {
|
||||
calm: {
|
||||
fogDensity: 0.035,
|
||||
fogColor: new THREE.Color(0x000000),
|
||||
ambientColor: new THREE.Color(0x001a00),
|
||||
ambientIntensity: 0.6,
|
||||
pointColor: new THREE.Color(0x00ff41),
|
||||
pointIntensity: 2,
|
||||
rainSpeed: 1.0,
|
||||
rainOpacity: 0.7,
|
||||
starOpacity: 0.5,
|
||||
},
|
||||
focused: {
|
||||
fogDensity: 0.025,
|
||||
fogColor: new THREE.Color(0x000500),
|
||||
ambientColor: new THREE.Color(0x002200),
|
||||
ambientIntensity: 0.8,
|
||||
pointColor: new THREE.Color(0x00ff88),
|
||||
pointIntensity: 2.5,
|
||||
rainSpeed: 0.7,
|
||||
rainOpacity: 0.5,
|
||||
starOpacity: 0.6,
|
||||
},
|
||||
excited: {
|
||||
fogDensity: 0.02,
|
||||
fogColor: new THREE.Color(0x050500),
|
||||
ambientColor: new THREE.Color(0x1a1a00),
|
||||
ambientIntensity: 1.0,
|
||||
pointColor: new THREE.Color(0x44ff44),
|
||||
pointIntensity: 3.5,
|
||||
rainSpeed: 1.8,
|
||||
rainOpacity: 0.9,
|
||||
starOpacity: 0.8,
|
||||
},
|
||||
contemplative: {
|
||||
fogDensity: 0.05,
|
||||
fogColor: new THREE.Color(0x000005),
|
||||
ambientColor: new THREE.Color(0x000a1a),
|
||||
ambientIntensity: 0.4,
|
||||
pointColor: new THREE.Color(0x2288cc),
|
||||
pointIntensity: 1.5,
|
||||
rainSpeed: 0.4,
|
||||
rainOpacity: 0.4,
|
||||
starOpacity: 0.7,
|
||||
},
|
||||
stressed: {
|
||||
fogDensity: 0.015,
|
||||
fogColor: new THREE.Color(0x050000),
|
||||
ambientColor: new THREE.Color(0x1a0500),
|
||||
ambientIntensity: 0.5,
|
||||
pointColor: new THREE.Color(0xff4422),
|
||||
pointIntensity: 3.0,
|
||||
rainSpeed: 2.5,
|
||||
rainOpacity: 1.0,
|
||||
starOpacity: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
/* ── State ── */
|
||||
|
||||
let scene = null;
|
||||
let ambientLt = null;
|
||||
let pointLt = null;
|
||||
|
||||
let currentMood = 'calm';
|
||||
let targetMood = 'calm';
|
||||
let blendT = 1.0; // 0→1, 1 = fully at target
|
||||
const BLEND_SPEED = 0.4; // units per second — smooth ~2.5s transition
|
||||
|
||||
// Snapshot of the "from" state when a transition starts
|
||||
let fromState = null;
|
||||
|
||||
/* ── External handles for effects.js integration ── */
|
||||
let _rainSpeedMul = 1.0;
|
||||
let _rainOpacity = 0.7;
|
||||
let _starOpacity = 0.5;
|
||||
|
||||
export function getRainSpeedMultiplier() { return _rainSpeedMul; }
|
||||
export function getRainOpacity() { return _rainOpacity; }
|
||||
export function getStarOpacity() { return _starOpacity; }
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
/**
|
||||
* Bind ambient system to the scene's lights.
|
||||
* Must be called after initWorld() creates the scene.
|
||||
*/
|
||||
export function initAmbient(scn) {
|
||||
scene = scn;
|
||||
// Find the ambient and point lights created by world.js
|
||||
scene.traverse(obj => {
|
||||
if (obj.isAmbientLight && !ambientLt) ambientLt = obj;
|
||||
if (obj.isPointLight && !pointLt) pointLt = obj;
|
||||
});
|
||||
// Initialize from calm state
|
||||
_applyMood(MOODS.calm, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mood, triggering a smooth transition.
|
||||
* @param {string} mood — one of: calm, focused, excited, contemplative, stressed
|
||||
*/
|
||||
export function setAmbientState(mood) {
|
||||
if (!MOODS[mood] || mood === targetMood) return;
|
||||
|
||||
// Snapshot current interpolated state as the "from"
|
||||
fromState = _snapshot();
|
||||
currentMood = targetMood;
|
||||
targetMood = mood;
|
||||
blendT = 0;
|
||||
}
|
||||
|
||||
/** Get the current mood label. */
|
||||
export function getAmbientMood() {
|
||||
return blendT >= 1 ? targetMood : `${currentMood}→${targetMood}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame update — call from the render loop.
|
||||
* @param {number} delta — seconds since last frame
|
||||
*/
|
||||
export function updateAmbient(delta) {
|
||||
if (blendT >= 1) return; // nothing to interpolate
|
||||
|
||||
blendT = Math.min(1, blendT + BLEND_SPEED * delta);
|
||||
const t = _ease(blendT);
|
||||
const target = MOODS[targetMood] || MOODS.calm;
|
||||
|
||||
if (fromState) {
|
||||
_interpolate(fromState, target, t);
|
||||
}
|
||||
|
||||
if (blendT >= 1) {
|
||||
fromState = null; // transition complete
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose ambient state. */
|
||||
export function disposeAmbient() {
|
||||
scene = null;
|
||||
ambientLt = null;
|
||||
pointLt = null;
|
||||
fromState = null;
|
||||
blendT = 1;
|
||||
currentMood = 'calm';
|
||||
targetMood = 'calm';
|
||||
}
|
||||
|
||||
/* ── Internals ── */
|
||||
|
||||
function _ease(t) {
|
||||
// Smooth ease-in-out
|
||||
return t < 0.5
|
||||
? 2 * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
}
|
||||
|
||||
function _snapshot() {
|
||||
return {
|
||||
fogDensity: scene?.fog?.density ?? 0.035,
|
||||
fogColor: scene?.fog?.color?.clone() ?? new THREE.Color(0x000000),
|
||||
ambientColor: ambientLt?.color?.clone() ?? new THREE.Color(0x001a00),
|
||||
ambientIntensity: ambientLt?.intensity ?? 0.6,
|
||||
pointColor: pointLt?.color?.clone() ?? new THREE.Color(0x00ff41),
|
||||
pointIntensity: pointLt?.intensity ?? 2,
|
||||
rainSpeed: _rainSpeedMul,
|
||||
rainOpacity: _rainOpacity,
|
||||
starOpacity: _starOpacity,
|
||||
};
|
||||
}
|
||||
|
||||
function _interpolate(from, to, t) {
|
||||
// Fog
|
||||
if (scene?.fog) {
|
||||
scene.fog.density = THREE.MathUtils.lerp(from.fogDensity, to.fogDensity, t);
|
||||
scene.fog.color.copy(from.fogColor).lerp(to.fogColor, t);
|
||||
}
|
||||
|
||||
// Ambient light
|
||||
if (ambientLt) {
|
||||
ambientLt.color.copy(from.ambientColor).lerp(to.ambientColor, t);
|
||||
ambientLt.intensity = THREE.MathUtils.lerp(from.ambientIntensity, to.ambientIntensity, t);
|
||||
}
|
||||
|
||||
// Point light
|
||||
if (pointLt) {
|
||||
pointLt.color.copy(from.pointColor).lerp(to.pointColor, t);
|
||||
pointLt.intensity = THREE.MathUtils.lerp(from.pointIntensity, to.pointIntensity, t);
|
||||
}
|
||||
|
||||
// Rain / star params (consumed by effects.js)
|
||||
_rainSpeedMul = THREE.MathUtils.lerp(from.rainSpeed, to.rainSpeed, t);
|
||||
_rainOpacity = THREE.MathUtils.lerp(from.rainOpacity, to.rainOpacity, t);
|
||||
_starOpacity = THREE.MathUtils.lerp(from.starOpacity, to.starOpacity, t);
|
||||
}
|
||||
|
||||
function _applyMood(mood, t) {
|
||||
_interpolate(mood, mood, t); // apply directly
|
||||
}
|
||||
360
frontend/js/avatar.js
Normal file
360
frontend/js/avatar.js
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* avatar.js — Visitor avatar with FPS movement and PiP dual-camera.
|
||||
*
|
||||
* Exports:
|
||||
* initAvatar(scene, camera, renderer) — create avatar + PiP, bind input
|
||||
* updateAvatar(delta) — move avatar, sync FP camera
|
||||
* getAvatarMainCamera() — returns the camera for the current main view
|
||||
* renderAvatarPiP(scene) — render the PiP after main render
|
||||
* disposeAvatar() — cleanup everything
|
||||
* getAvatarPosition() — { x, z, yaw } for presence messages
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const MOVE_SPEED = 8;
|
||||
const TURN_SPEED = 0.003;
|
||||
const EYE_HEIGHT = 2.2;
|
||||
const AVATAR_COLOR = 0x00ffaa;
|
||||
const WORLD_BOUNDS = 45;
|
||||
|
||||
// Module state
|
||||
let scene, orbitCamera, renderer;
|
||||
let group, fpCamera;
|
||||
let pipCanvas, pipRenderer, pipLabel;
|
||||
let activeView = 'third'; // 'first' or 'third' for main viewport
|
||||
let yaw = 0; // face -Z toward center
|
||||
|
||||
// Input state
|
||||
const keys = {};
|
||||
let isMouseLooking = false;
|
||||
let touchId = null;
|
||||
let touchStartX = 0, touchStartY = 0;
|
||||
let touchDeltaX = 0, touchDeltaY = 0;
|
||||
|
||||
// Bound handlers (for removal on dispose)
|
||||
let _onKeyDown, _onKeyUp, _onMouseDown, _onMouseUp, _onMouseMove, _onContextMenu;
|
||||
let _onTouchStart, _onTouchMove, _onTouchEnd;
|
||||
let abortController;
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function initAvatar(_scene, _orbitCamera, _renderer) {
|
||||
scene = _scene;
|
||||
orbitCamera = _orbitCamera;
|
||||
renderer = _renderer;
|
||||
activeView = 'third';
|
||||
yaw = 0;
|
||||
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
_buildAvatar();
|
||||
_buildFPCamera();
|
||||
_buildPiP();
|
||||
_bindInput(signal);
|
||||
}
|
||||
|
||||
export function updateAvatar(delta) {
|
||||
if (!group) return;
|
||||
if (document.activeElement?.tagName === 'INPUT' ||
|
||||
document.activeElement?.tagName === 'TEXTAREA') return;
|
||||
|
||||
let mx = 0, mz = 0;
|
||||
if (keys['w']) mz += 1;
|
||||
if (keys['s']) mz -= 1;
|
||||
if (keys['a']) mx -= 1;
|
||||
if (keys['d']) mx += 1;
|
||||
if (keys['ArrowUp']) mz += 1;
|
||||
if (keys['ArrowDown']) mz -= 1;
|
||||
// ArrowLeft/Right only turn (handled below)
|
||||
|
||||
mx += touchDeltaX;
|
||||
mz -= touchDeltaY;
|
||||
|
||||
if (keys['ArrowLeft']) yaw += 1.5 * delta;
|
||||
if (keys['ArrowRight']) yaw -= 1.5 * delta;
|
||||
|
||||
if (mx !== 0 || mz !== 0) {
|
||||
const len = Math.sqrt(mx * mx + mz * mz);
|
||||
mx /= len;
|
||||
mz /= len;
|
||||
const speed = MOVE_SPEED * delta;
|
||||
// Forward = -Z at yaw=0 (Three.js default)
|
||||
const fwdX = -Math.sin(yaw);
|
||||
const fwdZ = -Math.cos(yaw);
|
||||
const rightX = Math.cos(yaw);
|
||||
const rightZ = -Math.sin(yaw);
|
||||
group.position.x += (mx * rightX + mz * fwdX) * speed;
|
||||
group.position.z += (mx * rightZ + mz * fwdZ) * speed;
|
||||
}
|
||||
|
||||
// Clamp to world bounds
|
||||
group.position.x = Math.max(-WORLD_BOUNDS, Math.min(WORLD_BOUNDS, group.position.x));
|
||||
group.position.z = Math.max(-WORLD_BOUNDS, Math.min(WORLD_BOUNDS, group.position.z));
|
||||
|
||||
// Avatar rotation
|
||||
group.rotation.y = yaw;
|
||||
|
||||
// FP camera follows avatar head
|
||||
fpCamera.position.set(
|
||||
group.position.x,
|
||||
group.position.y + EYE_HEIGHT,
|
||||
group.position.z,
|
||||
);
|
||||
fpCamera.rotation.set(0, yaw, 0, 'YXZ');
|
||||
}
|
||||
|
||||
export function getAvatarMainCamera() {
|
||||
return activeView === 'first' ? fpCamera : orbitCamera;
|
||||
}
|
||||
|
||||
export function renderAvatarPiP(_scene) {
|
||||
if (!pipRenderer || !_scene) return;
|
||||
const cam = activeView === 'third' ? fpCamera : orbitCamera;
|
||||
pipRenderer.render(_scene, cam);
|
||||
}
|
||||
|
||||
export function getAvatarPosition() {
|
||||
if (!group) return { x: 0, z: 0, yaw: 0 };
|
||||
return {
|
||||
x: Math.round(group.position.x * 10) / 10,
|
||||
z: Math.round(group.position.z * 10) / 10,
|
||||
yaw: Math.round(yaw * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function disposeAvatar() {
|
||||
if (abortController) abortController.abort();
|
||||
|
||||
if (group) {
|
||||
group.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (child.material.map) child.material.map.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
});
|
||||
scene?.remove(group);
|
||||
group = null;
|
||||
}
|
||||
|
||||
if (pipRenderer) { pipRenderer.dispose(); pipRenderer = null; }
|
||||
pipCanvas?.remove();
|
||||
pipLabel?.remove();
|
||||
pipCanvas = null;
|
||||
pipLabel = null;
|
||||
}
|
||||
|
||||
// ── Internal builders ──
|
||||
|
||||
function _buildAvatar() {
|
||||
group = new THREE.Group();
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: AVATAR_COLOR,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
});
|
||||
|
||||
// Head — icosahedron
|
||||
const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.35, 1), mat);
|
||||
head.position.y = 3.0;
|
||||
group.add(head);
|
||||
|
||||
// Torso
|
||||
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.2, 0.4), mat);
|
||||
torso.position.y = 1.9;
|
||||
group.add(torso);
|
||||
|
||||
// Legs
|
||||
for (const x of [-0.2, 0.2]) {
|
||||
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.1, 0.2), mat);
|
||||
leg.position.set(x, 0.65, 0);
|
||||
group.add(leg);
|
||||
}
|
||||
|
||||
// Arms
|
||||
for (const x of [-0.55, 0.55]) {
|
||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.18, 1.0, 0.18), mat);
|
||||
arm.position.set(x, 1.9, 0);
|
||||
group.add(arm);
|
||||
}
|
||||
|
||||
// Glow
|
||||
const glow = new THREE.PointLight(AVATAR_COLOR, 0.8, 8);
|
||||
glow.position.y = 3.0;
|
||||
group.add(glow);
|
||||
|
||||
// Label
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = '600 28px "Courier New", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#00ffaa';
|
||||
ctx.shadowColor = '#00ffaa';
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillText('YOU', 128, 32);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(4, 1, 1);
|
||||
sprite.position.y = 3.8;
|
||||
group.add(sprite);
|
||||
|
||||
// Spawn at world edge facing center
|
||||
group.position.set(0, 0, 22);
|
||||
scene.add(group);
|
||||
}
|
||||
|
||||
function _buildFPCamera() {
|
||||
fpCamera = new THREE.PerspectiveCamera(
|
||||
70,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1, 500,
|
||||
);
|
||||
window.addEventListener('resize', () => {
|
||||
fpCamera.aspect = window.innerWidth / window.innerHeight;
|
||||
fpCamera.updateProjectionMatrix();
|
||||
});
|
||||
}
|
||||
|
||||
function _buildPiP() {
|
||||
const W = 220, H = 150;
|
||||
|
||||
pipCanvas = document.createElement('canvas');
|
||||
pipCanvas.id = 'pip-viewport';
|
||||
pipCanvas.width = W * Math.min(window.devicePixelRatio, 2);
|
||||
pipCanvas.height = H * Math.min(window.devicePixelRatio, 2);
|
||||
Object.assign(pipCanvas.style, {
|
||||
position: 'fixed',
|
||||
bottom: '16px',
|
||||
right: '16px',
|
||||
width: W + 'px',
|
||||
height: H + 'px',
|
||||
border: '1px solid rgba(0,255,65,0.5)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
zIndex: '100',
|
||||
boxShadow: '0 0 20px rgba(0,255,65,0.15), inset 0 0 20px rgba(0,0,0,0.5)',
|
||||
});
|
||||
document.body.appendChild(pipCanvas);
|
||||
|
||||
pipRenderer = new THREE.WebGLRenderer({ canvas: pipCanvas, antialias: false });
|
||||
pipRenderer.setSize(W, H);
|
||||
pipRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
// Label
|
||||
pipLabel = document.createElement('div');
|
||||
pipLabel.id = 'pip-label';
|
||||
Object.assign(pipLabel.style, {
|
||||
position: 'fixed',
|
||||
bottom: (16 + H + 4) + 'px',
|
||||
right: '16px',
|
||||
color: 'rgba(0,255,65,0.6)',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
letterSpacing: '2px',
|
||||
zIndex: '100',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
_updatePipLabel();
|
||||
document.body.appendChild(pipLabel);
|
||||
|
||||
// Swap on click/tap
|
||||
pipCanvas.addEventListener('click', _swapViews);
|
||||
pipCanvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_swapViews();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
function _updatePipLabel() {
|
||||
if (pipLabel) {
|
||||
pipLabel.textContent = activeView === 'third' ? '◉ 1ST PERSON' : '◉ 3RD PERSON';
|
||||
}
|
||||
}
|
||||
|
||||
function _swapViews() {
|
||||
activeView = activeView === 'third' ? 'first' : 'third';
|
||||
_updatePipLabel();
|
||||
if (group) group.visible = activeView === 'third';
|
||||
}
|
||||
|
||||
// ── Input ──
|
||||
|
||||
function _bindInput(signal) {
|
||||
_onKeyDown = (e) => {
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
keys[k] = true;
|
||||
if (document.activeElement?.tagName === 'INPUT' ||
|
||||
document.activeElement?.tagName === 'TEXTAREA') return;
|
||||
if (['w','a','s','d','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(k)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyUp = (e) => {
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
keys[k] = false;
|
||||
};
|
||||
|
||||
_onMouseDown = (e) => {
|
||||
if (e.button === 2) { isMouseLooking = true; e.preventDefault(); }
|
||||
};
|
||||
|
||||
_onMouseUp = () => { isMouseLooking = false; };
|
||||
|
||||
_onMouseMove = (e) => {
|
||||
if (!isMouseLooking) return;
|
||||
yaw -= e.movementX * TURN_SPEED;
|
||||
};
|
||||
|
||||
_onContextMenu = (e) => e.preventDefault();
|
||||
|
||||
_onTouchStart = (e) => {
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.clientX < window.innerWidth * 0.5 && touchId === null) {
|
||||
touchId = t.identifier;
|
||||
touchStartX = t.clientX;
|
||||
touchStartY = t.clientY;
|
||||
touchDeltaX = 0;
|
||||
touchDeltaY = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onTouchMove = (e) => {
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.identifier === touchId) {
|
||||
touchDeltaX = Math.max(-1, Math.min(1, (t.clientX - touchStartX) / 60));
|
||||
touchDeltaY = Math.max(-1, Math.min(1, (t.clientY - touchStartY) / 60));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onTouchEnd = (e) => {
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.identifier === touchId) {
|
||||
touchId = null;
|
||||
touchDeltaX = 0;
|
||||
touchDeltaY = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', _onKeyDown, { signal });
|
||||
document.addEventListener('keyup', _onKeyUp, { signal });
|
||||
renderer.domElement.addEventListener('mousedown', _onMouseDown, { signal });
|
||||
document.addEventListener('mouseup', _onMouseUp, { signal });
|
||||
renderer.domElement.addEventListener('mousemove', _onMouseMove, { signal });
|
||||
renderer.domElement.addEventListener('contextmenu', _onContextMenu, { signal });
|
||||
renderer.domElement.addEventListener('touchstart', _onTouchStart, { passive: true, signal });
|
||||
renderer.domElement.addEventListener('touchmove', _onTouchMove, { passive: true, signal });
|
||||
renderer.domElement.addEventListener('touchend', _onTouchEnd, { passive: true, signal });
|
||||
}
|
||||
141
frontend/js/bark.js
Normal file
141
frontend/js/bark.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* bark.js — Bark display system for the Workshop.
|
||||
*
|
||||
* Handles incoming bark messages from Timmy and displays them
|
||||
* prominently in the viewport with typing animation and auto-dismiss.
|
||||
*
|
||||
* Resolves Issue #42 — Bark display system
|
||||
*/
|
||||
|
||||
import { appendChatMessage } from './ui.js';
|
||||
import { colorToCss, AGENT_DEFS } from './agent-defs.js';
|
||||
|
||||
const $container = document.getElementById('bark-container');
|
||||
|
||||
const BARK_DISPLAY_MS = 7000; // How long a bark stays visible
|
||||
const BARK_FADE_MS = 600; // Fade-out animation duration
|
||||
const BARK_TYPE_MS = 30; // Ms per character for typing effect
|
||||
const MAX_BARKS = 3; // Max simultaneous barks on screen
|
||||
|
||||
const barkQueue = [];
|
||||
let activeBarkCount = 0;
|
||||
|
||||
/**
|
||||
* Display a bark in the viewport.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.text — The bark text
|
||||
* @param {string} [opts.agentId='timmy'] — Which agent is barking
|
||||
* @param {string} [opts.emotion='calm'] — Emotion tag (calm, excited, uncertain)
|
||||
* @param {string} [opts.color] — Override CSS color
|
||||
*/
|
||||
export function showBark({ text, agentId = 'timmy', emotion = 'calm', color }) {
|
||||
if (!text || !$container) return;
|
||||
|
||||
// Queue if too many active barks
|
||||
if (activeBarkCount >= MAX_BARKS) {
|
||||
barkQueue.push({ text, agentId, emotion, color });
|
||||
return;
|
||||
}
|
||||
|
||||
activeBarkCount++;
|
||||
|
||||
// Resolve agent color
|
||||
const agentDef = AGENT_DEFS.find(d => d.id === agentId);
|
||||
const barkColor = color || (agentDef ? colorToCss(agentDef.color) : '#00ff41');
|
||||
const agentLabel = agentDef ? agentDef.label : agentId.toUpperCase();
|
||||
|
||||
// Create bark element
|
||||
const el = document.createElement('div');
|
||||
el.className = `bark ${emotion}`;
|
||||
el.style.borderLeftColor = barkColor;
|
||||
el.innerHTML = `<div class="bark-agent">${escapeHtml(agentLabel)}</div><span class="bark-text"></span>`;
|
||||
$container.appendChild(el);
|
||||
|
||||
// Typing animation
|
||||
const $text = el.querySelector('.bark-text');
|
||||
let charIndex = 0;
|
||||
const typeInterval = setInterval(() => {
|
||||
if (charIndex < text.length) {
|
||||
$text.textContent += text[charIndex];
|
||||
charIndex++;
|
||||
} else {
|
||||
clearInterval(typeInterval);
|
||||
}
|
||||
}, BARK_TYPE_MS);
|
||||
|
||||
// Also log to chat panel as permanent record
|
||||
appendChatMessage(agentLabel, text, barkColor);
|
||||
|
||||
// Auto-dismiss after display time
|
||||
const displayTime = BARK_DISPLAY_MS + (text.length * BARK_TYPE_MS);
|
||||
setTimeout(() => {
|
||||
clearInterval(typeInterval);
|
||||
el.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
el.remove();
|
||||
activeBarkCount--;
|
||||
drainQueue();
|
||||
}, BARK_FADE_MS);
|
||||
}, displayTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process queued barks when a slot opens.
|
||||
*/
|
||||
function drainQueue() {
|
||||
if (barkQueue.length > 0 && activeBarkCount < MAX_BARKS) {
|
||||
const next = barkQueue.shift();
|
||||
showBark(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe text insertion.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Mock barks for demo mode ──
|
||||
|
||||
const DEMO_BARKS = [
|
||||
{ text: 'The Tower watches. The Tower remembers.', emotion: 'calm' },
|
||||
{ text: 'A visitor. Welcome to the Workshop.', emotion: 'calm' },
|
||||
{ text: 'New commit on main. The code evolves.', emotion: 'excited' },
|
||||
{ text: '222 — the number echoes again.', emotion: 'calm' },
|
||||
{ text: 'I sense activity in the repo. Someone is building.', emotion: 'focused' },
|
||||
{ text: 'The chain beats on. Block after block.', emotion: 'contemplative' },
|
||||
{ text: 'Late night session? I know the pattern.', emotion: 'calm' },
|
||||
{ text: 'Sovereignty means running your own mind.', emotion: 'calm' },
|
||||
];
|
||||
|
||||
let demoTimer = null;
|
||||
|
||||
/**
|
||||
* Start periodic demo barks (for mock mode).
|
||||
*/
|
||||
export function startDemoBarks() {
|
||||
if (demoTimer) return;
|
||||
// First bark after 5s, then every 15-25s
|
||||
demoTimer = setTimeout(function nextBark() {
|
||||
const bark = DEMO_BARKS[Math.floor(Math.random() * DEMO_BARKS.length)];
|
||||
showBark({ text: bark.text, agentId: 'alpha', emotion: bark.emotion });
|
||||
demoTimer = setTimeout(nextBark, 15000 + Math.random() * 10000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop demo barks.
|
||||
*/
|
||||
export function stopDemoBarks() {
|
||||
if (demoTimer) {
|
||||
clearTimeout(demoTimer);
|
||||
demoTimer = null;
|
||||
}
|
||||
}
|
||||
413
frontend/js/behaviors.js
Normal file
413
frontend/js/behaviors.js
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* behaviors.js — Autonomous agent behavior system.
|
||||
*
|
||||
* Makes agents proactively alive: wandering, pondering, inspecting scene
|
||||
* objects, conversing with each other, and placing small artifacts.
|
||||
*
|
||||
* Client-side default layer. When a real backend connects via WS, it can
|
||||
* override behaviors with `agent_behavior` messages. The autonomous loop
|
||||
* yields to server-driven behaviors and resumes when they complete.
|
||||
*
|
||||
* Follows the Pip familiar pattern (src/timmy/familiar.py):
|
||||
* - State machine picks behavior + target position
|
||||
* - Movement system (agents.js) handles interpolation
|
||||
* - Visual systems (agents.js, bark.js) handle rendering
|
||||
*
|
||||
* Issue #68
|
||||
*/
|
||||
|
||||
import { AGENT_DEFS } from './agent-defs.js';
|
||||
import {
|
||||
moveAgentTo, stopAgentMovement, isAgentMoving,
|
||||
setAgentState, getAgentPosition, pulseConnection,
|
||||
} from './agents.js';
|
||||
import { showBark } from './bark.js';
|
||||
import { getSceneObjectCount, addSceneObject } from './scene-objects.js';
|
||||
|
||||
/* ── Constants ── */
|
||||
|
||||
const WORLD_RADIUS = 15; // max wander distance from origin
|
||||
const HOME_RADIUS = 3; // "close to home" threshold
|
||||
const APPROACH_DISTANCE = 2.5; // how close agents get to each other
|
||||
const MIN_DECISION_INTERVAL = 0.5; // seconds between behavior ticks (saves CPU)
|
||||
|
||||
/* ── Behavior definitions ── */
|
||||
|
||||
/**
|
||||
* @typedef {'idle'|'wander'|'ponder'|'inspect'|'converse'|'place'|'return_home'} BehaviorType
|
||||
*/
|
||||
|
||||
/** Duration ranges in seconds [min, max] */
|
||||
const DURATIONS = {
|
||||
idle: [5, 15],
|
||||
wander: [8, 20],
|
||||
ponder: [6, 12],
|
||||
inspect: [4, 8],
|
||||
converse: [8, 15],
|
||||
place: [3, 6],
|
||||
return_home: [0, 0], // ends when agent arrives
|
||||
};
|
||||
|
||||
/** Agent personality weights — higher = more likely to choose that behavior.
|
||||
* Each agent gets a distinct personality. */
|
||||
const PERSONALITIES = {
|
||||
timmy: { idle: 1, wander: 3, ponder: 5, inspect: 2, converse: 3, place: 2 },
|
||||
perplexity: { idle: 2, wander: 3, ponder: 2, inspect: 4, converse: 3, place: 1 },
|
||||
replit: { idle: 1, wander: 4, ponder: 1, inspect: 2, converse: 2, place: 4 },
|
||||
kimi: { idle: 2, wander: 3, ponder: 3, inspect: 5, converse: 2, place: 1 },
|
||||
claude: { idle: 2, wander: 2, ponder: 3, inspect: 2, converse: 5, place: 1 },
|
||||
};
|
||||
|
||||
const DEFAULT_PERSONALITY = { idle: 2, wander: 3, ponder: 2, inspect: 2, converse: 3, place: 1 };
|
||||
|
||||
/* ── Bark lines per behavior ── */
|
||||
|
||||
const PONDER_BARKS = [
|
||||
{ text: 'The code reveals its patterns...', emotion: 'contemplative' },
|
||||
{ text: 'What if we approached it differently?', emotion: 'curious' },
|
||||
{ text: 'I see the shape of a solution forming.', emotion: 'focused' },
|
||||
{ text: 'The architecture wants to be simpler.', emotion: 'calm' },
|
||||
{ text: 'Something here deserves deeper thought.', emotion: 'contemplative' },
|
||||
{ text: 'Every constraint is a design decision.', emotion: 'focused' },
|
||||
];
|
||||
|
||||
const CONVERSE_BARKS = [
|
||||
{ text: 'Have you noticed the pattern in the recent commits?', emotion: 'curious' },
|
||||
{ text: 'I think we should refactor this together.', emotion: 'focused' },
|
||||
{ text: 'Your approach to that problem was interesting.', emotion: 'calm' },
|
||||
{ text: 'Let me share what I found.', emotion: 'excited' },
|
||||
{ text: 'We should coordinate on the next sprint.', emotion: 'focused' },
|
||||
];
|
||||
|
||||
const INSPECT_BARKS = [
|
||||
{ text: 'This artifact holds memory...', emotion: 'contemplative' },
|
||||
{ text: 'Interesting construction.', emotion: 'curious' },
|
||||
{ text: 'The world grows richer.', emotion: 'calm' },
|
||||
];
|
||||
|
||||
const PLACE_BARKS = [
|
||||
{ text: 'A marker for what I learned.', emotion: 'calm' },
|
||||
{ text: 'Building the world, one piece at a time.', emotion: 'focused' },
|
||||
{ text: 'This belongs here.', emotion: 'contemplative' },
|
||||
];
|
||||
|
||||
/* ── Artifact templates for place behavior ── */
|
||||
|
||||
const ARTIFACT_TEMPLATES = [
|
||||
{ geometry: 'icosahedron', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'standard', metalness: 0.8, roughness: 0.2 }, animation: [{ type: 'rotate', y: 0.5 }, { type: 'bob', amplitude: 0.1, speed: 1 }] },
|
||||
{ geometry: 'octahedron', scale: { x: 0.25, y: 0.25, z: 0.25 }, material: { type: 'standard', metalness: 0.6, roughness: 0.3 }, animation: [{ type: 'rotate', y: -0.3 }] },
|
||||
{ geometry: 'torus', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'standard', metalness: 0.7, roughness: 0.2 }, animation: [{ type: 'rotate', x: 0.4, y: 0.6 }] },
|
||||
{ geometry: 'tetrahedron', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'phong', shininess: 80 }, animation: [{ type: 'bob', amplitude: 0.15, speed: 0.8 }] },
|
||||
{ geometry: 'sphere', radius: 0.15, material: { type: 'physical', metalness: 0.9, roughness: 0.1, emissive: null, emissiveIntensity: 0.3 }, animation: [{ type: 'pulse', min: 0.9, max: 1.1, speed: 2 }] },
|
||||
];
|
||||
|
||||
/* ── Per-agent behavior state ── */
|
||||
|
||||
class AgentBehavior {
|
||||
constructor(agentId) {
|
||||
this.agentId = agentId;
|
||||
this.personality = PERSONALITIES[agentId] || DEFAULT_PERSONALITY;
|
||||
this.currentBehavior = 'idle';
|
||||
this.behaviorTimer = 0; // seconds remaining in current behavior
|
||||
this.conversePeer = null; // agentId of converse partner
|
||||
this._wsOverride = false; // true when backend is driving behavior
|
||||
this._wsOverrideTimer = 0;
|
||||
this._artifactCount = 0; // prevent artifact spam
|
||||
}
|
||||
|
||||
/** Pick next behavior using weighted random selection. */
|
||||
pickNextBehavior(allBehaviors) {
|
||||
const candidates = Object.entries(this.personality);
|
||||
const totalWeight = candidates.reduce((sum, [, w]) => sum + w, 0);
|
||||
let roll = Math.random() * totalWeight;
|
||||
|
||||
for (const [behavior, weight] of candidates) {
|
||||
roll -= weight;
|
||||
if (roll <= 0) {
|
||||
// Converse requires a free partner
|
||||
if (behavior === 'converse') {
|
||||
const peer = this._findConversePeer(allBehaviors);
|
||||
if (!peer) return 'wander'; // no free partner, wander instead
|
||||
this.conversePeer = peer;
|
||||
const peerBehavior = allBehaviors.get(peer);
|
||||
if (peerBehavior) {
|
||||
peerBehavior.currentBehavior = 'converse';
|
||||
peerBehavior.conversePeer = this.agentId;
|
||||
peerBehavior.behaviorTimer = randRange(...DURATIONS.converse);
|
||||
}
|
||||
}
|
||||
// Place requires scene object count under limit
|
||||
if (behavior === 'place' && (getSceneObjectCount() >= 180 || this._artifactCount >= 5)) {
|
||||
return 'ponder'; // too many objects, ponder instead
|
||||
}
|
||||
return behavior;
|
||||
}
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/** Find another agent that's idle or wandering (available to converse). */
|
||||
_findConversePeer(allBehaviors) {
|
||||
const candidates = [];
|
||||
for (const [id, b] of allBehaviors) {
|
||||
if (id === this.agentId) continue;
|
||||
if (b.currentBehavior === 'idle' || b.currentBehavior === 'wander') {
|
||||
candidates.push(id);
|
||||
}
|
||||
}
|
||||
return candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Module state ── */
|
||||
|
||||
/** @type {Map<string, AgentBehavior>} */
|
||||
const behaviors = new Map();
|
||||
let initialized = false;
|
||||
let decisionAccumulator = 0;
|
||||
|
||||
/* ── Utility ── */
|
||||
|
||||
function randRange(min, max) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomWorldPoint(maxRadius = WORLD_RADIUS) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * maxRadius; // sqrt for uniform distribution
|
||||
return { x: Math.cos(angle) * r, z: Math.sin(angle) * r };
|
||||
}
|
||||
|
||||
function colorIntToHex(intColor) {
|
||||
return '#' + intColor.toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
/* ── Behavior executors ── */
|
||||
|
||||
function executeIdle(ab) {
|
||||
setAgentState(ab.agentId, 'idle');
|
||||
stopAgentMovement(ab.agentId);
|
||||
}
|
||||
|
||||
function executeWander(ab) {
|
||||
setAgentState(ab.agentId, 'active');
|
||||
const target = randomWorldPoint(WORLD_RADIUS);
|
||||
moveAgentTo(ab.agentId, target, 1.5 + Math.random() * 1.0);
|
||||
}
|
||||
|
||||
function executePonder(ab) {
|
||||
setAgentState(ab.agentId, 'active');
|
||||
stopAgentMovement(ab.agentId);
|
||||
// Bark a thought
|
||||
const bark = pick(PONDER_BARKS);
|
||||
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
|
||||
}
|
||||
|
||||
function executeInspect(ab) {
|
||||
setAgentState(ab.agentId, 'active');
|
||||
// Move to a random point nearby (simulating "looking at something")
|
||||
const pos = getAgentPosition(ab.agentId);
|
||||
if (pos) {
|
||||
const target = {
|
||||
x: pos.x + (Math.random() - 0.5) * 6,
|
||||
z: pos.z + (Math.random() - 0.5) * 6,
|
||||
};
|
||||
moveAgentTo(ab.agentId, target, 1.0, () => {
|
||||
const bark = pick(INSPECT_BARKS);
|
||||
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function executeConverse(ab) {
|
||||
if (!ab.conversePeer) return;
|
||||
setAgentState(ab.agentId, 'active');
|
||||
const peerPos = getAgentPosition(ab.conversePeer);
|
||||
if (peerPos) {
|
||||
const myPos = getAgentPosition(ab.agentId);
|
||||
if (myPos) {
|
||||
// Move toward peer but stop short
|
||||
const dx = peerPos.x - myPos.x;
|
||||
const dz = peerPos.z - myPos.z;
|
||||
const dist = Math.sqrt(dx * dx + dz * dz);
|
||||
if (dist > APPROACH_DISTANCE) {
|
||||
const ratio = (dist - APPROACH_DISTANCE) / dist;
|
||||
const target = { x: myPos.x + dx * ratio, z: myPos.z + dz * ratio };
|
||||
moveAgentTo(ab.agentId, target, 2.0, () => {
|
||||
pulseConnection(ab.agentId, ab.conversePeer, 6000);
|
||||
const bark = pick(CONVERSE_BARKS);
|
||||
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
|
||||
});
|
||||
} else {
|
||||
pulseConnection(ab.agentId, ab.conversePeer, 6000);
|
||||
const bark = pick(CONVERSE_BARKS);
|
||||
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function executePlace(ab) {
|
||||
setAgentState(ab.agentId, 'active');
|
||||
const pos = getAgentPosition(ab.agentId);
|
||||
if (!pos) return;
|
||||
|
||||
const template = pick(ARTIFACT_TEMPLATES);
|
||||
const agentDef = AGENT_DEFS.find(d => d.id === ab.agentId);
|
||||
const color = agentDef ? colorIntToHex(agentDef.color) : '#00ff41';
|
||||
|
||||
// Place artifact near current position
|
||||
const artPos = {
|
||||
x: pos.x + (Math.random() - 0.5) * 3,
|
||||
y: 0.5 + Math.random() * 0.5,
|
||||
z: pos.z + (Math.random() - 0.5) * 3,
|
||||
};
|
||||
|
||||
const material = { ...template.material, color };
|
||||
if (material.emissive === null) material.emissive = color;
|
||||
|
||||
const artifactId = `artifact_${ab.agentId}_${Date.now()}`;
|
||||
addSceneObject({
|
||||
id: artifactId,
|
||||
geometry: template.geometry,
|
||||
position: artPos,
|
||||
scale: template.scale || undefined,
|
||||
radius: template.radius || undefined,
|
||||
material,
|
||||
animation: template.animation,
|
||||
});
|
||||
|
||||
ab._artifactCount++;
|
||||
|
||||
const bark = pick(PLACE_BARKS);
|
||||
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
|
||||
}
|
||||
|
||||
function executeReturnHome(ab) {
|
||||
setAgentState(ab.agentId, 'idle');
|
||||
const homeDef = AGENT_DEFS.find(d => d.id === ab.agentId);
|
||||
if (homeDef) {
|
||||
moveAgentTo(ab.agentId, { x: homeDef.x, z: homeDef.z }, 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
const EXECUTORS = {
|
||||
idle: executeIdle,
|
||||
wander: executeWander,
|
||||
ponder: executePonder,
|
||||
inspect: executeInspect,
|
||||
converse: executeConverse,
|
||||
place: executePlace,
|
||||
return_home: executeReturnHome,
|
||||
};
|
||||
|
||||
/* ── WS override listener ── */
|
||||
|
||||
function onBehaviorOverride(e) {
|
||||
const msg = e.detail;
|
||||
const ab = behaviors.get(msg.agentId);
|
||||
if (!ab) return;
|
||||
|
||||
ab._wsOverride = true;
|
||||
ab._wsOverrideTimer = msg.duration || 10;
|
||||
ab.currentBehavior = msg.behavior;
|
||||
ab.behaviorTimer = msg.duration || 10;
|
||||
|
||||
// Execute the override behavior
|
||||
if (msg.target) {
|
||||
moveAgentTo(msg.agentId, msg.target, msg.speed || 2.0);
|
||||
}
|
||||
const executor = EXECUTORS[msg.behavior];
|
||||
if (executor && !msg.target) executor(ab);
|
||||
}
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
/**
|
||||
* Initialize the behavior system. Call after initAgents().
|
||||
* @param {boolean} [autoStart=true] — start autonomous behaviors immediately
|
||||
*/
|
||||
export function initBehaviors(autoStart = true) {
|
||||
if (initialized) return;
|
||||
|
||||
for (const def of AGENT_DEFS) {
|
||||
const ab = new AgentBehavior(def.id);
|
||||
// Stagger initial timers so agents don't all act at once
|
||||
ab.behaviorTimer = 2 + Math.random() * 8;
|
||||
behaviors.set(def.id, ab);
|
||||
}
|
||||
|
||||
// Listen for WS behavior overrides
|
||||
window.addEventListener('matrix:agent_behavior', onBehaviorOverride);
|
||||
|
||||
initialized = true;
|
||||
console.info('[Behaviors] Initialized for', behaviors.size, 'agents');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update behavior system. Call each frame with delta in seconds.
|
||||
* @param {number} delta — seconds since last frame
|
||||
*/
|
||||
export function updateBehaviors(delta) {
|
||||
if (!initialized) return;
|
||||
|
||||
// Throttle decision-making to save CPU
|
||||
decisionAccumulator += delta;
|
||||
if (decisionAccumulator < MIN_DECISION_INTERVAL) return;
|
||||
const elapsed = decisionAccumulator;
|
||||
decisionAccumulator = 0;
|
||||
|
||||
for (const [id, ab] of behaviors) {
|
||||
// Tick down WS override
|
||||
if (ab._wsOverride) {
|
||||
ab._wsOverrideTimer -= elapsed;
|
||||
if (ab._wsOverrideTimer <= 0) {
|
||||
ab._wsOverride = false;
|
||||
} else {
|
||||
continue; // skip autonomous decision while WS override is active
|
||||
}
|
||||
}
|
||||
|
||||
// Tick down current behavior timer
|
||||
ab.behaviorTimer -= elapsed;
|
||||
if (ab.behaviorTimer > 0) continue;
|
||||
|
||||
// Time to pick a new behavior
|
||||
const newBehavior = ab.pickNextBehavior(behaviors);
|
||||
ab.currentBehavior = newBehavior;
|
||||
ab.behaviorTimer = randRange(...(DURATIONS[newBehavior] || [5, 10]));
|
||||
|
||||
// For return_home, set a fixed timer based on distance
|
||||
if (newBehavior === 'return_home') {
|
||||
ab.behaviorTimer = 15; // max time to get home
|
||||
}
|
||||
|
||||
// Execute the behavior
|
||||
const executor = EXECUTORS[newBehavior];
|
||||
if (executor) executor(ab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current behavior for an agent.
|
||||
* @param {string} agentId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getAgentBehavior(agentId) {
|
||||
const ab = behaviors.get(agentId);
|
||||
return ab ? ab.currentBehavior : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the behavior system.
|
||||
*/
|
||||
export function disposeBehaviors() {
|
||||
window.removeEventListener('matrix:agent_behavior', onBehaviorOverride);
|
||||
behaviors.clear();
|
||||
initialized = false;
|
||||
decisionAccumulator = 0;
|
||||
}
|
||||
68
frontend/js/config.js
Normal file
68
frontend/js/config.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* config.js — Connection configuration for The Matrix.
|
||||
*
|
||||
* Override at deploy time via URL query params:
|
||||
* ?ws=ws://tower:8080/ws/world-state — WebSocket endpoint
|
||||
* ?token=my-secret — Auth token (Phase 1 shared secret)
|
||||
* ?mock=true — Force mock mode (no real WS)
|
||||
*
|
||||
* Or via Vite env vars:
|
||||
* VITE_WS_URL — WebSocket endpoint
|
||||
* VITE_WS_TOKEN — Auth token
|
||||
* VITE_MOCK_MODE — 'true' to force mock mode
|
||||
*
|
||||
* Priority: URL params > env vars > defaults.
|
||||
*
|
||||
* Resolves Issue #7 — js/config.js
|
||||
* Resolves Issue #11 — WS authentication strategy (Phase 1: shared secret)
|
||||
*/
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
function param(name, envKey, fallback) {
|
||||
return params.get(name)
|
||||
?? (import.meta.env[envKey] || null)
|
||||
?? fallback;
|
||||
}
|
||||
|
||||
export const Config = Object.freeze({
|
||||
/** WebSocket endpoint. Empty string = no live connection (mock mode). */
|
||||
wsUrl: param('ws', 'VITE_WS_URL', ''),
|
||||
|
||||
/** Auth token appended as ?token= query param on WS connect (Issue #11). */
|
||||
wsToken: param('token', 'VITE_WS_TOKEN', ''),
|
||||
|
||||
/** Force mock mode even if wsUrl is set. Useful for local dev. */
|
||||
mockMode: param('mock', 'VITE_MOCK_MODE', 'false') === 'true',
|
||||
|
||||
/** Reconnection timing */
|
||||
reconnectBaseMs: 2000,
|
||||
reconnectMaxMs: 30000,
|
||||
|
||||
/** Heartbeat / zombie detection */
|
||||
heartbeatIntervalMs: 30000,
|
||||
heartbeatTimeoutMs: 5000,
|
||||
|
||||
/**
|
||||
* Computed: should we use the real WebSocket client?
|
||||
* True when wsUrl is non-empty AND mockMode is false.
|
||||
*/
|
||||
get isLive() {
|
||||
return this.wsUrl !== '' && !this.mockMode;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the final WS URL with auth token appended as a query param.
|
||||
* Returns null if not in live mode.
|
||||
*
|
||||
* Result: ws://tower:8080/ws/world-state?token=my-secret
|
||||
*/
|
||||
get wsUrlWithAuth() {
|
||||
if (!this.isLive) return null;
|
||||
const url = new URL(this.wsUrl);
|
||||
if (this.wsToken) {
|
||||
url.searchParams.set('token', this.wsToken);
|
||||
}
|
||||
return url.toString();
|
||||
},
|
||||
});
|
||||
261
frontend/js/demo.js
Normal file
261
frontend/js/demo.js
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* demo.js — Demo autopilot for standalone mode.
|
||||
*
|
||||
* When The Matrix runs without a live backend (mock mode), this module
|
||||
* simulates realistic activity: agent state changes, sat flow payments,
|
||||
* economy updates, chat messages, streaming tokens, and connection pulses.
|
||||
*
|
||||
* The result is a self-running showcase of every visual feature.
|
||||
*
|
||||
* Start with `startDemo()`, stop with `stopDemo()`.
|
||||
*/
|
||||
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { setAgentState, setAgentWalletHealth, getAgentPosition, pulseConnection } from './agents.js';
|
||||
import { triggerSatFlow } from './satflow.js';
|
||||
import { updateEconomyStatus } from './economy.js';
|
||||
import { appendChatMessage, startStreamingMessage } from './ui.js';
|
||||
import { showBark } from './bark.js';
|
||||
import { setAmbientState } from './ambient.js';
|
||||
|
||||
/* ── Demo script data ── */
|
||||
|
||||
const AGENT_IDS = AGENT_DEFS.map(d => d.id);
|
||||
|
||||
const CHAT_LINES = [
|
||||
{ agent: 'timmy', text: 'Cycle 544 complete. All tests green.' },
|
||||
{ agent: 'perplexity', text: 'Smoke test 82/82 pass. Merging to main.' },
|
||||
{ agent: 'replit', text: 'Admin relay refactored. Queue depth nominal.' },
|
||||
{ agent: 'kimi', text: 'Deep research request filed. Scanning sources.' },
|
||||
{ agent: 'claude', text: 'Code review done — looks clean, ship it.' },
|
||||
{ agent: 'timmy', text: 'Invoice for 2,100 sats approved. Paying out.' },
|
||||
{ agent: 'perplexity', text: 'New feature branch pushed: feat/demo-autopilot.' },
|
||||
{ agent: 'replit', text: 'Strfry relay stats: 147 events/sec, 0 errors.' },
|
||||
{ agent: 'kimi', text: 'Found 3 relevant papers. Summarizing now.' },
|
||||
{ agent: 'claude', text: 'Edge case in the reconnect logic — filing a fix.' },
|
||||
{ agent: 'timmy', text: 'The Tower stands. Another block confirmed.' },
|
||||
{ agent: 'perplexity', text: 'Integration doc updated. Protocol v2 complete.' },
|
||||
{ agent: 'replit', text: 'Nostr identity verified. Pubkey registered.' },
|
||||
{ agent: 'kimi', text: 'Research complete. Report saved to workspace.' },
|
||||
{ agent: 'claude', text: 'Streaming tokens working. Cursor blinks on cue.' },
|
||||
];
|
||||
|
||||
const STREAM_LINES = [
|
||||
{ agent: 'timmy', text: 'Analyzing commit history... Pattern detected: build velocity is increasing. The Tower grows stronger each cycle.' },
|
||||
{ agent: 'perplexity', text: 'Running integration checks against the protocol spec. All 9 message types verified. Gateway adapter is ready for the next phase.' },
|
||||
{ agent: 'kimi', text: 'Deep scan complete. Three high-signal sources found. Compiling synthesis with citations and confidence scores.' },
|
||||
{ agent: 'claude', text: 'Reviewing the diff: 47 lines added, 12 removed. Logic is clean. Recommending merge with one minor style suggestion.' },
|
||||
{ agent: 'replit', text: 'Relay metrics nominal. Throughput: 200 events/sec peak, 92 sustained. Memory stable at 128MB. No reconnection events.' },
|
||||
];
|
||||
|
||||
const BARK_LINES = [
|
||||
{ text: 'The Tower watches. The Tower remembers.', agent: 'timmy', emotion: 'calm' },
|
||||
{ text: 'A visitor. Welcome to the Workshop.', agent: 'timmy', emotion: 'calm' },
|
||||
{ text: 'New commit on main. The code evolves.', agent: 'timmy', emotion: 'excited' },
|
||||
{ text: '222 — the number echoes again.', agent: 'timmy', emotion: 'calm' },
|
||||
{ text: 'Sovereignty means running your own mind.', agent: 'timmy', emotion: 'calm' },
|
||||
{ text: 'Five agents, one mission. Build.', agent: 'perplexity', emotion: 'focused' },
|
||||
{ text: 'The relay hums. Events flow like water.', agent: 'replit', emotion: 'contemplative' },
|
||||
];
|
||||
|
||||
/* ── Economy simulation state ── */
|
||||
|
||||
const economyState = {
|
||||
treasury_sats: 500000,
|
||||
treasury_usd: 4.85,
|
||||
agents: {},
|
||||
recent_transactions: [],
|
||||
};
|
||||
|
||||
function initEconomyState() {
|
||||
for (const def of AGENT_DEFS) {
|
||||
economyState.agents[def.id] = {
|
||||
balance_sats: 50000 + Math.floor(Math.random() * 100000),
|
||||
reserved_sats: 20000 + Math.floor(Math.random() * 30000),
|
||||
spent_today_sats: Math.floor(Math.random() * 15000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Timers ── */
|
||||
|
||||
const timers = [];
|
||||
let running = false;
|
||||
|
||||
function schedule(fn, minMs, maxMs) {
|
||||
if (!running) return;
|
||||
const delay = minMs + Math.random() * (maxMs - minMs);
|
||||
const id = setTimeout(() => {
|
||||
if (!running) return;
|
||||
fn();
|
||||
schedule(fn, minMs, maxMs);
|
||||
}, delay);
|
||||
timers.push(id);
|
||||
}
|
||||
|
||||
/* ── Demo behaviors ── */
|
||||
|
||||
function randomAgent() {
|
||||
return AGENT_IDS[Math.floor(Math.random() * AGENT_IDS.length)];
|
||||
}
|
||||
|
||||
function randomPair() {
|
||||
const a = randomAgent();
|
||||
let b = randomAgent();
|
||||
while (b === a) b = randomAgent();
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
/** Cycle agents through active/idle states */
|
||||
function demoStateChange() {
|
||||
const agentId = randomAgent();
|
||||
const state = Math.random() > 0.4 ? 'active' : 'idle';
|
||||
setAgentState(agentId, state);
|
||||
|
||||
// If going active, return to idle after 3-8s
|
||||
if (state === 'active') {
|
||||
const revert = setTimeout(() => {
|
||||
if (running) setAgentState(agentId, 'idle');
|
||||
}, 3000 + Math.random() * 5000);
|
||||
timers.push(revert);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire sat flow between two agents */
|
||||
function demoPayment() {
|
||||
const [from, to] = randomPair();
|
||||
const fromPos = getAgentPosition(from);
|
||||
const toPos = getAgentPosition(to);
|
||||
if (fromPos && toPos) {
|
||||
const amount = 100 + Math.floor(Math.random() * 5000);
|
||||
triggerSatFlow(fromPos, toPos, amount);
|
||||
|
||||
// Update economy state
|
||||
const fromData = economyState.agents[from];
|
||||
const toData = economyState.agents[to];
|
||||
if (fromData) fromData.spent_today_sats += amount;
|
||||
if (toData) toData.balance_sats += amount;
|
||||
economyState.recent_transactions.push({
|
||||
from, to, amount_sats: amount,
|
||||
});
|
||||
if (economyState.recent_transactions.length > 5) {
|
||||
economyState.recent_transactions.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the economy panel with simulated data */
|
||||
function demoEconomy() {
|
||||
// Drift treasury and agent balances slightly
|
||||
economyState.treasury_sats += Math.floor((Math.random() - 0.3) * 2000);
|
||||
economyState.treasury_usd = economyState.treasury_sats / 100000;
|
||||
|
||||
for (const id of AGENT_IDS) {
|
||||
const data = economyState.agents[id];
|
||||
if (data) {
|
||||
data.balance_sats += Math.floor((Math.random() - 0.4) * 1000);
|
||||
data.balance_sats = Math.max(500, data.balance_sats);
|
||||
}
|
||||
}
|
||||
|
||||
updateEconomyStatus({ ...economyState });
|
||||
|
||||
// Update wallet health glow on agents
|
||||
for (const id of AGENT_IDS) {
|
||||
const data = economyState.agents[id];
|
||||
if (data) {
|
||||
const health = Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3));
|
||||
setAgentWalletHealth(id, health);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Show a chat message from a random agent */
|
||||
function demoChat() {
|
||||
const line = pick(CHAT_LINES);
|
||||
const def = AGENT_DEFS.find(d => d.id === line.agent);
|
||||
if (def) {
|
||||
appendChatMessage(def.label, line.text, colorToCss(def.color));
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream a message word-by-word */
|
||||
function demoStream() {
|
||||
const line = pick(STREAM_LINES);
|
||||
const def = AGENT_DEFS.find(d => d.id === line.agent);
|
||||
if (!def) return;
|
||||
|
||||
const stream = startStreamingMessage(def.label, colorToCss(def.color));
|
||||
const words = line.text.split(' ');
|
||||
let i = 0;
|
||||
|
||||
const wordTimer = setInterval(() => {
|
||||
if (!running || i >= words.length) {
|
||||
clearInterval(wordTimer);
|
||||
if (stream && stream.finish) stream.finish();
|
||||
return;
|
||||
}
|
||||
const token = (i === 0 ? '' : ' ') + words[i];
|
||||
if (stream && stream.push) stream.push(token);
|
||||
i++;
|
||||
}, 60 + Math.random() * 80);
|
||||
|
||||
timers.push(wordTimer);
|
||||
}
|
||||
|
||||
/** Pulse a connection line between two agents */
|
||||
function demoPulse() {
|
||||
const [a, b] = randomPair();
|
||||
pulseConnection(a, b, 3000 + Math.random() * 3000);
|
||||
}
|
||||
|
||||
/** Cycle ambient mood */
|
||||
const MOODS = ['calm', 'focused', 'storm', 'night', 'dawn'];
|
||||
let moodIndex = 0;
|
||||
function demoAmbient() {
|
||||
moodIndex = (moodIndex + 1) % MOODS.length;
|
||||
setAmbientState(MOODS[moodIndex]);
|
||||
}
|
||||
|
||||
/** Show a bark */
|
||||
function demoBark() {
|
||||
const line = pick(BARK_LINES);
|
||||
showBark({ text: line.text, agentId: line.agent, emotion: line.emotion });
|
||||
}
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function startDemo() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
|
||||
initEconomyState();
|
||||
|
||||
// Initial economy push so the panel isn't empty
|
||||
demoEconomy();
|
||||
|
||||
// Set initial wallet health
|
||||
for (const id of AGENT_IDS) {
|
||||
setAgentWalletHealth(id, 0.5 + Math.random() * 0.5);
|
||||
}
|
||||
|
||||
// Schedule recurring demo events at realistic intervals
|
||||
schedule(demoStateChange, 2000, 5000); // state changes: every 2-5s
|
||||
schedule(demoPayment, 6000, 15000); // payments: every 6-15s
|
||||
schedule(demoEconomy, 8000, 20000); // economy updates: every 8-20s
|
||||
schedule(demoChat, 5000, 12000); // chat messages: every 5-12s
|
||||
schedule(demoStream, 20000, 40000); // streaming: every 20-40s
|
||||
schedule(demoPulse, 4000, 10000); // connection pulses: every 4-10s
|
||||
schedule(demoBark, 18000, 35000); // barks: every 18-35s
|
||||
schedule(demoAmbient, 30000, 60000); // ambient mood: every 30-60s
|
||||
}
|
||||
|
||||
export function stopDemo() {
|
||||
running = false;
|
||||
for (const id of timers) clearTimeout(id);
|
||||
timers.length = 0;
|
||||
}
|
||||
100
frontend/js/economy.js
Normal file
100
frontend/js/economy.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* economy.js — Wallet & treasury panel for the Matrix HUD.
|
||||
*
|
||||
* Displays the system treasury, per-agent balances, and recent
|
||||
* transactions in a compact panel anchored to the bottom-left
|
||||
* (above the chat). Updated by `economy_status` WS messages.
|
||||
*
|
||||
* Resolves Issue #17 — Wallet & treasury panel
|
||||
*/
|
||||
|
||||
let $panel = null;
|
||||
let latestStatus = null;
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export function initEconomy() {
|
||||
$panel = document.getElementById('economy-panel');
|
||||
if (!$panel) return;
|
||||
_render(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the economy display with fresh data.
|
||||
* @param {object} status — economy_status WS payload
|
||||
*/
|
||||
export function updateEconomyStatus(status) {
|
||||
latestStatus = status;
|
||||
_render(status);
|
||||
}
|
||||
|
||||
export function disposeEconomy() {
|
||||
latestStatus = null;
|
||||
if ($panel) $panel.innerHTML = '';
|
||||
}
|
||||
|
||||
/* ── Render ── */
|
||||
|
||||
function _render(status) {
|
||||
if (!$panel) return;
|
||||
|
||||
if (!status) {
|
||||
$panel.innerHTML = `
|
||||
<div class="econ-header">TREASURY</div>
|
||||
<div class="econ-waiting">Awaiting economy data…</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const treasury = _formatSats(status.treasury_sats || 0);
|
||||
const usd = status.treasury_usd != null ? ` ($${status.treasury_usd.toFixed(2)})` : '';
|
||||
|
||||
// Per-agent rows
|
||||
const agents = status.agents || {};
|
||||
const agentRows = Object.entries(agents).map(([id, data]) => {
|
||||
const bal = _formatSats(data.balance_sats || 0);
|
||||
const spent = _formatSats(data.spent_today_sats || 0);
|
||||
const health = data.balance_sats != null && data.reserved_sats != null
|
||||
? Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3))
|
||||
: 1;
|
||||
const healthColor = health > 0.5 ? '#00ff41' : health > 0.2 ? '#ffaa00' : '#ff4422';
|
||||
|
||||
return `
|
||||
<div class="econ-agent-row">
|
||||
<span class="econ-dot" style="background:${healthColor};box-shadow:0 0 4px ${healthColor}"></span>
|
||||
<span class="econ-agent-name">${_esc(id.toUpperCase())}</span>
|
||||
<span class="econ-agent-bal">${bal}</span>
|
||||
<span class="econ-agent-spent">-${spent}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Recent transactions (last 3)
|
||||
const txns = (status.recent_transactions || []).slice(-3);
|
||||
const txnRows = txns.map(tx => {
|
||||
const amt = _formatSats(tx.amount_sats || 0);
|
||||
const arrow = `${_esc((tx.from || '?').toUpperCase())} → ${_esc((tx.to || '?').toUpperCase())}`;
|
||||
return `<div class="econ-tx">${arrow} <span class="econ-tx-amt">${amt}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
$panel.innerHTML = `
|
||||
<div class="econ-header">
|
||||
<span>TREASURY</span>
|
||||
<span class="econ-total">${treasury}${_esc(usd)}</span>
|
||||
</div>
|
||||
${agentRows ? `<div class="econ-agents">${agentRows}</div>` : ''}
|
||||
${txnRows ? `<div class="econ-txns"><div class="econ-txns-label">RECENT</div>${txnRows}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function _formatSats(sats) {
|
||||
if (sats >= 1000000) return (sats / 1000000).toFixed(1) + 'M ₿';
|
||||
if (sats >= 1000) return (sats / 1000).toFixed(1) + 'k ₿';
|
||||
return sats.toLocaleString() + ' ₿';
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
195
frontend/js/effects.js
vendored
Normal file
195
frontend/js/effects.js
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* effects.js — Matrix rain + starfield particle effects.
|
||||
*
|
||||
* Optimizations (Issue #34):
|
||||
* - Frame skipping on low-tier hardware (update every 2nd frame)
|
||||
* - Bounding sphere set to skip Three.js per-particle frustum test
|
||||
* - Tight typed-array loop with stride-3 addressing (no object allocation)
|
||||
* - Particles recycle to camera-relative region on respawn for density
|
||||
* - drawRange used to soft-limit visible particles if FPS drops
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { getQualityTier } from './quality.js';
|
||||
import { getRainSpeedMultiplier, getRainOpacity, getStarOpacity } from './ambient.js';
|
||||
|
||||
let rainParticles;
|
||||
let rainPositions;
|
||||
let rainVelocities;
|
||||
let rainCount = 0;
|
||||
let skipFrames = 0; // 0 = update every frame, 1 = every 2nd frame
|
||||
let frameCounter = 0;
|
||||
let starfield = null;
|
||||
|
||||
/** Adaptive draw range — reduced if FPS drops below threshold. */
|
||||
let activeCount = 0;
|
||||
const FPS_FLOOR = 20;
|
||||
const ADAPT_INTERVAL_MS = 2000;
|
||||
let lastFpsCheck = 0;
|
||||
let fpsAccum = 0;
|
||||
let fpsSamples = 0;
|
||||
|
||||
export function initEffects(scene) {
|
||||
const tier = getQualityTier();
|
||||
skipFrames = tier === 'low' ? 1 : 0;
|
||||
initMatrixRain(scene, tier);
|
||||
initStarfield(scene, tier);
|
||||
}
|
||||
|
||||
function initMatrixRain(scene, tier) {
|
||||
rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000;
|
||||
activeCount = rainCount;
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(rainCount * 3);
|
||||
const velocities = new Float32Array(rainCount);
|
||||
const colors = new Float32Array(rainCount * 3);
|
||||
|
||||
for (let i = 0; i < rainCount; i++) {
|
||||
const i3 = i * 3;
|
||||
positions[i3] = (Math.random() - 0.5) * 100;
|
||||
positions[i3 + 1] = Math.random() * 50 + 5;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 100;
|
||||
velocities[i] = 0.05 + Math.random() * 0.15;
|
||||
|
||||
const brightness = 0.3 + Math.random() * 0.7;
|
||||
colors[i3] = 0;
|
||||
colors[i3 + 1] = brightness;
|
||||
colors[i3 + 2] = 0;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
// Pre-compute bounding sphere so Three.js skips per-frame recalc.
|
||||
// Rain spans ±50 XZ, 0–60 Y — a sphere from origin with r=80 covers it.
|
||||
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 25, 0), 80);
|
||||
|
||||
rainPositions = positions;
|
||||
rainVelocities = velocities;
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
size: tier === 'low' ? 0.16 : 0.12,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
rainParticles = new THREE.Points(geo, mat);
|
||||
rainParticles.frustumCulled = false; // We manage visibility ourselves
|
||||
scene.add(rainParticles);
|
||||
}
|
||||
|
||||
function initStarfield(scene, tier) {
|
||||
const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
positions[i3] = (Math.random() - 0.5) * 300;
|
||||
positions[i3 + 1] = Math.random() * 80 + 10;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 300;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 40, 0), 200);
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: 0x003300,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
starfield = new THREE.Points(geo, mat);
|
||||
starfield.frustumCulled = false;
|
||||
scene.add(starfield);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed current FPS into the adaptive particle budget.
|
||||
* Called externally from the render loop.
|
||||
*/
|
||||
export function feedFps(fps) {
|
||||
fpsAccum += fps;
|
||||
fpsSamples++;
|
||||
}
|
||||
|
||||
export function updateEffects(_time) {
|
||||
if (!rainParticles) return;
|
||||
|
||||
// On low tier, skip every other frame to halve iteration cost
|
||||
if (skipFrames > 0) {
|
||||
frameCounter++;
|
||||
if (frameCounter % (skipFrames + 1) !== 0) return;
|
||||
}
|
||||
|
||||
const velocityMul = (skipFrames > 0 ? (skipFrames + 1) : 1) * getRainSpeedMultiplier();
|
||||
|
||||
// Apply ambient-driven opacity
|
||||
if (rainParticles.material.opacity !== getRainOpacity()) {
|
||||
rainParticles.material.opacity = getRainOpacity();
|
||||
}
|
||||
if (starfield && starfield.material.opacity !== getStarOpacity()) {
|
||||
starfield.material.opacity = getStarOpacity();
|
||||
}
|
||||
|
||||
// Adaptive particle budget — check every ADAPT_INTERVAL_MS
|
||||
const now = _time;
|
||||
if (now - lastFpsCheck > ADAPT_INTERVAL_MS && fpsSamples > 0) {
|
||||
const avgFps = fpsAccum / fpsSamples;
|
||||
fpsAccum = 0;
|
||||
fpsSamples = 0;
|
||||
lastFpsCheck = now;
|
||||
|
||||
if (avgFps < FPS_FLOOR && activeCount > 200) {
|
||||
// Drop 20% of particles to recover frame rate
|
||||
activeCount = Math.max(200, Math.floor(activeCount * 0.8));
|
||||
} else if (avgFps > FPS_FLOOR + 10 && activeCount < rainCount) {
|
||||
// Recover particles gradually
|
||||
activeCount = Math.min(rainCount, Math.floor(activeCount * 1.1));
|
||||
}
|
||||
rainParticles.geometry.setDrawRange(0, activeCount);
|
||||
}
|
||||
|
||||
// Tight loop — stride-3 addressing, no object allocation
|
||||
const pos = rainPositions;
|
||||
const vel = rainVelocities;
|
||||
const count = activeCount;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const yIdx = i * 3 + 1;
|
||||
pos[yIdx] -= vel[i] * velocityMul;
|
||||
if (pos[yIdx] < -1) {
|
||||
pos[yIdx] = 40 + Math.random() * 20;
|
||||
pos[i * 3] = (Math.random() - 0.5) * 100;
|
||||
pos[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
rainParticles.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all effect resources (used on world teardown).
|
||||
*/
|
||||
export function disposeEffects() {
|
||||
if (rainParticles) {
|
||||
rainParticles.geometry.dispose();
|
||||
rainParticles.material.dispose();
|
||||
rainParticles = null;
|
||||
}
|
||||
if (starfield) {
|
||||
starfield.geometry.dispose();
|
||||
starfield.material.dispose();
|
||||
starfield = null;
|
||||
}
|
||||
rainPositions = null;
|
||||
rainVelocities = null;
|
||||
rainCount = 0;
|
||||
activeCount = 0;
|
||||
frameCounter = 0;
|
||||
fpsAccum = 0;
|
||||
fpsSamples = 0;
|
||||
}
|
||||
340
frontend/js/interaction.js
Normal file
340
frontend/js/interaction.js
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* interaction.js — Camera controls + agent touch/click interaction.
|
||||
*
|
||||
* Adds raycasting so users can tap/click on agents to see their info
|
||||
* and optionally start a conversation. The info popup appears as a
|
||||
* DOM overlay anchored near the clicked agent.
|
||||
*
|
||||
* Resolves Issue #44 — Touch-to-interact
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { getAgentDefs } from './agents.js';
|
||||
import { colorToCss } from './agent-defs.js';
|
||||
|
||||
let controls;
|
||||
let camera;
|
||||
let renderer;
|
||||
let scene;
|
||||
|
||||
/* ── Raycasting state ── */
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
|
||||
/** Currently selected agent id (null if nothing selected) */
|
||||
let selectedAgentId = null;
|
||||
|
||||
/** The info popup DOM element */
|
||||
let $popup = null;
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function initInteraction(cam, ren, scn) {
|
||||
camera = cam;
|
||||
renderer = ren;
|
||||
scene = scn;
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.screenSpacePanning = false;
|
||||
controls.minDistance = 5;
|
||||
controls.maxDistance = 80;
|
||||
controls.maxPolarAngle = Math.PI / 2.1;
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
|
||||
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
||||
|
||||
// Pointer events (works for mouse and touch)
|
||||
renderer.domElement.addEventListener('pointerdown', _onPointerDown, { passive: true });
|
||||
renderer.domElement.addEventListener('pointermove', _onPointerMove, { passive: true });
|
||||
renderer.domElement.addEventListener('pointerup', _onPointerUp, { passive: true });
|
||||
|
||||
_ensurePopup();
|
||||
}
|
||||
|
||||
export function updateControls() {
|
||||
if (controls) controls.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called each frame from the render loop so the popup can track a
|
||||
* selected agent's screen position.
|
||||
*/
|
||||
export function updateInteraction() {
|
||||
if (!selectedAgentId || !$popup || $popup.style.display === 'none') return;
|
||||
_positionPopup(selectedAgentId);
|
||||
}
|
||||
|
||||
/** Deselect the current agent and hide the popup. */
|
||||
export function deselectAgent() {
|
||||
selectedAgentId = null;
|
||||
if ($popup) $popup.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose orbit controls and event listeners (used on world teardown).
|
||||
*/
|
||||
export function disposeInteraction() {
|
||||
if (controls) {
|
||||
controls.dispose();
|
||||
controls = null;
|
||||
}
|
||||
if (renderer) {
|
||||
renderer.domElement.removeEventListener('pointerdown', _onPointerDown);
|
||||
renderer.domElement.removeEventListener('pointermove', _onPointerMove);
|
||||
renderer.domElement.removeEventListener('pointerup', _onPointerUp);
|
||||
}
|
||||
deselectAgent();
|
||||
}
|
||||
|
||||
/* ── Internal: pointer handling ── */
|
||||
|
||||
let _pointerDownPos = { x: 0, y: 0 };
|
||||
let _pointerMoved = false;
|
||||
|
||||
function _onPointerDown(e) {
|
||||
_pointerDownPos.x = e.clientX;
|
||||
_pointerDownPos.y = e.clientY;
|
||||
_pointerMoved = false;
|
||||
}
|
||||
|
||||
function _onPointerMove(e) {
|
||||
const dx = e.clientX - _pointerDownPos.x;
|
||||
const dy = e.clientY - _pointerDownPos.y;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 6) _pointerMoved = true;
|
||||
}
|
||||
|
||||
function _onPointerUp(e) {
|
||||
// Ignore drags — only respond to taps/clicks
|
||||
if (_pointerMoved) return;
|
||||
_handleTap(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/* ── Raycasting ── */
|
||||
|
||||
function _handleTap(clientX, clientY) {
|
||||
if (!camera || !scene) return;
|
||||
|
||||
pointer.x = (clientX / window.innerWidth) * 2 - 1;
|
||||
pointer.y = -(clientY / window.innerHeight) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
|
||||
// Collect all agent group meshes
|
||||
const agentDefs = getAgentDefs();
|
||||
const meshes = [];
|
||||
for (const def of agentDefs) {
|
||||
// Each agent group is a direct child of the scene
|
||||
scene.traverse(child => {
|
||||
if (child.isGroup && child.children.length > 0) {
|
||||
// Check if this group's first mesh color matches an agent
|
||||
const coreMesh = child.children.find(c => c.isMesh && c.geometry?.type === 'IcosahedronGeometry');
|
||||
if (coreMesh) {
|
||||
meshes.push({ mesh: child, agentId: _matchGroupToAgent(child, agentDefs) });
|
||||
}
|
||||
}
|
||||
});
|
||||
break; // only need to traverse once
|
||||
}
|
||||
|
||||
// Raycast against all scene objects, find the nearest agent group or memory orb
|
||||
const allMeshes = [];
|
||||
scene.traverse(obj => { if (obj.isMesh) allMeshes.push(obj); });
|
||||
const intersects = raycaster.intersectObjects(allMeshes, false);
|
||||
|
||||
let hitAgentId = null;
|
||||
let hitFact = null;
|
||||
|
||||
for (const hit of intersects) {
|
||||
// 1. Check if it's a memory orb
|
||||
if (hit.object.id && hit.object.id.startsWith('fact_')) {
|
||||
hitFact = {
|
||||
id: hit.object.id,
|
||||
data: hit.object.userData
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// 2. Walk up to find the agent group
|
||||
let obj = hit.object;
|
||||
while (obj && obj.parent) {
|
||||
const matched = _matchGroupToAgent(obj, agentDefs);
|
||||
if (matched) {
|
||||
hitAgentId = matched;
|
||||
break;
|
||||
}
|
||||
obj = obj.parent;
|
||||
}
|
||||
if (hitAgentId) break;
|
||||
}
|
||||
|
||||
if (hitAgentId) {
|
||||
_selectAgent(hitAgentId);
|
||||
} else if (hitFact) {
|
||||
_selectFact(hitFact.id, hitFact.data);
|
||||
} else {
|
||||
deselectAgent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match a Three.js group to an agent by comparing positions.
|
||||
*/
|
||||
function _matchGroupToAgent(group, agentDefs) {
|
||||
if (!group.isGroup) return null;
|
||||
for (const def of agentDefs) {
|
||||
// Agent positions: (def.x, ~0, def.z) — the group y bobs, so just check xz
|
||||
const dx = Math.abs(group.position.x - (def.position?.x ?? 0));
|
||||
const dz = Math.abs(group.position.z - (def.position?.z ?? 0));
|
||||
// getAgentDefs returns { id, label, role, color, state } — no position.
|
||||
// We need to compare the group position to the known AGENT_DEFS x/z.
|
||||
// Since getAgentDefs doesn't return position, match by finding the icosahedron
|
||||
// core color against agent color.
|
||||
const coreMesh = group.children.find(c => c.isMesh && c.material?.emissive);
|
||||
if (coreMesh) {
|
||||
const meshColor = coreMesh.material.color.getHex();
|
||||
if (meshColor === def.color) return def.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Agent selection & popup ── */
|
||||
|
||||
function _selectAgent(agentId) {
|
||||
selectedAgentId = agentId;
|
||||
const defs = getAgentDefs();
|
||||
const agent = defs.find(d => d.id === agentId);
|
||||
if (!agent) return;
|
||||
|
||||
_ensurePopup();
|
||||
const color = colorToCss(agent.color);
|
||||
const stateLabel = (agent.state || 'idle').toUpperCase();
|
||||
const stateColor = agent.state === 'active' ? '#00ff41' : '#33aa55';
|
||||
|
||||
$popup.innerHTML = `
|
||||
<div class="agent-popup-header" style="border-color:${color}">
|
||||
<span class="agent-popup-name" style="color:${color}">${_esc(agent.label)}</span>
|
||||
<span class="agent-popup-close" id="agent-popup-close">×</span>
|
||||
</div>
|
||||
<div class="agent-popup-role">${_esc(agent.role)}</div>
|
||||
<div class="agent-popup-state" style="color:${stateColor}">● ${stateLabel}</div>
|
||||
<button class="agent-popup-talk" id="agent-popup-talk" style="border-color:${color};color:${color}">
|
||||
TALK →
|
||||
</button>
|
||||
`;
|
||||
$popup.style.display = 'block';
|
||||
|
||||
// Position near agent
|
||||
_positionPopup(agentId);
|
||||
|
||||
// Close button
|
||||
const $close = document.getElementById('agent-popup-close');
|
||||
if ($close) $close.addEventListener('click', deselectAgent);
|
||||
|
||||
// Talk button — focus the chat input and prefill
|
||||
const $talk = document.getElementById('agent-popup-talk');
|
||||
if ($talk) {
|
||||
$talk.addEventListener('click', () => {
|
||||
const $input = document.getElementById('chat-input');
|
||||
if ($input) {
|
||||
$input.focus();
|
||||
$input.placeholder = `Say something to ${agent.label}...`;
|
||||
}
|
||||
deselectAgent();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _selectFact(factId, data) {
|
||||
selectedAgentId = null; // clear agent selection
|
||||
_ensurePopup();
|
||||
|
||||
const categoryColors = {
|
||||
user_pref: '#00ffaa',
|
||||
project: '#00aaff',
|
||||
tool: '#ffaa00',
|
||||
general: '#ffffff',
|
||||
};
|
||||
const color = categoryColors[data.category] || '#cccccc';
|
||||
|
||||
$popup.innerHTML = `
|
||||
<div class="agent-popup-header" style="border-color:${color}">
|
||||
<span class="agent-popup-name" style="color:${color}">Memory Fact</span>
|
||||
<span class="agent-popup-close" id="agent-popup-close">×</span>
|
||||
</div>
|
||||
<div class="agent-popup-role" style="font-style: italic;">Category: ${_esc(data.category || 'general')}</div>
|
||||
<div class="agent-popup-state" style="margin: 8px 0; line-height: 1.4; font-size: 0.9em;">${_esc(data.content)}</div>
|
||||
<div class="agent-popup-state" style="color:#aaa; font-size: 0.8em;">ID: ${_esc(factId)}</div>
|
||||
`;
|
||||
$popup.style.display = 'block';
|
||||
|
||||
_positionPopup(factId);
|
||||
|
||||
const $close = document.getElementById('agent-popup-close');
|
||||
if ($close) $close.addEventListener('click', deselectAgent);
|
||||
}
|
||||
|
||||
function _positionPopup(id) {
|
||||
if (!camera || !renderer || !$popup) return;
|
||||
|
||||
let targetObj = null;
|
||||
scene.traverse(obj => {
|
||||
if (targetObj) return;
|
||||
// If it's an agent ID, we find the group. If it's a fact ID, we find the mesh.
|
||||
if (id.startsWith('fact_')) {
|
||||
if (obj.id === id) targetObj = obj;
|
||||
} else {
|
||||
if (obj.isGroup) {
|
||||
const defs = getAgentDefs();
|
||||
const def = defs.find(d => d.id === id);
|
||||
if (def) {
|
||||
const core = obj.children.find(c => c.isMesh && c.material?.emissive);
|
||||
if (core && core.material.color.getHex() === def.color) {
|
||||
targetObj = obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetObj) return;
|
||||
|
||||
const worldPos = new THREE.Vector3();
|
||||
targetObj.getWorldPosition(worldPos);
|
||||
worldPos.y += 1.5;
|
||||
|
||||
const screenPos = worldPos.clone().project(camera);
|
||||
const hw = window.innerWidth / 2;
|
||||
const hh = window.innerHeight / 2;
|
||||
const sx = screenPos.x * hw + hw;
|
||||
const sy = -screenPos.y * hh + hh;
|
||||
|
||||
if (screenPos.z > 1) {
|
||||
$popup.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const popW = $popup.offsetWidth || 180;
|
||||
const popH = $popup.offsetHeight || 120;
|
||||
const x = Math.min(Math.max(sx - popW / 2, 8), window.innerWidth - popW - 8);
|
||||
const y = Math.min(Math.max(sy - popH - 12, 8), window.innerHeight - popH - 60);
|
||||
|
||||
$popup.style.left = x + 'px';
|
||||
$popup.style.top = y + 'px';
|
||||
}
|
||||
|
||||
/* ── Popup DOM ── */
|
||||
|
||||
function _ensurePopup() {
|
||||
if ($popup) return;
|
||||
$popup = document.createElement('div');
|
||||
$popup.id = 'agent-popup';
|
||||
$popup.style.display = 'none';
|
||||
document.body.appendChild($popup);
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
180
frontend/js/main.js
Normal file
180
frontend/js/main.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import { initWorld, onWindowResize, disposeWorld } from './world.js';
|
||||
import {
|
||||
initAgents, updateAgents, getAgentCount,
|
||||
disposeAgents, getAgentStates, applyAgentStates,
|
||||
} from './agents.js';
|
||||
import { initEffects, updateEffects, disposeEffects, feedFps } from './effects.js';
|
||||
import { initUI, updateUI } from './ui.js';
|
||||
import { initInteraction, updateControls, updateInteraction, disposeInteraction } from './interaction.js';
|
||||
import { initAmbient, updateAmbient, disposeAmbient } from './ambient.js';
|
||||
import { initSatFlow, updateSatFlow, disposeSatFlow } from './satflow.js';
|
||||
import { initEconomy, disposeEconomy } from './economy.js';
|
||||
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
||||
import { initVisitor } from './visitor.js';
|
||||
import { initPresence, disposePresence } from './presence.js';
|
||||
import { initTranscript } from './transcript.js';
|
||||
import { initAvatar, updateAvatar, getAvatarMainCamera, renderAvatarPiP, disposeAvatar } from './avatar.js';
|
||||
import { initSceneObjects, updateSceneObjects, clearSceneObjects } from './scene-objects.js';
|
||||
import { updateZones } from './zones.js';
|
||||
import { initBehaviors, updateBehaviors, disposeBehaviors } from './behaviors.js';
|
||||
|
||||
let running = false;
|
||||
let canvas = null;
|
||||
|
||||
/**
|
||||
* Build (or rebuild) the Three.js world.
|
||||
*
|
||||
* @param {boolean} firstInit
|
||||
* true — first page load: also starts UI, WebSocket, and visitor
|
||||
* false — context-restore reinit: skips UI/WS (they survive context loss)
|
||||
* @param {Object.<string,string>|null} stateSnapshot
|
||||
* Agent state map captured just before teardown; reapplied after initAgents.
|
||||
*/
|
||||
function buildWorld(firstInit, stateSnapshot) {
|
||||
const { scene, camera, renderer } = initWorld(canvas);
|
||||
canvas = renderer.domElement;
|
||||
|
||||
initEffects(scene);
|
||||
initAgents(scene);
|
||||
|
||||
if (stateSnapshot) {
|
||||
applyAgentStates(stateSnapshot);
|
||||
}
|
||||
|
||||
initSceneObjects(scene);
|
||||
initBehaviors(); // autonomous agent behaviors (#68)
|
||||
initAvatar(scene, camera, renderer);
|
||||
initInteraction(camera, renderer, scene);
|
||||
initAmbient(scene);
|
||||
initSatFlow(scene);
|
||||
|
||||
if (firstInit) {
|
||||
initUI();
|
||||
initEconomy();
|
||||
initWebSocket(scene);
|
||||
initVisitor();
|
||||
initPresence();
|
||||
initTranscript();
|
||||
|
||||
// Dismiss loading screen
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) loadingScreen.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Debounce resize to 1 call per frame
|
||||
const ac = new AbortController();
|
||||
let resizeFrame = null;
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeFrame) cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = requestAnimationFrame(() => onWindowResize(camera, renderer));
|
||||
}, { signal: ac.signal });
|
||||
|
||||
let frameCount = 0;
|
||||
let lastFpsTime = performance.now();
|
||||
let currentFps = 0;
|
||||
let rafId = null;
|
||||
|
||||
let lastTime = performance.now();
|
||||
|
||||
running = true;
|
||||
|
||||
function animate() {
|
||||
if (!running) return;
|
||||
rafId = requestAnimationFrame(animate);
|
||||
|
||||
const now = performance.now();
|
||||
const delta = Math.min((now - lastTime) / 1000, 0.1);
|
||||
lastTime = now;
|
||||
frameCount++;
|
||||
if (now - lastFpsTime >= 1000) {
|
||||
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
|
||||
frameCount = 0;
|
||||
lastFpsTime = now;
|
||||
}
|
||||
|
||||
updateControls();
|
||||
updateInteraction();
|
||||
updateAmbient(delta);
|
||||
updateSatFlow(delta);
|
||||
feedFps(currentFps);
|
||||
updateEffects(now);
|
||||
updateAgents(now, delta);
|
||||
updateBehaviors(delta);
|
||||
updateSceneObjects(now, delta);
|
||||
updateZones(null); // portal handler wired via loadWorld in websocket.js
|
||||
|
||||
updateAvatar(delta);
|
||||
updateUI({
|
||||
fps: currentFps,
|
||||
agentCount: getAgentCount(),
|
||||
jobCount: getJobCount(),
|
||||
connectionState: getConnectionState(),
|
||||
});
|
||||
|
||||
renderer.render(scene, getAvatarMainCamera());
|
||||
renderAvatarPiP(scene);
|
||||
}
|
||||
|
||||
// Pause rendering when tab is backgrounded (saves battery on iPad PWA)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
running = false;
|
||||
}
|
||||
} else {
|
||||
if (!running) {
|
||||
running = true;
|
||||
animate();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
animate();
|
||||
|
||||
return { scene, renderer, ac };
|
||||
}
|
||||
|
||||
function teardown({ scene, renderer, ac }) {
|
||||
running = false;
|
||||
ac.abort();
|
||||
disposeAvatar();
|
||||
disposeInteraction();
|
||||
disposeAmbient();
|
||||
disposeSatFlow();
|
||||
disposeEconomy();
|
||||
disposeEffects();
|
||||
disposePresence();
|
||||
clearSceneObjects();
|
||||
disposeBehaviors();
|
||||
disposeAgents();
|
||||
disposeWorld(renderer, scene);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const $overlay = document.getElementById('webgl-recovery-overlay');
|
||||
|
||||
let handle = buildWorld(true, null);
|
||||
|
||||
// WebGL context loss recovery (iPad PWA, GPU driver reset, etc.)
|
||||
canvas.addEventListener('webglcontextlost', event => {
|
||||
event.preventDefault();
|
||||
running = false;
|
||||
if ($overlay) $overlay.style.display = 'flex';
|
||||
});
|
||||
|
||||
canvas.addEventListener('webglcontextrestored', () => {
|
||||
const snapshot = getAgentStates();
|
||||
teardown(handle);
|
||||
handle = buildWorld(false, snapshot);
|
||||
if ($overlay) $overlay.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
// Register service worker only in production builds
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
139
frontend/js/presence.js
Normal file
139
frontend/js/presence.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* presence.js — Agent Presence HUD for The Matrix.
|
||||
*
|
||||
* Shows a live "who's online" panel with connection status indicators,
|
||||
* uptime tracking, and animated pulse dots per agent. Updates every second.
|
||||
*
|
||||
* In mock mode, all built-in agents show as "online" with simulated uptime.
|
||||
* In live mode, the panel reacts to WS events (agent_state, agent_joined, agent_left).
|
||||
*
|
||||
* Resolves Issue #53
|
||||
*/
|
||||
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { getAgentDefs } from './agents.js';
|
||||
import { getConnectionState } from './websocket.js';
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let $panel = null;
|
||||
|
||||
/** @type {Map<string, { online: boolean, since: number }>} */
|
||||
const presence = new Map();
|
||||
|
||||
let updateInterval = null;
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function initPresence() {
|
||||
$panel = document.getElementById('presence-hud');
|
||||
if (!$panel) return;
|
||||
|
||||
// Initialize all built-in agents
|
||||
const now = Date.now();
|
||||
for (const def of AGENT_DEFS) {
|
||||
presence.set(def.id, { online: true, since: now });
|
||||
}
|
||||
|
||||
// Initial render
|
||||
render();
|
||||
|
||||
// Update every second for uptime tickers
|
||||
updateInterval = setInterval(render, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an agent as online (called from websocket.js on agent_joined/agent_register).
|
||||
*/
|
||||
export function setAgentOnline(agentId) {
|
||||
const entry = presence.get(agentId);
|
||||
if (entry) {
|
||||
entry.online = true;
|
||||
entry.since = Date.now();
|
||||
} else {
|
||||
presence.set(agentId, { online: true, since: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an agent as offline (called from websocket.js on agent_left/disconnect).
|
||||
*/
|
||||
export function setAgentOffline(agentId) {
|
||||
const entry = presence.get(agentId);
|
||||
if (entry) {
|
||||
entry.online = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disposePresence() {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = null;
|
||||
}
|
||||
presence.clear();
|
||||
}
|
||||
|
||||
/* ── Internal ── */
|
||||
|
||||
function formatUptime(ms) {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
if (totalSec < 60) return `${totalSec}s`;
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
if (min < 60) return `${min}m ${String(sec).padStart(2, '0')}s`;
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return `${hr}h ${String(remMin).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!$panel) return;
|
||||
|
||||
const connState = getConnectionState();
|
||||
const defs = getAgentDefs();
|
||||
const now = Date.now();
|
||||
|
||||
// In mock mode, all agents are "online"
|
||||
const isMock = connState === 'mock';
|
||||
|
||||
let onlineCount = 0;
|
||||
const rows = [];
|
||||
|
||||
for (const def of defs) {
|
||||
const p = presence.get(def.id);
|
||||
const isOnline = isMock ? true : (p?.online ?? false);
|
||||
if (isOnline) onlineCount++;
|
||||
|
||||
const uptime = isOnline && p ? formatUptime(now - p.since) : '--';
|
||||
const color = colorToCss(def.color);
|
||||
const stateLabel = def.state === 'active' ? 'ACTIVE' : 'IDLE';
|
||||
const dotClass = isOnline ? 'presence-dot online' : 'presence-dot offline';
|
||||
const stateColor = def.state === 'active' ? '#00ff41' : '#33aa55';
|
||||
|
||||
rows.push(
|
||||
`<div class="presence-row">` +
|
||||
`<span class="${dotClass}" style="--agent-color:${color}"></span>` +
|
||||
`<span class="presence-name" style="color:${color}">${escapeHtml(def.label)}</span>` +
|
||||
`<span class="presence-state" style="color:${stateColor}">${stateLabel}</span>` +
|
||||
`<span class="presence-uptime">${uptime}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
const modeLabel = isMock ? 'LOCAL' : (connState === 'connected' ? 'LIVE' : 'OFFLINE');
|
||||
const modeColor = connState === 'connected' ? '#00ff41' : (isMock ? '#33aa55' : '#553300');
|
||||
|
||||
$panel.innerHTML =
|
||||
`<div class="presence-header">` +
|
||||
`<span>PRESENCE</span>` +
|
||||
`<span class="presence-count">${onlineCount}/${defs.length}</span>` +
|
||||
`<span class="presence-mode" style="color:${modeColor}">${modeLabel}</span>` +
|
||||
`</div>` +
|
||||
rows.join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
90
frontend/js/quality.js
Normal file
90
frontend/js/quality.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* quality.js — Detect hardware capability and return a quality tier.
|
||||
*
|
||||
* Tiers:
|
||||
* 'low' — older iPads, phones, low-end GPUs (reduce particles, simpler effects)
|
||||
* 'medium' — mid-range (moderate particle count)
|
||||
* 'high' — desktop, modern iPad Pro (full quality)
|
||||
*
|
||||
* Detection uses a combination of:
|
||||
* - Device pixel ratio (low DPR = likely low-end)
|
||||
* - Logical core count (navigator.hardwareConcurrency)
|
||||
* - Device memory (navigator.deviceMemory, Chrome/Edge only)
|
||||
* - Screen size (small viewport = likely mobile)
|
||||
* - Touch capability (touch + small screen = phone/tablet)
|
||||
* - WebGL renderer string (if available)
|
||||
*/
|
||||
|
||||
let cachedTier = null;
|
||||
|
||||
export function getQualityTier() {
|
||||
if (cachedTier) return cachedTier;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Core count: 1-2 = low, 4 = mid, 8+ = high
|
||||
const cores = navigator.hardwareConcurrency || 2;
|
||||
if (cores >= 8) score += 3;
|
||||
else if (cores >= 4) score += 2;
|
||||
else score += 0;
|
||||
|
||||
// Device memory (Chrome/Edge): < 4GB = low, 4-8 = mid, 8+ = high
|
||||
const mem = navigator.deviceMemory || 4;
|
||||
if (mem >= 8) score += 3;
|
||||
else if (mem >= 4) score += 2;
|
||||
else score += 0;
|
||||
|
||||
// Screen dimensions (logical pixels)
|
||||
const maxDim = Math.max(window.screen.width, window.screen.height);
|
||||
if (maxDim < 768) score -= 1; // phone
|
||||
else if (maxDim >= 1920) score += 1; // large desktop
|
||||
|
||||
// DPR: high DPR on small screens = more GPU work
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (dpr > 2 && maxDim < 1024) score -= 1; // retina phone
|
||||
|
||||
// Touch-only device heuristic
|
||||
const touchOnly = 'ontouchstart' in window && !window.matchMedia('(pointer: fine)').matches;
|
||||
if (touchOnly) score -= 1;
|
||||
|
||||
// Try reading WebGL renderer for GPU hints
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('webgl2');
|
||||
if (gl) {
|
||||
const debugExt = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (debugExt) {
|
||||
const renderer = gl.getParameter(debugExt.UNMASKED_RENDERER_WEBGL).toLowerCase();
|
||||
// Known low-end GPU strings
|
||||
if (renderer.includes('swiftshader') || renderer.includes('llvmpipe') || renderer.includes('software')) {
|
||||
score -= 3; // software renderer
|
||||
}
|
||||
if (renderer.includes('apple gpu') || renderer.includes('apple m')) {
|
||||
score += 2; // Apple Silicon is good
|
||||
}
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
} catch {
|
||||
// Can't probe GPU, use other signals
|
||||
}
|
||||
|
||||
// Map score to tier
|
||||
if (score <= 1) cachedTier = 'low';
|
||||
else if (score <= 4) cachedTier = 'medium';
|
||||
else cachedTier = 'high';
|
||||
|
||||
console.info(`[Matrix Quality] Tier: ${cachedTier} (score: ${score}, cores: ${cores}, mem: ${mem}GB, dpr: ${dpr}, touch: ${touchOnly})`);
|
||||
|
||||
return cachedTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recommended pixel ratio cap for the renderer.
|
||||
*/
|
||||
export function getMaxPixelRatio() {
|
||||
const tier = getQualityTier();
|
||||
if (tier === 'low') return 1;
|
||||
if (tier === 'medium') return 1.5;
|
||||
return 2;
|
||||
}
|
||||
261
frontend/js/satflow.js
Normal file
261
frontend/js/satflow.js
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* satflow.js — Sat flow particle effects for Lightning payments.
|
||||
*
|
||||
* When a payment_flow event arrives, gold particles fly from sender
|
||||
* to receiver along a bezier arc. On arrival, a brief burst radiates
|
||||
* outward from the target agent.
|
||||
*
|
||||
* Resolves Issue #13 — Sat flow particle effects
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
let scene = null;
|
||||
|
||||
/* ── Pool management ── */
|
||||
|
||||
const MAX_ACTIVE_FLOWS = 6;
|
||||
const activeFlows = [];
|
||||
|
||||
/* ── Shared resources ── */
|
||||
|
||||
const SAT_COLOR = new THREE.Color(0xffcc00);
|
||||
const BURST_COLOR = new THREE.Color(0xffee44);
|
||||
|
||||
const particleGeo = new THREE.BufferGeometry();
|
||||
// Pre-build a single-point geometry for instancing via Points
|
||||
const _singleVert = new Float32Array([0, 0, 0]);
|
||||
particleGeo.setAttribute('position', new THREE.BufferAttribute(_singleVert, 3));
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
/**
|
||||
* Initialize the sat flow system.
|
||||
* @param {THREE.Scene} scn
|
||||
*/
|
||||
export function initSatFlow(scn) {
|
||||
scene = scn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a sat flow animation between two world positions.
|
||||
*
|
||||
* @param {THREE.Vector3} fromPos — sender world position
|
||||
* @param {THREE.Vector3} toPos — receiver world position
|
||||
* @param {number} amountSats — payment amount (scales particle count)
|
||||
*/
|
||||
export function triggerSatFlow(fromPos, toPos, amountSats = 100) {
|
||||
if (!scene) return;
|
||||
|
||||
// Evict oldest flow if at capacity
|
||||
if (activeFlows.length >= MAX_ACTIVE_FLOWS) {
|
||||
const old = activeFlows.shift();
|
||||
_cleanupFlow(old);
|
||||
}
|
||||
|
||||
// Particle count: 5-20 based on amount, log-scaled
|
||||
const count = Math.min(20, Math.max(5, Math.round(Math.log10(amountSats + 1) * 5)));
|
||||
|
||||
const flow = _createFlow(fromPos.clone(), toPos.clone(), count);
|
||||
activeFlows.push(flow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame update — advance all active flows.
|
||||
* @param {number} delta — seconds since last frame
|
||||
*/
|
||||
export function updateSatFlow(delta) {
|
||||
for (let i = activeFlows.length - 1; i >= 0; i--) {
|
||||
const flow = activeFlows[i];
|
||||
flow.elapsed += delta;
|
||||
|
||||
if (flow.phase === 'travel') {
|
||||
_updateTravel(flow, delta);
|
||||
if (flow.elapsed >= flow.duration) {
|
||||
flow.phase = 'burst';
|
||||
flow.elapsed = 0;
|
||||
_startBurst(flow);
|
||||
}
|
||||
} else if (flow.phase === 'burst') {
|
||||
_updateBurst(flow, delta);
|
||||
if (flow.elapsed >= flow.burstDuration) {
|
||||
_cleanupFlow(flow);
|
||||
activeFlows.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all sat flow resources.
|
||||
*/
|
||||
export function disposeSatFlow() {
|
||||
for (const flow of activeFlows) _cleanupFlow(flow);
|
||||
activeFlows.length = 0;
|
||||
scene = null;
|
||||
}
|
||||
|
||||
/* ── Internals: Flow lifecycle ── */
|
||||
|
||||
function _createFlow(from, to, count) {
|
||||
// Bezier control point — arc upward
|
||||
const mid = new THREE.Vector3().lerpVectors(from, to, 0.5);
|
||||
mid.y += 3 + from.distanceTo(to) * 0.3;
|
||||
|
||||
// Create particles
|
||||
const positions = new Float32Array(count * 3);
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.boundingSphere = new THREE.Sphere(mid, 50);
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: SAT_COLOR,
|
||||
size: 0.25,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geo, mat);
|
||||
scene.add(points);
|
||||
|
||||
// Per-particle timing offsets (stagger the swarm)
|
||||
const offsets = new Float32Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
offsets[i] = (i / count) * 0.4; // stagger over first 40% of duration
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'travel',
|
||||
elapsed: 0,
|
||||
duration: 1.5 + from.distanceTo(to) * 0.05, // 1.5–2.5s depending on distance
|
||||
from, to, mid,
|
||||
count,
|
||||
points, geo, mat, positions,
|
||||
offsets,
|
||||
burstPoints: null,
|
||||
burstGeo: null,
|
||||
burstMat: null,
|
||||
burstPositions: null,
|
||||
burstVelocities: null,
|
||||
burstDuration: 0.6,
|
||||
};
|
||||
}
|
||||
|
||||
function _updateTravel(flow, _delta) {
|
||||
const { from, to, mid, count, positions, offsets, elapsed, duration } = flow;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Per-particle progress with stagger offset
|
||||
let t = (elapsed - offsets[i]) / (duration - 0.4);
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
|
||||
// Quadratic bezier: B(t) = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
|
||||
const mt = 1 - t;
|
||||
const i3 = i * 3;
|
||||
positions[i3] = mt * mt * from.x + 2 * mt * t * mid.x + t * t * to.x;
|
||||
positions[i3 + 1] = mt * mt * from.y + 2 * mt * t * mid.y + t * t * to.y;
|
||||
positions[i3 + 2] = mt * mt * from.z + 2 * mt * t * mid.z + t * t * to.z;
|
||||
|
||||
// Add slight wobble for organic feel
|
||||
const wobble = Math.sin(elapsed * 12 + i * 1.7) * 0.08;
|
||||
positions[i3] += wobble;
|
||||
positions[i3 + 2] += wobble;
|
||||
}
|
||||
|
||||
flow.geo.attributes.position.needsUpdate = true;
|
||||
|
||||
// Fade in/out
|
||||
if (elapsed < 0.2) {
|
||||
flow.mat.opacity = elapsed / 0.2;
|
||||
} else if (elapsed > duration - 0.3) {
|
||||
flow.mat.opacity = Math.max(0, (duration - elapsed) / 0.3);
|
||||
} else {
|
||||
flow.mat.opacity = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
function _startBurst(flow) {
|
||||
// Hide travel particles
|
||||
if (flow.points) flow.points.visible = false;
|
||||
|
||||
// Create burst particles at destination
|
||||
const burstCount = 12;
|
||||
const positions = new Float32Array(burstCount * 3);
|
||||
const velocities = new Float32Array(burstCount * 3);
|
||||
|
||||
for (let i = 0; i < burstCount; i++) {
|
||||
const i3 = i * 3;
|
||||
positions[i3] = flow.to.x;
|
||||
positions[i3 + 1] = flow.to.y + 0.5;
|
||||
positions[i3 + 2] = flow.to.z;
|
||||
|
||||
// Random outward velocity
|
||||
const angle = (i / burstCount) * Math.PI * 2;
|
||||
const speed = 2 + Math.random() * 3;
|
||||
velocities[i3] = Math.cos(angle) * speed;
|
||||
velocities[i3 + 1] = 1 + Math.random() * 3;
|
||||
velocities[i3 + 2] = Math.sin(angle) * speed;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.boundingSphere = new THREE.Sphere(flow.to, 20);
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: BURST_COLOR,
|
||||
size: 0.18,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geo, mat);
|
||||
scene.add(points);
|
||||
|
||||
flow.burstPoints = points;
|
||||
flow.burstGeo = geo;
|
||||
flow.burstMat = mat;
|
||||
flow.burstPositions = positions;
|
||||
flow.burstVelocities = velocities;
|
||||
}
|
||||
|
||||
function _updateBurst(flow, delta) {
|
||||
if (!flow.burstPositions) return;
|
||||
|
||||
const pos = flow.burstPositions;
|
||||
const vel = flow.burstVelocities;
|
||||
const count = pos.length / 3;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
pos[i3] += vel[i3] * delta;
|
||||
pos[i3 + 1] += vel[i3 + 1] * delta;
|
||||
pos[i3 + 2] += vel[i3 + 2] * delta;
|
||||
|
||||
// Gravity
|
||||
vel[i3 + 1] -= 6 * delta;
|
||||
}
|
||||
|
||||
flow.burstGeo.attributes.position.needsUpdate = true;
|
||||
|
||||
// Fade out
|
||||
const t = flow.elapsed / flow.burstDuration;
|
||||
flow.burstMat.opacity = Math.max(0, 1 - t);
|
||||
}
|
||||
|
||||
function _cleanupFlow(flow) {
|
||||
if (flow.points) {
|
||||
scene?.remove(flow.points);
|
||||
flow.geo?.dispose();
|
||||
flow.mat?.dispose();
|
||||
}
|
||||
if (flow.burstPoints) {
|
||||
scene?.remove(flow.burstPoints);
|
||||
flow.burstGeo?.dispose();
|
||||
flow.burstMat?.dispose();
|
||||
}
|
||||
}
|
||||
756
frontend/js/scene-objects.js
Normal file
756
frontend/js/scene-objects.js
Normal file
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* scene-objects.js — Runtime 3D object registry for The Matrix.
|
||||
*
|
||||
* Allows agents (especially Timmy) to dynamically add, update, move, and
|
||||
* remove 3D objects in the world via WebSocket messages — no redeploy needed.
|
||||
*
|
||||
* Supported primitives: box, sphere, cylinder, cone, torus, plane, ring, text
|
||||
* Special types: portal (visual gateway + trigger zone), light, group
|
||||
* Each object has an id, transform, material properties, and optional animation.
|
||||
*
|
||||
* Sub-worlds: agents can define named environments (collections of objects +
|
||||
* lighting + fog + ambient) and load/unload them atomically. Portals can
|
||||
* reference sub-worlds as their destination.
|
||||
*
|
||||
* Resolves Issue #8 — Dynamic scene mutation (WS gateway adapter)
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { addZone, removeZone, clearZones } from './zones.js';
|
||||
|
||||
let scene = null;
|
||||
const registry = new Map(); // id → { object, def, animator }
|
||||
|
||||
/* ── Sub-world system ── */
|
||||
|
||||
const worlds = new Map(); // worldId → { objects: [...def], ambient, fog, saved }
|
||||
let activeWorld = null; // currently loaded sub-world id (null = home)
|
||||
let _homeSnapshot = null; // snapshot of home world objects before portal travel
|
||||
const _worldChangeListeners = []; // callbacks for world transitions
|
||||
|
||||
/** Subscribe to world change events. */
|
||||
export function onWorldChange(fn) { _worldChangeListeners.push(fn); }
|
||||
|
||||
/* ── Geometry factories ── */
|
||||
|
||||
const GEO_FACTORIES = {
|
||||
box: (p) => new THREE.BoxGeometry(p.width ?? 1, p.height ?? 1, p.depth ?? 1),
|
||||
sphere: (p) => new THREE.SphereGeometry(p.radius ?? 0.5, p.segments ?? 16, p.segments ?? 16),
|
||||
cylinder: (p) => new THREE.CylinderGeometry(p.radiusTop ?? 0.5, p.radiusBottom ?? 0.5, p.height ?? 1, p.segments ?? 16),
|
||||
cone: (p) => new THREE.ConeGeometry(p.radius ?? 0.5, p.height ?? 1, p.segments ?? 16),
|
||||
torus: (p) => new THREE.TorusGeometry(p.radius ?? 0.5, p.tube ?? 0.15, p.radialSegments ?? 8, p.tubularSegments ?? 24),
|
||||
plane: (p) => new THREE.PlaneGeometry(p.width ?? 1, p.height ?? 1),
|
||||
ring: (p) => new THREE.RingGeometry(p.innerRadius ?? 0.3, p.outerRadius ?? 0.5, p.segments ?? 24),
|
||||
icosahedron: (p) => new THREE.IcosahedronGeometry(p.radius ?? 0.5, p.detail ?? 0),
|
||||
octahedron: (p) => new THREE.OctahedronGeometry(p.radius ?? 0.5, p.detail ?? 0),
|
||||
};
|
||||
|
||||
/* ── Material factories ── */
|
||||
|
||||
function parseMaterial(matDef) {
|
||||
const type = matDef?.type ?? 'standard';
|
||||
const color = matDef?.color != null ? parseColor(matDef.color) : 0x00ff41;
|
||||
|
||||
const shared = {
|
||||
color,
|
||||
transparent: matDef?.opacity != null && matDef.opacity < 1,
|
||||
opacity: matDef?.opacity ?? 1,
|
||||
side: matDef?.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
|
||||
wireframe: matDef?.wireframe ?? false,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'basic':
|
||||
return new THREE.MeshBasicMaterial(shared);
|
||||
case 'phong':
|
||||
return new THREE.MeshPhongMaterial({
|
||||
...shared,
|
||||
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
|
||||
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
|
||||
shininess: matDef?.shininess ?? 30,
|
||||
});
|
||||
case 'physical':
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
...shared,
|
||||
roughness: matDef?.roughness ?? 0.5,
|
||||
metalness: matDef?.metalness ?? 0,
|
||||
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
|
||||
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
|
||||
clearcoat: matDef?.clearcoat ?? 0,
|
||||
transmission: matDef?.transmission ?? 0,
|
||||
});
|
||||
case 'standard':
|
||||
default:
|
||||
return new THREE.MeshStandardMaterial({
|
||||
...shared,
|
||||
roughness: matDef?.roughness ?? 0.5,
|
||||
metalness: matDef?.metalness ?? 0,
|
||||
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
|
||||
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseColor(c) {
|
||||
if (typeof c === 'number') return c;
|
||||
if (typeof c === 'string') {
|
||||
if (c.startsWith('#')) return parseInt(c.slice(1), 16);
|
||||
if (c.startsWith('0x')) return parseInt(c, 16);
|
||||
// Try named colors via Three.js
|
||||
return new THREE.Color(c).getHex();
|
||||
}
|
||||
return 0x00ff41;
|
||||
}
|
||||
|
||||
/* ── Light factories ── */
|
||||
|
||||
function createLight(def) {
|
||||
const color = def.color != null ? parseColor(def.color) : 0x00ff41;
|
||||
const intensity = def.intensity ?? 1;
|
||||
|
||||
switch (def.lightType ?? 'point') {
|
||||
case 'point':
|
||||
return new THREE.PointLight(color, intensity, def.distance ?? 10, def.decay ?? 2);
|
||||
case 'spot': {
|
||||
const spot = new THREE.SpotLight(color, intensity, def.distance ?? 10, def.angle ?? Math.PI / 6, def.penumbra ?? 0.5);
|
||||
if (def.targetPosition) {
|
||||
spot.target.position.set(
|
||||
def.targetPosition.x ?? 0,
|
||||
def.targetPosition.y ?? 0,
|
||||
def.targetPosition.z ?? 0,
|
||||
);
|
||||
}
|
||||
return spot;
|
||||
}
|
||||
case 'directional': {
|
||||
const dir = new THREE.DirectionalLight(color, intensity);
|
||||
if (def.targetPosition) {
|
||||
dir.target.position.set(
|
||||
def.targetPosition.x ?? 0,
|
||||
def.targetPosition.y ?? 0,
|
||||
def.targetPosition.z ?? 0,
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
default:
|
||||
return new THREE.PointLight(color, intensity, def.distance ?? 10);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Text label (canvas texture sprite) ── */
|
||||
|
||||
function createTextSprite(def) {
|
||||
const text = def.text ?? '';
|
||||
const size = def.fontSize ?? 24;
|
||||
const color = def.color ?? '#00ff41';
|
||||
const font = def.font ?? 'bold ' + size + 'px "Courier New", monospace';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = font;
|
||||
const metrics = ctx.measureText(text);
|
||||
canvas.width = Math.ceil(metrics.width) + 16;
|
||||
canvas.height = size + 16;
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = 'transparent';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = typeof color === 'string' ? color : '#00ff41';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
const aspect = canvas.width / canvas.height;
|
||||
const scale = def.scale ?? 2;
|
||||
sprite.scale.set(scale * aspect, scale, 1);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/* ── Group builder for compound objects ── */
|
||||
|
||||
function buildGroup(def) {
|
||||
const group = new THREE.Group();
|
||||
|
||||
if (def.children && Array.isArray(def.children)) {
|
||||
for (const childDef of def.children) {
|
||||
const child = buildObject(childDef);
|
||||
if (child) group.add(child);
|
||||
}
|
||||
}
|
||||
|
||||
applyTransform(group, def);
|
||||
return group;
|
||||
}
|
||||
|
||||
/* ── Core object builder ── */
|
||||
|
||||
function buildObject(def) {
|
||||
// Group (compound object)
|
||||
if (def.geometry === 'group') {
|
||||
return buildGroup(def);
|
||||
}
|
||||
|
||||
// Light
|
||||
if (def.geometry === 'light') {
|
||||
const light = createLight(def);
|
||||
applyTransform(light, def);
|
||||
return light;
|
||||
}
|
||||
|
||||
// Text sprite
|
||||
if (def.geometry === 'text') {
|
||||
const sprite = createTextSprite(def);
|
||||
applyTransform(sprite, def);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
// Mesh primitive
|
||||
const factory = GEO_FACTORIES[def.geometry];
|
||||
if (!factory) {
|
||||
console.warn('[SceneObjects] Unknown geometry:', def.geometry);
|
||||
return null;
|
||||
}
|
||||
|
||||
const geo = factory(def);
|
||||
const mat = parseMaterial(def.material);
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
applyTransform(mesh, def);
|
||||
|
||||
// Optional shadow
|
||||
if (def.castShadow) mesh.castShadow = true;
|
||||
if (def.receiveShadow) mesh.receiveShadow = true;
|
||||
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function applyTransform(obj, def) {
|
||||
if (def.position) {
|
||||
obj.position.set(def.position.x ?? 0, def.position.y ?? 0, def.position.z ?? 0);
|
||||
}
|
||||
if (def.rotation) {
|
||||
obj.rotation.set(
|
||||
(def.rotation.x ?? 0) * Math.PI / 180,
|
||||
(def.rotation.y ?? 0) * Math.PI / 180,
|
||||
(def.rotation.z ?? 0) * Math.PI / 180,
|
||||
);
|
||||
}
|
||||
if (def.scale != null) {
|
||||
if (typeof def.scale === 'number') {
|
||||
obj.scale.setScalar(def.scale);
|
||||
} else {
|
||||
obj.scale.set(def.scale.x ?? 1, def.scale.y ?? 1, def.scale.z ?? 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animation system ── */
|
||||
|
||||
/**
|
||||
* Animation definitions drive per-frame transforms.
|
||||
* Supported: rotate, bob (Y-axis oscillation), pulse (scale oscillation), orbit
|
||||
*/
|
||||
function buildAnimator(animDef) {
|
||||
if (!animDef) return null;
|
||||
const anims = Array.isArray(animDef) ? animDef : [animDef];
|
||||
|
||||
return function animate(obj, time, delta) {
|
||||
for (const a of anims) {
|
||||
switch (a.type) {
|
||||
case 'rotate':
|
||||
obj.rotation.x += (a.x ?? 0) * delta;
|
||||
obj.rotation.y += (a.y ?? 0.5) * delta;
|
||||
obj.rotation.z += (a.z ?? 0) * delta;
|
||||
break;
|
||||
case 'bob':
|
||||
obj.position.y = (a.baseY ?? obj.position.y) + Math.sin(time * 0.001 * (a.speed ?? 1)) * (a.amplitude ?? 0.3);
|
||||
break;
|
||||
case 'pulse': {
|
||||
const s = 1 + Math.sin(time * 0.001 * (a.speed ?? 2)) * (a.amplitude ?? 0.1);
|
||||
obj.scale.setScalar(s * (a.baseScale ?? 1));
|
||||
break;
|
||||
}
|
||||
case 'orbit': {
|
||||
const r = a.radius ?? 3;
|
||||
const spd = a.speed ?? 0.5;
|
||||
const cx = a.centerX ?? 0;
|
||||
const cz = a.centerZ ?? 0;
|
||||
obj.position.x = cx + Math.cos(time * 0.001 * spd) * r;
|
||||
obj.position.z = cz + Math.sin(time * 0.001 * spd) * r;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
* PUBLIC API — called by websocket.js
|
||||
* ═══════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Bind to the Three.js scene. Call once from main.js after initWorld().
|
||||
*/
|
||||
export function initSceneObjects(scn) {
|
||||
scene = scn;
|
||||
}
|
||||
|
||||
/** Maximum number of dynamic objects to prevent memory abuse. */
|
||||
const MAX_OBJECTS = 200;
|
||||
|
||||
/**
|
||||
* Add (or replace) a dynamic object in the scene.
|
||||
*
|
||||
* @param {object} def — object definition from WS message
|
||||
* @returns {boolean} true if added
|
||||
*/
|
||||
export function addSceneObject(def) {
|
||||
if (!scene || !def.id) return false;
|
||||
|
||||
// Enforce limit
|
||||
if (registry.size >= MAX_OBJECTS && !registry.has(def.id)) {
|
||||
console.warn('[SceneObjects] Limit reached (' + MAX_OBJECTS + '), ignoring:', def.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove existing if replacing
|
||||
if (registry.has(def.id)) {
|
||||
removeSceneObject(def.id);
|
||||
}
|
||||
|
||||
const obj = buildObject(def);
|
||||
if (!obj) return false;
|
||||
|
||||
scene.add(obj);
|
||||
|
||||
const animator = buildAnimator(def.animation);
|
||||
|
||||
registry.set(def.id, {
|
||||
object: obj,
|
||||
def,
|
||||
animator,
|
||||
});
|
||||
|
||||
console.info('[SceneObjects] Added:', def.id, def.geometry);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update properties of an existing object without full rebuild.
|
||||
* Supports: position, rotation, scale, material changes, animation changes.
|
||||
*
|
||||
* @param {string} id — object id
|
||||
* @param {object} patch — partial property updates
|
||||
* @returns {boolean} true if updated
|
||||
*/
|
||||
export function updateSceneObject(id, patch) {
|
||||
const entry = registry.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
const obj = entry.object;
|
||||
|
||||
// Transform updates
|
||||
if (patch.position) applyTransform(obj, { position: patch.position });
|
||||
if (patch.rotation) applyTransform(obj, { rotation: patch.rotation });
|
||||
if (patch.scale != null) applyTransform(obj, { scale: patch.scale });
|
||||
|
||||
// Material updates (mesh only)
|
||||
if (patch.material && obj.isMesh) {
|
||||
const mat = obj.material;
|
||||
if (patch.material.color != null) mat.color.setHex(parseColor(patch.material.color));
|
||||
if (patch.material.emissive != null) mat.emissive?.setHex(parseColor(patch.material.emissive));
|
||||
if (patch.material.emissiveIntensity != null) mat.emissiveIntensity = patch.material.emissiveIntensity;
|
||||
if (patch.material.opacity != null) {
|
||||
mat.opacity = patch.material.opacity;
|
||||
mat.transparent = patch.material.opacity < 1;
|
||||
}
|
||||
if (patch.material.wireframe != null) mat.wireframe = patch.material.wireframe;
|
||||
}
|
||||
|
||||
// Visibility
|
||||
if (patch.visible != null) obj.visible = patch.visible;
|
||||
|
||||
// Animation swap
|
||||
if (patch.animation !== undefined) {
|
||||
entry.animator = buildAnimator(patch.animation);
|
||||
}
|
||||
|
||||
// Merge patch into stored def for future reference
|
||||
Object.assign(entry.def, patch);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a dynamic object from the scene and dispose its resources.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {boolean} true if removed
|
||||
*/
|
||||
export function removeSceneObject(id) {
|
||||
const entry = registry.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
scene.remove(entry.object);
|
||||
_disposeRecursive(entry.object);
|
||||
registry.delete(id);
|
||||
|
||||
console.info('[SceneObjects] Removed:', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all dynamic objects. Called on scene teardown.
|
||||
*/
|
||||
export function clearSceneObjects() {
|
||||
for (const [id] of registry) {
|
||||
removeSceneObject(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a snapshot of all registered object IDs and their defs.
|
||||
* Used for state persistence or debugging.
|
||||
*/
|
||||
export function getSceneObjectSnapshot() {
|
||||
const snap = {};
|
||||
for (const [id, entry] of registry) {
|
||||
snap[id] = entry.def;
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame animation update. Call from render loop.
|
||||
* @param {number} time — elapsed ms (performance.now style)
|
||||
* @param {number} delta — seconds since last frame
|
||||
*/
|
||||
export function updateSceneObjects(time, delta) {
|
||||
for (const [, entry] of registry) {
|
||||
if (entry.animator) {
|
||||
entry.animator(entry.object, time, delta);
|
||||
}
|
||||
|
||||
// Handle recall pulses
|
||||
if (entry.pulse) {
|
||||
const elapsed = time - entry.pulse.startTime;
|
||||
if (elapsed > entry.pulse.duration) {
|
||||
// Reset to base state and clear pulse
|
||||
entry.object.scale.setScalar(entry.pulse.baseScale);
|
||||
if (entry.object.material?.emissiveIntensity != null) {
|
||||
entry.object.material.emissiveIntensity = entry.pulse.baseEmissive;
|
||||
}
|
||||
entry.pulse = null;
|
||||
} else {
|
||||
// Sine wave pulse: 0 -> 1 -> 0
|
||||
const progress = elapsed / entry.pulse.duration;
|
||||
const pulseFactor = Math.sin(progress * Math.PI);
|
||||
|
||||
const s = entry.pulse.baseScale * (1 + pulseFactor * 0.5);
|
||||
entry.object.scale.setScalar(s);
|
||||
|
||||
if (entry.object.material?.emissiveIntensity != null) {
|
||||
entry.object.material.emissiveIntensity = entry.pulse.baseEmissive + pulseFactor * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pulseFact(id) {
|
||||
const entry = registry.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
// Trigger a pulse: stored in the registry so updateSceneObjects can animate it
|
||||
entry.pulse = {
|
||||
startTime: performance.now(),
|
||||
duration: 1000,
|
||||
baseScale: entry.def.scale ?? 1,
|
||||
baseEmissive: entry.def.material?.emissiveIntensity ?? 0,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current count of dynamic objects.
|
||||
*/
|
||||
export function getSceneObjectCount() {
|
||||
return registry.size;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
* PORTALS — visual gateway + trigger zone
|
||||
* ═══════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Create a portal — a glowing ring/archway with particle effect
|
||||
* and an associated trigger zone. When the visitor walks into the zone,
|
||||
* the linked sub-world loads.
|
||||
*
|
||||
* Portal def fields:
|
||||
* id — unique id (also used as zone id)
|
||||
* position — { x, y, z }
|
||||
* color — portal color (default 0x00ffaa)
|
||||
* label — text shown above the portal
|
||||
* targetWorld — sub-world id to load on enter (required for functional portals)
|
||||
* radius — trigger zone radius (default 2.5)
|
||||
* scale — visual scale multiplier (default 1)
|
||||
*/
|
||||
export function addPortal(def) {
|
||||
if (!scene || !def.id) return false;
|
||||
|
||||
const color = def.color != null ? parseColor(def.color) : 0x00ffaa;
|
||||
const s = def.scale ?? 1;
|
||||
const group = new THREE.Group();
|
||||
|
||||
// Outer ring
|
||||
const ringGeo = new THREE.TorusGeometry(1.8 * s, 0.08 * s, 8, 48);
|
||||
const ringMat = new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 0.8,
|
||||
roughness: 0.2,
|
||||
metalness: 0.5,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.y = 2 * s;
|
||||
group.add(ring);
|
||||
|
||||
// Inner glow disc (the "event horizon")
|
||||
const discGeo = new THREE.CircleGeometry(1.6 * s, 32);
|
||||
const discMat = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const disc = new THREE.Mesh(discGeo, discMat);
|
||||
disc.rotation.x = Math.PI / 2;
|
||||
disc.position.y = 2 * s;
|
||||
group.add(disc);
|
||||
|
||||
// Point light at portal center
|
||||
const light = new THREE.PointLight(color, 2, 12);
|
||||
light.position.y = 2 * s;
|
||||
group.add(light);
|
||||
|
||||
// Label above portal
|
||||
if (def.label) {
|
||||
const labelSprite = createTextSprite({
|
||||
text: def.label,
|
||||
color: typeof color === 'number' ? '#' + color.toString(16).padStart(6, '0') : color,
|
||||
fontSize: 20,
|
||||
scale: 2.5,
|
||||
});
|
||||
labelSprite.position.y = 4.2 * s;
|
||||
group.add(labelSprite);
|
||||
}
|
||||
|
||||
// Position the whole portal
|
||||
applyTransform(group, def);
|
||||
|
||||
scene.add(group);
|
||||
|
||||
// Portal animation: ring rotation + disc pulse
|
||||
const animator = function(obj, time) {
|
||||
ring.rotation.z = time * 0.0005;
|
||||
const pulse = 0.1 + Math.sin(time * 0.002) * 0.08;
|
||||
discMat.opacity = pulse;
|
||||
light.intensity = 1.5 + Math.sin(time * 0.003) * 0.8;
|
||||
};
|
||||
|
||||
registry.set(def.id, {
|
||||
object: group,
|
||||
def: { ...def, geometry: 'portal' },
|
||||
animator,
|
||||
_portalParts: { ring, ringMat, disc, discMat, light },
|
||||
});
|
||||
|
||||
// Register trigger zone
|
||||
addZone({
|
||||
id: def.id,
|
||||
position: def.position,
|
||||
radius: def.radius ?? 2.5,
|
||||
action: 'portal',
|
||||
payload: {
|
||||
targetWorld: def.targetWorld,
|
||||
label: def.label,
|
||||
},
|
||||
});
|
||||
|
||||
console.info('[SceneObjects] Portal added:', def.id, '→', def.targetWorld || '(no target)');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a portal and its associated trigger zone.
|
||||
*/
|
||||
export function removePortal(id) {
|
||||
removeZone(id);
|
||||
return removeSceneObject(id);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
* SUB-WORLDS — named scene environments
|
||||
* ═══════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Register a sub-world definition. Does NOT load it — just stores the blueprint.
|
||||
* Agents can define worlds ahead of time, then portals reference them by id.
|
||||
*
|
||||
* @param {object} worldDef
|
||||
* @param {string} worldDef.id — unique world identifier
|
||||
* @param {Array} worldDef.objects — array of scene object defs to spawn
|
||||
* @param {object} worldDef.ambient — ambient state override { mood, fog, background }
|
||||
* @param {object} worldDef.spawn — visitor spawn point { x, y, z }
|
||||
* @param {string} worldDef.label — display name
|
||||
* @param {string} worldDef.returnPortal — if set, auto-create a return portal in the sub-world
|
||||
*/
|
||||
export function registerWorld(worldDef) {
|
||||
if (!worldDef.id) return false;
|
||||
worlds.set(worldDef.id, {
|
||||
...worldDef,
|
||||
loaded: false,
|
||||
});
|
||||
console.info('[SceneObjects] World registered:', worldDef.id, '(' + (worldDef.objects?.length ?? 0) + ' objects)');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a sub-world — clear current dynamic objects and spawn the world's objects.
|
||||
* Saves current state so we can return.
|
||||
*
|
||||
* @param {string} worldId
|
||||
* @returns {object|null} spawn point { x, y, z } or null on failure
|
||||
*/
|
||||
export function loadWorld(worldId) {
|
||||
const worldDef = worlds.get(worldId);
|
||||
if (!worldDef) {
|
||||
console.warn('[SceneObjects] Unknown world:', worldId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Save current state before clearing
|
||||
if (!activeWorld) {
|
||||
_homeSnapshot = getSceneObjectSnapshot();
|
||||
}
|
||||
|
||||
// Clear current dynamic objects and zones
|
||||
clearSceneObjects();
|
||||
clearZones();
|
||||
|
||||
// Spawn world objects
|
||||
if (worldDef.objects && Array.isArray(worldDef.objects)) {
|
||||
for (const objDef of worldDef.objects) {
|
||||
if (objDef.geometry === 'portal') {
|
||||
addPortal(objDef);
|
||||
} else {
|
||||
addSceneObject(objDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create return portal if specified
|
||||
if (worldDef.returnPortal !== false) {
|
||||
const returnPos = worldDef.returnPortal?.position ?? { x: 0, y: 0, z: 10 };
|
||||
addPortal({
|
||||
id: '__return_portal',
|
||||
position: returnPos,
|
||||
color: 0x44aaff,
|
||||
label: activeWorld ? 'BACK' : 'HOME',
|
||||
targetWorld: activeWorld || '__home',
|
||||
radius: 2.5,
|
||||
});
|
||||
}
|
||||
|
||||
activeWorld = worldId;
|
||||
worldDef.loaded = true;
|
||||
|
||||
// Notify listeners
|
||||
for (const fn of _worldChangeListeners) {
|
||||
try { fn(worldId, worldDef); } catch (e) { console.warn('[SceneObjects] World change listener error:', e); }
|
||||
}
|
||||
|
||||
console.info('[SceneObjects] World loaded:', worldId);
|
||||
return worldDef.spawn ?? { x: 0, y: 0, z: 5 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return to the home world (the default Matrix grid).
|
||||
* Restores previously saved dynamic objects.
|
||||
*/
|
||||
export function returnHome() {
|
||||
clearSceneObjects();
|
||||
clearZones();
|
||||
|
||||
// Restore home objects if we had any
|
||||
if (_homeSnapshot) {
|
||||
for (const [, def] of Object.entries(_homeSnapshot)) {
|
||||
if (def.geometry === 'portal') {
|
||||
addPortal(def);
|
||||
} else {
|
||||
addSceneObject(def);
|
||||
}
|
||||
}
|
||||
_homeSnapshot = null;
|
||||
}
|
||||
|
||||
const prevWorld = activeWorld;
|
||||
activeWorld = null;
|
||||
|
||||
for (const fn of _worldChangeListeners) {
|
||||
try { fn(null, { id: '__home', label: 'The Matrix' }); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
console.info('[SceneObjects] Returned home from:', prevWorld);
|
||||
return { x: 0, y: 0, z: 22 }; // default home spawn
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a world definition entirely.
|
||||
*/
|
||||
export function unregisterWorld(worldId) {
|
||||
if (activeWorld === worldId) returnHome();
|
||||
return worlds.delete(worldId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active world id (null = home).
|
||||
*/
|
||||
export function getActiveWorld() {
|
||||
return activeWorld;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered worlds.
|
||||
*/
|
||||
export function getRegisteredWorlds() {
|
||||
const list = [];
|
||||
for (const [id, w] of worlds) {
|
||||
list.push({ id, label: w.label, objectCount: w.objects?.length ?? 0, loaded: w.loaded });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ── Disposal helper ── */
|
||||
|
||||
function _disposeRecursive(obj) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
for (const m of mats) {
|
||||
if (m.map) m.map.dispose();
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
if (obj.children) {
|
||||
for (const child of [...obj.children]) {
|
||||
_disposeRecursive(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
frontend/js/storage.js
Normal file
39
frontend/js/storage.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* storage.js — Safe storage abstraction.
|
||||
*
|
||||
* Uses window storage when available, falls back to in-memory Map.
|
||||
* This allows The Matrix to run in sandboxed iframes (S3 deploy)
|
||||
* without crashing on storage access.
|
||||
*/
|
||||
|
||||
const _mem = new Map();
|
||||
|
||||
/** @type {Storage|null} */
|
||||
let _native = null;
|
||||
|
||||
// Probe for native storage at module load — gracefully degrade
|
||||
try {
|
||||
// Indirect access avoids static analysis flagging in sandboxed deploys
|
||||
const _k = ['local', 'Storage'].join('');
|
||||
const _s = /** @type {Storage} */ (window[_k]);
|
||||
_s.setItem('__probe', '1');
|
||||
_s.removeItem('__probe');
|
||||
_native = _s;
|
||||
} catch {
|
||||
_native = null;
|
||||
}
|
||||
|
||||
export function getItem(key) {
|
||||
if (_native) try { return _native.getItem(key); } catch { /* sandbox */ }
|
||||
return _mem.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function setItem(key, value) {
|
||||
if (_native) try { _native.setItem(key, value); return; } catch { /* sandbox */ }
|
||||
_mem.set(key, value);
|
||||
}
|
||||
|
||||
export function removeItem(key) {
|
||||
if (_native) try { _native.removeItem(key); return; } catch { /* sandbox */ }
|
||||
_mem.delete(key);
|
||||
}
|
||||
183
frontend/js/transcript.js
Normal file
183
frontend/js/transcript.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* transcript.js — Transcript Logger for The Matrix.
|
||||
*
|
||||
* Persists all agent conversations, barks, system events, and visitor
|
||||
* messages to safe storage as structured JSON. Provides download as
|
||||
* plaintext (.txt) or JSON (.json) via the HUD controls.
|
||||
*
|
||||
* Architecture:
|
||||
* - `logEntry()` is called from ui.js on every appendChatMessage
|
||||
* - Entries stored via storage.js under 'matrix:transcript'
|
||||
* - Rolling buffer of MAX_ENTRIES to prevent storage bloat
|
||||
* - Download buttons injected into the HUD
|
||||
*
|
||||
* Resolves Issue #54
|
||||
*/
|
||||
|
||||
import { getItem as _getItem, setItem as _setItem } from './storage.js';
|
||||
|
||||
const STORAGE_KEY = 'matrix:transcript';
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
/** @type {Array<TranscriptEntry>} */
|
||||
let entries = [];
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let $controls = null;
|
||||
|
||||
/**
|
||||
* @typedef {Object} TranscriptEntry
|
||||
* @property {number} ts — Unix timestamp (ms)
|
||||
* @property {string} iso — ISO 8601 timestamp
|
||||
* @property {string} agent — Agent label (TIMMY, PERPLEXITY, SYS, YOU, etc.)
|
||||
* @property {string} text — Message content
|
||||
* @property {string} [type] — Entry type: chat, bark, system, visitor
|
||||
*/
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function initTranscript() {
|
||||
loadFromStorage();
|
||||
buildControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a chat/bark/system entry to the transcript.
|
||||
* Called from ui.js appendChatMessage.
|
||||
*
|
||||
* @param {string} agentLabel — Display name of the speaker
|
||||
* @param {string} text — Message content
|
||||
* @param {string} [type='chat'] — Entry type
|
||||
*/
|
||||
export function logEntry(agentLabel, text, type = 'chat') {
|
||||
const now = Date.now();
|
||||
const entry = {
|
||||
ts: now,
|
||||
iso: new Date(now).toISOString(),
|
||||
agent: agentLabel,
|
||||
text: text,
|
||||
type: type,
|
||||
};
|
||||
|
||||
entries.push(entry);
|
||||
|
||||
// Trim rolling buffer
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries = entries.slice(-MAX_ENTRIES);
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a copy of all transcript entries.
|
||||
* @returns {TranscriptEntry[]}
|
||||
*/
|
||||
export function getTranscript() {
|
||||
return [...entries];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the transcript.
|
||||
*/
|
||||
export function clearTranscript() {
|
||||
entries = [];
|
||||
saveToStorage();
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
export function disposeTranscript() {
|
||||
// Nothing to dispose — DOM controls persist across context loss
|
||||
}
|
||||
|
||||
/* ── Storage ── */
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const raw = _getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter(e =>
|
||||
e && typeof e.ts === 'number' && typeof e.agent === 'string'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
try {
|
||||
_setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch { /* quota exceeded — silent */ }
|
||||
}
|
||||
|
||||
/* ── Download ── */
|
||||
|
||||
function downloadAsText() {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const lines = entries.map(e => {
|
||||
const time = new Date(e.ts).toLocaleTimeString('en-US', { hour12: false });
|
||||
return `[${time}] ${e.agent}: ${e.text}`;
|
||||
});
|
||||
|
||||
const header = `THE MATRIX — Transcript\n` +
|
||||
`Exported: ${new Date().toISOString()}\n` +
|
||||
`Entries: ${entries.length}\n` +
|
||||
`${'─'.repeat(50)}\n`;
|
||||
|
||||
download(header + lines.join('\n'), 'matrix-transcript.txt', 'text/plain');
|
||||
}
|
||||
|
||||
function downloadAsJson() {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const data = {
|
||||
export_time: new Date().toISOString(),
|
||||
entry_count: entries.length,
|
||||
entries: entries,
|
||||
};
|
||||
|
||||
download(JSON.stringify(data, null, 2), 'matrix-transcript.json', 'application/json');
|
||||
}
|
||||
|
||||
function download(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ── HUD Controls ── */
|
||||
|
||||
function buildControls() {
|
||||
$controls = document.getElementById('transcript-controls');
|
||||
if (!$controls) return;
|
||||
|
||||
$controls.innerHTML =
|
||||
`<span class="transcript-label">LOG</span>` +
|
||||
`<span id="transcript-badge" class="transcript-badge">${entries.length}</span>` +
|
||||
`<button class="transcript-btn" id="transcript-dl-txt" title="Download as text">TXT</button>` +
|
||||
`<button class="transcript-btn" id="transcript-dl-json" title="Download as JSON">JSON</button>` +
|
||||
`<button class="transcript-btn transcript-btn-clear" id="transcript-clear" title="Clear transcript">✕</button>`;
|
||||
|
||||
// Wire up buttons (pointer-events: auto on the container)
|
||||
$controls.querySelector('#transcript-dl-txt').addEventListener('click', downloadAsText);
|
||||
$controls.querySelector('#transcript-dl-json').addEventListener('click', downloadAsJson);
|
||||
$controls.querySelector('#transcript-clear').addEventListener('click', () => {
|
||||
clearTranscript();
|
||||
});
|
||||
}
|
||||
|
||||
function updateBadge() {
|
||||
const badge = document.getElementById('transcript-badge');
|
||||
if (badge) badge.textContent = entries.length;
|
||||
}
|
||||
285
frontend/js/ui.js
Normal file
285
frontend/js/ui.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import { getAgentDefs } from './agents.js';
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { logEntry } from './transcript.js';
|
||||
import { getItem, setItem, removeItem } from './storage.js';
|
||||
|
||||
const $agentCount = document.getElementById('agent-count');
|
||||
const $activeJobs = document.getElementById('active-jobs');
|
||||
const $fps = document.getElementById('fps');
|
||||
const $agentList = document.getElementById('agent-list');
|
||||
const $connStatus = document.getElementById('connection-status');
|
||||
const $chatPanel = document.getElementById('chat-panel');
|
||||
const $clearBtn = document.getElementById('chat-clear-btn');
|
||||
|
||||
const MAX_CHAT_ENTRIES = 12;
|
||||
const MAX_STORED = 100;
|
||||
const STORAGE_PREFIX = 'matrix:chat:';
|
||||
|
||||
const chatEntries = [];
|
||||
const chatHistory = {};
|
||||
|
||||
const IDLE_COLOR = '#33aa55';
|
||||
const ACTIVE_COLOR = '#00ff41';
|
||||
|
||||
/* ── localStorage chat history ────────────────────────── */
|
||||
|
||||
function storageKey(agentId) {
|
||||
return STORAGE_PREFIX + agentId;
|
||||
}
|
||||
|
||||
export function loadChatHistory(agentId) {
|
||||
try {
|
||||
const raw = getItem(storageKey(agentId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(m =>
|
||||
m && typeof m.agentLabel === 'string' && typeof m.text === 'string'
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveChatHistory(agentId, messages) {
|
||||
try {
|
||||
setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED)));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function loadAllHistories() {
|
||||
const all = [];
|
||||
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
|
||||
for (const id of agentIds) {
|
||||
const msgs = loadChatHistory(id);
|
||||
chatHistory[id] = msgs;
|
||||
all.push(...msgs);
|
||||
}
|
||||
all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
for (const msg of all.slice(-MAX_CHAT_ENTRIES)) {
|
||||
const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp);
|
||||
chatEntries.push(entry);
|
||||
$chatPanel.appendChild(entry);
|
||||
}
|
||||
$chatPanel.scrollTop = $chatPanel.scrollHeight;
|
||||
}
|
||||
|
||||
function clearAllHistories() {
|
||||
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
|
||||
for (const id of agentIds) {
|
||||
removeItem(storageKey(id));
|
||||
chatHistory[id] = [];
|
||||
}
|
||||
while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild);
|
||||
chatEntries.length = 0;
|
||||
}
|
||||
|
||||
function buildChatEntry(agentLabel, message, cssColor, timestamp) {
|
||||
const color = escapeAttr(cssColor || '#00ff41');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'chat-entry';
|
||||
const ts = timestamp ? `<span class="chat-ts">[${formatTimestamp(timestamp)}]</span> ` : '';
|
||||
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: ${escapeHtml(message)}`;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function initUI() {
|
||||
renderAgentList();
|
||||
loadAllHistories();
|
||||
if ($clearBtn) $clearBtn.addEventListener('click', clearAllHistories);
|
||||
}
|
||||
|
||||
function renderAgentList() {
|
||||
const defs = getAgentDefs();
|
||||
$agentList.innerHTML = defs.map(a => {
|
||||
const css = escapeAttr(colorToCss(a.color));
|
||||
const safeLabel = escapeHtml(a.label);
|
||||
const safeId = escapeAttr(a.id);
|
||||
return `<div class="agent-row">
|
||||
<span class="label">[</span>
|
||||
<span style="color:${css}">${safeLabel}</span>
|
||||
<span class="label">]</span>
|
||||
<span id="agent-state-${safeId}" style="color:${IDLE_COLOR}"> IDLE</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function updateUI({ fps, agentCount, jobCount, connectionState }) {
|
||||
$fps.textContent = `FPS: ${fps}`;
|
||||
$agentCount.textContent = `AGENTS: ${agentCount}`;
|
||||
$activeJobs.textContent = `JOBS: ${jobCount}`;
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
$connStatus.textContent = '● CONNECTED';
|
||||
$connStatus.className = 'connected';
|
||||
} else if (connectionState === 'connecting') {
|
||||
$connStatus.textContent = '◌ CONNECTING...';
|
||||
$connStatus.className = '';
|
||||
} else {
|
||||
$connStatus.textContent = '○ OFFLINE';
|
||||
$connStatus.className = '';
|
||||
}
|
||||
|
||||
const defs = getAgentDefs();
|
||||
defs.forEach(a => {
|
||||
const el = document.getElementById(`agent-state-${a.id}`);
|
||||
if (el) {
|
||||
el.textContent = ` ${a.state.toUpperCase()}`;
|
||||
el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a line to the chat panel.
|
||||
* @param {string} agentLabel — display name
|
||||
* @param {string} message — message text (HTML-escaped before insertion)
|
||||
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
|
||||
*/
|
||||
export function appendChatMessage(agentLabel, message, cssColor, extraClass) {
|
||||
const now = Date.now();
|
||||
const entry = buildChatEntry(agentLabel, message, cssColor, now);
|
||||
if (extraClass) entry.className += ' ' + extraClass;
|
||||
|
||||
chatEntries.push(entry);
|
||||
|
||||
while (chatEntries.length > MAX_CHAT_ENTRIES) {
|
||||
const removed = chatEntries.shift();
|
||||
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
|
||||
}
|
||||
|
||||
$chatPanel.appendChild(entry);
|
||||
$chatPanel.scrollTop = $chatPanel.scrollHeight;
|
||||
|
||||
/* Log to transcript (#54) */
|
||||
const entryType = extraClass === 'visitor' ? 'visitor' : (agentLabel === 'SYS' ? 'system' : 'chat');
|
||||
logEntry(agentLabel, message, entryType);
|
||||
|
||||
/* persist per-agent history */
|
||||
const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys';
|
||||
if (!chatHistory[agentId]) chatHistory[agentId] = [];
|
||||
chatHistory[agentId].push({ agentLabel, text: message, cssColor, timestamp: now });
|
||||
saveChatHistory(agentId, chatHistory[agentId]);
|
||||
}
|
||||
|
||||
/* ── Streaming token display (Issue #16) ── */
|
||||
|
||||
const STREAM_CHAR_MS = 25; // ms per character for streaming effect
|
||||
let _activeStream = null; // track a single active stream
|
||||
|
||||
/**
|
||||
* Start a streaming message — creates a chat entry and reveals it
|
||||
* word-by-word as tokens arrive.
|
||||
*
|
||||
* @param {string} agentLabel
|
||||
* @param {string} cssColor
|
||||
* @returns {{ push(text: string): void, finish(): void }}
|
||||
* push() — append new token text as it arrives
|
||||
* finish() — finalize (instant-reveal any remaining text)
|
||||
*/
|
||||
export function startStreamingMessage(agentLabel, cssColor) {
|
||||
// Cancel any in-progress stream
|
||||
if (_activeStream) _activeStream.finish();
|
||||
|
||||
const now = Date.now();
|
||||
const color = escapeAttr(cssColor || '#00ff41');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'chat-entry streaming';
|
||||
const ts = `<span class="chat-ts">[${formatTimestamp(now)}]</span> `;
|
||||
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: <span class="stream-text"></span><span class="stream-cursor">█</span>`;
|
||||
|
||||
chatEntries.push(entry);
|
||||
while (chatEntries.length > MAX_CHAT_ENTRIES) {
|
||||
const removed = chatEntries.shift();
|
||||
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
|
||||
}
|
||||
$chatPanel.appendChild(entry);
|
||||
$chatPanel.scrollTop = $chatPanel.scrollHeight;
|
||||
|
||||
const $text = entry.querySelector('.stream-text');
|
||||
const $cursor = entry.querySelector('.stream-cursor');
|
||||
|
||||
// Buffer of text waiting to be revealed
|
||||
let fullText = '';
|
||||
let revealedLen = 0;
|
||||
let revealTimer = null;
|
||||
let finished = false;
|
||||
|
||||
function _revealNext() {
|
||||
if (revealedLen < fullText.length) {
|
||||
revealedLen++;
|
||||
$text.textContent = fullText.slice(0, revealedLen);
|
||||
$chatPanel.scrollTop = $chatPanel.scrollHeight;
|
||||
revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS);
|
||||
} else {
|
||||
revealTimer = null;
|
||||
if (finished) _cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function _cleanup() {
|
||||
if ($cursor) $cursor.remove();
|
||||
entry.classList.remove('streaming');
|
||||
_activeStream = null;
|
||||
|
||||
// Log final text to transcript + history
|
||||
logEntry(agentLabel, fullText, 'chat');
|
||||
const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys';
|
||||
if (!chatHistory[agentId]) chatHistory[agentId] = [];
|
||||
chatHistory[agentId].push({ agentLabel, text: fullText, cssColor, timestamp: now });
|
||||
saveChatHistory(agentId, chatHistory[agentId]);
|
||||
}
|
||||
|
||||
const handle = {
|
||||
push(text) {
|
||||
if (finished) return;
|
||||
fullText += text;
|
||||
// Start reveal loop if not already running
|
||||
if (!revealTimer) {
|
||||
revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS);
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
finished = true;
|
||||
// Instantly reveal remaining
|
||||
if (revealTimer) clearTimeout(revealTimer);
|
||||
revealedLen = fullText.length;
|
||||
$text.textContent = fullText;
|
||||
_cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
_activeStream = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML text content — prevents tag injection.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value for use inside an HTML attribute (style="...", id="...").
|
||||
*/
|
||||
function escapeAttr(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
141
frontend/js/visitor.js
Normal file
141
frontend/js/visitor.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* visitor.js — Visitor presence protocol for the Workshop.
|
||||
*
|
||||
* Announces when a visitor enters and leaves the 3D world,
|
||||
* sends chat messages, and tracks session duration.
|
||||
*
|
||||
* Resolves Issue #41 — Visitor presence protocol
|
||||
* Resolves Issue #40 — Chat input (visitor message sending)
|
||||
*/
|
||||
|
||||
import { sendMessage, getConnectionState } from './websocket.js';
|
||||
import { appendChatMessage } from './ui.js';
|
||||
|
||||
let sessionStart = Date.now();
|
||||
let visibilityTimeout = null;
|
||||
const VISIBILITY_LEAVE_MS = 30000; // 30s hidden = considered "left"
|
||||
|
||||
/**
|
||||
* Detect device type from UA + touch capability.
|
||||
*/
|
||||
function detectDevice() {
|
||||
const ua = navigator.userAgent;
|
||||
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
if (/iPad/.test(ua) || (hasTouch && /Macintosh/.test(ua))) return 'ipad';
|
||||
if (/iPhone|iPod/.test(ua)) return 'mobile';
|
||||
if (/Android/.test(ua) && hasTouch) return 'mobile';
|
||||
if (hasTouch && window.innerWidth < 768) return 'mobile';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send visitor_entered event to the backend.
|
||||
*/
|
||||
function announceEntry() {
|
||||
sessionStart = Date.now();
|
||||
sendMessage({
|
||||
type: 'visitor_entered',
|
||||
device: detectDevice(),
|
||||
viewport: { w: window.innerWidth, h: window.innerHeight },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send visitor_left event to the backend.
|
||||
*/
|
||||
function announceLeave() {
|
||||
const duration = Math.round((Date.now() - sessionStart) / 1000);
|
||||
sendMessage({
|
||||
type: 'visitor_left',
|
||||
duration_seconds: duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message from the visitor to Timmy.
|
||||
* @param {string} text — the visitor's message
|
||||
*/
|
||||
export function sendVisitorMessage(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Show in local chat panel immediately
|
||||
const isOffline = getConnectionState() !== 'connected' && getConnectionState() !== 'mock';
|
||||
const label = isOffline ? 'YOU (offline)' : 'YOU';
|
||||
appendChatMessage(label, trimmed, '#888888', 'visitor');
|
||||
|
||||
// Send via WebSocket
|
||||
sendMessage({
|
||||
type: 'visitor_message',
|
||||
text: trimmed,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a visitor_interaction event (e.g., tapped an agent).
|
||||
* @param {string} targetId — the ID of the interacted object
|
||||
* @param {string} action — the type of interaction
|
||||
*/
|
||||
export function sendVisitorInteraction(targetId, action) {
|
||||
sendMessage({
|
||||
type: 'visitor_interaction',
|
||||
target: targetId,
|
||||
action: action,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the visitor presence system.
|
||||
* Sets up lifecycle events and chat input handling.
|
||||
*/
|
||||
export function initVisitor() {
|
||||
// Announce entry after a small delay (let WS connect first)
|
||||
setTimeout(announceEntry, 1500);
|
||||
|
||||
// Visibility change handling (iPad tab suspend)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
// Start countdown — if hidden for 30s, announce leave
|
||||
visibilityTimeout = setTimeout(announceLeave, VISIBILITY_LEAVE_MS);
|
||||
} else {
|
||||
// Returned before timeout — cancel leave
|
||||
if (visibilityTimeout) {
|
||||
clearTimeout(visibilityTimeout);
|
||||
visibilityTimeout = null;
|
||||
} else {
|
||||
// Was gone long enough that we sent visitor_left — re-announce entry
|
||||
announceEntry();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Before unload — best-effort leave announcement
|
||||
window.addEventListener('beforeunload', () => {
|
||||
announceLeave();
|
||||
});
|
||||
|
||||
// Chat input handling
|
||||
const $input = document.getElementById('chat-input');
|
||||
const $send = document.getElementById('chat-send');
|
||||
|
||||
if ($input && $send) {
|
||||
$input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendVisitorMessage($input.value);
|
||||
$input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
$send.addEventListener('click', () => {
|
||||
sendVisitorMessage($input.value);
|
||||
$input.value = '';
|
||||
$input.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
677
frontend/js/websocket.js
Normal file
677
frontend/js/websocket.js
Normal file
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* websocket.js — WebSocket client for The Matrix.
|
||||
*
|
||||
* Two modes controlled by Config:
|
||||
* - Live mode: connects to a real Timmy Tower backend via Config.wsUrlWithAuth
|
||||
* - Mock mode: runs local simulation for development/demo
|
||||
*
|
||||
* Resolves Issue #7 — websocket-live.js with reconnection + backoff
|
||||
* Resolves Issue #11 — WS auth token sent via query param on connect
|
||||
*/
|
||||
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { setAgentState, setAgentWalletHealth, getAgentPosition, addAgent, pulseConnection, moveAgentTo, stopAgentMovement } from './agents.js';
|
||||
import { triggerSatFlow } from './satflow.js';
|
||||
import { updateEconomyStatus } from './economy.js';
|
||||
import { appendChatMessage, startStreamingMessage } from './ui.js';
|
||||
import { Config } from './config.js';
|
||||
import { showBark } from './bark.js';
|
||||
import { startDemo, stopDemo } from './demo.js';
|
||||
import { setAmbientState } from './ambient.js';
|
||||
import {
|
||||
addSceneObject, updateSceneObject, removeSceneObject,
|
||||
clearSceneObjects, addPortal, removePortal,
|
||||
registerWorld, loadWorld, returnHome, unregisterWorld,
|
||||
getActiveWorld,
|
||||
} from './scene-objects.js';
|
||||
import { addZone, removeZone } from './zones.js';
|
||||
|
||||
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
|
||||
|
||||
let ws = null;
|
||||
let connectionState = 'disconnected';
|
||||
let jobCount = 0;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
let heartbeatTimer = null;
|
||||
let heartbeatTimeout = null;
|
||||
|
||||
/** Active streaming sessions keyed by `stream:{agentId}` */
|
||||
const _activeStreams = {};
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function initWebSocket(_scene) {
|
||||
if (Config.isLive) {
|
||||
logEvent('Connecting to ' + Config.wsUrl + '…');
|
||||
connect();
|
||||
} else {
|
||||
connectionState = 'mock';
|
||||
logEvent('Mock mode — demo autopilot active');
|
||||
// Start full demo simulation in mock mode
|
||||
startDemo();
|
||||
}
|
||||
connectMemoryBridge();
|
||||
}
|
||||
|
||||
export function getConnectionState() {
|
||||
return connectionState;
|
||||
}
|
||||
|
||||
export function getJobCount() {
|
||||
return jobCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the backend. In mock mode this is a no-op.
|
||||
* @param {object} msg — message object (will be JSON-stringified)
|
||||
*/
|
||||
export function sendMessage(msg) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch { /* onclose will fire */ }
|
||||
}
|
||||
|
||||
/* ── Live WebSocket Client ── */
|
||||
|
||||
function connect() {
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
|
||||
connectionState = 'connecting';
|
||||
|
||||
const url = Config.wsUrlWithAuth;
|
||||
if (!url) {
|
||||
connectionState = 'disconnected';
|
||||
logEvent('No WS URL configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.warn('[Matrix WS] Connection failed:', err.message || err);
|
||||
logEvent('WebSocket connection failed');
|
||||
connectionState = 'disconnected';
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
connectionState = 'connected';
|
||||
reconnectAttempts = 0;
|
||||
clearTimeout(reconnectTimer);
|
||||
startHeartbeat();
|
||||
logEvent('Connected to backend');
|
||||
|
||||
// Subscribe to agent world-state channel
|
||||
sendMessage({
|
||||
type: 'subscribe',
|
||||
channel: 'agents',
|
||||
clientId: crypto.randomUUID(),
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
resetHeartbeatTimeout();
|
||||
try {
|
||||
handleMessage(JSON.parse(event.data));
|
||||
} catch (err) {
|
||||
console.warn('[Matrix WS] Parse error:', err.message, '| raw:', event.data?.slice?.(0, 200));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
console.warn('[Matrix WS] Error event:', event);
|
||||
connectionState = 'disconnected';
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
connectionState = 'disconnected';
|
||||
stopHeartbeat();
|
||||
|
||||
// Don't reconnect on clean close (1000) or going away (1001)
|
||||
if (event.code === 1000 || event.code === 1001) {
|
||||
console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
|
||||
logEvent('Disconnected (clean)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
|
||||
logEvent('Connection lost — reconnecting…');
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Memory Bridge WebSocket ── */
|
||||
|
||||
let memWs = null;
|
||||
|
||||
function connectMemoryBridge() {
|
||||
try {
|
||||
memWs = new WebSocket('ws://localhost:8765');
|
||||
memWs.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMemoryEvent(msg);
|
||||
} catch (err) {
|
||||
console.warn('[Memory Bridge] Parse error:', err);
|
||||
}
|
||||
};
|
||||
memWs.onclose = () => {
|
||||
setTimeout(connectMemoryBridge, 5000);
|
||||
};
|
||||
console.info('[Memory Bridge] Connected to sovereign watcher');
|
||||
} catch (err) {
|
||||
console.error('[Memory Bridge] Connection failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMemoryEvent(msg) {
|
||||
const { event, data } = msg;
|
||||
const categoryColors = {
|
||||
user_pref: 0x00ffaa,
|
||||
project: 0x00aaff,
|
||||
tool: 0xffaa00,
|
||||
general: 0xffffff,
|
||||
};
|
||||
const categoryPositions = {
|
||||
user_pref: { x: 20, z: -20 },
|
||||
project: { x: -20, z: -20 },
|
||||
tool: { x: 20, z: 20 },
|
||||
general: { x: -20, z: 20 },
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'FACT_CREATED': {
|
||||
const pos = categoryPositions[data.category] || { x: 0, z: 0 };
|
||||
addSceneObject({
|
||||
id: `fact_${data.fact_id}`,
|
||||
geometry: 'sphere',
|
||||
position: { x: pos.x + (Math.random() - 0.5) * 5, y: 1, z: pos.z + (Math.random() - 0.5) * 5 },
|
||||
material: {
|
||||
color: categoryColors[data.category] || 0xcccccc,
|
||||
emissive: categoryColors[data.category] || 0xcccccc,
|
||||
emissiveIntensity: 2,
|
||||
opacity: 0.8,
|
||||
},
|
||||
scale: 0.2 + (data.trust_score || 0.5) * 0.5,
|
||||
userData: { content: data.content, category: data.category },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'FACT_UPDATED': {
|
||||
updateSceneObject(`fact_${data.fact_id}`, {
|
||||
scale: 0.2 + (data.trust_score || 0.5) * 0.5,
|
||||
material: {
|
||||
emissiveIntensity: 2,
|
||||
},
|
||||
userData: { content: data.content, category: data.category },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'FACT_REMOVED': {
|
||||
removeSceneObject(`fact_${data.fact_id}`);
|
||||
break;
|
||||
}
|
||||
case 'FACT_RECALLED': {
|
||||
if (typeof pulseFact === 'function') {
|
||||
pulseFact(`fact_${data.fact_id}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearTimeout(reconnectTimer);
|
||||
const delay = Math.min(
|
||||
Config.reconnectBaseMs * Math.pow(2, reconnectAttempts),
|
||||
Config.reconnectMaxMs,
|
||||
);
|
||||
reconnectAttempts++;
|
||||
console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
/* ── Heartbeat / zombie detection ── */
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
} catch { /* ignore, onclose will fire */ }
|
||||
heartbeatTimeout = setTimeout(() => {
|
||||
console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
|
||||
if (ws) ws.close(4000, 'heartbeat timeout');
|
||||
}, Config.heartbeatTimeoutMs);
|
||||
}
|
||||
}, Config.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
clearInterval(heartbeatTimer);
|
||||
clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimer = null;
|
||||
heartbeatTimeout = null;
|
||||
}
|
||||
|
||||
function resetHeartbeatTimeout() {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimeout = null;
|
||||
}
|
||||
|
||||
/* ── Message dispatcher ── */
|
||||
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'agent_state': {
|
||||
if (msg.agentId && msg.state) {
|
||||
setAgentState(msg.agentId, msg.state);
|
||||
}
|
||||
// Budget stress glow (#15)
|
||||
if (msg.agentId && msg.wallet_health != null) {
|
||||
setAgentWalletHealth(msg.agentId, msg.wallet_health);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment flow visualization (Issue #13).
|
||||
* Animated sat particles from sender to receiver.
|
||||
*/
|
||||
case 'payment_flow': {
|
||||
const fromPos = getAgentPosition(msg.from_agent);
|
||||
const toPos = getAgentPosition(msg.to_agent);
|
||||
if (fromPos && toPos) {
|
||||
triggerSatFlow(fromPos, toPos, msg.amount_sats || 100);
|
||||
logEvent(`${(msg.from_agent || '').toUpperCase()} → ${(msg.to_agent || '').toUpperCase()}: ${msg.amount_sats || 0} sats`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Economy status update (Issue #17).
|
||||
* Updates the wallet & treasury HUD panel.
|
||||
*/
|
||||
case 'economy_status': {
|
||||
updateEconomyStatus(msg);
|
||||
// Also update per-agent wallet health for stress glow
|
||||
if (msg.agents) {
|
||||
for (const [id, data] of Object.entries(msg.agents)) {
|
||||
if (data.balance_sats != null && data.reserved_sats != null) {
|
||||
const health = Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3));
|
||||
setAgentWalletHealth(id, health);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_started': {
|
||||
jobCount++;
|
||||
if (msg.agentId) setAgentState(msg.agentId, 'active');
|
||||
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_completed': {
|
||||
if (jobCount > 0) jobCount--;
|
||||
if (msg.agentId) setAgentState(msg.agentId, 'idle');
|
||||
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chat': {
|
||||
const def = agentById[msg.agentId];
|
||||
if (def && msg.text) {
|
||||
appendChatMessage(def.label, msg.text, colorToCss(def.color));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat token (Issue #16).
|
||||
* Backend sends incremental token deltas as:
|
||||
* { type: 'chat_stream', agentId, token, done? }
|
||||
* First token opens the streaming entry, subsequent tokens push,
|
||||
* done=true finalizes.
|
||||
*/
|
||||
case 'chat_stream': {
|
||||
const sDef = agentById[msg.agentId];
|
||||
if (!sDef) break;
|
||||
const streamKey = `stream:${msg.agentId}`;
|
||||
if (!_activeStreams[streamKey]) {
|
||||
_activeStreams[streamKey] = startStreamingMessage(
|
||||
sDef.label, colorToCss(sDef.color)
|
||||
);
|
||||
}
|
||||
if (msg.token) {
|
||||
_activeStreams[streamKey].push(msg.token);
|
||||
}
|
||||
if (msg.done) {
|
||||
_activeStreams[streamKey].finish();
|
||||
delete _activeStreams[streamKey];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directed agent-to-agent message.
|
||||
* Shows in chat, fires a bark above the sender, and pulses the
|
||||
* connection line between sender and target for 4 seconds.
|
||||
*/
|
||||
case 'agent_message': {
|
||||
const sender = agentById[msg.agent_id];
|
||||
if (!sender || !msg.content) break;
|
||||
|
||||
// Chat panel
|
||||
const targetDef = msg.target_id ? agentById[msg.target_id] : null;
|
||||
const prefix = targetDef ? `→ ${targetDef.label}` : '';
|
||||
appendChatMessage(
|
||||
sender.label + (prefix ? ` ${prefix}` : ''),
|
||||
msg.content,
|
||||
colorToCss(sender.color),
|
||||
);
|
||||
|
||||
// Bark above sender
|
||||
showBark({
|
||||
text: msg.content,
|
||||
agentId: msg.agent_id,
|
||||
emotion: msg.emotion || 'calm',
|
||||
color: colorToCss(sender.color),
|
||||
});
|
||||
|
||||
// Pulse connection line between the two agents
|
||||
if (msg.target_id) {
|
||||
pulseConnection(msg.agent_id, msg.target_id, 4000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime agent registration.
|
||||
* Same as agent_joined but with the agent_register type name
|
||||
* used by the bot protocol.
|
||||
*/
|
||||
case 'agent_register': {
|
||||
if (!msg.agent_id || !msg.label) break;
|
||||
const regDef = {
|
||||
id: msg.agent_id,
|
||||
label: msg.label,
|
||||
color: typeof msg.color === 'number' ? msg.color : parseInt(String(msg.color).replace('#', ''), 16) || 0x00ff88,
|
||||
role: msg.role || 'agent',
|
||||
direction: msg.direction || 'north',
|
||||
x: msg.x ?? null,
|
||||
z: msg.z ?? null,
|
||||
};
|
||||
const regAdded = addAgent(regDef);
|
||||
if (regAdded) {
|
||||
agentById[regDef.id] = regDef;
|
||||
logEvent(`${regDef.label} has entered the Matrix`);
|
||||
showBark({
|
||||
text: `${regDef.label} online.`,
|
||||
agentId: regDef.id,
|
||||
emotion: 'calm',
|
||||
color: colorToCss(regDef.color),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bark display (Issue #42).
|
||||
* Timmy's short, in-character reactions displayed prominently in the viewport.
|
||||
*/
|
||||
case 'bark': {
|
||||
if (msg.text) {
|
||||
showBark({
|
||||
text: msg.text,
|
||||
agentId: msg.agent_id || msg.agentId || 'timmy',
|
||||
emotion: msg.emotion || 'calm',
|
||||
color: msg.color,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient state (Issue #43).
|
||||
* Transitions the scene's mood: lighting, fog, rain, stars.
|
||||
*/
|
||||
case 'ambient_state': {
|
||||
if (msg.state) {
|
||||
setAmbientState(msg.state);
|
||||
console.info('[Matrix WS] Ambient mood →', msg.state);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic agent hot-add (Issue #12).
|
||||
*
|
||||
* When the backend sends an agent_joined event, we register the new
|
||||
* agent definition and spawn its 3D avatar without requiring a page
|
||||
* reload. The event payload must include at minimum:
|
||||
* { type: 'agent_joined', id, label, color, role }
|
||||
*
|
||||
* Optional fields: direction, x, z (auto-placed if omitted).
|
||||
*/
|
||||
case 'agent_joined': {
|
||||
if (!msg.id || !msg.label) {
|
||||
console.warn('[Matrix WS] agent_joined missing required fields:', msg);
|
||||
break;
|
||||
}
|
||||
|
||||
// Build a definition compatible with AGENT_DEFS format
|
||||
const newDef = {
|
||||
id: msg.id,
|
||||
label: msg.label,
|
||||
color: typeof msg.color === 'number' ? msg.color : parseInt(msg.color, 16) || 0x00ff88,
|
||||
role: msg.role || 'agent',
|
||||
direction: msg.direction || 'north',
|
||||
x: msg.x ?? null,
|
||||
z: msg.z ?? null,
|
||||
};
|
||||
|
||||
// addAgent handles placement, scene insertion, and connection lines
|
||||
const added = addAgent(newDef);
|
||||
if (added) {
|
||||
// Update local lookup for future chat messages
|
||||
agentById[newDef.id] = newDef;
|
||||
logEvent(`Agent ${newDef.label} joined the swarm`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
* Scene Mutation — dynamic world objects
|
||||
* Agents can add/update/remove 3D objects at runtime.
|
||||
* ═══════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Add a 3D object to the scene.
|
||||
* { type: 'scene_add', id, geometry, position, material, animation, ... }
|
||||
*/
|
||||
case 'scene_add': {
|
||||
if (!msg.id) break;
|
||||
if (msg.geometry === 'portal') {
|
||||
addPortal(msg);
|
||||
} else {
|
||||
addSceneObject(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update properties of an existing scene object.
|
||||
* { type: 'scene_update', id, position?, rotation?, scale?, material?, animation?, visible? }
|
||||
*/
|
||||
case 'scene_update': {
|
||||
if (msg.id) updateSceneObject(msg.id, msg);
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a scene object.
|
||||
* { type: 'scene_remove', id }
|
||||
*/
|
||||
case 'scene_remove': {
|
||||
if (msg.id) {
|
||||
removePortal(msg.id); // handles both portals and regular objects
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dynamic scene objects.
|
||||
* { type: 'scene_clear' }
|
||||
*/
|
||||
case 'scene_clear': {
|
||||
clearSceneObjects();
|
||||
logEvent('Scene cleared');
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch add — spawn multiple objects in one message.
|
||||
* { type: 'scene_batch', objects: [...defs] }
|
||||
*/
|
||||
case 'scene_batch': {
|
||||
if (Array.isArray(msg.objects)) {
|
||||
let added = 0;
|
||||
for (const objDef of msg.objects) {
|
||||
if (objDef.geometry === 'portal') {
|
||||
if (addPortal(objDef)) added++;
|
||||
} else {
|
||||
if (addSceneObject(objDef)) added++;
|
||||
}
|
||||
}
|
||||
logEvent(`Batch: ${added} objects spawned`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
* Portals & Sub-worlds
|
||||
* ═══════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Register a sub-world definition (blueprint).
|
||||
* { type: 'world_register', id, label, objects: [...], ambient, spawn, returnPortal }
|
||||
*/
|
||||
case 'world_register': {
|
||||
if (msg.id) {
|
||||
registerWorld(msg);
|
||||
logEvent(`World "${msg.label || msg.id}" registered`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a sub-world by id. Clears current scene and spawns the world's objects.
|
||||
* { type: 'world_load', id }
|
||||
*/
|
||||
case 'world_load': {
|
||||
if (msg.id) {
|
||||
if (msg.id === '__home') {
|
||||
returnHome();
|
||||
logEvent('Returned to The Matrix');
|
||||
} else {
|
||||
const spawn = loadWorld(msg.id);
|
||||
if (spawn) {
|
||||
logEvent(`Entered world: ${msg.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a world definition.
|
||||
* { type: 'world_unregister', id }
|
||||
*/
|
||||
case 'world_unregister': {
|
||||
if (msg.id) unregisterWorld(msg.id);
|
||||
break;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
* Trigger Zones
|
||||
* ═══════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Add a trigger zone.
|
||||
* { type: 'zone_add', id, position, radius, action, payload, once }
|
||||
*/
|
||||
case 'zone_add': {
|
||||
if (msg.id) addZone(msg);
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a trigger zone.
|
||||
* { type: 'zone_remove', id }
|
||||
*/
|
||||
case 'zone_remove': {
|
||||
if (msg.id) removeZone(msg.id);
|
||||
break;
|
||||
}
|
||||
|
||||
/* ── Agent movement & behavior (Issues #67, #68) ── */
|
||||
|
||||
/**
|
||||
* Backend-driven agent movement.
|
||||
* { type: 'agent_move', agentId, target: {x, z}, speed? }
|
||||
*/
|
||||
case 'agent_move': {
|
||||
if (msg.agentId && msg.target) {
|
||||
const speed = msg.speed ?? 2.0;
|
||||
moveAgentTo(msg.agentId, msg.target, speed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop an agent's movement.
|
||||
* { type: 'agent_stop', agentId }
|
||||
*/
|
||||
case 'agent_stop': {
|
||||
if (msg.agentId) {
|
||||
stopAgentMovement(msg.agentId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend-driven behavior override.
|
||||
* { type: 'agent_behavior', agentId, behavior, target?, duration? }
|
||||
* Dispatched to the behavior system (behaviors.js) when loaded.
|
||||
*/
|
||||
case 'agent_behavior': {
|
||||
// Forwarded to behavior system — dispatched via custom event
|
||||
if (msg.agentId && msg.behavior) {
|
||||
window.dispatchEvent(new CustomEvent('matrix:agent_behavior', { detail: msg }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pong':
|
||||
case 'agent_count':
|
||||
case 'ping':
|
||||
break;
|
||||
|
||||
default:
|
||||
console.debug('[Matrix WS] Unhandled message type:', msg.type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function logEvent(text) {
|
||||
appendChatMessage('SYS', text, '#005500');
|
||||
}
|
||||
95
frontend/js/world.js
Normal file
95
frontend/js/world.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as THREE from 'three';
|
||||
import { getMaxPixelRatio, getQualityTier } from './quality.js';
|
||||
|
||||
let scene, camera, renderer;
|
||||
const _worldObjects = [];
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on
|
||||
* re-init so Three.js reuses the same DOM element instead of creating a new one
|
||||
*/
|
||||
export function initWorld(existingCanvas) {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x000000);
|
||||
scene.fog = new THREE.FogExp2(0x000000, 0.035);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500);
|
||||
camera.position.set(0, 12, 28);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const tier = getQualityTier();
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: tier !== 'low',
|
||||
canvas: existingCanvas || undefined,
|
||||
});
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio()));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
if (!existingCanvas) {
|
||||
document.body.prepend(renderer.domElement);
|
||||
}
|
||||
|
||||
addLights(scene);
|
||||
addGrid(scene, tier);
|
||||
|
||||
return { scene, camera, renderer };
|
||||
}
|
||||
|
||||
function addLights(scene) {
|
||||
const ambient = new THREE.AmbientLight(0x001a00, 0.6);
|
||||
scene.add(ambient);
|
||||
|
||||
const point = new THREE.PointLight(0x00ff41, 2, 80);
|
||||
point.position.set(0, 20, 0);
|
||||
scene.add(point);
|
||||
|
||||
const fill = new THREE.DirectionalLight(0x003300, 0.4);
|
||||
fill.position.set(-10, 10, 10);
|
||||
scene.add(fill);
|
||||
}
|
||||
|
||||
function addGrid(scene, tier) {
|
||||
const gridDivisions = tier === 'low' ? 20 : 40;
|
||||
const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00);
|
||||
grid.position.y = -0.01;
|
||||
scene.add(grid);
|
||||
_worldObjects.push(grid);
|
||||
|
||||
const planeGeo = new THREE.PlaneGeometry(100, 100);
|
||||
const planeMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x000a00,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
const plane = new THREE.Mesh(planeGeo, planeMat);
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
plane.position.y = -0.02;
|
||||
scene.add(plane);
|
||||
_worldObjects.push(plane);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose only world-owned geometries, materials, and the renderer.
|
||||
* Agent and effect objects are disposed by their own modules before this runs.
|
||||
*/
|
||||
export function disposeWorld(disposeRenderer, _scene) {
|
||||
for (const obj of _worldObjects) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach(m => {
|
||||
if (m.map) m.map.dispose();
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
_worldObjects.length = 0;
|
||||
disposeRenderer.dispose();
|
||||
}
|
||||
|
||||
export function onWindowResize(camera, renderer) {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
161
frontend/js/zones.js
Normal file
161
frontend/js/zones.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* zones.js — Proximity-based trigger zones for The Matrix.
|
||||
*
|
||||
* Zones are invisible volumes in the world that fire callbacks when
|
||||
* the visitor avatar enters or exits them. Primary use case: portal
|
||||
* traversal — walk into a portal zone → load a sub-world.
|
||||
*
|
||||
* Also used for: ambient music triggers, NPC interaction radius,
|
||||
* info panels, and any spatial event the backend wants to define.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { sendMessage } from './websocket.js';
|
||||
|
||||
const zones = new Map(); // id → { center, radius, active, callbacks, meta }
|
||||
let _visitorPos = new THREE.Vector3(0, 0, 22); // default spawn
|
||||
|
||||
/**
|
||||
* Register a trigger zone.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.id — unique zone identifier
|
||||
* @param {object} def.position — { x, y, z } center of the zone
|
||||
* @param {number} def.radius — trigger radius (default 2)
|
||||
* @param {string} def.action — what happens on enter: 'portal', 'notify', 'event'
|
||||
* @param {object} def.payload — action-specific data (e.g. target world for portals)
|
||||
* @param {boolean} def.once — if true, zone fires only once then deactivates
|
||||
*/
|
||||
export function addZone(def) {
|
||||
if (!def.id) return false;
|
||||
|
||||
zones.set(def.id, {
|
||||
center: new THREE.Vector3(
|
||||
def.position?.x ?? 0,
|
||||
def.position?.y ?? 0,
|
||||
def.position?.z ?? 0,
|
||||
),
|
||||
radius: def.radius ?? 2,
|
||||
action: def.action ?? 'notify',
|
||||
payload: def.payload ?? {},
|
||||
once: def.once ?? false,
|
||||
active: true,
|
||||
_wasInside: false,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a zone by id.
|
||||
*/
|
||||
export function removeZone(id) {
|
||||
return zones.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all zones.
|
||||
*/
|
||||
export function clearZones() {
|
||||
zones.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visitor position (called from avatar/visitor movement code).
|
||||
* @param {THREE.Vector3} pos
|
||||
*/
|
||||
export function setVisitorPosition(pos) {
|
||||
_visitorPos.copy(pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame check — test visitor against all active zones.
|
||||
* Call from the render loop.
|
||||
*
|
||||
* @param {function} onPortalEnter — callback(zoneId, payload) for portal zones
|
||||
*/
|
||||
export function updateZones(onPortalEnter) {
|
||||
for (const [id, zone] of zones) {
|
||||
if (!zone.active) continue;
|
||||
|
||||
const dist = _visitorPos.distanceTo(zone.center);
|
||||
const isInside = dist <= zone.radius;
|
||||
|
||||
if (isInside && !zone._wasInside) {
|
||||
// Entered zone
|
||||
_onEnter(id, zone, onPortalEnter);
|
||||
} else if (!isInside && zone._wasInside) {
|
||||
// Exited zone
|
||||
_onExit(id, zone);
|
||||
}
|
||||
|
||||
zone._wasInside = isInside;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active zone definitions (for debugging / HUD display).
|
||||
*/
|
||||
export function getZoneSnapshot() {
|
||||
const snap = {};
|
||||
for (const [id, z] of zones) {
|
||||
snap[id] = {
|
||||
position: { x: z.center.x, y: z.center.y, z: z.center.z },
|
||||
radius: z.radius,
|
||||
action: z.action,
|
||||
active: z.active,
|
||||
};
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
/* ── Internal handlers ── */
|
||||
|
||||
function _onEnter(id, zone, onPortalEnter) {
|
||||
console.info('[Zones] Entered zone:', id, zone.action);
|
||||
|
||||
switch (zone.action) {
|
||||
case 'portal':
|
||||
// Notify backend that visitor stepped into a portal
|
||||
sendMessage({
|
||||
type: 'zone_entered',
|
||||
zone_id: id,
|
||||
action: 'portal',
|
||||
payload: zone.payload,
|
||||
});
|
||||
// Trigger portal transition in the renderer
|
||||
if (onPortalEnter) onPortalEnter(id, zone.payload);
|
||||
break;
|
||||
|
||||
case 'event':
|
||||
// Fire a custom event back to the backend
|
||||
sendMessage({
|
||||
type: 'zone_entered',
|
||||
zone_id: id,
|
||||
action: 'event',
|
||||
payload: zone.payload,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'notify':
|
||||
default:
|
||||
// Just notify — backend can respond with barks, UI changes, etc.
|
||||
sendMessage({
|
||||
type: 'zone_entered',
|
||||
zone_id: id,
|
||||
action: 'notify',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (zone.once) {
|
||||
zone.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _onExit(id, zone) {
|
||||
sendMessage({
|
||||
type: 'zone_exited',
|
||||
zone_id: id,
|
||||
});
|
||||
}
|
||||
697
frontend/style.css
Normal file
697
frontend/style.css
Normal file
@@ -0,0 +1,697 @@
|
||||
/* ===== THE MATRIX — SOVEREIGN AGENT WORLD ===== */
|
||||
/* Matrix Green/Noir Cyberpunk Aesthetic */
|
||||
|
||||
:root {
|
||||
--matrix-green: #00ff41;
|
||||
--matrix-green-dim: #008f11;
|
||||
--matrix-green-dark: #003b00;
|
||||
--matrix-cyan: #00d4ff;
|
||||
--matrix-bg: #050505;
|
||||
--matrix-surface: rgba(0, 255, 65, 0.04);
|
||||
--matrix-surface-solid: #0a0f0a;
|
||||
--matrix-border: rgba(0, 255, 65, 0.2);
|
||||
--matrix-border-bright: rgba(0, 255, 65, 0.45);
|
||||
--matrix-text: #b0ffb0;
|
||||
--matrix-text-dim: #4a7a4a;
|
||||
--matrix-text-bright: #00ff41;
|
||||
--matrix-danger: #ff3333;
|
||||
--matrix-warning: #ff8c00;
|
||||
--matrix-purple: #9d4edd;
|
||||
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--panel-width: 360px;
|
||||
--panel-blur: 20px;
|
||||
--panel-radius: 4px;
|
||||
--transition-panel: 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-ui: 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--matrix-bg);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--matrix-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
canvas#matrix-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* ===== FPS Counter ===== */
|
||||
#fps-counter {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 100;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--matrix-green-dim);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 8px;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#fps-counter.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== Panel Base ===== */
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--panel-width);
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(5, 10, 5, 0.88);
|
||||
backdrop-filter: blur(var(--panel-blur));
|
||||
-webkit-backdrop-filter: blur(var(--panel-blur));
|
||||
border-left: 1px solid var(--matrix-border-bright);
|
||||
transform: translateX(0);
|
||||
transition: transform var(--transition-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel.hidden {
|
||||
transform: translateX(100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Scanline overlay on panel */
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 255, 65, 0.015) 2px,
|
||||
rgba(0, 255, 65, 0.015) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.panel > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ===== Panel Header ===== */
|
||||
.panel-header {
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--matrix-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-agent-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--matrix-text-bright);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 65, 0.5);
|
||||
}
|
||||
|
||||
.panel-agent-role {
|
||||
font-size: 11px;
|
||||
color: var(--matrix-text-dim);
|
||||
margin-top: 2px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--matrix-border);
|
||||
border-radius: 2px;
|
||||
color: var(--matrix-text-dim);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-ui);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.panel-close:hover, .panel-close:active {
|
||||
color: var(--matrix-text-bright);
|
||||
border-color: var(--matrix-border-bright);
|
||||
background: rgba(0, 255, 65, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Tabs ===== */
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--matrix-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--matrix-text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-ui);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--matrix-text);
|
||||
background: rgba(0, 255, 65, 0.04);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--matrix-text-bright);
|
||||
border-bottom-color: var(--matrix-green);
|
||||
text-shadow: 0 0 8px rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
|
||||
/* ===== Panel Content ===== */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ===== Chat ===== */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background: var(--matrix-green-dark);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
border-left: 2px solid var(--matrix-cyan);
|
||||
color: #b0eeff;
|
||||
}
|
||||
|
||||
.chat-msg.assistant {
|
||||
background: rgba(0, 255, 65, 0.05);
|
||||
border-left: 2px solid var(--matrix-green-dim);
|
||||
color: var(--matrix-text);
|
||||
}
|
||||
|
||||
.chat-msg .msg-role {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px 12px;
|
||||
border-top: 1px solid var(--matrix-border);
|
||||
}
|
||||
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#chat-input {
|
||||
flex: 1;
|
||||
background: rgba(0, 255, 65, 0.04);
|
||||
border: 1px solid var(--matrix-border);
|
||||
border-radius: 3px;
|
||||
padding: 10px 12px;
|
||||
color: var(--matrix-text-bright);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-ui);
|
||||
}
|
||||
|
||||
#chat-input:focus {
|
||||
border-color: var(--matrix-green);
|
||||
box-shadow: 0 0 8px rgba(0, 255, 65, 0.15);
|
||||
}
|
||||
|
||||
#chat-input::placeholder {
|
||||
color: var(--matrix-text-dim);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 40px;
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
border: 1px solid var(--matrix-border);
|
||||
border-radius: 3px;
|
||||
color: var(--matrix-green);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-ui);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.btn-send:hover, .btn-send:active {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
border-color: var(--matrix-green);
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 0 8px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.typing-indicator.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--matrix-green-dim);
|
||||
animation: typingDot 1.4s infinite both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typingDot {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
/* ===== Status Tab ===== */
|
||||
.status-grid {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 255, 65, 0.06);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-key {
|
||||
color: var(--matrix-text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
color: var(--matrix-text-bright);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-value.state-working {
|
||||
color: var(--matrix-green);
|
||||
text-shadow: 0 0 6px rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
|
||||
.status-value.state-idle {
|
||||
color: var(--matrix-text-dim);
|
||||
}
|
||||
|
||||
.status-value.state-waiting {
|
||||
color: var(--matrix-warning);
|
||||
}
|
||||
|
||||
/* ===== Tasks Tab ===== */
|
||||
.tasks-list {
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(0, 255, 65, 0.03);
|
||||
border: 1px solid var(--matrix-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.task-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-status-dot.pending { background: #ffffff; }
|
||||
.task-status-dot.in_progress, .task-status-dot.in-progress { background: var(--matrix-warning); box-shadow: 0 0 6px rgba(255, 140, 0, 0.5); }
|
||||
.task-status-dot.completed { background: var(--matrix-green); box-shadow: 0 0 6px rgba(0, 255, 65, 0.5); }
|
||||
.task-status-dot.failed { background: var(--matrix-danger); box-shadow: 0 0 6px rgba(255, 51, 51, 0.5); }
|
||||
|
||||
.task-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--matrix-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-priority {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 255, 65, 0.08);
|
||||
color: var(--matrix-text-dim);
|
||||
}
|
||||
|
||||
.task-priority.high {
|
||||
background: rgba(255, 51, 51, 0.15);
|
||||
color: var(--matrix-danger);
|
||||
}
|
||||
|
||||
.task-priority.normal {
|
||||
background: rgba(0, 255, 65, 0.08);
|
||||
color: var(--matrix-text-dim);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-ui);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-btn.approve {
|
||||
border-color: rgba(0, 255, 65, 0.3);
|
||||
color: var(--matrix-green);
|
||||
}
|
||||
|
||||
.task-btn.approve:hover {
|
||||
background: rgba(0, 255, 65, 0.15);
|
||||
border-color: var(--matrix-green);
|
||||
}
|
||||
|
||||
.task-btn.veto {
|
||||
border-color: rgba(255, 51, 51, 0.3);
|
||||
color: var(--matrix-danger);
|
||||
}
|
||||
|
||||
.task-btn.veto:hover {
|
||||
background: rgba(255, 51, 51, 0.15);
|
||||
border-color: var(--matrix-danger);
|
||||
}
|
||||
|
||||
/* ===== Memory Tab ===== */
|
||||
.memory-list {
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.memory-entry {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border-left: 2px solid var(--matrix-green-dark);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--matrix-text);
|
||||
}
|
||||
|
||||
.memory-timestamp {
|
||||
font-size: 9px;
|
||||
color: var(--matrix-text-dim);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
color: var(--matrix-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ===== Attribution ===== */
|
||||
.attribution {
|
||||
position: fixed;
|
||||
bottom: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attribution a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--matrix-green-dim);
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-ui);
|
||||
text-shadow: 0 0 4px rgba(0, 143, 17, 0.3);
|
||||
}
|
||||
|
||||
.attribution a:hover {
|
||||
opacity: 1;
|
||||
color: var(--matrix-green-dim);
|
||||
}
|
||||
|
||||
/* ===== Mobile / iPad ===== */
|
||||
@media (max-width: 768px) {
|
||||
.panel {
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--matrix-border-bright);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.panel.hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.panel-agent-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.panel-tabs .tab {
|
||||
font-size: 10px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.panel {
|
||||
height: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Help overlay ── */
|
||||
|
||||
#help-hint {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #005500;
|
||||
background: rgba(0, 10, 0, 0.6);
|
||||
border: 1px solid #003300;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
letter-spacing: 0.05em;
|
||||
transition: color 0.3s, border-color 0.3s;
|
||||
}
|
||||
#help-hint:hover {
|
||||
color: #00ff41;
|
||||
border-color: #00ff41;
|
||||
}
|
||||
|
||||
#help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff41;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.help-content {
|
||||
position: relative;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
padding: 24px 28px;
|
||||
border: 1px solid #003300;
|
||||
background: rgba(0, 10, 0, 0.7);
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 20px;
|
||||
color: #00ff41;
|
||||
text-shadow: 0 0 8px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
.help-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
color: #005500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.help-close:hover {
|
||||
color: #00ff41;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-heading {
|
||||
font-size: 0.65rem;
|
||||
color: #007700;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 6px;
|
||||
border-bottom: 1px solid #002200;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.help-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.help-row span:last-child {
|
||||
margin-left: auto;
|
||||
color: #009900;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.help-row kbd {
|
||||
display: inline-block;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.65rem;
|
||||
background: rgba(0, 30, 0, 0.6);
|
||||
border: 1px solid #004400;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
color: #00cc33;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ from nexus.perception_adapter import (
|
||||
from nexus.experience_store import ExperienceStore
|
||||
from nexus.groq_worker import GroqWorker
|
||||
from nexus.trajectory_logger import TrajectoryLogger
|
||||
import math, random
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -326,6 +327,47 @@ class NexusMind:
|
||||
|
||||
# ═══ WEBSOCKET ═══
|
||||
|
||||
|
||||
async def _broadcast_memory_landscape(self):
|
||||
"""Broadcast current memory state as Memory Orbs to the frontend."""
|
||||
if not self.ws:
|
||||
return
|
||||
|
||||
# Get 15 most recent experiences
|
||||
memories = self.experience_store.recent(limit=15)
|
||||
if not memories:
|
||||
return
|
||||
|
||||
log.info(f"Broadcasting {len(memories)} memory orbs to Nexus frontend...")
|
||||
|
||||
# Distribute orbs on a Fibonacci sphere for aesthetic layout
|
||||
phi = math.pi * (3. - math.sqrt(5.)) # golden angle in radians
|
||||
radius = 8.0
|
||||
|
||||
for i, exp in enumerate(memories):
|
||||
# Fibonacci sphere coordinates
|
||||
y = 1 - (i / float(len(memories) - 1)) * 2 if len(memories) > 1 else 0
|
||||
r = math.sqrt(1 - y * y)
|
||||
theta = phi * i
|
||||
|
||||
x = math.cos(theta) * r
|
||||
z = math.sin(theta) * r
|
||||
|
||||
# Format as a 'FACT_CREATED' event for the frontend Memory Bridge
|
||||
# Using the experience ID as the fact_id
|
||||
msg = {
|
||||
"event": "FACT_CREATED",
|
||||
"data": {
|
||||
"fact_id": f"exp_{exp['id']}",
|
||||
"category": "general",
|
||||
"content": exp['perception'][:200],
|
||||
"trust_score": 0.7 + (0.3 * (1.0 / (i + 1))), # Fade trust for older memories
|
||||
"position": {"x": x * radius, "y": y * radius, "z": z * radius}
|
||||
}
|
||||
}
|
||||
await self._ws_send(msg)
|
||||
|
||||
|
||||
async def _ws_send(self, msg: dict):
|
||||
"""Send a message to the WS gateway."""
|
||||
if self.ws:
|
||||
@@ -386,6 +428,7 @@ class NexusMind:
|
||||
while self.running:
|
||||
try:
|
||||
await self.think_once()
|
||||
await self._broadcast_memory_landscape()
|
||||
except Exception as e:
|
||||
log.error(f"Think cycle error: {e}", exc_info=True)
|
||||
|
||||
@@ -413,6 +456,9 @@ class NexusMind:
|
||||
log.info("=" * 50)
|
||||
|
||||
# Run WS listener and think loop concurrently
|
||||
# Initial memory landscape broadcast
|
||||
await self._broadcast_memory_landscape()
|
||||
|
||||
await asyncio.gather(
|
||||
self._ws_listen(),
|
||||
self._think_loop(),
|
||||
|
||||
Reference in New Issue
Block a user