artemis-command-center / dashboard_3d.html
ftrtemis's picture
Add Pip-Boy 3D arm + Fallout-style UI overlay (TAB/P toggle)
f80b4b6 verified
Raw
History Blame
177 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ARTEMIS — Command Center</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#1a1a1a;font-family:'Segoe UI','Cascadia Code',system-ui,sans-serif;cursor:none}
/* ── Loading ─────────────────────────── */
#loading{position:fixed;inset:0;background:#1a1a1a;z-index:1000;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:20px;transition:opacity .8s}
#loading.done{opacity:0;pointer-events:none}
#load-title{font-size:1.8rem;font-weight:300;color:#d4c5a9;letter-spacing:.3em;text-transform:uppercase}
#load-sub{font-size:.85rem;color:#8a7e6a;letter-spacing:.15em}
#load-bar{width:300px;height:2px;background:#2a2a2a;border-radius:1px;overflow:hidden;margin-top:8px}
#load-fill{width:0%;height:100%;background:linear-gradient(90deg,#d4c5a9,#f0e6cc);transition:width .3s}
/* ── Lock Screen (click to start) ────── */
#lock-screen{position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:16px;background:rgba(26,26,26,.85);cursor:pointer;transition:opacity .4s}
#lock-screen.hidden{opacity:0;pointer-events:none}
#lock-screen h2{font-size:1.2rem;font-weight:400;color:#d4c5a9;letter-spacing:.2em;text-transform:uppercase}
#lock-screen p{font-size:.82rem;color:#8a7e6a;letter-spacing:.08em}
/* ── Crosshair ───────────────────────── */
#crosshair{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;z-index:10;opacity:0;transition:opacity .3s}
#crosshair.visible{opacity:.5}
#crosshair::before,#crosshair::after{content:'';position:absolute;background:#d4c5a9}
#crosshair::before{width:1px;height:14px;left:50%;top:50%;transform:translate(-50%,-50%)}
#crosshair::after{width:14px;height:1px;top:50%;left:50%;transform:translate(-50%,-50%)}
/* ── HUD ─────────────────────────────── */
#hud{position:fixed;inset:0;pointer-events:none;z-index:10;opacity:0;transition:opacity .4s}
#hud.visible{opacity:1}
#hud *{pointer-events:auto}
#room-label{position:absolute;top:20px;left:24px;font-size:.72rem;color:#8a7e6a;letter-spacing:.2em;
text-transform:uppercase;transition:all .4s}
#room-label span{color:#d4c5a9;font-weight:500}
#backend-status{position:absolute;top:20px;right:24px;display:flex;align-items:center;gap:8px;
font-size:.68rem;color:#8a7e6a;letter-spacing:.08em;text-transform:uppercase}
#status-dot{width:8px;height:8px;border-radius:50%;background:#8a4a4a;transition:background .3s,box-shadow .3s}
#status-dot.ok{background:#6a9a6a;box-shadow:0 0 8px rgba(106,154,106,.4)}
#interact-prompt{position:absolute;bottom:120px;left:50%;transform:translateX(-50%);
font-size:.82rem;color:#d4c5a9;letter-spacing:.1em;opacity:0;transition:opacity .3s;
background:rgba(26,26,26,.7);padding:8px 20px;border-radius:3px;
border:1px solid rgba(212,197,169,.15)}
#interact-prompt.visible{opacity:1}
#controls-hint{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);
display:flex;gap:24px;font-size:.65rem;color:rgba(138,126,106,.5);letter-spacing:.06em}
#narrator{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
font-size:1rem;color:#d4c5a9;letter-spacing:.06em;line-height:1.8;text-align:center;
max-width:600px;opacity:0;transition:opacity .8s;pointer-events:none;
text-shadow:0 0 20px rgba(26,26,26,.8)}
#narrator.visible{opacity:1}
/* ── Tool Panel ──────────────────────── */
#tool-panel{position:fixed;top:0;right:-420px;width:420px;height:100%;z-index:100;
background:rgba(32,30,26,.96);border-left:1px solid rgba(212,197,169,.1);
backdrop-filter:blur(12px);transition:right .35s ease;display:flex;flex-direction:column;
pointer-events:auto;cursor:default}
#tool-panel.open{right:0}
#tp-header{padding:20px 24px 16px;border-bottom:1px solid rgba(212,197,169,.08);display:flex;
align-items:center;justify-content:space-between}
#tp-title{font-size:.9rem;font-weight:600;color:#d4c5a9;letter-spacing:.08em;text-transform:uppercase}
#tp-close{background:none;border:none;color:#8a7e6a;cursor:pointer;font-size:1.2rem;padding:4px 8px;
font-family:inherit;transition:color .2s}
#tp-close:hover{color:#c4a070}
#tp-body{flex:1;overflow-y:auto;padding:20px 24px;display:flex;flex-direction:column;gap:12px}
.tp-desc{font-size:.85rem;color:#a09080;line-height:1.6}
.tp-section{margin-top:8px}
.tp-section-label{font-size:.65rem;color:#8a7e6a;letter-spacing:.12em;text-transform:uppercase;margin-bottom:6px}
.tp-tags{display:flex;flex-wrap:wrap;gap:4px}
.tp-tag{font-size:.65rem;padding:3px 10px;border-radius:2px;border:1px solid rgba(212,197,169,.12);
color:#a09080;background:rgba(212,197,169,.04);letter-spacing:.04em}
.tp-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.tp-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;
border:1px solid rgba(212,197,169,.15);border-radius:3px;background:rgba(212,197,169,.04);
color:#a09080;font-family:inherit;font-size:.78rem;cursor:pointer;
letter-spacing:.04em;transition:all .25s}
.tp-btn:hover{color:#d4c5a9;border-color:rgba(212,197,169,.3);background:rgba(212,197,169,.08)}
.tp-btn.launch{border-color:rgba(106,154,106,.3);color:#6a9a6a}
.tp-btn.launch:hover{background:rgba(106,154,106,.1)}
.tp-btn.stop{border-color:rgba(180,100,100,.3);color:#b46464}
.tp-btn.stop:hover{background:rgba(180,100,100,.1)}
.tp-status{display:flex;align-items:center;gap:8px;font-size:.78rem;color:#8a7e6a;margin-top:8px}
.tp-status-dot{width:8px;height:8px;border-radius:50%;background:#5a5a5a}
.tp-status-dot.running{background:#6a9a6a;box-shadow:0 0 6px rgba(106,154,106,.4);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ── Toast ────────────────────────────── */
#toast-container{position:fixed;top:60px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{padding:10px 20px;border-radius:3px;font-size:.82rem;background:rgba(32,30,26,.95);
border:1px solid rgba(212,197,169,.15);color:#d4c5a9;animation:toastIn .3s;pointer-events:auto}
.toast.success{border-color:rgba(106,154,106,.3)}
.toast.error{border-color:rgba(180,100,100,.3)}
@keyframes toastIn{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:none}}
/* ── Space Iframe Modal ─────────────── */
#space-modal{position:fixed;inset:0;z-index:300;display:none;align-items:center;justify-content:center;
background:rgba(10,10,15,.92);backdrop-filter:blur(6px)}
#space-modal.open{display:flex}
#space-box{width:min(1100px,96vw);height:min(780px,88vh);background:#0d1117;
border:1px solid #1e3a5f;border-radius:8px;display:flex;flex-direction:column;
box-shadow:0 0 40px rgba(88,166,255,.15)}
#space-header{padding:10px 16px;border-bottom:1px solid #1e3a5f;display:flex;
align-items:center;justify-content:space-between;flex-shrink:0}
#space-title{font-size:.8rem;color:#58a6ff;letter-spacing:.08em;text-transform:uppercase;font-weight:600}
#space-close{background:none;border:none;color:#8b949e;cursor:pointer;font-size:1.2rem;font-family:inherit;line-height:1}
#space-close:hover{color:#f0f6fc}
#space-iframe{flex:1;border:none;border-radius:0 0 8px 8px;width:100%}
/* ── Output Modal ────────────────────── */
#output-modal{position:fixed;inset:0;z-index:200;display:none;align-items:center;justify-content:center;
background:rgba(20,18,16,.8);backdrop-filter:blur(4px)}
#output-modal.open{display:flex}
#output-box{width:700px;max-height:70vh;background:rgba(32,30,26,.98);border:1px solid rgba(212,197,169,.1);
border-radius:4px;display:flex;flex-direction:column}
#output-header{padding:12px 20px;border-bottom:1px solid rgba(212,197,169,.08);display:flex;
align-items:center;justify-content:space-between}
#output-title{font-size:.82rem;color:#d4c5a9;letter-spacing:.06em;text-transform:uppercase}
#output-close{background:none;border:none;color:#8a7e6a;cursor:pointer;font-size:1.1rem;font-family:inherit}
#output-content{flex:1;overflow-y:auto;padding:16px 20px;font-family:'Cascadia Code',monospace;
font-size:.78rem;color:#a09080;line-height:1.6;white-space:pre-wrap;word-break:break-all}
/* Scrollbars */
::-webkit-scrollbar{width:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(212,197,169,.12);border-radius:2px}
::-webkit-scrollbar-thumb:hover{background:rgba(212,197,169,.25)}
::selection{background:rgba(212,197,169,.2)}
/* ── Game Menu ───────────────────── */
#game-menu{position:fixed;inset:8% 20% 8% 20%;z-index:1500;background:rgba(18,18,18,.98);border:1px solid rgba(212,197,169,.06);color:#d4c5a9;display:none;flex-direction:column;border-radius:8px;overflow:hidden}
#game-menu.open{display:flex}
#game-menu .gm-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid rgba(212,197,169,.04);font-weight:600}
#game-menu .gm-body{display:flex;gap:12px;padding:12px 16px;flex:1;overflow:auto}
.gm-column{flex:1;min-width:220px}
.gm-section{margin-bottom:12px}
.gm-label{font-size:.78rem;color:#a09080;margin-bottom:6px;display:block;text-transform:uppercase;letter-spacing:.06em}
.gm-control{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.gm-slider{width:100%}
.gm-actions{display:flex;gap:8px;padding:12px;border-top:1px solid rgba(212,197,169,.04);justify-content:flex-end}
.gm-keybinds{display:flex;flex-direction:column;gap:6px}
.gm-keybind-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.82rem;color:#d4c5a9}
.gm-keybind-row .gm-key-btn{min-width:80px;text-align:center;padding:4px 10px;background:rgba(212,197,169,.06);border:1px solid rgba(212,197,169,.15);border-radius:3px;color:#d4c5a9;cursor:pointer;font-family:'Cascadia Code',monospace;font-size:.75rem}
.gm-keybind-row .gm-key-btn.listening{background:rgba(180,140,60,.18);border-color:rgba(212,170,80,.5);color:#f0d090}
/* ── Cinematic Transition Overlays ─── */
#fx-static{position:fixed;inset:0;z-index:1200;pointer-events:none;opacity:0;mix-blend-mode:screen;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.92' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.85 0 0 0 0 0.78 0 0 0 0 0.65 0 0 0 0.45 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
background-size:240px 240px;animation:fxStaticShift 240ms steps(6) infinite;transition:opacity .25s linear}
@keyframes fxStaticShift{0%{background-position:0 0}25%{background-position:-30px 12px}50%{background-position:18px -22px}75%{background-position:-8px 30px}100%{background-position:24px 6px}}
#fx-supernova{position:fixed;inset:0;z-index:1210;pointer-events:none;opacity:0;mix-blend-mode:screen;
background:radial-gradient(circle at 50% 30%, rgba(255,250,220,1) 0%, rgba(255,210,120,.8) 12%, rgba(255,170,60,.4) 28%, rgba(120,60,20,.1) 50%, transparent 70%);
transition:opacity .15s linear}
#fx-nebula{position:fixed;inset:0;z-index:1190;pointer-events:none;opacity:0;mix-blend-mode:screen;transition:opacity .8s ease-out;
background:
radial-gradient(ellipse 60% 50% at 8% 12%, rgba(190, 90,255,.55), transparent 60%),
radial-gradient(ellipse 50% 60% at 96% 18%, rgba( 80,200,255,.55), transparent 60%),
radial-gradient(ellipse 65% 55% at 4% 92%, rgba(255, 80,200,.45), transparent 60%),
radial-gradient(ellipse 55% 60% at 98% 88%, rgba(120, 80,255,.5), transparent 60%)}
#fx-vignette{position:fixed;inset:0;z-index:1195;pointer-events:none;opacity:0;
background:radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,.6) 75%, rgba(0,0,0,.95) 100%);
transition:opacity .6s ease-out}
#fx-shake{position:fixed;inset:0;z-index:1100;pointer-events:none}
.fx-shudder{animation:fxShudder 380ms cubic-bezier(.36,.07,.19,.97) both}
@keyframes fxShudder{
10%,90%{transform:translate3d(-1px,0,0)}
20%,80%{transform:translate3d(2px,0,0)}
30%,50%,70%{transform:translate3d(-4px,1px,0)}
40%,60%{transform:translate3d(4px,-1px,0)}
}
/* Space-dust shimmer (crystalline shards in periphery) */
#fx-shimmer{position:fixed;inset:0;z-index:1188;pointer-events:none;opacity:0;mix-blend-mode:screen;transition:opacity 1.6s ease-out;
background-image:
radial-gradient(circle at 12% 18%, rgba(220,235,255,.9) 0, transparent 1.4px),
radial-gradient(circle at 88% 22%, rgba(200,220,255,.85) 0, transparent 1.2px),
radial-gradient(circle at 8% 78%, rgba(240,245,255,.9) 0, transparent 1.6px),
radial-gradient(circle at 92% 84%, rgba(220,230,255,.85) 0, transparent 1.4px),
radial-gradient(circle at 28% 92%, rgba(255,255,255,.7) 0, transparent 1.2px),
radial-gradient(circle at 72% 8%, rgba(255,255,255,.7) 0, transparent 1.2px);
background-size: 240px 240px;
animation:fxShimmer 3.2s ease-in-out infinite alternate}
@keyframes fxShimmer{
0% {filter:brightness(1) blur(0)}
50% {filter:brightness(1.6) blur(.4px)}
100%{filter:brightness(1) blur(0)}
}
/* Galaxy button detonation */
#galaxy-btn.detonating{animation:fxBtnDetonate 480ms ease-out forwards;border-color:rgba(255,210,120,.9);
box-shadow:0 0 60px rgba(255,200,100,.7),0 0 120px rgba(255,160,60,.4)}
@keyframes fxBtnDetonate{
0%{transform:scale(1)}
20%{transform:scale(1.18);background:rgba(80,60,30,.9)}
60%{transform:scale(.92)}
100%{transform:scale(1.06);opacity:0}
}
#galaxy-btn .g-dot.detonate{animation:fxDotDetonate 480ms ease-out forwards}
@keyframes fxDotDetonate{
0%{transform:scale(1);box-shadow:0 0 10px rgba(240,216,144,.7)}
40%{transform:scale(4);box-shadow:0 0 80px rgba(255,220,140,1),0 0 160px rgba(255,180,80,.6)}
100%{transform:scale(8);opacity:0;box-shadow:0 0 200px rgba(255,255,255,.0)}
}
/* ── Galaxy Mode Button ──────────────── */
#galaxy-btn{position:fixed;bottom:60px;right:24px;z-index:15;
padding:12px 22px;display:flex;align-items:center;gap:10px;
background:rgba(26,26,26,.72);backdrop-filter:blur(10px);
border:1px solid rgba(212,197,169,.22);border-radius:3px;
color:#d4c5a9;font-family:inherit;font-size:.78rem;letter-spacing:.15em;
text-transform:uppercase;cursor:pointer;opacity:0;pointer-events:none;
transition:all .3s ease;text-shadow:0 0 12px rgba(212,197,169,.3)}
#galaxy-btn.visible{opacity:1;pointer-events:auto}
#galaxy-btn:hover{border-color:rgba(212,197,169,.55);background:rgba(40,36,30,.85);
box-shadow:0 0 24px rgba(212,197,169,.12)}
#galaxy-btn .g-dot{width:8px;height:8px;border-radius:50%;
background:radial-gradient(circle,#f0d890 0%,#8a6a2a 70%);
box-shadow:0 0 10px rgba(240,216,144,.7);animation:galaxy-pulse 2.4s infinite}
@keyframes galaxy-pulse{0%,100%{opacity:.8;transform:scale(1)}50%{opacity:1;transform:scale(1.15)}}
/* ── Galaxy HUD (visible in galaxy mode) ── */
#galaxy-hud{position:fixed;inset:0;pointer-events:none;z-index:12;
opacity:0;transition:opacity .6s ease}
#galaxy-hud.visible{opacity:1}
#galaxy-title{position:absolute;top:24px;left:50%;transform:translateX(-50%);
font-size:.9rem;color:#d4c5a9;letter-spacing:.3em;text-transform:uppercase;
text-shadow:0 0 20px rgba(26,26,26,.8)}
#galaxy-subtitle{position:absolute;top:50px;left:50%;transform:translateX(-50%);
font-size:.68rem;color:#8a7e6a;letter-spacing:.2em;text-transform:uppercase}
#galaxy-exit-hint{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);
font-size:.75rem;color:#d4c5a9;letter-spacing:.15em;text-transform:uppercase;
background:rgba(26,26,26,.6);padding:8px 18px;border-radius:3px;
border:1px solid rgba(212,197,169,.15);text-shadow:0 0 10px rgba(26,26,26,.6)}
#galaxy-controls-hint{position:absolute;bottom:56px;left:50%;transform:translateX(-50%);
font-size:.62rem;color:rgba(138,126,106,.55);letter-spacing:.1em;
display:flex;gap:20px}
/* ── Galaxy tooltip (on hover) ─────────── */
#galaxy-tooltip{position:fixed;z-index:80;pointer-events:none;
background:rgba(32,30,26,.92);backdrop-filter:blur(10px);
border:1px solid rgba(212,197,169,.2);border-radius:3px;
padding:10px 14px;max-width:360px;font-size:.78rem;
opacity:0;transition:opacity .15s;color:#d4c5a9}
#galaxy-tooltip.visible{opacity:1}
#galaxy-tooltip .gt-name{font-weight:600;color:#f0e6cc;margin-bottom:2px;
word-break:break-all}
#galaxy-tooltip .gt-path{color:#a09080;font-size:.7rem;margin-bottom:4px;
word-break:break-all}
#galaxy-tooltip .gt-meta{color:#8a7e6a;font-size:.68rem;letter-spacing:.04em}
/* ── Galaxy loading overlay ─────────────── */
#galaxy-loading{position:fixed;inset:0;z-index:300;display:none;
flex-direction:column;align-items:center;justify-content:center;gap:14px;
background:rgba(8,8,12,.94)}
#galaxy-loading.visible{display:flex}
#galaxy-loading h3{font-size:1.1rem;color:#d4c5a9;letter-spacing:.25em;
text-transform:uppercase;font-weight:300}
#galaxy-loading p{font-size:.72rem;color:#8a7e6a;letter-spacing:.1em}
#galaxy-loading .gl-bar{width:280px;height:2px;background:rgba(212,197,169,.1);
overflow:hidden}
#galaxy-loading .gl-fill{height:100%;width:0%;
background:linear-gradient(90deg,#d4c5a9,#f0e6cc);transition:width .25s}
/* ── Galaxy Sidebar (filters, search, sliders) ── */
#galaxy-sidebar{position:fixed;top:0;left:0;height:100%;width:min(340px,92vw);
z-index:13;background:rgba(26,24,20,.82);backdrop-filter:blur(14px);
border-right:1px solid rgba(212,197,169,.12);
transform:translateX(-100%);transition:transform .4s ease;
display:flex;flex-direction:column;padding:20px 22px;overflow-y:auto;
pointer-events:auto;cursor:default}
#galaxy-sidebar.open{transform:translateX(0)}
#galaxy-sidebar h3{font-size:.95rem;color:#d4c5a9;letter-spacing:.22em;
text-transform:uppercase;font-weight:400;margin-bottom:4px}
#galaxy-sidebar .gs-sub{font-size:.68rem;color:#8a7e6a;letter-spacing:.1em;
margin-bottom:18px}
#galaxy-sidebar .gs-section{font-size:.62rem;color:#8a7e6a;letter-spacing:.2em;
text-transform:uppercase;margin:16px 0 8px;font-weight:500}
#galaxy-sidebar-toggle{position:fixed;top:20px;left:20px;z-index:14;
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
background:rgba(26,24,20,.72);backdrop-filter:blur(10px);
border:1px solid rgba(212,197,169,.18);border-radius:3px;
color:#d4c5a9;font-size:1.1rem;cursor:pointer;
opacity:0;pointer-events:none;transition:all .3s ease;font-family:inherit}
#galaxy-sidebar-toggle.visible{opacity:1;pointer-events:auto}
#galaxy-sidebar-toggle:hover{border-color:rgba(212,197,169,.45);
background:rgba(40,36,30,.85)}
#galaxy-sidebar-toggle.shifted{left:calc(min(340px,92vw) + 12px)}
/* Source toggles */
.gs-src-row{display:flex;align-items:center;gap:10px;padding:6px 0;
cursor:pointer;font-size:.78rem;color:#d4c5a9}
.gs-src-row input{width:14px;height:14px;accent-color:#c4a070;cursor:pointer}
.gs-src-dot{width:10px;height:10px;border-radius:50%;
box-shadow:0 0 6px currentColor;flex-shrink:0}
.gs-src-label{flex:1;color:#d4c5a9}
.gs-src-count{color:#8a7e6a;font-size:.68rem;letter-spacing:.04em}
/* Sliders */
.gs-slider-row{display:flex;align-items:center;gap:10px;margin-bottom:4px}
.gs-slider-row input[type=range]{flex:1;accent-color:#c4a070;cursor:pointer}
.gs-slider-val{font-size:.72rem;color:#a09080;min-width:44px;text-align:right;
letter-spacing:.04em}
/* Search */
#galaxy-search{width:100%;padding:9px 12px;
background:rgba(212,197,169,.04);border:1px solid rgba(212,197,169,.15);
border-radius:3px;color:#d4c5a9;font-family:inherit;font-size:.82rem;
outline:none;transition:border-color .2s;letter-spacing:.02em}
#galaxy-search:focus{border-color:rgba(212,197,169,.5)}
#galaxy-search::placeholder{color:rgba(138,126,106,.6)}
#galaxy-search-results{margin-top:8px;max-height:40vh;overflow-y:auto}
.gs-result{padding:7px 10px;border-radius:2px;cursor:pointer;
font-size:.76rem;color:#d4c5a9;display:flex;align-items:center;gap:8px;
transition:background .15s}
.gs-result:hover{background:rgba(212,197,169,.08)}
.gs-result .gs-res-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;
box-shadow:0 0 5px currentColor}
.gs-result .gs-res-name{flex:1;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;color:#d4c5a9}
.gs-result .gs-res-ext{color:#8a7e6a;font-size:.68rem;flex-shrink:0}
.gs-result-more{padding:8px;color:#8a7e6a;font-size:.7rem;
letter-spacing:.04em;text-align:center}
/* ── Mobile Controls ───────────────────── */
body.touch-device{cursor:default}
body.touch-device #crosshair{opacity:.25}
#mobile-ui{position:fixed;inset:0;z-index:20;pointer-events:none;display:none}
#mobile-ui.visible{display:block}
#mobile-joystick{position:absolute;left:18px;bottom:24px;width:132px;height:132px;pointer-events:auto;touch-action:none}
#mobile-stick-base{position:absolute;inset:0;border-radius:50%;
border:1px solid rgba(212,197,169,.35);background:rgba(26,26,26,.38);backdrop-filter:blur(4px)}
#mobile-stick{position:absolute;left:50%;top:50%;width:56px;height:56px;transform:translate(-50%,-50%);
border-radius:50%;border:1px solid rgba(212,197,169,.45);background:rgba(212,197,169,.2)}
#mobile-actions{position:absolute;right:18px;bottom:24px;display:flex;flex-direction:column;gap:10px;pointer-events:auto}
.mobile-btn{min-width:88px;padding:10px 12px;border-radius:6px;border:1px solid rgba(212,197,169,.28);
background:rgba(26,26,26,.58);backdrop-filter:blur(6px);color:#d4c5a9;letter-spacing:.08em;
text-transform:uppercase;font-size:.68rem;font-family:inherit}
@media (max-width: 900px) {
#controls-hint{display:none}
#room-label{top:12px;left:12px;font-size:.62rem}
#backend-status{top:12px;right:12px;font-size:.58rem}
#tool-panel{width:min(100vw,420px)}
#game-menu{inset:4% 3% 4% 3%}
#output-box{width:min(95vw,700px)}
}
/* ── Pip-Boy UI overlay (Fallout-style CRT) ─────────── */
#pipboy-ui{position:fixed;inset:0;z-index:30;display:none;
pointer-events:none;background:radial-gradient(ellipse at center,
rgba(0,0,0,0) 40%, rgba(0,0,0,.85) 100%);
font-family:'Courier New','VT323',monospace;color:#5dff8f;
text-shadow:0 0 4px #1eff5a, 0 0 12px rgba(10,255,68,.6);
letter-spacing:.04em}
#pipboy-ui.visible{display:block;pointer-events:auto}
#pipboy-screen{position:absolute;left:50%;top:50%;
transform:translate(-50%,-50%);
width:min(78vw,1100px);height:min(78vh,720px);
border:2px solid #1eff5a;border-radius:18px;
background:radial-gradient(ellipse at center,#031108 0%,#000 95%);
box-shadow:0 0 40px rgba(30,255,90,.45) inset, 0 0 60px rgba(30,255,90,.4);
overflow:hidden;padding:18px 22px 32px}
#pipboy-screen::before{content:"";position:absolute;inset:0;
background:repeating-linear-gradient(0deg,
rgba(30,255,90,.06) 0 1px, transparent 1px 3px);
pointer-events:none;mix-blend-mode:screen}
#pipboy-screen::after{content:"";position:absolute;inset:0;
background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.75) 100%);
pointer-events:none;mix-blend-mode:multiply}
.pb-tabs{display:flex;gap:14px;border-bottom:1px solid #1eff5a;
padding-bottom:6px;letter-spacing:.22em;font-size:1.4rem;font-weight:bold}
.pb-tab{padding:4px 14px;cursor:pointer;opacity:.55;user-select:none;
transition:opacity .15s}
.pb-tab.active{opacity:1;background:#1eff5a;color:#000;
text-shadow:none;border-radius:3px}
.pb-tab:hover{opacity:.9}
.pb-subtabs{display:flex;gap:14px;margin-top:10px;font-size:.95rem;
letter-spacing:.14em;opacity:.85}
.pb-subtab{padding:2px 10px;cursor:pointer;
border-bottom:1px solid transparent;user-select:none}
.pb-subtab.active{border-bottom-color:#1eff5a}
.pb-body{margin-top:14px;height:calc(100% - 130px);overflow:auto;
font-size:1.05rem;line-height:1.55;padding-right:6px}
.pb-body::-webkit-scrollbar{width:8px}
.pb-body::-webkit-scrollbar-thumb{background:#1eff5a;border-radius:2px}
.pb-body h3{margin:6px 0 10px;letter-spacing:.22em;font-size:1.15rem}
.pb-row{display:flex;justify-content:space-between;align-items:center;
border-bottom:1px dashed rgba(30,255,90,.35);padding:5px 4px}
.pb-bar{display:inline-block;width:160px;height:10px;
border:1px solid #1eff5a;margin-left:8px;vertical-align:middle}
.pb-bar > span{display:block;height:100%;background:#1eff5a;
box-shadow:0 0 8px #1eff5a}
.pb-list{list-style:none;padding:0;margin:0}
.pb-list li{padding:6px 8px;cursor:pointer;
border-left:2px solid transparent;transition:background .12s}
.pb-list li:hover, .pb-list li.sel{background:rgba(30,255,90,.18);
border-left-color:#1eff5a}
.pb-footer{position:absolute;left:22px;right:22px;bottom:10px;
border-top:1px solid #1eff5a;padding-top:6px;
display:flex;justify-content:space-between;font-size:.85rem;
letter-spacing:.14em;opacity:.9}
#pipboy-flicker{position:absolute;inset:0;pointer-events:none;
background:rgba(30,255,90,.04);animation:pbflick 4.5s infinite}
@keyframes pbflick{
0%,96%,100%{opacity:1}
97%{opacity:.55}
98%{opacity:.85}
99%{opacity:.65}
}
#pipboy-hint{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);
z-index:31;color:#1eff5a;font-family:'Courier New',monospace;
font-size:.85rem;letter-spacing:.2em;opacity:.85;pointer-events:none;
text-shadow:0 0 6px #1eff5a;display:none}
#pipboy-ui.visible ~ #pipboy-hint{display:block}
</style>
</head>
<body>
<!-- Loading -->
<div id="loading">
<div id="load-title">ARTEMIS</div>
<div id="load-sub">Initializing Environment</div>
<div id="load-bar"><div id="load-fill"></div></div>
</div>
<!-- Lock screen (click to start FPS) -->
<div id="lock-screen" class="hidden">
<h2>Click to Enter</h2>
<p>WASD to move &middot; Mouse to look &middot; E to interact &middot; ESC to release</p>
</div>
<!-- Crosshair -->
<div id="crosshair"></div>
<!-- HUD -->
<div id="hud">
<div id="room-label">LOCATION <span id="room-name">Central Hub</span></div>
<div id="backend-status">BACKEND <div id="status-dot"></div></div>
<div id="interact-prompt">Press E to interact</div>
<div id="controls-hint">
<span>WASD Move</span><span>Mouse Look</span><span>E Interact</span><span>F Galaxy</span><span>TAB Pip-Boy</span><span>ESC Menu</span><span>SHIFT Sprint</span>
</div>
<div id="narrator"></div>
</div>
<!-- Mobile touch controls -->
<div id="mobile-ui">
<div id="mobile-joystick">
<div id="mobile-stick-base"></div>
<div id="mobile-stick"></div>
</div>
<div id="mobile-actions">
<button class="mobile-btn" id="mobile-interact">Interact</button>
<button class="mobile-btn" id="mobile-menu">Menu</button>
<button class="mobile-btn" id="mobile-galaxy">Galaxy</button>
</div>
</div>
<!-- Pre-recorded voice narration -->
<audio id="voice-built-tools" preload="auto" src="/assets/The%20Workshop.wav"></audio>
<audio id="voice-central-hub" preload="auto" src="/assets/The%20Center%20Narration.wav"></audio>
<!-- Tool Panel -->
<div id="tool-panel">
<div id="tp-header">
<span id="tp-title"></span>
<button id="tp-close" onclick="closeToolPanel()"></button>
</div>
<div id="tp-body"></div>
</div>
<!-- Space Iframe Modal -->
<div id="space-modal">
<div id="space-box">
<div id="space-header">
<span id="space-title">ARTEMIS OMNI CHAT</span>
<button id="space-close" onclick="closeSpaceModal()"></button>
</div>
<iframe id="space-iframe" src="" allow="microphone *; camera *; autoplay *"></iframe>
</div>
</div>
<!-- Output Modal -->
<div id="output-modal">
<div id="output-box">
<div id="output-header">
<span id="output-title">Output</span>
<button id="output-close" onclick="closeOutputModal()"></button>
</div>
<div id="output-content"></div>
</div>
</div>
<!-- Toast -->
<div id="toast-container"></div>
<!-- Pip-Boy UI overlay -->
<div id="pipboy-ui">
<div id="pipboy-screen">
<div class="pb-tabs">
<div class="pb-tab active" data-tab="STAT">STAT</div>
<div class="pb-tab" data-tab="INV">INV</div>
<div class="pb-tab" data-tab="DATA">DATA</div>
<div class="pb-tab" data-tab="MAP">MAP</div>
<div class="pb-tab" data-tab="RADIO">RADIO</div>
</div>
<div class="pb-subtabs" id="pb-subtabs"></div>
<div class="pb-body" id="pb-body"></div>
<div class="pb-footer">
<span id="pb-loc">LOC: Central Hub</span>
<span id="pb-time">--:--</span>
<span>HP 100/100</span>
<span>AP 90/90</span>
<span id="pb-caps">CAPS 1247</span>
</div>
<div id="pipboy-flicker"></div>
</div>
</div>
<div id="pipboy-hint">[ TAB / P ] CLOSE PIP-BOY</div>
<!-- Cinematic transition overlays (galaxy enter/exit) -->
<div id="fx-nebula"></div>
<div id="fx-shimmer"></div>
<div id="fx-vignette"></div>
<div id="fx-static"></div>
<div id="fx-supernova"></div>
<!-- Game Menu (M) -->
<div id="game-menu" aria-hidden="true">
<div class="gm-header"><div>Menu</div><button id="gm-close" class="tp-btn"></button></div>
<div class="gm-body">
<div class="gm-column">
<div class="gm-section"><div class="gm-label">Audio</div>
<div class="gm-control"><label>Master</label><input id="gm-audio-master" type="range" min="0" max="1" step="0.01" class="gm-slider" value="1"></div>
<div class="gm-control"><button id="gm-audio-mute" class="tp-btn" data-muted="0">Mute</button></div>
</div>
<div class="gm-section"><div class="gm-label">Display</div>
<div class="gm-control"><label>Brightness</label><input id="gm-display-bright" type="range" min="0.2" max="2" step="0.05" class="gm-slider" value="1"></div>
<div class="gm-control"><label>FOV</label><input id="gm-display-fov" type="range" min="45" max="110" step="1" class="gm-slider" value="75"></div>
</div>
</div>
<div class="gm-column">
<div class="gm-section"><div class="gm-label">Gameplay</div>
<div class="gm-control"><label>Walk Speed</label><input id="gm-walk-speed" type="range" min="1" max="10" step="0.1" class="gm-slider" value="4.5"></div>
<div class="gm-control"><label>Sprint Mult</label><input id="gm-sprint-mult" type="range" min="1" max="3" step="0.05" class="gm-slider" value="1.8"></div>
<div class="gm-control"><label>Mouse Sens</label><input id="gm-mouse-sens" type="range" min="0.2" max="3" step="0.05" class="gm-slider" value="1"></div>
<div class="gm-control"><label>Touch Look Sens</label><input id="gm-touch-look-sens" type="range" min="0.02" max="0.3" step="0.01" class="gm-slider" value="0.08"></div>
<div class="gm-control"><label>Touch Look Cap</label><input id="gm-touch-look-cap" type="range" min="4" max="20" step="1" class="gm-slider" value="10"></div>
</div>
<div class="gm-section"><div class="gm-label">Controls</div>
<div id="gm-keybinds" class="gm-keybinds"></div>
<div class="gm-control" style="margin-top:8px"><button id="gm-rebind-reset" class="tp-btn">Reset Bindings</button></div>
</div>
</div>
</div>
<div class="gm-actions"><button id="gm-reset" class="tp-btn">Reset</button><button id="gm-save" class="tp-btn launch">Save</button></div>
</div>
<!-- ─── Galaxy Mode UI ─────────────────── -->
<button id="galaxy-btn" onclick="enterGalaxyMode()">
<span class="g-dot"></span>
<span>File Galaxy</span>
</button>
<div id="galaxy-hud">
<div id="galaxy-title">File Galaxy</div>
<div id="galaxy-subtitle">1,748,673 files · orbital view</div>
<div id="galaxy-controls-hint">
<span>Drag Rotate</span><span>Scroll Zoom</span><span>Right-drag Pan</span><span>Click Fly-to</span>
</div>
<div id="galaxy-exit-hint">ESC or F · Return to Command Center</div>
</div>
<div id="galaxy-tooltip">
<div class="gt-name"></div>
<div class="gt-path"></div>
<div class="gt-meta"></div>
</div>
<div id="galaxy-loading">
<h3>Loading File Galaxy</h3>
<p id="gl-status">Fetching constellation data…</p>
<div class="gl-bar"><div class="gl-fill" id="gl-fill"></div></div>
</div>
<!-- Galaxy Sidebar -->
<button id="galaxy-sidebar-toggle" onclick="toggleGalaxySidebar()"></button>
<div id="galaxy-sidebar">
<h3>File Galaxy</h3>
<div class="gs-sub" id="gs-stats"></div>
<div class="gs-section">Sources</div>
<div id="gs-source-toggles"></div>
<div class="gs-section">Density</div>
<div class="gs-slider-row">
<input type="range" id="gs-density" min="5" max="100" value="100"
oninput="onGalaxyDensityChange(this.value)">
<span class="gs-slider-val" id="gs-density-val">100%</span>
</div>
<div class="gs-section">Point Size</div>
<div class="gs-slider-row">
<input type="range" id="gs-size" min="1" max="8" step="0.5" value="3"
oninput="onGalaxySizeChange(this.value)">
<span class="gs-slider-val" id="gs-size-val">3</span>
</div>
<div class="gs-section">Search</div>
<input type="text" id="galaxy-search" placeholder="filename, path, or extension"
oninput="onGalaxySearch(this.value)">
<div id="galaxy-search-results"></div>
</div>
<!-- ════════════ THREE.JS ════════════ -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { SageAvatar } from '/assets/sage_avatar.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
// Guard against optional/undefined objects being added to the scene graph.
const _origObject3DAdd = THREE.Object3D.prototype.add;
THREE.Object3D.prototype.add = function (...objects) {
const valid = objects.filter((obj) => obj && obj.isObject3D);
if (valid.length === 0) return this;
return _origObject3DAdd.apply(this, valid);
};
// ═══════════════════════════════════════════════════
// ── CONFIGURATION
// ═══════════════════════════════════════════════════
const CFG = {
CEILING_H: 3.4,
EYE_H: 1.65,
WALL_THICK: 0.15,
DOOR_W: 2.8,
DOOR_H: 2.6,
MOVE_SPEED: 4.5,
SPRINT_MULT: 1.8,
PLAYER_RADIUS: 0.35,
INTERACT_DIST: 2.8,
MOUSE_SENS: 0.002,
};
const API = location.origin;
const runningTools = {};
let backendOk = false;
// ═══════════════════════════════════════════════════
// ── GLOBALS
// ═══════════════════════════════════════════════════
let scene, camera, renderer, composer, controls;
let sageAvatar = null;
let clock, raycaster, interactRay;
let collisionBoxes = [];
let interactiveObjects = [];
let roomZones = [];
let currentRoom = 'Central Hub';
let toolPanelOpen = false;
const IS_TOUCH_DEVICE = window.matchMedia('(pointer: coarse)').matches || ('ontouchstart' in window);
const mobileState = {
enabled: false,
lookTouchId: null,
joyTouchId: null,
yaw: 0,
pitch: 0,
pendingYaw: 0,
pendingPitch: 0,
};
const mobileMove = { x: 0, y: 0 };
const joyRadius = 40;
const joyDeadzone = 0.16;
const joyCurve = 1.45;
let maxLookDeltaPx = 10;
let touchLookMultiplier = 0.08;
// Movement state
const moveState = { forward: false, backward: false, left: false, right: false, sprint: false };
const velocity = new THREE.Vector3();
const direction = new THREE.Vector3();
const playerPos = new THREE.Vector3(0, CFG.EYE_H, 0);
// ═══════════════════════════════════════════════════
// ── TOOL DATA
// ═══════════════════════════════════════════════════
const TOOLS = {
artemis: { name: 'ARTEMIS (Sage)', desc: 'Unified AI kernel — memory, mind, senses, voice, bridge, guardian, feelings, appraisal. The central nervous system.', tags: ['Python','FastAPI','SQLite','WebSocket'], hasLaunch: true },
ears: { name: 'ARTEMIS Ears', desc: 'Adaptive audio perception. Speech-to-text via faster-whisper, audio analysis via librosa. The hearing sense.', tags: ['Python','faster-whisper','librosa'], hasLaunch: true },
csm: { name: 'CSM-1B Voice', desc: 'Conversational speech synthesis. Wav-to-wav generation on RTX 5070 Ti via WSL CUDA.', tags: ['Python','PyTorch','WSL/CUDA'], hasLaunch: true },
'neural-sim': { name: 'Neural Simulation', desc: '3D LLM training dashboard. Two Three.js brain models, 24 diagnostic panels, Qwen3-8B + LoRA fine-tuning.', tags: ['Python','Three.js','Chart.js','WSL/CUDA'], hasLaunch: true, url: 'http://localhost:8765' },
'artemis-server': { name: 'Artemis Server', desc: 'Portable HTTP file server with Cloudflare tunnel integration and Tkinter GUI.', tags: ['Go','Python/Tkinter'], hasLaunch: true, spaceUrl: 'https://ftrtemis-artemis-server.hf.space' },
comms: { name: 'Comms Platform', desc: 'AI-coordinated node-to-node encrypted communication platform.', tags: ['Python','FastAPI','WebSocket'], hasLaunch: true },
'converter-gui': { name: 'Bat→Exe Converter', desc: 'Reverse-engineered reimplementation. Converts .bat scripts to standalone .exe with custom C stubs.', tags: ['Python','C'], hasLaunch: true, spaceUrl: 'https://ftrtemis-converter.hf.space' },
gateway: { name: 'Gateway Router', desc: 'Dual-node virtual private router. WireGuard config generation and keepalive management.', tags: ['Python','WireGuard'], hasLaunch: true, spaceUrl: 'https://ftrtemis-gateway.hf.space' },
mirror: { name: 'Mirror', desc: 'ADB screen mirror service. Captures Android device screen and serves it via FastAPI.', tags: ['Python','FastAPI','ADB'], hasLaunch: true },
ghidra: { name: 'Ghidra 12.0.4', desc: 'Full static analysis suite for binaries — DEX, SO, APK, ELF, PE. Pre-built distribution.', tags: ['Java','Ghidra'], hasLaunch: true },
frida: { name: 'Frida 17.8.2', desc: 'Runtime function hooking for Android. ARM + ARM64 server binaries ready.', tags: ['JavaScript','Android'], hasLaunch: false },
wireshark: { name: 'Wireshark + Zeek + nDPI', desc: 'Packet capture, protocol dissection, JA3 fingerprinting, deep packet inspection.', tags: ['Network','WSL'], hasLaunch: false },
'ghidra-scripts': { name: 'Ghidra Scripts', desc: '9 automated analysis scripts — decompilation, string extraction, import analysis, section mapping.', tags: ['Python','Ghidra'], hasLaunch: false },
'frida-hooks': { name: 'Frida Hooks', desc: 'SSL pinning bypass, function tracing, memory dumping hook scripts.', tags: ['JavaScript','Frida'], hasLaunch: false },
playbooks: { name: 'Playbooks', desc: '4 YAML automation playbooks — guardian-start, neural-sim-launch, WSL dep-check, WSL emergency-stop.', tags: ['YAML','Python'], hasLaunch: false },
'ref-apks': { name: 'Reference APK Library', desc: '13 decompiled APK packages studied for architectural patterns and design principles.', tags: ['APK','Java','Smali'], hasLaunch: false },
'omni-chat': { name: 'Sage (HF Space)', desc: 'Multimodal AI interface — audio, video, image, and text in; voice + text out. Runs live on ftrtemis/Sage HuggingFace Space.', tags: ['Python','Gradio','DashScope','Multimodal'], hasLaunch: false, spaceUrl: 'https://ftrtemis-sage.hf.space' },
};
// Room definitions: which tools are in which room
const ROOM_TOOLS = {
'AI Systems Lab': ['artemis','ears','csm','neural-sim','omni-chat'],
'Built Tools Workshop': ['artemis-server','comms','converter-gui','gateway','mirror'],
'Analysis Lab': ['ghidra','frida','wireshark'],
'Scripts & Automation': ['ghidra-scripts','frida-hooks','playbooks','ref-apks'],
};
// ═══════════════════════════════════════════════════
// ── TEXTURE GENERATORS (Canvas)
// ═══════════════════════════════════════════════════
function makeCanvasTexture(w, h, drawFn) {
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
drawFn(ctx, w, h);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function wallTexture() {
return makeCanvasTexture(256, 256, (ctx, w, h) => {
// Warm cream base
ctx.fillStyle = '#e8e0d4';
ctx.fillRect(0, 0, w, h);
// Subtle noise
for (let i = 0; i < 800; i++) {
const x = Math.random() * w, y = Math.random() * h;
const v = 200 + Math.random() * 40;
ctx.fillStyle = `rgb(${v},${v-8},${v-16})`;
ctx.fillRect(x, y, 2, 2);
}
// Faint horizontal line (drywall seam)
ctx.strokeStyle = 'rgba(180,170,155,.15)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke();
});
}
function floorTexture() {
return makeCanvasTexture(512, 512, (ctx, w, h) => {
// Dark industrial floor
ctx.fillStyle = '#3a3832';
ctx.fillRect(0, 0, w, h);
// Tile grid
const tileSize = 64;
ctx.strokeStyle = 'rgba(80,76,68,.3)';
ctx.lineWidth = 1;
for (let x = 0; x <= w; x += tileSize) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let y = 0; y <= h; y += tileSize) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
// Subtle noise
for (let i = 0; i < 2000; i++) {
const x = Math.random() * w, y = Math.random() * h;
const v = 50 + Math.random() * 20;
ctx.fillStyle = `rgba(${v},${v-2},${v-4},.4)`;
ctx.fillRect(x, y, 1, 1);
}
});
}
function ceilingTexture() {
return makeCanvasTexture(256, 256, (ctx, w, h) => {
ctx.fillStyle = '#f0ece4';
ctx.fillRect(0, 0, w, h);
// Ceiling tile grid
ctx.strokeStyle = 'rgba(180,172,160,.2)';
ctx.lineWidth = 1;
const tile = 128;
for (let x = 0; x <= w; x += tile) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let y = 0; y <= h; y += tile) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
});
}
function trimTexture() {
return makeCanvasTexture(64, 64, (ctx, w, h) => {
ctx.fillStyle = '#5a4a38';
ctx.fillRect(0, 0, w, h);
// Wood grain
for (let y = 0; y < h; y += 2) {
ctx.strokeStyle = `rgba(${70+Math.random()*20},${55+Math.random()*15},${40+Math.random()*10},.3)`;
ctx.beginPath(); ctx.moveTo(0, y + Math.random()*2); ctx.lineTo(w, y + Math.random()*2); ctx.stroke();
}
});
}
function doorTexture() {
return makeCanvasTexture(128, 256, (ctx, w, h) => {
ctx.fillStyle = '#6a5a48';
ctx.fillRect(0, 0, w, h);
// Door panel insets
ctx.strokeStyle = 'rgba(90,75,55,.5)';
ctx.lineWidth = 2;
ctx.strokeRect(12, 12, w-24, h/2 - 20);
ctx.strokeRect(12, h/2 + 8, w-24, h/2 - 20);
// Wood grain
for (let y = 0; y < h; y += 3) {
ctx.strokeStyle = `rgba(${80+Math.random()*20},${65+Math.random()*15},${48+Math.random()*10},.15)`;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
// Handle
ctx.fillStyle = '#b8a888';
ctx.fillRect(w - 24, h/2 - 6, 8, 12);
});
}
function signTexture(text, accent = '#8a9aa8') {
return makeCanvasTexture(512, 128, (ctx, w, h) => {
// Dark background
ctx.fillStyle = '#2a2824';
ctx.fillRect(0,0,w,h);
// Border
ctx.strokeStyle = accent;
ctx.lineWidth = 2;
ctx.strokeRect(4,4,w-4,h-4);
// Text
ctx.font = 'bold 32px Segoe UI, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = accent;
ctx.fillText(text, w/2, h/2);
});
}
function monitorTexture(toolId) {
const tool = TOOLS[toolId];
if (!tool) return makeCanvasTexture(512, 384, (ctx,w,h) => { ctx.fillStyle='#111';ctx.fillRect(0,0,w,h); });
return makeCanvasTexture(512, 384, (ctx, w, h) => {
// Screen bg
ctx.fillStyle = '#0c0e10';
ctx.fillRect(0,0,w,h);
// Subtle grid
ctx.strokeStyle = 'rgba(100,140,160,.06)';
ctx.lineWidth = 1;
for (let x = 0; x < w; x += 32) { ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke(); }
for (let y = 0; y < h; y += 32) { ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke(); }
// Header bar
ctx.fillStyle = 'rgba(60,90,110,.15)';
ctx.fillRect(0,0,w,48);
ctx.strokeStyle = 'rgba(100,140,160,.2)';
ctx.beginPath();ctx.moveTo(0,48);ctx.lineTo(w,48);ctx.stroke();
// Title
ctx.font = 'bold 20px Segoe UI, sans-serif';
ctx.fillStyle = '#a0c0d0';
ctx.textAlign = 'left';
ctx.fillText(tool.name, 16, 32);
// Status indicator
const running = runningTools[toolId];
ctx.beginPath();
ctx.arc(w - 24, 24, 6, 0, Math.PI*2);
ctx.fillStyle = running ? '#6a9a6a' : '#5a5a5a';
ctx.fill();
// Description
ctx.font = '14px Segoe UI, sans-serif';
ctx.fillStyle = '#7a8a90';
const words = tool.desc.split(' ');
let line = '', lineY = 80;
for (const word of words) {
const test = line + word + ' ';
if (ctx.measureText(test).width > w - 32) {
ctx.fillText(line.trim(), 16, lineY);
line = word + ' '; lineY += 20;
if (lineY > 160) break;
} else { line = test; }
}
if (line.trim()) ctx.fillText(line.trim(), 16, lineY);
// Tags
let tagX = 16;
const tagY = 200;
ctx.font = '12px Cascadia Code, monospace';
for (const tag of tool.tags) {
const tw = ctx.measureText(tag).width + 16;
ctx.strokeStyle = 'rgba(100,140,160,.25)';
ctx.strokeRect(tagX, tagY, tw, 22);
ctx.fillStyle = '#6a8a9a';
ctx.fillText(tag, tagX + 8, tagY + 15);
tagX += tw + 6;
if (tagX > w - 40) break;
}
// Bottom status bar
ctx.fillStyle = 'rgba(60,90,110,.1)';
ctx.fillRect(0, h-36, w, 36);
ctx.font = '11px Cascadia Code, monospace';
ctx.fillStyle = running ? '#6a9a6a' : '#5a5a5a';
ctx.fillText(running ? '● RUNNING' : '○ STOPPED', 16, h-14);
if (tool.hasLaunch) {
ctx.fillStyle = '#4a6a7a';
ctx.fillText('PRESS E TO MANAGE', w - 160, h-14);
}
// Scanline effect
for (let y = 0; y < h; y += 4) {
ctx.fillStyle = 'rgba(0,0,0,.04)';
ctx.fillRect(0, y, w, 2);
}
});
}
function hubFloorTexture() {
return makeCanvasTexture(512, 512, (ctx, w, h) => {
// Dark floor base
ctx.fillStyle = '#3a3832';
ctx.fillRect(0, 0, w, h);
// Compass rose / directional markers
ctx.strokeStyle = 'rgba(160,150,130,.15)';
ctx.lineWidth = 2;
// Circle
ctx.beginPath(); ctx.arc(w/2, h/2, 200, 0, Math.PI*2); ctx.stroke();
ctx.beginPath(); ctx.arc(w/2, h/2, 140, 0, Math.PI*2); ctx.stroke();
// Cross lines
ctx.beginPath(); ctx.moveTo(w/2, 40); ctx.lineTo(w/2, h-40); ctx.stroke();
ctx.beginPath(); ctx.moveTo(40, h/2); ctx.lineTo(w-40, h/2); ctx.stroke();
// ARTEMIS text
ctx.font = 'bold 36px Segoe UI, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(160,150,130,.12)';
ctx.fillText('ARTEMIS', w/2, h/2 + 12);
// Direction labels
ctx.font = '14px Segoe UI, sans-serif';
ctx.fillStyle = 'rgba(160,150,130,.2)';
ctx.fillText('AI SYSTEMS', w/2, 70);
ctx.fillText('SCRIPTS', w/2, h - 56);
ctx.save(); ctx.translate(w-56, h/2); ctx.rotate(-Math.PI/2); ctx.fillText('ANALYSIS', 0, 0); ctx.restore();
ctx.save(); ctx.translate(56, h/2); ctx.rotate(Math.PI/2); ctx.fillText('TOOLS', 0, 0); ctx.restore();
});
}
// ═══════════════════════════════════════════════════
// ── MATERIALS
// ═══════════════════════════════════════════════════
let MAT = {};
function buildMaterials() {
const wallTex = wallTexture();
wallTex.wrapS = wallTex.wrapT = THREE.RepeatWrapping;
wallTex.repeat.set(2, 1);
const floorTex = floorTexture();
floorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;
const ceilTex = ceilingTexture();
ceilTex.wrapS = ceilTex.wrapT = THREE.RepeatWrapping;
MAT = {
wall: new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.85, metalness: 0.0 }),
floor: new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.75, metalness: 0.1 }),
ceiling: new THREE.MeshStandardMaterial({ map: ceilTex, roughness: 0.9, metalness: 0.0 }),
trim: new THREE.MeshStandardMaterial({ map: trimTexture(), roughness: 0.6, metalness: 0.1 }),
doorFrame: new THREE.MeshStandardMaterial({ color: 0x4a3a2a, roughness: 0.5, metalness: 0.1 }),
monitorBezel: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.3, metalness: 0.6 }),
desk: new THREE.MeshStandardMaterial({ color: 0x5a5048, roughness: 0.55, metalness: 0.15 }),
metal: new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 0.35, metalness: 0.7 }),
lightPanel: new THREE.MeshStandardMaterial({
color: 0xfff8ee, emissive: 0xfff4e0, emissiveIntensity: 0.8, roughness: 1.0, metalness: 0.0
}),
lightPanelOff: new THREE.MeshStandardMaterial({
color: 0xe0d8cc, roughness: 0.9, metalness: 0.0
}),
};
}
// ═══════════════════════════════════════════════════
// ── MAP DEFINITION
// ═══════════════════════════════════════════════════
// Each room: { name, x1,z1,x2,z2 (floor bounds), openings[], color, tools[] }
// Openings define which wall has a doorway and where
const ROOMS = [
// Central Hub
{ name: 'Central Hub', x1:-4.5, z1:-4.5, x2:4.5, z2:4.5, color: '#a09888',
openings: [
{ wall:'n', center:0 }, { wall:'e', center:0 },
{ wall:'s', center:0 }, { wall:'w', center:0 },
]
},
// North corridor
{ name: 'North Corridor', x1:-1.5, z1:4.5, x2:1.5, z2:9, color: '#8a9aa8', openings: [
{ wall:'s', center:0 }, { wall:'n', center:0 },
]},
// AI Systems Lab
{ name: 'AI Systems Lab', x1:-5.5, z1:9, x2:5.5, z2:17, color: '#8aaab8', openings: [
{ wall:'s', center:0 },
], tools: ['artemis','ears','csm','neural-sim'] },
// East corridor
{ name: 'East Corridor', x1:4.5, z1:-1.5, x2:9, z2:1.5, color: '#a0a888', openings: [
{ wall:'w', center:0 }, { wall:'e', center:0 },
]},
// Analysis Lab
{ name: 'Analysis Lab', x1:9, z1:-5.5, x2:18, z2:5.5, color: '#a89a88', openings: [
{ wall:'w', center:0 },
], tools: ['ghidra','frida','wireshark'] },
// South corridor
{ name: 'South Corridor', x1:-1.5, z1:-9, x2:1.5, z2:-4.5, color: '#9a8ab8', openings: [
{ wall:'n', center:0 }, { wall:'s', center:0 },
]},
// Scripts & Automation
{ name: 'Scripts & Automation', x1:-5.5, z1:-17, x2:5.5, z2:-9, color: '#9a88b8', openings: [
{ wall:'n', center:0 },
], tools: ['ghidra-scripts','frida-hooks','playbooks','ref-apks'] },
// West corridor
{ name: 'West Corridor', x1:-9, z1:-1.5, x2:-4.5, z2:1.5, color: '#88a898', openings: [
{ wall:'e', center:0 }, { wall:'w', center:0 },
]},
// Built Tools Workshop
{ name: 'Built Tools Workshop', x1:-18, z1:-5.5, x2:-9, z2:5.5, color: '#88a888', openings: [
{ wall:'e', center:0 },
], tools: ['artemis-server','comms','converter-gui','gateway','mirror'] },
];
// ═══════════════════════════════════════════════════
// ── GEOMETRY BUILDERS
// ═══════════════════════════════════════════════════
function addBox(w, h, d, x, y, z, mat, castShadow=false, receiveShadow=false) {
const geo = new THREE.BoxGeometry(w, h, d);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.castShadow = castShadow;
mesh.receiveShadow = receiveShadow;
scene.add(mesh);
return mesh;
}
function addCollisionBox(x1, z1, x2, z2, y=0, h=CFG.CEILING_H) {
collisionBoxes.push(new THREE.Box3(
new THREE.Vector3(Math.min(x1,x2), y, Math.min(z1,z2)),
new THREE.Vector3(Math.max(x1,x2), y+h, Math.max(z1,z2))
));
}
function buildWallSegment(x, z, w, h, d, mat) {
const mesh = addBox(w, h, d, x, h/2, z, mat, false, true);
return mesh;
}
function buildRoom(room) {
const { x1, z1, x2, z2, openings = [], name } = room;
const w = x2 - x1, d = z2 - z1;
const cx = (x1 + x2) / 2, cz = (z1 + z2) / 2;
const H = CFG.CEILING_H;
const T = CFG.WALL_THICK;
const DW = CFG.DOOR_W;
const DH = CFG.DOOR_H;
// Floor
const floorMat = (name === 'Central Hub') ?
new THREE.MeshStandardMaterial({ map: hubFloorTexture(), roughness: 0.7, metalness: 0.1 }) :
MAT.floor.clone();
floorMat.map = floorMat.map || MAT.floor.map;
if (floorMat.map && name !== 'Central Hub') {
floorMat.map = floorMat.map.clone();
floorMat.map.repeat.set(w/4, d/4);
floorMat.map.wrapS = floorMat.map.wrapT = THREE.RepeatWrapping;
}
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(cx, 0.001, cz);
floor.receiveShadow = true;
scene.add(floor);
// Ceiling
const ceilMat = MAT.ceiling.clone();
if (ceilMat.map) {
ceilMat.map = ceilMat.map.clone();
ceilMat.map.repeat.set(w/3, d/3);
ceilMat.map.wrapS = ceilMat.map.wrapT = THREE.RepeatWrapping;
}
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), ceilMat);
ceil.rotation.x = Math.PI / 2;
ceil.position.set(cx, H, cz);
scene.add(ceil);
// Room zone for detection
roomZones.push({ name, x1, z1, x2, z2 });
// Walls — for each side, check if there's an opening
const sides = [
{ wall: 'n', sx: x1, sz: z2, ex: x2, ez: z2, axis: 'x', normal: [0,0,-1] },
{ wall: 's', sx: x1, sz: z1, ex: x2, ez: z1, axis: 'x', normal: [0,0,1] },
{ wall: 'e', sx: x2, sz: z1, ex: x2, ez: z2, axis: 'z', normal: [-1,0,0] },
{ wall: 'w', sx: x1, sz: z1, ex: x1, ez: z2, axis: 'z', normal: [1,0,0] },
];
for (const side of sides) {
const opening = openings.find(o => o.wall === side.wall);
if (side.axis === 'x') {
// Wall runs along X
const wallLen = Math.abs(side.ex - side.sx);
const wallZ = side.sz;
const wallCx = (side.sx + side.ex) / 2;
if (opening) {
const openCenter = wallCx + (opening.center || 0);
const leftLen = openCenter - DW/2 - side.sx;
const rightLen = side.ex - (openCenter + DW/2);
// Left segment
if (leftLen > 0.1) {
const lx = side.sx + leftLen/2;
buildWallSegment(lx, wallZ, leftLen, H, T, MAT.wall);
addCollisionBox(side.sx, wallZ - T/2, side.sx + leftLen, wallZ + T/2);
}
// Right segment
if (rightLen > 0.1) {
const rx = side.ex - rightLen/2;
buildWallSegment(rx, wallZ, rightLen, H, T, MAT.wall);
addCollisionBox(side.ex - rightLen, wallZ - T/2, side.ex, wallZ + T/2);
}
// Lintel above door
const lintelH = H - DH;
if (lintelH > 0.1) {
buildWallSegment(openCenter, wallZ, DW, lintelH, T, MAT.wall);
// No collision for lintel (above player)
}
// Door frame
addBox(0.08, DH, T+0.04, openCenter - DW/2, DH/2, wallZ, MAT.doorFrame);
addBox(0.08, DH, T+0.04, openCenter + DW/2, DH/2, wallZ, MAT.doorFrame);
addBox(DW+0.08, 0.08, T+0.04, openCenter, DH, wallZ, MAT.doorFrame);
} else {
// Solid wall
buildWallSegment(wallCx, wallZ, wallLen, H, T, MAT.wall);
addCollisionBox(side.sx, wallZ - T/2, side.ex, wallZ + T/2);
}
} else {
// Wall runs along Z
const wallLen = Math.abs(side.ez - side.sz);
const wallX = side.sx;
const wallCz = (side.sz + side.ez) / 2;
if (opening) {
const openCenter = wallCz + (opening.center || 0);
const leftLen = openCenter - DW/2 - side.sz;
const rightLen = side.ez - (openCenter + DW/2);
if (leftLen > 0.1) {
const lz = side.sz + leftLen/2;
buildWallSegment(wallX, lz, T, H, leftLen, MAT.wall);
addCollisionBox(wallX - T/2, side.sz, wallX + T/2, side.sz + leftLen);
}
if (rightLen > 0.1) {
const rz = side.ez - rightLen/2;
buildWallSegment(wallX, rz, T, H, rightLen, MAT.wall);
addCollisionBox(wallX - T/2, side.ez - rightLen, wallX + T/2, side.ez);
}
const lintelH = H - DH;
if (lintelH > 0.1) {
buildWallSegment(wallX, openCenter, T, lintelH, DW, MAT.wall);
}
// Door frame
addBox(T+0.04, DH, 0.08, wallX, DH/2, openCenter - DW/2, MAT.doorFrame);
addBox(T+0.04, DH, 0.08, wallX, DH/2, openCenter + DW/2, MAT.doorFrame);
addBox(T+0.04, 0.08, DW+0.08, wallX, DH, openCenter, MAT.doorFrame);
} else {
buildWallSegment(wallX, wallCz, T, H, wallLen, MAT.wall);
addCollisionBox(wallX - T/2, side.sz, wallX + T/2, side.ez);
}
}
}
// Baseboards
const baseH = 0.12, baseD = 0.03;
const baseColor = new THREE.MeshStandardMaterial({ color: 0x4a3a2a, roughness: 0.6, metalness: 0.1 });
// North baseboard
if (!openings.find(o => o.wall === 'n'))
addBox(w, baseH, baseD, cx, baseH/2, z2 - baseD/2, baseColor);
// South baseboard
if (!openings.find(o => o.wall === 's'))
addBox(w, baseH, baseD, cx, baseH/2, z1 + baseD/2, baseColor);
// East baseboard
if (!openings.find(o => o.wall === 'e'))
addBox(baseD, baseH, d, x2 - baseD/2, baseH/2, cz, baseColor);
// West baseboard
if (!openings.find(o => o.wall === 'w'))
addBox(baseD, baseH, d, x1 + baseD/2, baseH/2, cz, baseColor);
// Ceiling light panels
buildCeilingLights(room);
// Room sign above doorways (only for main rooms, not corridors)
if (name.includes('Corridor') === false && name !== 'Central Hub') {
buildRoomSign(room);
}
}
function buildCeilingLights(room) {
const { x1, z1, x2, z2 } = room;
const w = x2 - x1, d = z2 - z1;
const cx = (x1+x2)/2, cz = (z1+z2)/2;
const H = CFG.CEILING_H;
// Place lights in a grid pattern
const spacing = 3.5;
const panelW = 1.2, panelD = 0.4;
for (let gx = cx - w/2 + spacing/2; gx < cx + w/2; gx += spacing) {
for (let gz = cz - d/2 + spacing/2; gz < cz + d/2; gz += spacing) {
// Light panel geometry
addBox(panelW, 0.04, panelD, gx, H - 0.02, gz, MAT.lightPanel);
// Recessed housing around it
addBox(panelW + 0.1, 0.06, panelD + 0.1, gx, H - 0.04, gz, MAT.metal);
// Actual light source
const light = new THREE.PointLight(0xfff4e0, 0.6, 8, 1.5);
light.position.set(gx, H - 0.1, gz);
light.castShadow = false; // Performance
scene.add(light);
}
}
}
function buildRoomSign(room) {
// Find the room's entry opening
const { openings, name, x1, z1, x2, z2 } = room;
if (!openings || !openings.length) return;
const opening = openings[0];
const H = CFG.CEILING_H;
const cx = (x1+x2)/2, cz = (z1+z2)/2;
const tex = signTexture(name.toUpperCase(), room.color || '#8a9aa8');
const signMat = new THREE.MeshStandardMaterial({
map: tex, emissive: 0xffffff, emissiveMap: tex, emissiveIntensity: 0.15,
roughness: 0.8, metalness: 0.2
});
let signX, signZ, rotY;
switch (opening.wall) {
case 's': signX = cx; signZ = z1 + 0.2; rotY = 0; break;
case 'n': signX = cx; signZ = z2 - 0.2; rotY = Math.PI; break;
case 'w': signX = x1 + 0.2; signZ = cz; rotY = Math.PI/2; break;
case 'e': signX = x2 - 0.2; signZ = cz; rotY = -Math.PI/2; break;
}
const sign = new THREE.Mesh(new THREE.PlaneGeometry(2.0, 0.5), signMat);
sign.position.set(signX, H - 0.5, signZ);
sign.rotation.y = rotY;
scene.add(sign);
}
// ═══════════════════════════════════════════════════
// ── TERMINAL (MONITOR) BUILDERS
// ═══════════════════════════════════════════════════
const monitorMeshes = {}; // toolId → mesh (for updating textures)
function buildTerminal(toolId, x, y, z, rotY) {
const group = new THREE.Group();
group.position.set(x, y, z);
group.rotation.y = rotY;
// Monitor bezel
const bezelW = 1.1, bezelH = 0.85, bezelD = 0.06;
const bezel = new THREE.Mesh(new THREE.BoxGeometry(bezelW, bezelH, bezelD), MAT.monitorBezel);
bezel.castShadow = true;
group.add(bezel);
// Screen face
const screenTex = monitorTexture(toolId);
const screenMat = new THREE.MeshStandardMaterial({
map: screenTex, emissive: 0xffffff, emissiveMap: screenTex,
emissiveIntensity: 0.5, roughness: 0.1, metalness: 0.0,
});
const screen = new THREE.Mesh(new THREE.PlaneGeometry(1.0, 0.72), screenMat);
screen.position.z = bezelD/2 + 0.002;
screen.userData = { toolId, type: 'terminal' };
group.add(screen);
interactiveObjects.push(screen);
monitorMeshes[toolId] = screen;
// Monitor glow light
const hex = TOOLS[toolId] ? 0x405060 : 0x303030;
const glow = new THREE.PointLight(hex, 0.15, 2.5, 2);
glow.position.z = 0.15;
group.add(glow);
// Wall mount bracket
const bracket = new THREE.Mesh(
new THREE.BoxGeometry(0.15, 0.15, 0.08),
MAT.metal
);
bracket.position.z = -bezelD/2 - 0.04;
group.add(bracket);
scene.add(group);
return group;
}
function buildDesk(x, z, w, d, rotY = 0) {
const group = new THREE.Group();
group.position.set(x, 0, z);
group.rotation.y = rotY;
const topH = 0.04, legH = 0.72, topY = legH + topH/2;
// Desktop surface
const top = new THREE.Mesh(new THREE.BoxGeometry(w, topH, d), MAT.desk);
top.position.y = topY;
top.castShadow = true;
top.receiveShadow = true;
group.add(top);
// Legs
const legGeo = new THREE.BoxGeometry(0.04, legH, 0.04);
const offX = w/2 - 0.06, offZ = d/2 - 0.06;
[[-offX,-offZ],[offX,-offZ],[offX,offZ],[-offX,offZ]].forEach(([lx,lz]) => {
const leg = new THREE.Mesh(legGeo, MAT.metal);
leg.position.set(lx, legH/2, lz);
group.add(leg);
});
scene.add(group);
// Add collision
const cos = Math.cos(rotY), sin = Math.sin(rotY);
const halfW = w/2 + 0.1, halfD = d/2 + 0.1;
// Simplified AABB (ignores rotation for small desks)
addCollisionBox(x - halfW, z - halfD, x + halfW, z + halfD, 0, topY + topH/2);
return group;
}
function buildRoomFurniture(room) {
if (!room.tools || !room.tools.length) return;
const { x1, z1, x2, z2, tools } = room;
const cx = (x1+x2)/2, cz = (z1+z2)/2;
const rw = x2 - x1, rd = z2 - z1;
// Place terminals along walls
const monitorY = 1.55; // Eye-level mounted
const wallOffset = 0.05; // Distance from wall
// Determine which wall is the "back" (opposite to the opening)
const opening = room.openings[0];
let backWall, sideWall1, sideWall2;
switch (opening.wall) {
case 's': // Entry from south, back wall is north
backWall = { axis: 'x', z: z2, dir: Math.PI, xRange: [x1+1.2, x2-1.2] };
sideWall1 = { axis: 'z', x: x1, dir: Math.PI/2, zRange: [z1+1.5, z2-1] };
sideWall2 = { axis: 'z', x: x2, dir: -Math.PI/2, zRange: [z1+1.5, z2-1] };
break;
case 'n':
backWall = { axis: 'x', z: z1, dir: 0, xRange: [x1+1.2, x2-1.2] };
sideWall1 = { axis: 'z', x: x1, dir: Math.PI/2, zRange: [z1+1, z2-1.5] };
sideWall2 = { axis: 'z', x: x2, dir: -Math.PI/2, zRange: [z1+1, z2-1.5] };
break;
case 'w':
backWall = { axis: 'z', x: x2, dir: -Math.PI/2, zRange: [z1+1.2, z2-1.2] };
sideWall1 = { axis: 'x', z: z1, dir: 0, xRange: [x1+1.5, x2-1] };
sideWall2 = { axis: 'x', z: z2, dir: Math.PI, xRange: [x1+1.5, x2-1] };
break;
case 'e':
backWall = { axis: 'z', x: x1, dir: Math.PI/2, zRange: [z1+1.2, z2-1.2] };
sideWall1 = { axis: 'x', z: z1, dir: 0, xRange: [x1+1, x2-1.5] };
sideWall2 = { axis: 'x', z: z2, dir: Math.PI, xRange: [x1+1, x2-1.5] };
break;
}
// Distribute terminals
const walls = [backWall, sideWall1, sideWall2];
let toolIdx = 0;
for (const wall of walls) {
if (toolIdx >= tools.length) break;
// How many monitors fit on this wall
let available;
if (wall.axis === 'x') {
available = (wall.xRange[1] - wall.xRange[0]);
} else {
available = (wall.zRange[1] - wall.zRange[0]);
}
const maxOnWall = Math.min(Math.floor(available / 1.6), tools.length - toolIdx, 3);
if (maxOnWall <= 0) continue;
for (let i = 0; i < maxOnWall && toolIdx < tools.length; i++, toolIdx++) {
const t = (i + 0.5) / maxOnWall; // 0..1 evenly distributed
let mx, mz;
if (wall.axis === 'x') {
mx = wall.xRange[0] + t * (wall.xRange[1] - wall.xRange[0]);
mz = wall.z + (wall.dir === Math.PI ? -wallOffset : wallOffset);
} else {
mz = wall.zRange[0] + t * (wall.zRange[1] - wall.zRange[0]);
mx = wall.x + (wall.dir === -Math.PI/2 ? -wallOffset : wallOffset);
}
buildTerminal(tools[toolIdx], mx, monitorY, mz, wall.dir);
// Desk below each terminal
const deskOff = 0.6;
let dx = mx, dz = mz;
if (wall.axis === 'x') {
dz += (wall.dir === Math.PI ? -deskOff : deskOff);
} else {
dx += (wall.dir === -Math.PI/2 ? -deskOff : deskOff);
}
buildDesk(dx, dz, 1.0, 0.6, wall.dir);
}
}
}
// ═══════════════════════════════════════════════════
// ── LIGHTING
// ═══════════════════════════════════════════════════
function buildLighting() {
// Ambient — very subtle warm fill
const ambient = new THREE.AmbientLight(0xfff4e0, 0.15);
scene.add(ambient);
// Hemisphere — warm sky, cool ground
const hemi = new THREE.HemisphereLight(0xfff4e0, 0x3a3832, 0.2);
scene.add(hemi);
}
// ═══════════════════════════════════════════════════
// ── ENVIRONMENT DETAILS
// ═══════════════════════════════════════════════════
function buildEnvironmentDetails() {
// Central Hub — large overhead light fixture
const hubLight = new THREE.SpotLight(0xfff4e0, 1.5, 12, Math.PI/4, 0.5, 1);
hubLight.position.set(0, CFG.CEILING_H - 0.1, 0);
hubLight.target.position.set(0, 0, 0);
scene.add(hubLight);
scene.add(hubLight.target);
// Hub center floor emblem glow
const emblLight = new THREE.PointLight(0xd4c5a9, 0.1, 5, 2);
emblLight.position.set(0, 0.1, 0);
scene.add(emblLight);
// Directional arrow signs on hub walls
buildDirectionSign(0, 1.6, 4.3, 0, '↑ AI SYSTEMS', '#8aaab8');
buildDirectionSign(0, 1.6, -4.3, Math.PI, '↑ SCRIPTS', '#9a88b8');
buildDirectionSign(4.3, 1.6, 0, -Math.PI/2, '↑ ANALYSIS', '#a89a88');
buildDirectionSign(-4.3, 1.6, 0, Math.PI/2, '↑ TOOLS', '#88a888');
// Fire extinguisher in corridor (environmental detail)
buildFireExtinguisher(-1.3, 0, 6.5);
// Potted plant in hub corner
buildPlant(3.5, 0, 3.5);
buildPlant(-3.5, 0, -3.5);
// Water cooler
buildWaterCooler(3.8, 0, -3);
// Clock on hub wall
buildWallClock(0, 2.5, -4.4);
}
function buildDirectionSign(x, y, z, rotY, text, color) {
const tex = makeCanvasTexture(400, 80, (ctx, w, h) => {
ctx.fillStyle = '#2a2824';
ctx.fillRect(0,0,w,h);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.strokeRect(2,2,w-4,h-4);
ctx.font = 'bold 22px Segoe UI, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.fillText(text, w/2, h/2);
});
const mat = new THREE.MeshStandardMaterial({
map: tex, emissive: 0xffffff, emissiveMap: tex, emissiveIntensity: 0.1,
roughness: 0.8, metalness: 0.2
});
const sign = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 0.24), mat);
sign.position.set(x, y, z);
sign.rotation.y = rotY;
scene.add(sign);
}
function buildFireExtinguisher(x, y, z) {
const group = new THREE.Group();
group.position.set(x, y, z);
// Body
const body = new THREE.Mesh(
new THREE.CylinderGeometry(0.06, 0.06, 0.35, 8),
new THREE.MeshStandardMaterial({ color: 0xcc3333, roughness: 0.4, metalness: 0.3 })
);
body.position.y = 0.85;
group.add(body);
// Top
const top = new THREE.Mesh(
new THREE.CylinderGeometry(0.03, 0.06, 0.06, 8),
new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.3, metalness: 0.6 })
);
top.position.y = 1.06;
group.add(top);
// Wall bracket
const bracket = new THREE.Mesh(
new THREE.BoxGeometry(0.15, 0.04, 0.08),
MAT.metal
);
bracket.position.y = 0.9;
bracket.position.z = -0.05;
group.add(bracket);
scene.add(group);
}
function buildPlant(x, y, z) {
const group = new THREE.Group();
group.position.set(x, y, z);
// Pot
const pot = new THREE.Mesh(
new THREE.CylinderGeometry(0.15, 0.12, 0.25, 8),
new THREE.MeshStandardMaterial({ color: 0x6a5a4a, roughness: 0.7, metalness: 0.1 })
);
pot.position.y = 0.125;
group.add(pot);
// Foliage (simple sphere cluster)
const leafMat = new THREE.MeshStandardMaterial({ color: 0x4a7a4a, roughness: 0.8, metalness: 0.0 });
for (let i = 0; i < 5; i++) {
const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.08 + Math.random()*0.06, 6, 6), leafMat);
leaf.position.set((Math.random()-0.5)*0.12, 0.3 + Math.random()*0.15, (Math.random()-0.5)*0.12);
group.add(leaf);
}
scene.add(group);
addCollisionBox(x-0.2, z-0.2, x+0.2, z+0.2, 0, 0.5);
}
function buildWaterCooler(x, y, z) {
const group = new THREE.Group();
group.position.set(x, y, z);
// Body
const body = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.9, 0.3),
new THREE.MeshStandardMaterial({ color: 0xe8e0d8, roughness: 0.5, metalness: 0.2 })
);
body.position.y = 0.45;
group.add(body);
// Water bottle
const bottle = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.1, 0.35, 8),
new THREE.MeshStandardMaterial({ color: 0x88bbdd, roughness: 0.1, metalness: 0.0, transparent: true, opacity: 0.6 })
);
bottle.position.y = 1.1;
group.add(bottle);
scene.add(group);
addCollisionBox(x-0.2, z-0.2, x+0.2, z+0.2, 0, 1.3);
}
function buildWallClock(x, y, z) {
// Simple clock face
const tex = makeCanvasTexture(128, 128, (ctx, w, h) => {
ctx.fillStyle = '#f0ece4';
ctx.beginPath(); ctx.arc(w/2, h/2, 60, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#4a3a2a';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(w/2, h/2, 60, 0, Math.PI*2); ctx.stroke();
// Hour marks
for (let i = 0; i < 12; i++) {
const a = (i/12) * Math.PI * 2 - Math.PI/2;
ctx.beginPath();
ctx.moveTo(w/2 + Math.cos(a)*48, h/2 + Math.sin(a)*48);
ctx.lineTo(w/2 + Math.cos(a)*55, h/2 + Math.sin(a)*55);
ctx.stroke();
}
// Hands (static — shows ~10:10)
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(w/2, h/2); ctx.lineTo(w/2-20, h/2-30); ctx.stroke();
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(w/2, h/2); ctx.lineTo(w/2+18, h/2-35); ctx.stroke();
ctx.fillStyle = '#4a3a2a';
ctx.beginPath(); ctx.arc(w/2, h/2, 3, 0, Math.PI*2); ctx.fill();
});
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.6, metalness: 0.1 });
const clock = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);
clock.position.set(x, y, z);
scene.add(clock);
}
// ═══════════════════════════════════════════════════
// ── AUDIO (Web Audio API)
// ═══════════════════════════════════════════════════
let audioCtx, footstepGain, ambientGain, masterGain;
let stepTimer = 0;
const STEP_INTERVAL = 0.45;
const SPRINT_STEP_INTERVAL = 0.28;
function initAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Master gain — single point that menu can control
masterGain = audioCtx.createGain();
masterGain.gain.value = 1.0;
masterGain.connect(audioCtx.destination);
window.__artemis_master_gain = masterGain;
// Apply any saved volume immediately
try {
const saved = JSON.parse(localStorage.getItem('artemis.settings.v1') || '{}');
if (typeof saved.master === 'number') masterGain.gain.value = saved.mute ? 0 : saved.master;
} catch(e) {}
// Ambient hum
ambientGain = audioCtx.createGain();
ambientGain.gain.value = 0.008;
ambientGain.connect(masterGain);
// Very quiet HVAC-like noise
const bufSize = audioCtx.sampleRate * 2;
const noiseBuffer = audioCtx.createBuffer(1, bufSize, audioCtx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufSize; i++) {
data[i] = (Math.random() * 2 - 1);
}
const noise = audioCtx.createBufferSource();
noise.buffer = noiseBuffer;
noise.loop = true;
// Low-pass to make it a hum
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 120;
filter.Q.value = 0.5;
noise.connect(filter);
filter.connect(ambientGain);
noise.start();
// Footstep gain node
footstepGain = audioCtx.createGain();
footstepGain.gain.value = 0.08;
footstepGain.connect(masterGain);
}
function playFootstep() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.value = 80 + Math.random() * 60;
gain.gain.setValueAtTime(0.06 + Math.random() * 0.03, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.08);
osc.connect(gain);
gain.connect(masterGain || audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
}
function playInteractSound() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = 600;
osc.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.06);
gain.gain.setValueAtTime(0.06, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);
osc.connect(gain);
gain.connect(masterGain || audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.15);
}
// ═══════════════════════════════════════════════════
// ── NARRATOR SYSTEM (existing text overlay)
// ═══════════════════════════════════════════════════
const narratorEl = document.getElementById('narrator');
let narratorTimeout = null;
const visitedRooms = new Set();
const NARRATOR_LINES = {
'Central Hub': "You find yourself at the center of everything. Four corridors branch outward — each one leading somewhere that matters.",
'AI Systems Lab': "The AI Systems Lab. This is where thought happens, or at least, where the machinery that produces something resembling thought is kept carefully maintained.",
'Built Tools Workshop': "The workshop. Every tool here was built by hand — reverse-engineered from something that already existed, then rebuilt from scratch. Because that's how you learn what something really does.",
'Analysis Lab': "The Analysis Lab. Ghidra, Frida, Wireshark — the instruments of understanding. Nothing passes through this workspace without being understood first.",
'Scripts & Automation': "Scripts and automation. The quiet workers. They do the tedious things reliably so you can do the interesting things freely.",
};
function narratorSpeak(text) {
if (narratorTimeout) clearTimeout(narratorTimeout);
narratorEl.textContent = text;
narratorEl.classList.add('visible');
narratorTimeout = setTimeout(() => {
narratorEl.classList.remove('visible');
}, 6000);
}
function checkRoomNarration(roomName) {
if (visitedRooms.has(roomName)) return;
if (!NARRATOR_LINES[roomName]) return;
visitedRooms.add(roomName);
setTimeout(() => narratorSpeak(NARRATOR_LINES[roomName]), 800);
}
// ═══════════════════════════════════════════════════
// ── VOICE NARRATION SYSTEM (new - proximity based)
// ═══════════════════════════════════════════════════
let currentVoiceRoom = null;
let voiceDelayTimer = null;
let currentVoiceAudio = null;
let ttsSpeaking = false;
// Mapping of room names to potential audio element IDs (fallback to TTS if no element found)
const ROOM_VOICE_IDS = {
'Central Hub': 'voice-central-hub',
'AI Systems Lab': 'voice-ai-systems',
'Built Tools Workshop': 'voice-built-tools',
'Analysis Lab': 'voice-analysis',
'Scripts & Automation': 'voice-scripts',
'North Corridor': 'voice-north-corridor',
'South Corridor': 'voice-south-corridor',
'East Corridor': 'voice-east-corridor',
'West Corridor': 'voice-west-corridor'
};
function stopVoiceNarration() {
if (voiceDelayTimer) {
clearTimeout(voiceDelayTimer);
voiceDelayTimer = null;
}
if (currentVoiceAudio) {
currentVoiceAudio.pause();
currentVoiceAudio.currentTime = 0;
currentVoiceAudio = null;
}
if ('speechSynthesis' in window && ttsSpeaking) {
window.speechSynthesis.cancel();
ttsSpeaking = false;
}
currentVoiceRoom = null;
}
function playVoiceForRoom(roomName) {
// Prefer audio tag if present (will auto-fallback to TTS since no audio tags exist)
const audioId = ROOM_VOICE_IDS[roomName];
const audioEl = audioId ? document.getElementById(audioId) : null;
if (audioEl) {
currentVoiceAudio = audioEl;
currentVoiceAudio.currentTime = 0;
currentVoiceAudio.play().catch(() => {});
currentVoiceAudio.onended = () => {
currentVoiceAudio = null;
currentVoiceRoom = null;
};
return;
}
// Fallback to TTS using existing text line for this room
if ('speechSynthesis' in window && NARRATOR_LINES[roomName]) {
const utter = new SpeechSynthesisUtterance(NARRATOR_LINES[roomName]);
utter.rate = 1.0;
utter.pitch = 1.0;
utter.onend = () => {
ttsSpeaking = false;
currentVoiceRoom = null;
};
ttsSpeaking = true;
window.speechSynthesis.speak(utter);
}
}
// Called whenever room changes (with up to 1s delay)
function handleVoiceRoomChange(nextRoomName) {
if (currentVoiceRoom === nextRoomName) return;
// Leaving current area: stop any pending or playing narration
stopVoiceNarration();
if (!nextRoomName || (!ROOM_VOICE_IDS[nextRoomName] && !NARRATOR_LINES[nextRoomName])) {
return;
}
currentVoiceRoom = nextRoomName;
// Start after up to 1 second; only if still in this room
voiceDelayTimer = setTimeout(() => {
if (currentVoiceRoom === nextRoomName) {
playVoiceForRoom(nextRoomName);
}
}, 1000);
}
// ═══════════════════════════════════════════════════
// ── PLAYER / FPS CONTROLS
// ═══════════════════════════════════════════════════
function initControls() {
controls = new PointerLockControls(camera, document.body);
const lockScreen = document.getElementById('lock-screen');
const crosshair = document.getElementById('crosshair');
const hud = document.getElementById('hud');
lockScreen.addEventListener('click', () => {
if (mobileState.enabled) {
lockScreen.classList.add('hidden');
crosshair.classList.add('visible');
hud.classList.add('visible');
if (!audioCtx) initAudio();
else if (audioCtx.state === 'suspended') audioCtx.resume();
return;
}
controls.lock();
});
controls.addEventListener('lock', () => {
// Suppress FPS HUD reveal when not in FPS mode (e.g. during galaxy transition).
if (typeof currentMode !== 'undefined' && currentMode !== 'fps') return;
lockScreen.classList.add('hidden');
crosshair.classList.add('visible');
hud.classList.add('visible');
if (!audioCtx) initAudio();
else if (audioCtx.state === 'suspended') audioCtx.resume();
});
controls.addEventListener('unlock', () => {
// Don't re-show FPS lock screen when we're in/entering galaxy mode.
if (typeof currentMode !== 'undefined' && currentMode !== 'fps') return;
if (!toolPanelOpen && !pipBoyOpen) {
lockScreen.classList.remove('hidden');
}
crosshair.classList.remove('visible');
});
// Keyboard (rebind-aware via window.__artemis_keymap)
function _resolveAction(code) {
const km = window.__artemis_keymap || {};
if (km[code]) return km[code];
switch (code) {
case 'KeyW': case 'ArrowUp': return 'forward';
case 'KeyS': case 'ArrowDown': return 'backward';
case 'KeyA': case 'ArrowLeft': return 'left';
case 'KeyD': case 'ArrowRight': return 'right';
case 'ShiftLeft': case 'ShiftRight': return 'sprint';
case 'KeyE': return 'interact';
case 'Escape': return 'escape';
default: return null;
}
}
document.addEventListener('keydown', (e) => {
if (window.__artemis_consume_keys) return;
const action = _resolveAction(e.code);
switch (action) {
case 'forward': moveState.forward = true; break;
case 'backward': moveState.backward = true; break;
case 'left': moveState.left = true; break;
case 'right': moveState.right = true; break;
case 'sprint': moveState.sprint = true; break;
case 'interact': handleInteract(); break;
case 'escape':
if (toolPanelOpen) { closeToolPanel(); e.preventDefault(); }
break;
}
});
document.addEventListener('keyup', (e) => {
if (window.__artemis_consume_keys) return;
const action = _resolveAction(e.code);
switch (action) {
case 'forward': moveState.forward = false; break;
case 'backward': moveState.backward = false; break;
case 'left': moveState.left = false; break;
case 'right': moveState.right = false; break;
case 'sprint': moveState.sprint = false; break;
}
});
// Left-click to interact when pointer is locked and aiming at something
document.addEventListener('click', (e) => {
if (controls.isLocked && aimedObject && e.button === 0) {
handleInteract();
}
});
scene.add(controls.object);
if (IS_TOUCH_DEVICE) {
initMobileControls();
}
}
function initMobileControls() {
mobileState.enabled = true;
document.body.classList.add('touch-device');
const mobileUI = document.getElementById('mobile-ui');
const joystick = document.getElementById('mobile-joystick');
const stick = document.getElementById('mobile-stick');
const lockScreen = document.getElementById('lock-screen');
const hud = document.getElementById('hud');
const crosshair = document.getElementById('crosshair');
const hint = document.getElementById('controls-hint');
if (hint) hint.textContent = 'Touch look · Left stick move · Buttons interact/menu/galaxy';
mobileUI.classList.add('visible');
const title = lockScreen.querySelector('h2');
const sub = lockScreen.querySelector('p');
if (title) title.textContent = 'Tap to Enter';
if (sub) sub.textContent = 'Touch to look · Left stick to move · Tap buttons to interact';
camera.rotation.order = 'YXZ';
mobileState.yaw = camera.rotation.y;
mobileState.pitch = camera.rotation.x;
const setJoy = (touch) => {
const rect = joystick.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
let dx = touch.clientX - cx;
let dy = touch.clientY - cy;
const len = Math.hypot(dx, dy);
if (len > joyRadius) {
dx = (dx / len) * joyRadius;
dy = (dy / len) * joyRadius;
}
stick.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
const nx = dx / joyRadius;
const ny = dy / joyRadius;
const mag = Math.hypot(nx, ny);
if (mag <= joyDeadzone) {
mobileMove.x = 0;
mobileMove.y = 0;
return;
}
const scaled = Math.pow((mag - joyDeadzone) / (1 - joyDeadzone), joyCurve);
const out = Math.min(1, scaled);
const ux = nx / mag;
const uy = ny / mag;
mobileMove.x = ux * out;
mobileMove.y = uy * out;
};
const resetJoy = () => {
mobileMove.x = 0;
mobileMove.y = 0;
stick.style.transform = 'translate(-50%,-50%)';
};
joystick.addEventListener('touchstart', (e) => {
const t = e.changedTouches[0];
mobileState.joyTouchId = t.identifier;
setJoy(t);
e.preventDefault();
}, { passive: false });
joystick.addEventListener('touchmove', (e) => {
for (const t of e.changedTouches) {
if (t.identifier === mobileState.joyTouchId) {
setJoy(t);
e.preventDefault();
break;
}
}
}, { passive: false });
joystick.addEventListener('touchend', (e) => {
for (const t of e.changedTouches) {
if (t.identifier === mobileState.joyTouchId) {
mobileState.joyTouchId = null;
resetJoy();
break;
}
}
}, { passive: true });
renderer.domElement.addEventListener('touchstart', (e) => {
if (currentMode !== 'fps' || toolPanelOpen) return;
if (e.target.closest('#mobile-joystick') || e.target.closest('#mobile-actions')) return;
if (mobileState.lookTouchId !== null) return;
const t = e.changedTouches[0];
mobileState.lookTouchId = t.identifier;
renderer.domElement.dataset.touchPrevX = String(t.clientX);
renderer.domElement.dataset.touchPrevY = String(t.clientY);
}, { passive: true });
renderer.domElement.addEventListener('touchmove', (e) => {
if (mobileState.lookTouchId === null || currentMode !== 'fps') return;
const touch = Array.from(e.touches).find(t => t.identifier === mobileState.lookTouchId);
if (!touch) return;
const prevX = Number(renderer.domElement.dataset.touchPrevX || touch.clientX);
const prevY = Number(renderer.domElement.dataset.touchPrevY || touch.clientY);
let dx = touch.clientX - prevX;
let dy = touch.clientY - prevY;
dx = Math.max(-maxLookDeltaPx, Math.min(maxLookDeltaPx, dx));
dy = Math.max(-maxLookDeltaPx, Math.min(maxLookDeltaPx, dy));
renderer.domElement.dataset.touchPrevX = String(touch.clientX);
renderer.domElement.dataset.touchPrevY = String(touch.clientY);
// Accumulate — do NOT apply here; touchmove fires ~10x/frame.
// The animate() loop drains pendingYaw/pendingPitch once per frame.
mobileState.pendingYaw -= dx;
mobileState.pendingPitch -= dy;
e.preventDefault();
}, { passive: false });
renderer.domElement.addEventListener('touchcancel', () => {
mobileState.lookTouchId = null;
delete renderer.domElement.dataset.touchPrevX;
delete renderer.domElement.dataset.touchPrevY;
}, { passive: true });
renderer.domElement.addEventListener('touchend', (e) => {
for (const t of e.changedTouches) {
if (t.identifier === mobileState.lookTouchId) {
mobileState.lookTouchId = null;
delete renderer.domElement.dataset.touchPrevX;
delete renderer.domElement.dataset.touchPrevY;
break;
}
}
}, { passive: true });
document.getElementById('mobile-interact')?.addEventListener('click', () => handleInteract());
document.getElementById('mobile-menu')?.addEventListener('click', () => toggleMenu());
document.getElementById('mobile-galaxy')?.addEventListener('click', () => {
if (currentMode === 'fps') enterGalaxyMode();
else if (currentMode === 'galaxy') exitGalaxyMode();
});
lockScreen.addEventListener('click', () => {
hud.classList.add('visible');
crosshair.classList.add('visible');
});
}
function testCollision(pos) {
const r = CFG.PLAYER_RADIUS;
const playerBox = new THREE.Box3(
new THREE.Vector3(pos.x - r, 0, pos.z - r),
new THREE.Vector3(pos.x + r, CFG.EYE_H + 0.2, pos.z + r)
);
for (const box of collisionBoxes) {
if (playerBox.intersectsBox(box)) return true;
}
return false;
}
function updateMovement(delta) {
if (!mobileState.enabled && !controls.isLocked) return;
if (pipBoyOpen) return;
const sprinting = moveState.sprint;
const speed = CFG.MOVE_SPEED * (sprinting ? CFG.SPRINT_MULT : 1.0);
const damping = 8;
// Deceleration
velocity.x -= velocity.x * damping * delta;
velocity.z -= velocity.z * damping * delta;
const inputForward = Number(moveState.forward) - Number(moveState.backward) + (-mobileMove.y);
const inputRight = Number(moveState.right) - Number(moveState.left) + mobileMove.x;
direction.z = inputForward;
direction.x = inputRight;
direction.normalize();
if (Math.abs(inputForward) > 0.01) velocity.z -= direction.z * speed * delta;
if (Math.abs(inputRight) > 0.01) velocity.x -= direction.x * speed * delta;
// Get movement in world space
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0,1,0)).normalize();
const moveX = right.x * (-velocity.x * delta) + forward.x * (-velocity.z * delta);
const moveZ = right.z * (-velocity.x * delta) + forward.z * (-velocity.z * delta);
// Try X movement
const newPos = camera.position.clone();
newPos.x += moveX;
if (!testCollision(newPos)) {
camera.position.x = newPos.x;
}
// Try Z movement
newPos.copy(camera.position);
newPos.z += moveZ;
if (!testCollision(newPos)) {
camera.position.z = newPos.z;
}
// Footstep sounds
const isMoving = Math.abs(inputForward) > 0.01 || Math.abs(inputRight) > 0.01;
if (isMoving && (controls.isLocked || mobileState.enabled)) {
const stepInterval = sprinting ? SPRINT_STEP_INTERVAL : STEP_INTERVAL;
stepTimer += delta;
if (stepTimer >= stepInterval) {
playFootstep();
stepTimer = 0;
}
} else {
stepTimer = STEP_INTERVAL * 0.8; // Reset near trigger for immediate step on move
}
// Room detection
detectRoom();
}
function detectRoom() {
const px = camera.position.x, pz = camera.position.z;
let foundRoom = null;
for (const zone of roomZones) {
if (px >= zone.x1 && px <= zone.x2 && pz >= zone.z1 && pz <= zone.z2) {
foundRoom = zone.name;
if (currentRoom !== zone.name) {
currentRoom = zone.name;
document.getElementById('room-name').textContent = zone.name;
checkRoomNarration(zone.name);
handleVoiceRoomChange(zone.name); // NEW: trigger voice narrator
}
return;
}
}
// Outside all defined zones
if (currentRoom !== null) {
currentRoom = null;
document.getElementById('room-name').textContent = '';
handleVoiceRoomChange(null); // NEW: stop or reset voice when leaving
}
}
// ═══════════════════════════════════════════════════
// ── INTERACTION SYSTEM
// ═══════════════════════════════════════════════════
let aimedObject = null;
const interactPrompt = document.getElementById('interact-prompt');
function updateAimDetection() {
if (!mobileState.enabled && !controls.isLocked) {
if (aimedObject) { aimedObject = null; interactPrompt.classList.remove('visible'); }
return;
}
raycaster.setFromCamera(new THREE.Vector2(0,0), camera);
// Build candidate objects: tools + Sage avatar collider
const candidates = [...interactiveObjects];
if (sageAvatar && sageAvatar.group) candidates.push(sageAvatar.group);
const intersects = raycaster.intersectObjects(candidates, true);
if (intersects.length > 0 && intersects[0].distance <= CFG.INTERACT_DIST) {
const obj = intersects[0].object;
// Check if this hit belongs to Sage
let isSage = false;
let p = obj;
while (p) { if (p === sageAvatar?.group) { isSage = true; break; } p = p.parent; }
if (isSage) {
if (aimedObject !== obj) {
aimedObject = obj;
interactPrompt.textContent = 'Press E — Talk to Sage';
interactPrompt.classList.add('visible');
}
} else if (obj.userData.toolId) {
if (aimedObject !== obj) {
aimedObject = obj;
const tool = TOOLS[obj.userData.toolId];
interactPrompt.textContent = `Press E — ${tool ? tool.name : obj.userData.toolId}`;
interactPrompt.classList.add('visible');
}
} else {
if (aimedObject) { aimedObject = null; interactPrompt.classList.remove('visible'); }
}
} else {
if (aimedObject) {
aimedObject = null;
interactPrompt.classList.remove('visible');
}
}
}
function handleInteract() {
if (!aimedObject) return;
// Check if aimed at Sage
let isSage = false;
let p = aimedObject;
while (p) { if (p === sageAvatar?.group) { isSage = true; break; } p = p.parent; }
if (isSage) {
playInteractSound();
sageAvatar.toggleChat();
// Release pointer lock so user can type and use Hold button
if (sageAvatar._ui?.isVisible()) {
document.exitPointerLock();
} else if (controls) {
controls.lock();
}
return;
}
const toolId = aimedObject.userData.toolId;
if (toolId) {
playInteractSound();
openToolPanel(toolId);
}
}
// ═══════════════════════════════════════════════════
// ── TOOL PANEL (UI)
// ═══════════════════════════════════════════════════
function openToolPanel(toolId) {
const tool = TOOLS[toolId];
if (!tool) return;
toolPanelOpen = true;
controls.unlock();
document.getElementById('tp-title').textContent = tool.name;
const body = document.getElementById('tp-body');
body.innerHTML = '';
// Description
const desc = document.createElement('div');
desc.className = 'tp-desc';
desc.textContent = tool.desc;
body.appendChild(desc);
// Tags
if (tool.tags.length) {
const section = document.createElement('div');
section.className = 'tp-section';
section.innerHTML = '<div class="tp-section-label">Stack</div>';
const tags = document.createElement('div');
tags.className = 'tp-tags';
tool.tags.forEach(t => {
const tag = document.createElement('span');
tag.className = 'tp-tag';
tag.textContent = t;
tags.appendChild(tag);
});
section.appendChild(tags);
body.appendChild(section);
}
// Status
const status = document.createElement('div');
status.className = 'tp-status';
status.innerHTML = `<div class="tp-status-dot ${runningTools[toolId] ? 'running' : ''}"></div>
<span>${runningTools[toolId] ? 'Running' : 'Stopped'}</span>`;
body.appendChild(status);
// Actions
if (tool.hasLaunch) {
const actions = document.createElement('div');
actions.className = 'tp-actions';
const launchBtn = document.createElement('button');
launchBtn.className = 'tp-btn launch';
launchBtn.textContent = '▶ Launch';
launchBtn.onclick = () => launchTool(toolId);
actions.appendChild(launchBtn);
const stopBtn = document.createElement('button');
stopBtn.className = 'tp-btn stop';
stopBtn.textContent = '■ Stop';
stopBtn.onclick = () => stopTool(toolId);
actions.appendChild(stopBtn);
const outputBtn = document.createElement('button');
outputBtn.className = 'tp-btn';
outputBtn.textContent = '🖥 Output';
outputBtn.onclick = () => viewOutput(toolId);
actions.appendChild(outputBtn);
if (tool.url) {
const webBtn = document.createElement('button');
webBtn.className = 'tp-btn';
webBtn.textContent = '🌐 Open';
webBtn.onclick = () => window.open(tool.url, '_blank');
actions.appendChild(webBtn);
}
body.appendChild(actions);
}
// Space embed button (for tools with a linked HF Space)
if (tool.spaceUrl) {
const spaceSection = document.createElement('div');
spaceSection.className = 'tp-section';
spaceSection.innerHTML = '<div class="tp-section-label">Linked Space</div>';
const spaceBtn = document.createElement('button');
spaceBtn.className = 'tp-btn launch';
spaceBtn.style.cssText = 'width:100%;margin-top:4px;background:linear-gradient(135deg,#1f6feb,#0d419d)';
spaceBtn.textContent = '🚀 Open in Dashboard';
spaceBtn.onclick = () => openSpaceModal(tool.spaceUrl, tool.name.toUpperCase());
spaceSection.appendChild(spaceBtn);
body.appendChild(spaceSection);
}
document.getElementById('tool-panel').classList.add('open');
document.body.style.cursor = 'default';
}
function closeToolPanel() {
toolPanelOpen = false;
document.getElementById('tool-panel').classList.remove('open');
document.body.style.cursor = 'none';
// Re-lock after a short delay
setTimeout(() => {
if (!toolPanelOpen) controls.lock();
}, 100);
}
// ═══════════════════════════════════════════════════
// ── API INTEGRATION
// ═══════════════════════════════════════════════════
async function checkBackend() {
try {
const resp = await fetch(`${API}/api/status`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
backendOk = true;
document.getElementById('status-dot').classList.add('ok');
const data = await resp.json();
// Update running status
if (data.tools) {
for (const [id, info] of Object.entries(data.tools)) {
runningTools[id] = info.running;
}
refreshMonitors();
}
}
} catch {
backendOk = false;
document.getElementById('status-dot').classList.remove('ok');
}
}
async function launchTool(id) {
try {
const r = await fetch(`${API}/api/launch`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({tool_id: id})
});
if (r.ok) {
runningTools[id] = true;
showToast(`${TOOLS[id]?.name || id} launched`, 'success');
refreshMonitors();
openToolPanel(id); // Refresh panel
} else {
const d = await r.json().catch(() => ({}));
showToast(d.detail || 'Launch failed', 'error');
}
} catch(e) { showToast(`Error: ${e.message}`, 'error'); }
}
async function stopTool(id) {
try {
const r = await fetch(`${API}/api/stop`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({tool_id: id})
});
if (r.ok) {
runningTools[id] = false;
showToast(`${TOOLS[id]?.name || id} stopped`, 'success');
refreshMonitors();
openToolPanel(id);
} else {
const d = await r.json().catch(() => ({}));
showToast(d.detail || 'Stop failed', 'error');
}
} catch(e) { showToast(`Error: ${e.message}`, 'error'); }
}
async function viewOutput(id) {
const modal = document.getElementById('output-modal');
const content = document.getElementById('output-content');
document.getElementById('output-title').textContent = `${TOOLS[id]?.name || id} — Output`;
content.textContent = 'Loading...';
modal.classList.add('open');
try {
const r = await fetch(`${API}/api/output/${id}`);
if (r.ok) {
const d = await r.json();
content.textContent = d.output || '(no output)';
} else {
content.textContent = '(failed to fetch output)';
}
} catch(e) {
content.textContent = `Error: ${e.message}`;
}
}
function closeOutputModal() {
document.getElementById('output-modal').classList.remove('open');
}
// ── SPACE IFRAME MODAL ──────────────────────────────────────────────
function openSpaceModal(url, title) {
const modal = document.getElementById('space-modal');
document.getElementById('space-title').textContent = title || 'SPACE';
document.getElementById('space-iframe').src = url;
modal.classList.add('open');
controls.unlock();
document.body.style.cursor = 'default';
}
function closeSpaceModal() {
const modal = document.getElementById('space-modal');
modal.classList.remove('open');
document.getElementById('space-iframe').src = '';
setTimeout(() => {
if (!toolPanelOpen) controls.lock();
}, 100);
}
function refreshMonitors() {
for (const [toolId, mesh] of Object.entries(monitorMeshes)) {
const newTex = monitorTexture(toolId);
mesh.material.map = newTex;
mesh.material.emissiveMap = newTex;
mesh.material.needsUpdate = true;
}
}
function showToast(msg, type='info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// ═══════════════════════════════════════════════════
// ── POST-PROCESSING
// ═══════════════════════════════════════════════════
function initPostProcessing() {
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.15, // strength (subtle)
0.8, // radius
0.85 // threshold (only bright things bloom)
);
composer.addPass(bloomPass);
}
// ═══════════════════════════════════════════════════
// ── INIT
// ═══════════════════════════════════════════════════
function updateLoadBar(pct) {
document.getElementById('load-fill').style.width = pct + '%';
}
async function init() {
clock = new THREE.Clock();
raycaster = new THREE.Raycaster();
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
scene.fog = new THREE.Fog(0x1a1a1a, 15, 35);
// Camera — far clip extended to 2000 for galaxy mode
camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.05, 2000);
camera.position.set(0, CFG.EYE_H, 0);
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
updateLoadBar(10);
// Materials
buildMaterials();
updateLoadBar(25);
// Build rooms
for (const room of ROOMS) {
buildRoom(room);
}
updateLoadBar(50);
// Furniture and terminals
for (const room of ROOMS) {
buildRoomFurniture(room);
}
updateLoadBar(70);
// Lighting
buildLighting();
updateLoadBar(80);
// Environment details
buildEnvironmentDetails();
updateLoadBar(85);
// Controls
initControls();
updateLoadBar(90);
// Pip-Boy (left-arm device + UI overlay)
initPipBoy();
// Post-processing
initPostProcessing();
updateLoadBar(95);
// Event listeners
window.addEventListener('resize', onResize);
// Sage Avatar — instantiate in AI Systems Lab (center ~0, 0, 13)
// Lab bounds: x1:-5.5 z1:9 x2:5.5 z2:17 → center (0, 0, 13)
// rotationY: Math.PI faces south toward the entrance corridor
sageAvatar = new SageAvatar(scene, camera, {
position: new THREE.Vector3(0, 0, 13),
rotationY: Math.PI,
});
window.sageAvatar = sageAvatar; // expose for debugging + external triggers
// Backend check
checkBackend();
setInterval(checkBackend, 8000);
// Loading complete
updateLoadBar(100);
await new Promise(r => setTimeout(r, 400));
document.getElementById('loading').classList.add('done');
document.getElementById('lock-screen').classList.remove('hidden');
// Start the initial narration after player enters
visitedRooms.clear();
// Render loop
animate();
}
// ═══════════════════════════════════════════════════
// ── PIP-BOY 3000 (left-arm device + Fallout UI)
// ═══════════════════════════════════════════════════
let pipboy = null; // { group, screen, t, target, posLow/High, rotLow/High }
let pipBoyOpen = false; // UI overlay visible
function initPipBoy() {
const group = new THREE.Group();
group.name = 'pipboy_arm';
// Skin / sleeve / device materials
const skinMat = new THREE.MeshStandardMaterial({ color: 0xc9a884, roughness: 0.85 });
const sleeveMat = new THREE.MeshStandardMaterial({ color: 0x2a3220, roughness: 0.95 });
const pipMat = new THREE.MeshStandardMaterial({ color: 0x3b4a2a, roughness: 0.7, metalness: 0.25 });
const knobMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4a, roughness: 0.4, metalness: 0.6 });
const screenMat = new THREE.MeshBasicMaterial({ color: 0x1eff5a });
// Upper sleeve (rolled-up jumpsuit)
const sleeve = new THREE.Mesh(new THREE.CylinderGeometry(0.062, 0.072, 0.30, 16), sleeveMat);
sleeve.position.y = 0.25;
group.add(sleeve);
// Forearm — cylinder going from elbow toward wrist (group local +Y is toward shoulder)
const forearm = new THREE.Mesh(new THREE.CylinderGeometry(0.054, 0.064, 0.38, 16), skinMat);
forearm.position.y = -0.05;
group.add(forearm);
// Hand
const hand = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.14, 0.06), skinMat);
hand.position.y = -0.31;
group.add(hand);
// Pip-Boy body on inside of wrist (faces +Z so it points at camera when raised)
const body = new THREE.Mesh(new THREE.BoxGeometry(0.20, 0.18, 0.13), pipMat);
body.position.set(0, -0.10, 0.07);
group.add(body);
// Side knobs (3 dials on left edge)
for (let i = 0; i < 3; i++) {
const k = new THREE.Mesh(new THREE.CylinderGeometry(0.013, 0.013, 0.022, 12), knobMat);
k.rotation.z = Math.PI / 2;
k.position.set(-0.11, -0.15 + i * 0.05, 0.07);
group.add(k);
}
// Antenna stub
const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, 0.10, 8), knobMat);
ant.position.set(0.09, -0.03, 0.07);
ant.rotation.x = -0.35;
group.add(ant);
// Glowing screen face
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.13, 0.10), screenMat);
screen.position.set(0, -0.09, 0.136);
group.add(screen);
// Soft green glow light
const sLight = new THREE.PointLight(0x1eff5a, 0.55, 0.6);
sLight.position.set(0, -0.09, 0.20);
group.add(sLight);
// Attach to camera so it follows the view
camera.add(group);
scene.add(camera); // ensure camera is in scene graph for camera-children to render
pipboy = {
group,
screen,
t: 0,
target: 0,
// Lowered: down at the player's left side, mostly out of frame
posLow: new THREE.Vector3(-0.45, -0.95, -0.25),
rotLow: new THREE.Euler(-1.55, 0.20, 0.10),
// Raised: across the chest, screen tilted toward camera
posHigh: new THREE.Vector3(-0.18, -0.22, -0.45),
rotHigh: new THREE.Euler(-0.25, 0.55, 0.15),
};
group.position.copy(pipboy.posLow);
group.rotation.copy(pipboy.rotLow);
}
function updatePipBoy(delta) {
if (!pipboy) return;
if (pipboy.t === pipboy.target) return;
const speed = 3.0;
const dir = pipboy.target > pipboy.t ? 1 : -1;
pipboy.t = Math.max(0, Math.min(1, pipboy.t + dir * speed * delta));
// ease-in-out cubic
const x = pipboy.t;
const e = x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
pipboy.group.position.lerpVectors(pipboy.posLow, pipboy.posHigh, e);
pipboy.group.rotation.x = pipboy.rotLow.x + (pipboy.rotHigh.x - pipboy.rotLow.x) * e;
pipboy.group.rotation.y = pipboy.rotLow.y + (pipboy.rotHigh.y - pipboy.rotLow.y) * e;
pipboy.group.rotation.z = pipboy.rotLow.z + (pipboy.rotHigh.z - pipboy.rotLow.z) * e;
// Reveal/hide UI when fully at end of travel
if (pipboy.t >= 1 && pipboy.target === 1 && !pipBoyOpen) showPipBoyUI();
if (pipboy.t <= 0 && pipboy.target === 0 && pipBoyOpen) hidePipBoyUI();
}
function togglePipBoy() {
if (!pipboy) return;
if (currentMode !== 'fps') return;
if (toolPanelOpen) return;
pipboy.target = pipboy.target === 1 ? 0 : 1;
// Hide UI immediately when starting to lower so it tracks the arm
if (pipboy.target === 0 && pipBoyOpen) hidePipBoyUI();
}
function showPipBoyUI() {
pipBoyOpen = true;
const ui = document.getElementById('pipboy-ui');
if (!ui) return;
ui.classList.add('visible');
const loc = document.getElementById('pb-loc');
if (loc) loc.textContent = 'LOC: ' + (currentRoom || 'Unknown');
pipBoySetTab(pbActiveTab || 'STAT');
// Release pointer so the user can interact with the UI
if (controls && controls.isLocked) document.exitPointerLock();
// Hide the FPS crosshair while in Pip-Boy
const ch = document.getElementById('crosshair');
if (ch) ch.classList.remove('visible');
}
function hidePipBoyUI() {
pipBoyOpen = false;
const ui = document.getElementById('pipboy-ui');
if (ui) ui.classList.remove('visible');
}
const PIPBOY_TABS = {
STAT: { subs: ['STATUS', 'SPECIAL', 'PERKS'], render: pbRenderStat },
INV: { subs: ['WEAPONS', 'APPAREL', 'AID', 'MISC'], render: pbRenderInv },
DATA: { subs: ['QUESTS', 'WORKSHOPS', 'STATS'], render: pbRenderData },
MAP: { subs: ['LOCAL', 'WORLD'], render: pbRenderMap },
RADIO: { subs: ['STATIONS'], render: pbRenderRadio },
};
let pbActiveTab = 'STAT';
let pbActiveSub = 'STATUS';
function pipBoySetTab(t) {
pbActiveTab = t;
pbActiveSub = PIPBOY_TABS[t].subs[0];
document.querySelectorAll('#pipboy-ui .pb-tab').forEach((el) => {
el.classList.toggle('active', el.dataset.tab === t);
});
pipBoyRenderSubs();
PIPBOY_TABS[t].render();
}
function pipBoyRenderSubs() {
const el = document.getElementById('pb-subtabs');
if (!el) return;
el.innerHTML = '';
for (const s of PIPBOY_TABS[pbActiveTab].subs) {
const d = document.createElement('div');
d.className = 'pb-subtab' + (s === pbActiveSub ? ' active' : '');
d.textContent = s;
d.onclick = () => {
pbActiveSub = s;
pipBoyRenderSubs();
PIPBOY_TABS[pbActiveTab].render();
};
el.appendChild(d);
}
}
function pbBar(pct) {
return `<span class="pb-bar"><span style="width:${Math.max(0,Math.min(100,pct))}%"></span></span>`;
}
function pbRenderStat() {
const body = document.getElementById('pb-body');
if (!body) return;
if (pbActiveSub === 'STATUS') {
body.innerHTML = `
<h3>VAULT DWELLER &mdash; ARTEMIS OPERATOR</h3>
<div class="pb-row"><span>HP</span><span>100/100 ${pbBar(100)}</span></div>
<div class="pb-row"><span>AP</span><span>90/90 ${pbBar(90)}</span></div>
<div class="pb-row"><span>RAD</span><span>0 ${pbBar(0)}</span></div>
<div class="pb-row"><span>HUNGER</span><span>${pbBar(20)}</span></div>
<div class="pb-row"><span>THIRST</span><span>${pbBar(15)}</span></div>
<div class="pb-row"><span>WEIGHT</span><span>42 / 220</span></div>
<div class="pb-row"><span>CAPS</span><span>1,247</span></div>
<div class="pb-row"><span>LEVEL</span><span>23 &mdash; XP 14,820 / 18,000 ${pbBar(82)}</span></div>`;
} else if (pbActiveSub === 'SPECIAL') {
const stats = [['STR',7],['PER',6],['END',5],['CHA',4],['INT',9],['AGI',6],['LCK',5]];
body.innerHTML = stats.map(([k,v]) =>
`<div class="pb-row"><span>${k}</span><span>${v} ${pbBar(v*10)}</span></div>`).join('');
} else {
body.innerHTML = `<ul class="pb-list">
<li>Hacker &mdash; Lvl 3</li>
<li>Gun Nut &mdash; Lvl 4</li>
<li>Lone Wanderer &mdash; Lvl 2</li>
<li>Idiot Savant &mdash; Lvl 1</li>
<li>Local Leader &mdash; Lvl 2</li>
<li>Science! &mdash; Lvl 3</li>
</ul>`;
}
}
function pbRenderInv() {
const body = document.getElementById('pb-body');
if (!body) return;
const sets = {
WEAPONS: ['10mm Pistol','Combat Rifle','Plasma Gun','Frag Grenade x4','Cryolator'],
APPAREL: ['Vault 111 Jumpsuit','Combat Armor (Sturdy)','Pip-Boy 3000 Mk IV'],
AID: ['Stimpak x12','RadAway x4','Med-X x2','Nuka-Cola x8','Purified Water x6'],
MISC: ['Bobby Pin x17','Fusion Core x3','Pre-War Money x42','Holotape: Sage Logs'],
};
body.innerHTML = '<ul class="pb-list">' +
sets[pbActiveSub].map((x) => `<li>${x}</li>`).join('') + '</ul>';
}
function pbRenderData() {
const body = document.getElementById('pb-body');
if (!body) return;
const sets = {
QUESTS: [
'[ACTIVE] Pip-Boy Dashboard Integration',
'[PAUSED] Native Audio Pipeline (toolbox/nab)',
'[DONE] Voice Chat WebSocket',
'[DONE] Galaxy Cinematic Transition',
],
WORKSHOPS: [
'Sanctuary &mdash; 7 settlers',
'Red Rocket &mdash; 2 settlers',
'The Castle &mdash; 14 settlers',
],
STATS: [
'Distance Walked: 184.2 km',
'Locks Picked: 47',
'Terminals Hacked: 23',
'Tools Launched: 162',
'Sessions Archived: 069',
],
};
body.innerHTML = '<ul class="pb-list">' +
sets[pbActiveSub].map((x) => `<li>${x}</li>`).join('') + '</ul>';
}
function pbRenderMap() {
const body = document.getElementById('pb-body');
if (!body) return;
body.innerHTML = `
<div style="position:relative;height:100%;border:1px solid #1eff5a">
<svg width="100%" height="100%" viewBox="0 0 400 300" style="opacity:.9">
<defs><pattern id="pbgrid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1eff5a"
stroke-width=".4" opacity=".5"/>
</pattern></defs>
<rect width="100%" height="100%" fill="url(#pbgrid)"/>
<rect x="170" y="130" width="60" height="40" fill="none" stroke="#1eff5a"/>
<text x="200" y="125" fill="#1eff5a" text-anchor="middle" font-size="10">CENTRAL HUB</text>
<rect x="60" y="60" width="80" height="50" fill="none" stroke="#1eff5a"/>
<text x="100" y="55" fill="#1eff5a" text-anchor="middle" font-size="10">AI SYSTEMS</text>
<rect x="260" y="60" width="80" height="50" fill="none" stroke="#1eff5a"/>
<text x="300" y="55" fill="#1eff5a" text-anchor="middle" font-size="10">BUILT TOOLS</text>
<rect x="60" y="200" width="80" height="50" fill="none" stroke="#1eff5a"/>
<text x="100" y="195" fill="#1eff5a" text-anchor="middle" font-size="10">ANALYSIS LAB</text>
<rect x="260" y="200" width="80" height="50" fill="none" stroke="#1eff5a"/>
<text x="300" y="195" fill="#1eff5a" text-anchor="middle" font-size="10">SCRIPTS</text>
<line x1="140" y1="85" x2="170" y2="135" stroke="#1eff5a" stroke-dasharray="3 3"/>
<line x1="260" y1="85" x2="230" y2="135" stroke="#1eff5a" stroke-dasharray="3 3"/>
<line x1="140" y1="225" x2="170" y2="170" stroke="#1eff5a" stroke-dasharray="3 3"/>
<line x1="260" y1="225" x2="230" y2="170" stroke="#1eff5a" stroke-dasharray="3 3"/>
<circle cx="200" cy="150" r="5" fill="#1eff5a">
<animate attributeName="r" values="4;7;4" dur="1.5s" repeatCount="indefinite"/>
</circle>
<text x="210" y="154" fill="#1eff5a" font-size="10">YOU</text>
</svg>
</div>`;
}
function pbRenderRadio() {
const body = document.getElementById('pb-body');
if (!body) return;
body.innerHTML = `<ul class="pb-list">
<li class="sel">&#9654; Diamond City Radio</li>
<li>Classical Radio</li>
<li>Galaxy News Radio</li>
<li>Enclave Radio</li>
<li>ARTEMIS Local Beacon</li>
</ul>`;
}
// Pip-Boy keyboard binding (Tab or P toggles; Esc closes when open)
document.addEventListener('keydown', (e) => {
if (window.__artemis_consume_keys) return;
if (e.code === 'Tab' || e.code === 'KeyP') {
if (currentMode !== 'fps') return;
e.preventDefault();
if (e.repeat) return;
togglePipBoy();
} else if (e.code === 'Escape' && pipBoyOpen) {
e.preventDefault();
togglePipBoy();
}
});
// Tab clicks within the Pip-Boy UI
document.addEventListener('click', (e) => {
if (!pipBoyOpen) return;
const tab = e.target.closest('.pb-tab');
if (tab && tab.dataset.tab) pipBoySetTab(tab.dataset.tab);
});
// Footer clock
setInterval(() => {
const t = document.getElementById('pb-time');
if (!t) return;
const d = new Date();
t.textContent = String(d.getHours()).padStart(2, '0') + ':' +
String(d.getMinutes()).padStart(2, '0');
}, 1000);
// ═══════════════════════════════════════════════════
// ── ANIMATION LOOP
// ═══════════════════════════════════════════════════
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05); // Cap delta to prevent tunneling
const time = performance.now() * 0.001;
if (currentMode === 'fps') {
// Drain accumulated touch-look deltas once per frame (prevents stacking from multi-fire touchmove)
if (mobileState.enabled && (mobileState.pendingYaw !== 0 || mobileState.pendingPitch !== 0)) {
const sens = CFG.MOUSE_SENS * touchLookMultiplier;
mobileState.yaw += mobileState.pendingYaw * sens;
mobileState.pitch += mobileState.pendingPitch * sens;
mobileState.pitch = Math.max(-1.35, Math.min(1.35, mobileState.pitch));
camera.rotation.y = mobileState.yaw;
camera.rotation.x = mobileState.pitch;
mobileState.pendingYaw = 0;
mobileState.pendingPitch = 0;
}
updateMovement(delta);
updateAimDetection();
} else if (currentMode === 'galaxy' || currentMode === 'transition') {
if (orbitControls) orbitControls.update();
if (filePoints) {
filePoints.rotation.y += 0.00012;
if (filePoints.material.uniforms) {
filePoints.material.uniforms.uTime.value = time;
}
}
if (nebulaGroup) {
nebulaGroup.rotation.y += 0.00012;
nebulaGroup.children.forEach((sprite, i) => {
sprite.material.opacity = 0.10 + 0.04 * Math.sin(time * 0.3 + i);
});
}
if (bgGalaxy) bgGalaxy.rotation.y += 0.0002;
}
// Cinematic mote burst (only active during transition)
if (window.__fx_mote_update) window.__fx_mote_update(delta);
// Pip-Boy raise/lower animation
updatePipBoy(delta);
// Sage avatar per-frame update (lip sync, expressions, eyes, idle body)
if (sageAvatar) sageAvatar.update(delta, clock);
composer.render();
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
// ═══════════════════════════════════════════════════════════════════════
// ══ GALAXY MODE ════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
let currentMode = 'fps'; // 'fps' | 'galaxy' | 'transition'
let roomGroup = null; // wraps all room/lab geometry
let galaxyGroup = null; // wraps galaxy scene objects
let orbitControls = null;
let galaxyData = null;
let galaxyLoaded = false;
let galaxyLoadInFlight = false;
let filePoints = null, bgGalaxy = null, starsObj = null, nebulaGroup = null;
let galaxyCenter = new THREE.Vector3();
let galaxyRadius = 50;
let spiralPositions = null;
let baseFileSizes = null;
let galaxyHoveredIndex = -1;
let galaxyRaycaster = null;
let galaxyMouse = null;
let originalFog = null;
let originalBackground = null;
let savedFpsCamPos = new THREE.Vector3();
let savedFpsCamQuat = new THREE.Quaternion();
// Filter / search state
let galaxyActiveSources = new Set();
let galaxyDensityPct = 100;
let galaxyPointSize = 3;
let galaxySearchMatches = new Set();
const GAL_HIGHLIGHT = new THREE.Color('#ffd88a');
const GAL_DIM_ALPHA = 0.08;
const GAL_SRC_COLORS = {
android: new THREE.Color('#4dff4d'),
windows_c: new THREE.Color('#4d8dff'),
windows_d: new THREE.Color('#c04dff'),
wsl: new THREE.Color('#ff8c4d'),
};
const GAL_WARM = new THREE.Color('#ffd080');
const GAL_COOL = new THREE.Color('#4060cc');
// ── Galaxy textures (procedural) ──────────────────────
function galMakeStarTex(size) {
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
g.addColorStop(0.0, 'rgba(255,255,255,0.9)');
g.addColorStop(0.08,'rgba(255,255,255,0.6)');
g.addColorStop(0.25,'rgba(200,220,255,0.15)');
g.addColorStop(0.5, 'rgba(100,140,255,0.03)');
g.addColorStop(1.0, 'rgba(0,0,0,0)');
ctx.fillStyle = g; ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true;
return tex;
}
function galMakeNebulaTex(hue, size) {
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
for (let k = 0; k < 5; k++) {
const ox = size * (0.3 + Math.random() * 0.4);
const oy = size * (0.3 + Math.random() * 0.4);
const r = size * (0.2 + Math.random() * 0.3);
const g = ctx.createRadialGradient(ox, oy, 0, ox, oy, r);
const h = hue + (Math.random() - 0.5) * 30;
g.addColorStop(0.0, `hsla(${h}, 80%, 60%, 0.12)`);
g.addColorStop(0.4, `hsla(${h}, 70%, 40%, 0.06)`);
g.addColorStop(1.0, `hsla(${h}, 60%, 20%, 0)`);
ctx.fillStyle = g; ctx.fillRect(0, 0, size, size);
}
const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true;
return tex;
}
// ── Background starfield (HDR-style) ───────────────────
function galCreateStars() {
const count = 6000;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const temps = [
new THREE.Color('#aaccff'), new THREE.Color('#ffffff'),
new THREE.Color('#ffe8cc'), new THREE.Color('#ffccaa'),
new THREE.Color('#ccddff'),
];
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 250 + Math.random() * 500;
positions[i*3] = r * Math.sin(phi) * Math.cos(theta);
positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
positions[i*3+2] = r * Math.cos(phi);
const c = temps[Math.floor(Math.random() * temps.length)];
colors[i*3] = c.r; colors[i*3+1] = c.g; colors[i*3+2] = c.b;
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({
size: 1.0, sizeAttenuation: true, transparent: true,
opacity: 0.85, vertexColors: true, depthWrite: false,
blending: THREE.AdditiveBlending, map: galMakeStarTex(64),
});
starsObj = new THREE.Points(geom, mat);
galaxyGroup.add(starsObj);
}
// ── Decorative background spiral galaxy ────────────────
function galCreateBgGalaxy() {
const count = 30000;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const arms = 4;
const armSep = (2 * Math.PI) / arms;
const radius = 110;
const centerCol = new THREE.Color('#ffcc77');
const midCol = new THREE.Color('#cc88ff');
const armCol = new THREE.Color('#4466cc');
for (let i = 0; i < count; i++) {
const dist = Math.pow(Math.random(), 1.8) * radius;
const armIdx = Math.floor(Math.random() * arms);
const windAngle = dist * 0.12;
const spread = 0.8 + dist * 0.03;
const angle = armIdx * armSep + windAngle + (Math.random() - 0.5) * spread * 0.5;
const ySpread = Math.exp(-dist * 0.05) * 3;
positions[i*3] = Math.cos(angle) * dist + (Math.random() - 0.5) * spread;
positions[i*3+1] = (Math.random() - 0.5) * ySpread;
positions[i*3+2] = Math.sin(angle) * dist + (Math.random() - 0.5) * spread;
const t = dist / radius;
const c = t < 0.4
? new THREE.Color().lerpColors(centerCol, midCol, t / 0.4)
: new THREE.Color().lerpColors(midCol, armCol, (t - 0.4) / 0.6);
colors[i*3] = c.r; colors[i*3+1] = c.g; colors[i*3+2] = c.b;
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({
size: 0.12, sizeAttenuation: true, transparent: true,
opacity: 0.08, vertexColors: true, depthWrite: false,
blending: THREE.AdditiveBlending,
});
bgGalaxy = new THREE.Points(geom, mat);
bgGalaxy.rotation.set(0.3, 0, -0.1);
galaxyGroup.add(bgGalaxy);
}
// ── Spiral remap (UMAP → galactic disk) ───────────────
function galRemapToSpiral(files) {
const n = files.length;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const f of files) {
if (f.x < minX) minX = f.x; if (f.x > maxX) maxX = f.x;
if (f.y < minY) minY = f.y; if (f.y > maxY) maxY = f.y;
if (f.z < minZ) minZ = f.z; if (f.z > maxZ) maxZ = f.z;
}
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
const rangeZ = maxZ - minZ || 1;
const ARMS = 4, ARM_SEP = (2 * Math.PI) / ARMS;
const WIND = 0.15, DISK_R = 65, DISK_H = 4;
const positions = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const f = files[i];
const nx = (f.x - minX) / rangeX;
const ny = (f.y - minY) / rangeY;
const nz = (f.z - minZ) / rangeZ;
const cx = nx - 0.5, cz = nz - 0.5;
const dist = Math.sqrt(cx*cx + cz*cz) * 2;
const angle = Math.atan2(cz, cx);
const armIdx = Math.round((angle / ARM_SEP) % ARMS);
const armBase = armIdx * ARM_SEP;
const spiralAngle = armBase + dist * WIND * 20 + (angle - armBase) * 0.3;
const r = Math.pow(dist, 0.8) * DISK_R;
const yScale = Math.exp(-dist * 2) * DISK_H;
const yPos = (ny - 0.5) * yScale;
positions[i*3] = Math.cos(spiralAngle) * r;
positions[i*3+1] = yPos;
positions[i*3+2] = Math.sin(spiralAngle) * r;
}
return positions;
}
// ── Nebulae (drifting dust clouds) ─────────────────────
function galCreateNebulae(center, radius) {
if (nebulaGroup) { galaxyGroup.remove(nebulaGroup); }
nebulaGroup = new THREE.Group();
const hues = [240, 270, 200, 320, 180, 290, 210];
const count = 24;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5;
const dist = radius * (0.2 + Math.random() * 0.7);
const x = center.x + Math.cos(angle) * dist;
const z = center.z + Math.sin(angle) * dist;
const y = center.y + (Math.random() - 0.5) * 4;
const size = 10 + Math.random() * 20;
const hue = hues[i % hues.length];
const mat = new THREE.SpriteMaterial({
map: galMakeNebulaTex(hue, 256), transparent: true,
opacity: 0.12 + Math.random() * 0.08,
blending: THREE.AdditiveBlending, depthWrite: false,
color: new THREE.Color(`hsl(${hue}, 60%, 50%)`),
});
const sprite = new THREE.Sprite(mat);
sprite.position.set(x, y, z);
sprite.scale.set(size, size, 1);
nebulaGroup.add(sprite);
}
galaxyGroup.add(nebulaGroup);
}
// ── Build file point cloud ─────────────────────────────
function galBuildFilePoints(data) {
if (filePoints) {
galaxyGroup.remove(filePoints);
filePoints.geometry.dispose();
filePoints.material.dispose();
}
const files = data.files;
const n = files.length;
spiralPositions = galRemapToSpiral(files);
let cx = 0, cy = 0, cz = 0;
for (let i = 0; i < n; i++) {
cx += spiralPositions[i*3]; cy += spiralPositions[i*3+1]; cz += spiralPositions[i*3+2];
}
cx /= n; cy /= n; cz /= n;
galaxyCenter.set(cx, cy, cz);
let maxR = 0;
for (let i = 0; i < n; i++) {
const dx = spiralPositions[i*3] - cx;
const dy = spiralPositions[i*3+1] - cy;
const dz = spiralPositions[i*3+2] - cz;
const r = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (r > maxR) maxR = r;
}
galaxyRadius = maxR || 50;
const colors = new Float32Array(n * 3);
const alphas = new Float32Array(n).fill(1.0);
const sizes = new Float32Array(n);
const twinklePhase = new Float32Array(n);
baseFileSizes = new Float32Array(n);
let maxLog = 0;
for (let i = 0; i < n; i++) {
const s = files[i].s || 1;
baseFileSizes[i] = Math.log10(s + 1);
if (baseFileSizes[i] > maxLog) maxLog = baseFileSizes[i];
}
const tmpSrc = new THREE.Color();
const tmpGrad = new THREE.Color();
const tmpFinal = new THREE.Color();
const basePointSize = 3;
for (let i = 0; i < n; i++) {
const f = files[i];
const dx = spiralPositions[i*3] - cx;
const dz = spiralPositions[i*3+2] - cz;
const rFrac = Math.min(Math.sqrt(dx*dx + dz*dz) / galaxyRadius, 1);
tmpSrc.copy(GAL_SRC_COLORS[f.src] || new THREE.Color('#ffffff'));
tmpGrad.lerpColors(GAL_WARM, GAL_COOL, rFrac);
tmpFinal.r = tmpSrc.r * 0.6 + tmpGrad.r * 0.4;
tmpFinal.g = tmpSrc.g * 0.6 + tmpGrad.g * 0.4;
tmpFinal.b = tmpSrc.b * 0.6 + tmpGrad.b * 0.4;
colors[i*3] = tmpFinal.r; colors[i*3+1] = tmpFinal.g; colors[i*3+2] = tmpFinal.b;
const sizeFactor = 0.5 + (baseFileSizes[i] / (maxLog || 1)) * 2.0;
sizes[i] = basePointSize * sizeFactor;
twinklePhase[i] = Math.random() * Math.PI * 2;
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(spiralPositions, 3));
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geom.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
geom.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
geom.setAttribute('phase', new THREE.BufferAttribute(twinklePhase, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0.0 }, uStarTex: { value: galMakeStarTex(128) } },
vertexShader: `
attribute float alpha;
attribute float aSize;
attribute float phase;
uniform float uTime;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = color;
float twinkle = 0.85 + 0.15 * sin(uTime * 1.5 + phase * 6.2831);
vAlpha = alpha * twinkle;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (200.0 / -mvPosition.z);
gl_PointSize = clamp(gl_PointSize, 1.0, 50.0);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform sampler2D uStarTex;
varying vec3 vColor;
varying float vAlpha;
void main() {
vec4 texel = texture2D(uStarTex, gl_PointCoord);
float brightness = texel.r;
if (brightness < 0.01) discard;
gl_FragColor = vec4(vColor * brightness, vAlpha * brightness);
}
`,
transparent: true, depthWrite: false, vertexColors: true,
blending: THREE.AdditiveBlending,
});
filePoints = new THREE.Points(geom, mat);
galaxyGroup.add(filePoints);
galCreateNebulae(galaxyCenter, galaxyRadius);
// Stash per-point metadata for visibility/search updates
filePoints.userData.baseSizes = sizes.slice();
filePoints.userData.maxLog = maxLog || 1;
}
// ── Recompute visibility (sources + density + search) ─
function galUpdateVisibility() {
if (!galaxyData || !filePoints) return;
const files = galaxyData.files;
const n = files.length;
const alphaAttr = filePoints.geometry.getAttribute('alpha');
const colorAttr = filePoints.geometry.getAttribute('color');
const sizeAttr = filePoints.geometry.getAttribute('aSize');
const hasSearch = galaxySearchMatches.size > 0;
const maxLog = filePoints.userData.maxLog;
const tmpSrc = new THREE.Color();
const tmpGrad = new THREE.Color();
const tmpFinal = new THREE.Color();
const cx = galaxyCenter.x, cz = galaxyCenter.z;
let visCount = 0;
for (let i = 0; i < n; i++) {
const f = files[i];
const srcOn = galaxyActiveSources.has(f.src);
const densityOk = (i % 100) < galaxyDensityPct;
const visible = srcOn && densityOk;
const sizeFactor = 0.5 + (baseFileSizes[i] / maxLog) * 2.0;
const baseSize = galaxyPointSize * sizeFactor;
if (hasSearch) {
const isMatch = galaxySearchMatches.has(i);
if (isMatch && srcOn) {
colorAttr.setXYZ(i, GAL_HIGHLIGHT.r, GAL_HIGHLIGHT.g, GAL_HIGHLIGHT.b);
sizeAttr.setX(i, baseSize * 2.5);
alphaAttr.setX(i, 1.0);
visCount++;
} else {
const dx = spiralPositions[i*3] - cx, dz = spiralPositions[i*3+2] - cz;
const rFrac = Math.min(Math.sqrt(dx*dx + dz*dz) / galaxyRadius, 1);
tmpSrc.copy(GAL_SRC_COLORS[f.src] || new THREE.Color('#ffffff'));
tmpGrad.lerpColors(GAL_WARM, GAL_COOL, rFrac);
tmpFinal.r = tmpSrc.r * 0.6 + tmpGrad.r * 0.4;
tmpFinal.g = tmpSrc.g * 0.6 + tmpGrad.g * 0.4;
tmpFinal.b = tmpSrc.b * 0.6 + tmpGrad.b * 0.4;
colorAttr.setXYZ(i, tmpFinal.r, tmpFinal.g, tmpFinal.b);
sizeAttr.setX(i, baseSize);
alphaAttr.setX(i, visible ? GAL_DIM_ALPHA : 0.0);
}
} else {
const dx = spiralPositions[i*3] - cx, dz = spiralPositions[i*3+2] - cz;
const rFrac = Math.min(Math.sqrt(dx*dx + dz*dz) / galaxyRadius, 1);
tmpSrc.copy(GAL_SRC_COLORS[f.src] || new THREE.Color('#ffffff'));
tmpGrad.lerpColors(GAL_WARM, GAL_COOL, rFrac);
tmpFinal.r = tmpSrc.r * 0.6 + tmpGrad.r * 0.4;
tmpFinal.g = tmpSrc.g * 0.6 + tmpGrad.g * 0.4;
tmpFinal.b = tmpSrc.b * 0.6 + tmpGrad.b * 0.4;
colorAttr.setXYZ(i, tmpFinal.r, tmpFinal.g, tmpFinal.b);
sizeAttr.setX(i, baseSize);
alphaAttr.setX(i, visible ? 1.0 : 0.0);
if (visible) visCount++;
}
}
alphaAttr.needsUpdate = true;
colorAttr.needsUpdate = true;
sizeAttr.needsUpdate = true;
const stats = document.getElementById('gs-stats');
if (stats && galaxyData) {
stats.textContent = `${visCount.toLocaleString()} / ${n.toLocaleString()} points · ${galaxyData.meta.total_indexed.toLocaleString()} total indexed`;
}
}
// ── Populate sidebar source toggles from galaxy metadata ─
function galPopulateSourceToggles() {
if (!galaxyData) return;
// Reset sliders to authoritative defaults (Chromium may restore stale range values on reload)
const densitySlider = document.getElementById('gs-density');
const sizeSlider = document.getElementById('gs-size');
densitySlider.value = 100;
sizeSlider.value = 3;
galaxyDensityPct = 100;
galaxyPointSize = 3;
document.getElementById('gs-density-val').textContent = '100%';
document.getElementById('gs-size-val').textContent = '3';
const root = document.getElementById('gs-source-toggles');
root.innerHTML = '';
galaxyActiveSources.clear();
for (const [key, meta] of Object.entries(galaxyData.meta.sources)) {
galaxyActiveSources.add(key);
const row = document.createElement('label');
row.className = 'gs-src-row';
row.innerHTML =
`<input type="checkbox" class="gs-src-cb" data-src="${key}" checked>` +
`<span class="gs-src-dot" style="background:${meta.color};color:${meta.color}"></span>` +
`<span class="gs-src-label">${meta.label}</span>` +
`<span class="gs-src-count">${meta.sampled_files.toLocaleString()}</span>`;
root.appendChild(row);
}
root.querySelectorAll('.gs-src-cb').forEach((cb) => {
cb.addEventListener('change', () => {
galaxyActiveSources.clear();
root.querySelectorAll('.gs-src-cb').forEach((x) => {
if (x.checked) galaxyActiveSources.add(x.dataset.src);
});
galUpdateVisibility();
});
});
}
// ── Sidebar / slider / search handlers ────────────────
function toggleGalaxySidebar() {
const sb = document.getElementById('galaxy-sidebar');
const btn = document.getElementById('galaxy-sidebar-toggle');
sb.classList.toggle('open');
btn.classList.toggle('shifted');
}
function onGalaxyDensityChange(v) {
galaxyDensityPct = parseInt(v, 10);
document.getElementById('gs-density-val').textContent = galaxyDensityPct + '%';
galUpdateVisibility();
}
function onGalaxySizeChange(v) {
galaxyPointSize = parseFloat(v);
document.getElementById('gs-size-val').textContent = v;
galUpdateVisibility();
}
function onGalaxySearch(q) {
galaxySearchMatches.clear();
const resultsEl = document.getElementById('galaxy-search-results');
const query = (q || '').trim().toLowerCase();
if (!galaxyData || !query) {
resultsEl.innerHTML = '';
galUpdateVisibility();
return;
}
const results = [];
const files = galaxyData.files;
for (let i = 0; i < files.length; i++) {
const f = files[i];
if ((f.n && f.n.toLowerCase().includes(query))
|| (f.p && f.p.toLowerCase().includes(query))
|| (f.ext && f.ext.toLowerCase().includes(query))) {
galaxySearchMatches.add(i);
if (results.length < 50) results.push({ idx: i, ...f });
}
}
galUpdateVisibility();
const srcMeta = galaxyData.meta.sources;
resultsEl.innerHTML = results.map((r) => {
const col = (srcMeta[r.src] && srcMeta[r.src].color) || '#d4c5a9';
return `<div class="gs-result" data-idx="${r.idx}">` +
`<span class="gs-res-dot" style="background:${col};color:${col}"></span>` +
`<span class="gs-res-name">${escapeHtml(r.n || '')}</span>` +
`<span class="gs-res-ext">${escapeHtml(r.ext || '')}</span></div>`;
}).join('');
if (galaxySearchMatches.size > 50) {
resultsEl.innerHTML += `<div class="gs-result-more">…and ${galaxySearchMatches.size - 50} more</div>`;
}
resultsEl.querySelectorAll('.gs-result').forEach((el) => {
el.addEventListener('click', () => {
const i = parseInt(el.dataset.idx, 10);
galFlyToIndex(i);
});
});
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function galFlyToIndex(i) {
if (!spiralPositions) return;
const target = new THREE.Vector3(spiralPositions[i*3], spiralPositions[i*3+1], spiralPositions[i*3+2]);
if (filePoints) target.applyMatrix4(filePoints.matrixWorld);
const dir = new THREE.Vector3().subVectors(camera.position, orbitControls.target).normalize();
const dest = target.clone().add(dir.multiplyScalar(10));
const startPos = camera.position.clone();
const startTarget = orbitControls.target.clone();
const duration = 1000;
const t0 = performance.now();
(function fly() {
const t = Math.min((performance.now() - t0) / duration, 1);
const e = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2;
camera.position.lerpVectors(startPos, dest, e);
orbitControls.target.lerpVectors(startTarget, target, e);
orbitControls.update();
if (t < 1) requestAnimationFrame(fly);
})();
}
window.toggleGalaxySidebar = toggleGalaxySidebar;
window.onGalaxyDensityChange = onGalaxyDensityChange;
window.onGalaxySizeChange = onGalaxySizeChange;
window.onGalaxySearch = onGalaxySearch;
// ── Lazy-load galaxy JSON ──────────────────────────────
async function loadGalaxyData() {
if (galaxyLoaded || galaxyLoadInFlight) return;
galaxyLoadInFlight = true;
const status = document.getElementById('gl-status');
const fill = document.getElementById('gl-fill');
try {
status.textContent = 'Fetching constellation data…';
fill.style.width = '20%';
const url = `${API}/galaxy_5k.json`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
status.textContent = 'Parsing star catalogue…';
fill.style.width = '55%';
galaxyData = await resp.json();
status.textContent = 'Rendering galactic disk…';
fill.style.width = '80%';
galBuildFilePoints(galaxyData);
galPopulateSourceToggles();
galUpdateVisibility();
fill.style.width = '100%';
galaxyLoaded = true;
} catch (err) {
console.error('[galaxy] load failed', err);
status.textContent = `Load failed: ${err.message}`;
throw err;
} finally {
galaxyLoadInFlight = false;
}
}
// ── Fade helper (traverse materials) ───────────────────
function fadeGroup(group, targetOpacity, duration, onDone) {
const mats = [];
group.traverse((obj) => {
if (obj.material) {
const arr = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of arr) {
if (!m.userData.__origOpacity) m.userData.__origOpacity = m.opacity ?? 1;
if (!m.userData.__origTransparent) m.userData.__origTransparent = m.transparent;
m.transparent = true;
mats.push({ mat: m, from: m.opacity ?? 1, to: targetOpacity });
}
}
});
const start = performance.now();
function step() {
const t = Math.min(1, (performance.now() - start) / duration);
const e = t * t * (3 - 2 * t); // smoothstep
for (const { mat, from, to } of mats) mat.opacity = from + (to - from) * e;
if (t < 1) requestAnimationFrame(step);
else if (onDone) onDone();
}
step();
}
// ── Galaxy mouse handlers ──────────────────────────────
function galOnPointerMove(e) {
if (currentMode !== 'galaxy' || !filePoints || !galaxyData) return;
galaxyMouse.x = (e.clientX / window.innerWidth) * 2 - 1;
galaxyMouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
galaxyRaycaster.setFromCamera(galaxyMouse, camera);
galaxyRaycaster.params.Points.threshold = 0.6;
const hits = galaxyRaycaster.intersectObject(filePoints);
const tip = document.getElementById('galaxy-tooltip');
if (hits.length > 0) {
const idx = hits[0].index;
const f = galaxyData.files[idx];
galaxyHoveredIndex = idx;
tip.querySelector('.gt-name').textContent = f.n || '(unnamed)';
tip.querySelector('.gt-path').textContent = f.p || '';
tip.querySelector('.gt-meta').textContent =
`${f.src || '?'} · ${formatBytes(f.s || 0)}`;
tip.style.left = (e.clientX + 14) + 'px';
tip.style.top = (e.clientY + 14) + 'px';
tip.classList.add('visible');
document.body.style.cursor = 'pointer';
} else {
galaxyHoveredIndex = -1;
tip.classList.remove('visible');
document.body.style.cursor = 'default';
}
}
function galOnPointerClick() {
if (currentMode !== 'galaxy' || galaxyHoveredIndex < 0) return;
const i = galaxyHoveredIndex;
const target = new THREE.Vector3(spiralPositions[i*3], spiralPositions[i*3+1], spiralPositions[i*3+2]);
if (filePoints) target.applyMatrix4(filePoints.matrixWorld);
const dir = new THREE.Vector3().subVectors(camera.position, orbitControls.target).normalize();
const dest = target.clone().add(dir.multiplyScalar(10));
const startPos = camera.position.clone();
const startTarget = orbitControls.target.clone();
const duration = 1000;
const t0 = performance.now();
(function fly() {
const t = Math.min((performance.now() - t0) / duration, 1);
const e = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2;
camera.position.lerpVectors(startPos, dest, e);
orbitControls.target.lerpVectors(startTarget, target, e);
orbitControls.update();
if (t < 1) requestAnimationFrame(fly);
})();
}
function formatBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
if (n < 1024*1024*1024) return `${(n/1024/1024).toFixed(1)} MB`;
return `${(n/1024/1024/1024).toFixed(2)} GB`;
}
// ═══════════════════════════════════════════════════
// ── CINEMATIC TRANSITION HELPERS
// ═══════════════════════════════════════════════════
let _fxMotes = null; // THREE.Points spawned during dissolve
let _fxMoteUpdate = null; // per-frame updater registered into animate()
function fxAudioSubBassDrop() {
if (!audioCtx) return;
// Sub-bass swell: descending sine 110Hz -> 22Hz over ~4s (slow, heavy)
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.type = 'sine';
const t = audioCtx.currentTime;
osc.frequency.setValueAtTime(110, t);
osc.frequency.exponentialRampToValueAtTime(22, t + 4.0);
g.gain.setValueAtTime(0.0, t);
g.gain.linearRampToValueAtTime(0.6, t + 0.5);
g.gain.linearRampToValueAtTime(0.45, t + 3.5);
g.gain.exponentialRampToValueAtTime(0.001, t + 5.0);
osc.connect(g); g.connect(masterGain || audioCtx.destination);
osc.start(t); osc.stop(t + 5.2);
// Pitch-shift the ambient hum: deep duck through the whole transition
if (ambientGain) {
const ag = ambientGain.gain;
const v = ag.value;
ag.cancelScheduledValues(t);
ag.setValueAtTime(v, t);
ag.linearRampToValueAtTime(v * 0.15, t + 1.5);
ag.linearRampToValueAtTime(v, t + 6.0);
}
}
function fxAudioSupernovaCrack() {
if (!audioCtx) return;
// White-noise burst with fast attack + long tail
const dur = 1.8;
const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * dur, audioCtx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1);
const src = audioCtx.createBufferSource(); src.buffer = buf;
const filt = audioCtx.createBiquadFilter(); filt.type = 'bandpass';
filt.frequency.value = 2400; filt.Q.value = 0.6;
const g = audioCtx.createGain();
const t = audioCtx.currentTime;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.18, t + 0.04);
g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
src.connect(filt); filt.connect(g); g.connect(masterGain || audioCtx.destination);
src.start(t); src.stop(t + dur);
}
function fxSpawnMoteBurst() {
if (!roomGroup || !THREE) return;
// Sample positions from inside roomGroup AABB; color by source palette.
const box = new THREE.Box3().setFromObject(roomGroup);
if (!isFinite(box.min.x)) return;
const COUNT = 4500;
const positions = new Float32Array(COUNT * 3);
const colors = new Float32Array(COUNT * 3);
const velocities = new Float32Array(COUNT * 3);
// Source palette
const palette = [
[0.30, 0.95, 0.45], // Android — emerald
[0.65, 0.40, 0.95], // Windows — bruised purple
[1.00, 0.55, 0.20], // WSL — tangerine
[0.85, 0.78, 0.55], // Drywall warm cream (rare)
];
for (let i = 0; i < COUNT; i++) {
const i3 = i * 3;
positions[i3] = box.min.x + Math.random() * (box.max.x - box.min.x);
positions[i3+1] = box.min.y + Math.random() * (box.max.y - box.min.y);
positions[i3+2] = box.min.z + Math.random() * (box.max.z - box.min.z);
const c = palette[(Math.random() * palette.length) | 0];
colors[i3] = c[0]; colors[i3+1] = c[1]; colors[i3+2] = c[2];
// Drift mostly upward + outward from center
const cx = (box.max.x + box.min.x) * 0.5;
const cz = (box.max.z + box.min.z) * 0.5;
const dx = positions[i3] - cx;
const dz = positions[i3+2] - cz;
const len = Math.hypot(dx, dz) || 1;
velocities[i3] = (dx / len) * (0.4 + Math.random() * 0.6);
velocities[i3+1] = 0.8 + Math.random() * 1.6;
velocities[i3+2] = (dz / len) * (0.4 + Math.random() * 0.6);
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({
size: 0.10,
vertexColors: true,
transparent: true,
opacity: 0.0,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
_fxMotes = new THREE.Points(geom, mat);
_fxMotes.userData.velocities = velocities;
_fxMotes.userData.t0 = performance.now();
scene.add(_fxMotes);
// Per-frame updater attached via window so animate() can pick it up
_fxMoteUpdate = (delta) => {
if (!_fxMotes) return;
const elapsed = (performance.now() - _fxMotes.userData.t0) / 1000;
const lifetime = 6.0;
const k = Math.min(1, elapsed / lifetime);
// Opacity rises slowly then sustains then falls
const fadeIn = Math.min(1, k / 0.25);
const fadeOut = (k > 0.7) ? (1 - (k - 0.7) / 0.3) : 1;
mat.opacity = fadeIn * fadeOut * 0.95;
// Drift positions — slower for cinematic
const pos = geom.attributes.position.array;
const vel = velocities;
for (let i = 0; i < pos.length; i += 3) {
pos[i] += vel[i] * delta * 0.45;
pos[i+1] += vel[i+1] * delta * 0.45;
pos[i+2] += vel[i+2] * delta * 0.45;
}
geom.attributes.position.needsUpdate = true;
if (k >= 1) {
scene.remove(_fxMotes);
geom.dispose(); mat.dispose();
_fxMotes = null;
_fxMoteUpdate = null;
}
};
window.__fx_mote_update = _fxMoteUpdate;
}
function fxBeginEnterCinematic() {
// Button detonation
const btn = document.getElementById('galaxy-btn');
const dot = btn?.querySelector('.g-dot');
if (btn) btn.classList.add('detonating');
if (dot) dot.classList.add('detonate');
// Crosshair quick fade
document.getElementById('crosshair')?.classList.remove('visible');
// Screen shudder (apply to canvas wrapper if present, else body)
const target = renderer?.domElement || document.body;
target.classList.add('fx-shudder');
setTimeout(() => target.classList.remove('fx-shudder'), 420);
// Static overlay fade in
const stat = document.getElementById('fx-static');
if (stat) { stat.style.opacity = '0.55'; }
// Vignette darkens the corners
const vig = document.getElementById('fx-vignette');
if (vig) { vig.style.opacity = '1'; }
// Audio: sub-bass + ambient duck
fxAudioSubBassDrop();
// Spawn dissolve motes from room geometry
fxSpawnMoteBurst();
// Schedule supernova flash + nebula bloom (slow, X-Men Dark Phoenix style)
setTimeout(() => {
const sh = document.getElementById('fx-shimmer');
if (sh) sh.style.opacity = '0.7';
}, 900);
setTimeout(() => {
const neb = document.getElementById('fx-nebula');
if (neb) { neb.style.transition = 'opacity 2.4s ease-out'; neb.style.opacity = '0.85'; }
}, 1200);
setTimeout(() => {
const sn = document.getElementById('fx-supernova');
if (sn) { sn.style.transition = 'opacity 1.8s ease-out'; sn.style.opacity = '1'; }
fxAudioSupernovaCrack();
}, 2400);
setTimeout(() => {
const sn = document.getElementById('fx-supernova');
if (sn) { sn.style.transition = 'opacity 2.6s ease-out'; sn.style.opacity = '0'; }
}, 4400);
}
function fxEndEnterCinematic() {
// Static + vignette fade out as we settle into galaxy view
const stat = document.getElementById('fx-static');
if (stat) { stat.style.opacity = '0'; }
const vig = document.getElementById('fx-vignette');
if (vig) { vig.style.opacity = '0'; }
// Tone the corner nebula down (let the real 3D nebulae carry the look)
const neb = document.getElementById('fx-nebula');
if (neb) { neb.style.transition = 'opacity 1.4s ease-out'; neb.style.opacity = '0.18'; }
// Keep a light shimmer of space dust
const sh = document.getElementById('fx-shimmer');
if (sh) { sh.style.transition = 'opacity 2.0s ease-out'; sh.style.opacity = '0.12'; }
// Reset detonation classes for next time
setTimeout(() => {
const btn = document.getElementById('galaxy-btn');
const dot = btn?.querySelector('.g-dot');
btn?.classList.remove('detonating');
dot?.classList.remove('detonate');
}, 600);
}
function fxBeginExitCinematic() {
const target = renderer?.domElement || document.body;
target.classList.add('fx-shudder');
setTimeout(() => target.classList.remove('fx-shudder'), 420);
// Brief static during reverse
const stat = document.getElementById('fx-static');
if (stat) { stat.style.opacity = '0.35'; }
// Nebula + shimmer fade out
const neb = document.getElementById('fx-nebula');
if (neb) neb.style.opacity = '0';
const sh = document.getElementById('fx-shimmer');
if (sh) sh.style.opacity = '0';
}
function fxEndExitCinematic() {
const stat = document.getElementById('fx-static');
if (stat) { stat.style.opacity = '0'; }
const vig = document.getElementById('fx-vignette');
if (vig) { vig.style.opacity = '0'; }
}
// ── Enter / exit galaxy mode ───────────────────────────
async function enterGalaxyMode() {
if (currentMode !== 'fps') return;
currentMode = 'transition';
// Save FPS camera state
savedFpsCamPos.copy(camera.position);
savedFpsCamQuat.copy(camera.quaternion);
// Unlock pointer, hide FPS HUD + button
if (controls.isLocked) controls.unlock();
document.getElementById('crosshair').classList.remove('visible');
document.getElementById('hud').classList.remove('visible');
document.getElementById('lock-screen').classList.add('hidden');
// ── CINEMATIC PHASE 0: button detonation, shudder, static, sub-bass, motes ──
fxBeginEnterCinematic();
// Save fog / bg once
if (originalFog === null) { originalFog = scene.fog; originalBackground = scene.background; }
// Lazy-load galaxy BEFORE starting the visual transition so the fade-in
// shows the real galaxy rather than empty space.
const loading = document.getElementById('galaxy-loading');
if (!galaxyLoaded) {
loading.classList.add('visible');
try {
await loadGalaxyData();
} catch (err) {
loading.classList.remove('visible');
showToast('Galaxy load failed — ' + err.message, 'error');
document.getElementById('hud').classList.add('visible');
document.getElementById('galaxy-btn').classList.add('visible');
fxEndEnterCinematic();
currentMode = 'fps';
return;
}
loading.classList.remove('visible');
}
// Prepare galaxy group: visible, transparent, starting at opacity 0
galaxyGroup.visible = true;
galaxyGroup.traverse((obj) => {
if (obj.material) {
const arr = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of arr) {
if (m.userData.__origOpacity === undefined) m.userData.__origOpacity = m.opacity ?? 1;
m.transparent = true;
m.opacity = 0;
}
}
});
// ── Precompile galaxy shaders BEFORE starting the crossfade.
// Without this, the first frame the galaxy is visible causes a
// multi-hundred-ms hitch while stars/bgGalaxy/filePoints/nebulae
// shaders compile. renderer.compile() forces that work upfront.
try {
// Move camera temporarily to where it'll end up so precompile covers the
// frustum the transition will render through.
const precompileCamPos = new THREE.Vector3(
galaxyCenter.x + galaxyRadius * 0.8,
galaxyCenter.y + galaxyRadius * 0.56,
galaxyCenter.z + galaxyRadius * 0.8,
);
const savedPos = camera.position.clone();
const savedQuat = camera.quaternion.clone();
camera.position.copy(precompileCamPos);
camera.lookAt(galaxyCenter);
renderer.compile(scene, camera);
// Prime with one invisible render to warm pipeline state.
composer.render();
// Restore for the actual transition start
camera.position.copy(savedPos);
camera.quaternion.copy(savedQuat);
// Yield so the browser can post-process compilations before the loop starts
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
} catch (e) {
console.warn('[galaxy] precompile failed (non-fatal)', e);
}
// Crossfade scene background from room tone to deep space
const bgFromHex = (scene.background && scene.background.isColor)
? scene.background.getHex() : 0x1a1a1a;
const bgTo = new THREE.Color('#050508');
const bgAnim = new THREE.Color(bgFromHex);
scene.background = bgAnim;
// Fog also eases out
if (scene.fog && scene.fog.color) scene.fog.far = scene.fog.far; // keep reference
const fogOriginalFar = scene.fog ? scene.fog.far : null;
// Camera path: glide from current FPS eye position out to orbital view
const camStart = camera.position.clone();
const camStartQuat = camera.quaternion.clone();
const dist = galaxyRadius * 1.6;
const camEnd = new THREE.Vector3(
galaxyCenter.x + dist * 0.5,
galaxyCenter.y + dist * 0.35,
galaxyCenter.z + dist * 0.5,
);
// Compute end quaternion (camera looking at galaxy center)
const endCam = camera.clone();
endCam.position.copy(camEnd);
endCam.lookAt(galaxyCenter);
const camEndQuat = endCam.quaternion.clone();
// Cache room materials + original opacities for crossfade
const roomMats = [];
roomGroup.traverse((obj) => {
if (obj.material) {
const arr = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of arr) {
if (m.userData.__origOpacity === undefined) m.userData.__origOpacity = m.opacity ?? 1;
m.transparent = true;
roomMats.push({ mat: m, from: m.opacity });
}
}
});
const galMats = [];
galaxyGroup.traverse((obj) => {
if (obj.material) {
const arr = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of arr) {
galMats.push({ mat: m, to: m.userData.__origOpacity ?? 1 });
}
}
});
// Crossfade loop — 6.0s total (slow, cinematic), smoothstep eased
const DURATION = 6000;
const t0 = performance.now();
await new Promise((resolve) => {
function step() {
const raw = Math.min(1, (performance.now() - t0) / DURATION);
const t = raw * raw * (3 - 2 * raw); // smoothstep
// Fade rooms out
for (const r of roomMats) r.mat.opacity = r.from * (1 - t);
// Fade galaxy in (slightly delayed curve so it reveals gradually)
const galT = Math.max(0, (t - 0.15) / 0.85);
const galEased = galT * galT * (3 - 2 * galT);
for (const g of galMats) g.mat.opacity = g.to * galEased;
// Camera glide (slerp for rotation)
camera.position.lerpVectors(camStart, camEnd, t);
camera.quaternion.copy(camStartQuat).slerp(camEndQuat, t);
// Background crossfade
bgAnim.copy(new THREE.Color(bgFromHex)).lerp(bgTo, t);
// Fog easing — push far plane outward then kill it
if (scene.fog && fogOriginalFar !== null) {
scene.fog.far = fogOriginalFar + (800 - fogOriginalFar) * t;
}
if (raw < 1) requestAnimationFrame(step);
else resolve();
}
step();
});
// Finalize
roomGroup.visible = false;
scene.fog = null;
scene.background = new THREE.Color('#050508');
// Activate orbit controls
if (!orbitControls) {
orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.06;
orbitControls.minDistance = 5;
orbitControls.maxDistance = 400;
}
orbitControls.target.copy(galaxyCenter);
orbitControls.enabled = true;
orbitControls.update();
document.getElementById('galaxy-sidebar-toggle').classList.add('visible');
document.getElementById('galaxy-hud').classList.add('visible');
fxEndEnterCinematic();
currentMode = 'galaxy';
}
async function exitGalaxyMode() {
if (currentMode !== 'galaxy') return;
currentMode = 'transition';
document.getElementById('galaxy-hud').classList.remove('visible');
document.getElementById('galaxy-sidebar').classList.remove('open');
document.getElementById('galaxy-sidebar-toggle').classList.remove('visible', 'shifted');
document.getElementById('galaxy-tooltip').classList.remove('visible');
if (orbitControls) orbitControls.enabled = false;
fxBeginExitCinematic();
// Reveal rooms again at 0 opacity ready to fade in
roomGroup.visible = true;
const roomMats = [];
roomGroup.traverse((obj) => {
if (obj.material) {
const arr = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of arr) {
m.transparent = true;
m.opacity = 0;
roomMats.push({ mat: m, to: m.userData.__origOpacity ?? 1 });
}
}
});
const galMats = [];
galaxyGroup.traverse((obj) => {
if (obj.material) {
const arr = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of arr) galMats.push({ mat: m, from: m.opacity ?? 1 });
}
});
// Background crossfade back
const bgFrom = scene.background ? scene.background.getHex() : 0x050508;
const bgTo = (originalBackground && originalBackground.isColor)
? originalBackground.getHex() : 0x1a1a1a;
const bgAnim = new THREE.Color(bgFrom);
scene.background = bgAnim;
// Camera glide back to saved FPS pose
const camStart = camera.position.clone();
const camStartQuat = camera.quaternion.clone();
const DURATION = 4000;
const t0 = performance.now();
await new Promise((resolve) => {
function step() {
const raw = Math.min(1, (performance.now() - t0) / DURATION);
const t = raw * raw * (3 - 2 * raw);
// Galaxy fades first, rooms follow
const galT = Math.min(1, t / 0.7);
const galEased = galT * galT * (3 - 2 * galT);
for (const g of galMats) g.mat.opacity = g.from * (1 - galEased);
const roomT = Math.max(0, (t - 0.3) / 0.7);
const roomEased = roomT * roomT * (3 - 2 * roomT);
for (const r of roomMats) r.mat.opacity = r.to * roomEased;
camera.position.lerpVectors(camStart, savedFpsCamPos, t);
camera.quaternion.copy(camStartQuat).slerp(savedFpsCamQuat, t);
bgAnim.copy(new THREE.Color(bgFrom)).lerp(new THREE.Color(bgTo), t);
if (raw < 1) requestAnimationFrame(step);
else resolve();
}
step();
});
// Finalize
galaxyGroup.visible = false;
scene.fog = originalFog;
scene.background = originalBackground;
camera.position.copy(savedFpsCamPos);
camera.quaternion.copy(savedFpsCamQuat);
document.getElementById('hud').classList.add('visible');
document.getElementById('galaxy-btn').classList.add('visible');
document.getElementById('lock-screen').classList.remove('hidden');
fxEndExitCinematic();
currentMode = 'fps';
}
// Expose so HTML onclick can reach it
window.enterGalaxyMode = enterGalaxyMode;
window.exitGalaxyMode = exitGalaxyMode;
// ── Wrap rooms into roomGroup + init galaxy scaffolding ─
function initGalaxyScaffolding() {
roomGroup = new THREE.Group();
roomGroup.name = 'roomGroup';
galaxyGroup = new THREE.Group();
galaxyGroup.name = 'galaxyGroup';
galaxyGroup.visible = false;
// Move all current scene children (except camera + PointerLockControls rig) into roomGroup
const children = [...scene.children];
for (const c of children) {
if (c.isCamera) continue;
if (controls && c === controls.object) continue;
scene.remove(c);
roomGroup.add(c);
}
scene.add(roomGroup);
scene.add(galaxyGroup);
// Raycaster + mouse for galaxy
galaxyRaycaster = new THREE.Raycaster();
galaxyMouse = new THREE.Vector2(-999, -999);
// Precreate background stars + bg galaxy once (lightweight)
galCreateStars();
galCreateBgGalaxy();
// Pointer events for galaxy mode
window.addEventListener('mousemove', galOnPointerMove);
window.addEventListener('click', galOnPointerClick);
// ESC to exit galaxy mode (or close sidebar first)
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape' && currentMode === 'galaxy') {
const sb = document.getElementById('galaxy-sidebar');
if (sb.classList.contains('open')) {
e.preventDefault();
sb.classList.remove('open');
document.getElementById('galaxy-sidebar-toggle').classList.remove('shifted');
return;
}
e.preventDefault();
exitGalaxyMode();
}
});
// F key → toggle Galaxy mode (works from FPS or galaxy).
// Ignored when typing in an input (e.g. galaxy search field).
window.addEventListener('keydown', (e) => {
if (e.code !== 'KeyF') return;
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
if (currentMode === 'fps') {
e.preventDefault();
enterGalaxyMode();
} else if (currentMode === 'galaxy') {
e.preventDefault();
exitGalaxyMode();
}
});
// Reveal the button when FPS mode is active (once pointer locks)
if (controls) {
controls.addEventListener('lock', () => {
if (currentMode === 'fps') {
document.getElementById('galaxy-btn').classList.add('visible');
}
});
controls.addEventListener('unlock', () => {
// When pointer unlocks (e.g. opening tool panel) the button hides via lock-screen visibility
if (currentMode === 'fps' && !toolPanelOpen) {
document.getElementById('galaxy-btn').classList.remove('visible');
}
});
}
}
// Start
(async () => {
await init();
initGalaxyScaffolding();
})();
// ═══════════════════════════════════════════════════
// ── GAME MENU (M key)
// ═══════════════════════════════════════════════════
const GM_KEY = 'artemis.settings.v1';
const GM_DEFAULT_KEYS = {
forward: 'KeyW',
backward: 'KeyS',
left: 'KeyA',
right: 'KeyD',
sprint: 'ShiftLeft',
interact: 'KeyE',
};
const GM_DEFAULTS = {
master: 1.0,
mute: false,
brightness: 1.0,
fov: 75,
walkSpeed: CFG.MOVE_SPEED,
sprintMult: CFG.SPRINT_MULT,
mouseSens: 1.0,
touchLookSens: touchLookMultiplier,
touchLookCap: maxLookDeltaPx,
keys: { ...GM_DEFAULT_KEYS },
};
let _gm_listening_for = null;
function _gm_loadRaw() {
try {
const raw = localStorage.getItem(GM_KEY);
if (!raw) return null;
const obj = JSON.parse(raw);
return { ...GM_DEFAULTS, ...obj, keys: { ...GM_DEFAULT_KEYS, ...(obj.keys || {}) } };
} catch(e) { return null; }
}
function _gm_buildKeymap(keys) {
const map = {};
for (const action of Object.keys(keys)) {
const code = keys[action];
if (code) map[code] = action;
}
window.__artemis_keymap = map;
}
function _gm_applyToRuntime(s) {
CFG.MOVE_SPEED = Number(s.walkSpeed);
CFG.SPRINT_MULT = Number(s.sprintMult);
CFG.MOUSE_SENS = Number(s.mouseSens);
touchLookMultiplier = Number(s.touchLookSens);
maxLookDeltaPx = Number(s.touchLookCap);
if (typeof camera !== 'undefined') {
camera.fov = Number(s.fov);
camera.updateProjectionMatrix();
}
if (typeof controls !== 'undefined' && controls && 'pointerSpeed' in controls) {
controls.pointerSpeed = Number(s.mouseSens);
}
if (typeof renderer !== 'undefined' && renderer) {
renderer.toneMappingExposure = Number(s.brightness);
}
if (window.__artemis_master_gain) {
window.__artemis_master_gain.gain.value = s.mute ? 0 : Number(s.master);
}
document.querySelectorAll('audio').forEach(a => {
a.volume = s.mute ? 0 : Math.min(1, Number(s.master));
});
_gm_buildKeymap(s.keys);
}
function _gm_renderKeybinds(keys) {
const host = document.getElementById('gm-keybinds');
if (!host) return;
host.innerHTML = '';
const labels = {
forward: 'Forward', backward: 'Backward', left: 'Strafe Left',
right: 'Strafe Right', sprint: 'Sprint', interact: 'Interact',
};
for (const action of Object.keys(GM_DEFAULT_KEYS)) {
const row = document.createElement('div');
row.className = 'gm-keybind-row';
row.innerHTML = `<span>${labels[action] || action}</span>`;
const btn = document.createElement('button');
btn.className = 'gm-key-btn';
btn.dataset.action = action;
btn.textContent = (keys[action] || '—').replace(/^Key/, '');
btn.addEventListener('click', () => _gm_beginRebind(action, btn));
row.appendChild(btn);
host.appendChild(row);
}
}
function _gm_beginRebind(action, btn) {
document.querySelectorAll('.gm-key-btn.listening').forEach(b => {
b.classList.remove('listening');
const a = b.dataset.action;
const cur = (_gm_currentSettings().keys[a] || '—').replace(/^Key/, '');
b.textContent = cur;
});
_gm_listening_for = action;
window.__artemis_consume_keys = true;
btn.classList.add('listening');
btn.textContent = 'Press a key…';
}
function _gm_currentSettings() {
return _gm_loadRaw() || { ...GM_DEFAULTS, keys: { ...GM_DEFAULT_KEYS } };
}
function _gm_readUI() {
const s = _gm_currentSettings();
const num = (id, def) => Number(document.getElementById(id)?.value ?? def);
s.master = num('gm-audio-master', s.master);
s.mute = document.getElementById('gm-audio-mute')?.dataset.muted === '1';
s.brightness = num('gm-display-bright', s.brightness);
s.fov = num('gm-display-fov', s.fov);
s.walkSpeed = num('gm-walk-speed', s.walkSpeed);
s.sprintMult = num('gm-sprint-mult', s.sprintMult);
s.mouseSens = num('gm-mouse-sens', s.mouseSens);
s.touchLookSens = num('gm-touch-look-sens', s.touchLookSens);
s.touchLookCap = num('gm-touch-look-cap', s.touchLookCap);
return s;
}
function _gm_writeUI(s) {
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
set('gm-audio-master', s.master);
set('gm-display-bright', s.brightness);
set('gm-display-fov', s.fov);
set('gm-walk-speed', s.walkSpeed);
set('gm-sprint-mult', s.sprintMult);
set('gm-mouse-sens', s.mouseSens);
set('gm-touch-look-sens', s.touchLookSens);
set('gm-touch-look-cap', s.touchLookCap);
const mute = document.getElementById('gm-audio-mute');
if (mute) {
mute.dataset.muted = s.mute ? '1' : '0';
mute.textContent = s.mute ? 'Unmute' : 'Mute';
}
_gm_renderKeybinds(s.keys);
}
function loadSettings() {
const s = _gm_loadRaw() || { ...GM_DEFAULTS, keys: { ...GM_DEFAULT_KEYS } };
_gm_writeUI(s);
_gm_applyToRuntime(s);
return s;
}
function saveSettings() {
const s = _gm_readUI();
localStorage.setItem(GM_KEY, JSON.stringify(s));
_gm_applyToRuntime(s);
showToast('Settings saved', 'success');
}
function resetSettings() {
localStorage.removeItem(GM_KEY);
loadSettings();
showToast('Settings reset', 'success');
}
function showMenu() {
const menu = document.getElementById('game-menu');
if (!menu) return;
if (document.pointerLockElement === document.body) {
try { document.exitPointerLock(); } catch(e) {}
}
menu.classList.add('open');
menu.setAttribute('aria-hidden', 'false');
}
function hideMenu() {
const menu = document.getElementById('game-menu');
if (!menu) return;
menu.classList.remove('open');
menu.setAttribute('aria-hidden', 'true');
saveSettings();
_gm_listening_for = null;
window.__artemis_consume_keys = false;
}
function toggleMenu() {
const menu = document.getElementById('game-menu');
if (menu && menu.classList.contains('open')) hideMenu();
else showMenu();
}
document.addEventListener('keydown', (e) => {
if (_gm_listening_for) {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
_gm_listening_for = null;
window.__artemis_consume_keys = false;
_gm_renderKeybinds(_gm_currentSettings().keys);
return;
}
const action = _gm_listening_for;
const s = _gm_readUI();
s.keys = { ...s.keys, [action]: e.code };
for (const a of Object.keys(s.keys)) {
if (a !== action && s.keys[a] === e.code) s.keys[a] = '';
}
localStorage.setItem(GM_KEY, JSON.stringify(s));
_gm_buildKeymap(s.keys);
_gm_renderKeybinds(s.keys);
_gm_listening_for = null;
window.__artemis_consume_keys = false;
return;
}
if (e.code === 'KeyM' && !e.repeat) {
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
toggleMenu();
}
}, true);
(function attachMenuHandlers() {
const $ = (id) => document.getElementById(id);
const close = $('gm-close');
const save = $('gm-save');
const reset = $('gm-reset');
const mute = $('gm-audio-mute');
const master = $('gm-audio-master');
const bright = $('gm-display-bright');
const fov = $('gm-display-fov');
const walk = $('gm-walk-speed');
const sprint = $('gm-sprint-mult');
const sens = $('gm-mouse-sens');
const touchSens = $('gm-touch-look-sens');
const touchCap = $('gm-touch-look-cap');
const rebindReset = $('gm-rebind-reset');
if (close) close.addEventListener('click', hideMenu);
if (save) save.addEventListener('click', saveSettings);
if (reset) reset.addEventListener('click', resetSettings);
if (rebindReset) rebindReset.addEventListener('click', () => {
const s = _gm_currentSettings();
s.keys = { ...GM_DEFAULT_KEYS };
localStorage.setItem(GM_KEY, JSON.stringify(s));
_gm_buildKeymap(s.keys);
_gm_renderKeybinds(s.keys);
showToast('Bindings reset', 'success');
});
if (mute) mute.addEventListener('click', () => {
const now = mute.dataset.muted === '1';
mute.dataset.muted = now ? '0' : '1';
mute.textContent = now ? 'Mute' : 'Unmute';
_gm_applyToRuntime(_gm_readUI());
});
for (const el of [master, bright, fov, walk, sprint, sens, touchSens, touchCap]) {
if (el) el.addEventListener('input', () => _gm_applyToRuntime(_gm_readUI()));
}
loadSettings();
})();
</script>
</body>
</html>