[grok] Timmy mood lighting — atmosphere shifts with state (#207) #363

Closed
grok wants to merge 19 commits from grok/issue-207 into main
9 changed files with 1115 additions and 2447 deletions

View File

@@ -14,15 +14,12 @@ jobs:
- name: Validate HTML
run: |
# Check index.html exists and is valid-ish
test -f index.html || { echo "ERROR: index.html missing"; exit 1; }
# Check for unclosed tags (basic)
python3 -c "
import html.parser, sys
class V(html.parser.HTMLParser):
def __init__(self):
super().__init__()
self.errors = []
def handle_starttag(self, tag, attrs): pass
def handle_endtag(self, tag): pass
v = V()
@@ -36,7 +33,6 @@ jobs:
- name: Validate JavaScript
run: |
# Syntax check all JS files
FAIL=0
for f in $(find . -name '*.js' -not -path './node_modules/*' -not -name 'sw.js'); do
if ! node --check "$f" 2>/dev/null; then
@@ -50,7 +46,6 @@ jobs:
- name: Validate JSON
run: |
# Check all JSON files parse
FAIL=0
for f in $(find . -name '*.json' -not -path './node_modules/*'); do
if ! python3 -c "import json; json.load(open('$f'))"; then
@@ -64,7 +59,6 @@ jobs:
- name: Check file size budget
run: |
# Performance budget: no single JS file > 500KB
FAIL=0
for f in $(find . -name '*.js' -not -path './node_modules/*'); do
SIZE=$(wc -c < "$f")
@@ -76,3 +70,35 @@ jobs:
fi
done
exit $FAIL
auto-merge:
needs: validate
runs-on: ubuntu-latest
steps:
- name: Merge PR
env:
GITEA_TOKEN: ${{ secrets.MERGE_TOKEN }}
run: |
PR_NUM=$(echo "${{ github.event.pull_request.number }}")
REPO="${{ github.repository }}"
API="http://143.198.27.163:3000/api/v1"
echo "CI passed. Auto-merging PR #${PR_NUM}..."
# Squash merge
RESULT=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do":"squash","delete_branch_after_merge":true}' \
"${API}/repos/${REPO}/pulls/${PR_NUM}/merge")
HTTP_CODE=$(echo "$RESULT" | tail -1)
BODY=$(echo "$RESULT" | head -n -1)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "405" ]; then
echo "Merged successfully (or already merged)"
else
echo "Merge failed: HTTP ${HTTP_CODE}"
echo "$BODY"
# Don't fail the job — PR stays open for manual review
fi

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.aider*

2257
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ services:
restart: unless-stopped
ports:
- "4200:80"
volumes:
- .:/usr/share/nginx/html:ro
labels:
- "deployment=main"
@@ -16,5 +18,7 @@ services:
restart: unless-stopped
ports:
- "4201:80"
volumes:
- .:/usr/share/nginx/html:ro
labels:
- "deployment=staging"

View File

@@ -1,225 +1,56 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<!--
______ __
/ ____/___ ____ ___ ____ __ __/ /____ _____
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
/_/
Created with Perplexity Computer
https://www.perplexity.ai/computer
-->
<meta name="generator" content="Perplexity Computer">
<meta name="author" content="Perplexity Computer">
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
<link rel="author" href="https://www.perplexity.ai/computer">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Timmy's Sovereign Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
}
}
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timmy's Nexus</title>
<meta name="description" content="A sovereign 3D world">
<meta property="og:title" content="Timmy's Nexus">
<meta property="og:description" content="A sovereign 3D world">
<meta property="og:image" content="https://example.com/og-image.png">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Timmy's Nexus">
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.183.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.183.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
<div class="loader-sigil">
<svg viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<circle cx="60" cy="60" r="55" fill="none" stroke="url(#sigil-grad)" stroke-width="1.5" opacity="0.4"/>
<circle cx="60" cy="60" r="45" fill="none" stroke="url(#sigil-grad)" stroke-width="1" opacity="0.3">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="8s" repeatCount="indefinite"/>
</circle>
<polygon points="60,15 95,80 25,80" fill="none" stroke="#4af0c0" stroke-width="1.5" opacity="0.6">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="-360 60 60" dur="12s" repeatCount="indefinite"/>
</polygon>
<circle cx="60" cy="60" r="8" fill="#4af0c0" opacity="0.8">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
<h1 class="loader-title">THE NEXUS</h1>
<p class="loader-subtitle">Initializing Sovereign Space...</p>
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
<!-- Top Right: Audio Toggle -->
<div id="audio-control" class="hud-controls" style="position: absolute; top: 8px; right: 8px;">
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound" style="background-color: var(--color-primary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔊
</button>
<button id="debug-toggle" class="chat-toggle-btn" aria-label="Toggle debug mode" style="background-color: var(--color-secondary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔍
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
<div id="overview-indicator">
<span>MAP VIEW</span>
<span class="overview-hint">[Tab] to exit</span>
</div>
<div id="photo-indicator">
<span>PHOTO MODE</span>
<span class="photo-hint">[P] exit &nbsp;|&nbsp; [[] focus- &nbsp; []] focus+ &nbsp; focus: <span id="photo-focus">5.0</span></span>
</div>
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<script type="module" src="app.js"></script>
<div id="loading" style="position: fixed; top: 0; left: 0; right: 0; height: 4px; background: #222; z-index: 1000;">
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>
</div>
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- Top Left: Debug -->
<div id="debug-overlay" class="hud-debug"></div>
<!-- Top Center: Location -->
<div class="hud-location">
<span class="hud-location-icon"></span>
<span id="hud-location-text">The Nexus</span>
</div>
<!-- Top Right: Agent Log -->
<div class="hud-agent-log" id="hud-agent-log">
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
<div id="agent-log-content" class="agent-log-content"></div>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">
<span class="chat-status-dot"></span>
<span>Timmy Terminal</span>
<button id="chat-toggle" class="chat-toggle-btn" aria-label="Toggle chat"></button>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-msg chat-msg-system">
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
</div>
<div class="chat-msg chat-msg-timmy">
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
<button id="chat-send" class="chat-send-btn" aria-label="Send message"></button>
</div>
</div>
<!-- Controls hint + nav mode -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
</div>
<!-- Portal Hint -->
<div id="portal-hint" class="portal-hint" style="display:none;">
<div class="portal-hint-key">F</div>
<div class="portal-hint-text">Enter <span id="portal-hint-name"></span></div>
</div>
<!-- Vision Hint -->
<div id="vision-hint" class="vision-hint" style="display:none;">
<div class="vision-hint-key">E</div>
<div class="vision-hint-text">Read <span id="vision-hint-title"></span></div>
</div>
<!-- Vision Overlay -->
<div id="vision-overlay" class="vision-overlay" style="display:none;">
<div class="vision-overlay-content">
<div class="vision-overlay-header">
<div class="vision-overlay-status" id="vision-status-dot"></div>
<div class="vision-overlay-title" id="vision-overlay-title">VISION POINT</div>
</div>
<h2 id="vision-title-display">SOVEREIGNTY</h2>
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
</div>
</div>
<!-- Portal Activation Overlay -->
<div id="portal-overlay" class="portal-overlay" style="display:none;">
<div class="portal-overlay-content">
<div class="portal-overlay-header">
<div class="portal-overlay-status" id="portal-status-dot"></div>
<div class="portal-overlay-title" id="portal-overlay-title">PORTAL ACTIVATED</div>
</div>
<h2 id="portal-name-display">MORROWIND</h2>
<p id="portal-desc-display">The Vvardenfell harness. Ash storms and ancient mysteries.</p>
<div class="portal-redirect-box" id="portal-redirect-box">
<div class="portal-redirect-label">REDIRECTING IN</div>
<div class="portal-redirect-timer" id="portal-timer">5</div>
</div>
<div class="portal-error-box" id="portal-error-box" style="display:none;">
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
</div>
</div>
</div>
</div>
<!-- Click to Enter -->
<div id="enter-prompt" style="display:none;">
<div class="enter-content">
<h2>Enter The Nexus</h2>
<p>Click anywhere to begin</p>
</div>
</div>
<canvas id="nexus-canvas"></canvas>
<footer class="nexus-footer">
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
Created with Perplexity Computer
</a>
</footer>
<script type="module" src="./app.js"></script>
<!-- Live Refresh: polls Gitea for new commits on main, reloads when SHA changes -->
<div id="live-refresh-banner" style="
display:none; position:fixed; top:0; left:0; right:0; z-index:9999;
background:linear-gradient(90deg,#4af0c0,#7b5cff);
color:#050510; font-family:'JetBrains Mono',monospace; font-size:13px;
padding:8px 16px; text-align:center; font-weight:600;
">⚡ NEW DEPLOYMENT DETECTED — Reloading in <span id="lr-countdown">5</span>s…</div>
<script>
(function() {
const GITEA = 'http://143.198.27.163:3000/api/v1';
const REPO = 'Timmy_Foundation/the-nexus';
const BRANCH = 'main';
const INTERVAL = 30000; // poll every 30s
let knownSha = null;
async function fetchLatestSha() {
try {
const r = await fetch(`${GITEA}/repos/${REPO}/branches/${BRANCH}`, { cache: 'no-store' });
if (!r.ok) return null;
const d = await r.json();
return d.commit && d.commit.id ? d.commit.id : null;
} catch (e) { return null; }
}
async function poll() {
const sha = await fetchLatestSha();
if (!sha) return;
if (knownSha === null) { knownSha = sha; return; }
if (sha !== knownSha) {
knownSha = sha;
const banner = document.getElementById('live-refresh-banner');
const countdown = document.getElementById('lr-countdown');
banner.style.display = 'block';
let t = 5;
const tick = setInterval(() => {
t--;
countdown.textContent = t;
if (t <= 0) { clearInterval(tick); location.reload(); }
}, 1000);
}
}
// Start polling after page is interactive
fetchLatestSha().then(sha => { knownSha = sha; });
setInterval(poll, INTERVAL);
})();
</script>
</body>
</html>

20
manifest.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "Timmy's Nexus",
"short_name": "Nexus",
"start_url": "/",
"display": "fullscreen",
"background_color": "#050510",
"theme_color": "#050510",
"icons": [
{
"src": "icons/t-logo-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/t-logo-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

745
style.css
View File

@@ -1,638 +1,185 @@
/* === NEXUS DESIGN SYSTEM === */
/* === DESIGN SYSTEM — NEXUS === */
:root {
--font-display: 'Orbitron', sans-serif;
--font-body: 'JetBrains Mono', monospace;
--color-bg: #050510;
--color-surface: rgba(10, 15, 40, 0.85);
--color-border: rgba(74, 240, 192, 0.2);
--color-border-bright: rgba(74, 240, 192, 0.5);
--color-text: #c8d8e8;
--color-text-muted: #5a6a8a;
--color-text-bright: #e0f0ff;
--color-primary: #4af0c0;
--color-primary-dim: rgba(74, 240, 192, 0.3);
--color-secondary: #7b5cff;
--color-danger: #ff4466;
--color-warning: #ffaa22;
--color-gold: #ffd700;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 18px;
--text-xl: 24px;
--text-2xl: 36px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--panel-blur: 16px;
--panel-radius: 8px;
--transition-ui: 200ms cubic-bezier(0.16, 1, 0.3, 1);
--color-bg: #000008;
--color-primary: #4488ff;
--color-secondary: #334488;
--color-text: #ccd6f6;
--color-text-muted: #4a5568;
--font-body: 'Courier New', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
body {
background: var(--color-bg);
font-family: var(--font-body);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
canvas#nexus-canvas {
display: block;
font-family: var(--font-body);
overflow: hidden;
width: 100vw;
height: 100vh;
}
canvas {
display: block;
position: fixed;
top: 0;
left: 0;
}
/* === LOADING SCREEN === */
#loading-screen {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.8s ease;
}
#loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loader-content {
text-align: center;
}
.loader-sigil {
margin-bottom: var(--space-6);
}
.loader-title {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: 0.3em;
color: var(--color-primary);
text-shadow: 0 0 30px rgba(74, 240, 192, 0.4);
margin-bottom: var(--space-2);
}
.loader-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
letter-spacing: 0.1em;
margin-bottom: var(--space-6);
}
.loader-bar {
width: 200px;
height: 2px;
background: rgba(74, 240, 192, 0.15);
border-radius: 1px;
margin: 0 auto;
overflow: hidden;
}
.loader-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
border-radius: 1px;
transition: width 0.3s ease;
}
/* === ENTER PROMPT === */
#enter-prompt {
position: fixed;
inset: 0;
z-index: 500;
background: rgba(5, 5, 16, 0.7);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.5s ease;
}
#enter-prompt.fade-out {
opacity: 0;
pointer-events: none;
}
.enter-content {
text-align: center;
}
.enter-content h2 {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-primary);
letter-spacing: 0.2em;
text-shadow: 0 0 20px rgba(74, 240, 192, 0.3);
margin-bottom: var(--space-2);
}
.enter-content p {
font-size: var(--text-sm);
color: var(--color-text-muted);
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* === GAME UI (HUD) === */
.game-ui {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
font-family: var(--font-body);
color: var(--color-text);
}
.game-ui button, .game-ui input, .game-ui [data-interactive] {
pointer-events: auto;
}
/* Debug overlay */
.hud-debug {
position: absolute;
top: var(--space-3);
left: var(--space-3);
background: rgba(0, 0, 0, 0.7);
color: #0f0;
font-size: var(--text-xs);
line-height: 1.5;
padding: var(--space-2) var(--space-3);
border-radius: 4px;
white-space: pre;
pointer-events: none;
font-variant-numeric: tabular-nums lining-nums;
}
/* Location indicator */
.hud-location {
position: absolute;
top: var(--space-3);
left: 50%;
transform: translateX(-50%);
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.15em;
color: var(--color-primary);
text-shadow: 0 0 10px rgba(74, 240, 192, 0.3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.hud-location-icon {
font-size: 16px;
animation: spin-slow 10s linear infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Controls hint */
/* === HUD === */
.hud-controls {
position: absolute;
bottom: var(--space-3);
left: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
pointer-events: none;
}
.hud-controls span {
color: var(--color-primary);
font-weight: 600;
}
#nav-mode-label {
color: var(--color-gold);
font-weight: 700;
letter-spacing: 0.05em;
z-index: 10;
}
/* Portal Hint */
.portal-hint {
position: absolute;
/* === AUDIO TOGGLE === */
#audio-toggle {
font-size: 14px;
background-color: var(--color-primary);
color: var(--color-bg);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-family: var(--font-body);
transition: background-color 0.3s ease;
cursor: pointer;
}
#audio-toggle:hover {
background-color: var(--color-secondary);
}
#audio-toggle.muted {
background-color: var(--color-text-muted);
}
/* === DEBUG MODE === */
#debug-toggle {
margin-left: 8px;
}
.collision-box {
outline: 2px solid red;
outline-offset: 2px;
}
.light-source {
outline: 2px dashed yellow;
outline-offset: 2px;
}
/* === OVERVIEW MODE === */
#overview-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, 100px);
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 0, 0, 0.8);
padding: var(--space-2) var(--space-4);
transform: translate(-50%, -50%);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-primary);
border-radius: 4px;
animation: hint-float 2s ease-in-out infinite;
}
@keyframes hint-float {
0%, 100% { transform: translate(-50%, 100px); }
50% { transform: translate(-50%, 90px); }
}
.portal-hint-key {
background: var(--color-primary);
color: var(--color-bg);
font-weight: 700;
padding: 2px 8px;
border-radius: 2px;
}
.portal-hint-text {
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.05em;
}
#portal-hint-name {
color: var(--color-primary);
font-weight: 700;
padding: 4px 10px;
background: rgba(0, 0, 8, 0.6);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
/* Agent Log HUD */
.hud-agent-log {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 280px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
border-left: 2px solid var(--color-primary);
padding: var(--space-3);
#overview-indicator.visible {
display: block;
}
.overview-hint {
margin-left: 12px;
color: var(--color-text-muted);
font-size: 10px;
pointer-events: none;
}
.agent-log-header {
font-family: var(--font-display);
color: var(--color-primary);
letter-spacing: 0.1em;
margin-bottom: var(--space-2);
opacity: 0.8;
}
.agent-log-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-log-entry {
animation: log-fade-in 0.5s ease-out forwards;
opacity: 0;
}
@keyframes log-fade-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
.agent-log-tag {
font-weight: 700;
margin-right: 4px;
}
.tag-timmy { color: var(--color-primary); }
.tag-kimi { color: var(--color-secondary); }
.tag-claude { color: var(--color-gold); }
.tag-perplexity { color: #4488ff; }
.agent-log-text {
color: var(--color-text-muted);
}
/* Vision Hint */
.vision-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 140px);
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 0, 0, 0.8);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gold);
border-radius: 4px;
animation: hint-float-vision 2s ease-in-out infinite;
}
@keyframes hint-float-vision {
0%, 100% { transform: translate(-50%, 140px); }
50% { transform: translate(-50%, 130px); }
}
.vision-hint-key {
background: var(--color-gold);
color: var(--color-bg);
font-weight: 700;
padding: 2px 8px;
border-radius: 2px;
}
.vision-hint-text {
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.05em;
}
#vision-hint-title {
color: var(--color-gold);
font-weight: 700;
}
/* Vision Overlay */
.vision-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.9);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 1000;
}
.vision-overlay-content {
width: 100%;
max-width: 600px;
text-align: center;
padding: var(--space-8);
border: 1px solid var(--color-gold);
border-radius: var(--panel-radius);
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
}
.vision-overlay-header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.vision-overlay-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-gold);
box-shadow: 0 0 10px var(--color-gold);
}
.vision-overlay-title {
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.2em;
color: var(--color-gold);
}
.vision-overlay-content h2 {
font-family: var(--font-display);
font-size: var(--text-2xl);
margin-bottom: var(--space-4);
letter-spacing: 0.1em;
color: var(--color-text-bright);
}
.vision-overlay-content p {
color: var(--color-text);
font-size: var(--text-lg);
line-height: 1.8;
margin-bottom: var(--space-8);
font-style: italic;
}
.vision-close-btn {
background: var(--color-gold);
color: var(--color-bg);
border: none;
padding: var(--space-2) var(--space-8);
border-radius: 4px;
font-family: var(--font-display);
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease;
}
.vision-close-btn:hover {
transform: scale(1.05);
}
/* Portal Activation Overlay */
.portal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.95);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 1000;
}
.portal-overlay-content {
width: 100%;
max-width: 500px;
text-align: center;
padding: var(--space-8);
}
.portal-overlay-header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.portal-overlay-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 10px var(--color-primary);
}
.portal-overlay-title {
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.2em;
color: var(--color-primary);
}
.portal-overlay-content h2 {
font-family: var(--font-display);
font-size: var(--text-2xl);
margin-bottom: var(--space-4);
letter-spacing: 0.1em;
}
.portal-overlay-content p {
color: var(--color-text-muted);
font-size: var(--text-base);
line-height: 1.6;
margin-bottom: var(--space-8);
}
.portal-redirect-box {
border: 1px solid var(--color-primary-dim);
padding: var(--space-6);
border-radius: var(--panel-radius);
}
.portal-redirect-label {
font-size: var(--text-xs);
letter-spacing: 0.2em;
margin-bottom: var(--space-2);
}
.portal-redirect-timer {
font-family: var(--font-display);
font-size: 48px;
font-weight: 700;
color: var(--color-primary);
}
.portal-error-box {
border: 1px solid var(--color-danger);
padding: var(--space-6);
border-radius: var(--panel-radius);
}
.portal-error-msg {
color: var(--color-danger);
font-weight: 700;
margin-bottom: var(--space-4);
}
.portal-close-btn {
background: var(--color-danger);
color: white;
border: none;
padding: var(--space-2) var(--space-6);
border-radius: 4px;
font-family: var(--font-display);
cursor: pointer;
}
/* === CHAT PANEL === */
.chat-panel {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
width: 380px;
max-height: 400px;
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
transition: max-height var(--transition-ui);
}
.chat-panel.collapsed {
max-height: 42px;
}
.chat-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
font-family: var(--font-display);
font-size: var(--text-xs);
letter-spacing: 0.1em;
font-weight: 500;
color: var(--color-text-bright);
cursor: pointer;
flex-shrink: 0;
}
.chat-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
animation: dot-pulse 2s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.6; }
@keyframes overview-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.chat-toggle-btn {
margin-left: auto;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 14px;
cursor: pointer;
transition: transform var(--transition-ui);
}
.chat-panel.collapsed .chat-toggle-btn {
transform: rotate(180deg);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.2) transparent;
}
.chat-msg {
font-size: var(--text-xs);
line-height: 1.6;
padding: var(--space-1) 0;
}
.chat-msg-prefix {
font-weight: 700;
}
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }
.chat-msg-error .chat-msg-prefix { color: var(--color-danger); }
.chat-input-row {
display: flex;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-input {
flex: 1;
background: transparent;
border: none;
padding: var(--space-3) var(--space-4);
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-bright);
outline: none;
}
.chat-input::placeholder {
color: var(--color-text-muted);
}
.chat-send-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: var(--space-3) var(--space-4);
color: var(--color-primary);
font-size: 16px;
cursor: pointer;
transition: background var(--transition-ui);
}
.chat-send-btn:hover {
background: rgba(74, 240, 192, 0.1);
/* === PHOTO MODE === */
body.photo-mode .hud-controls {
display: none;
}
/* === FOOTER === */
.nexus-footer {
body.photo-mode #overview-indicator {
display: none !important;
}
#photo-indicator {
display: none;
position: fixed;
bottom: var(--space-1);
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-size: 10px;
opacity: 0.3;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-primary);
padding: 4px 12px;
background: rgba(0, 0, 8, 0.5);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
.nexus-footer a {
#photo-indicator.visible {
display: block;
}
.photo-hint {
margin-left: 12px;
color: var(--color-text-muted);
text-decoration: none;
font-size: 10px;
letter-spacing: 0.1em;
}
.nexus-footer a:hover {
#photo-focus {
color: var(--color-primary);
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 32px);
right: var(--space-4);
bottom: var(--space-4);
}
.hud-controls {
display: none;
}
/* === SOVEREIGNTY EASTER EGG === */
#sovereignty-msg {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ffd700;
font-family: var(--font-body);
font-size: 13px;
letter-spacing: 0.3em;
text-transform: uppercase;
pointer-events: none;
z-index: 30;
border: 1px solid #ffd700;
padding: 8px 20px;
background: rgba(0, 0, 8, 0.7);
white-space: nowrap;
text-align: center;
}
#sovereignty-msg.visible {
display: block;
animation: sovereignty-flash 2.5s ease-out forwards;
}
@keyframes sovereignty-flash {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.85); }
15% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
40% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}

150
test.js Normal file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Nexus Test Harness
* Validates the scene loads without errors using only Node.js built-ins.
* Run: node test.js
*/
import { execSync } from 'child_process';
import { readFileSync, statSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
let passed = 0;
let failed = 0;
function pass(name) {
console.log(`${name}`);
passed++;
}
function fail(name, reason) {
console.log(`${name}`);
if (reason) console.log(`${reason}`);
failed++;
}
function section(name) {
console.log(`\n${name}`);
}
// ── Syntax checks ──────────────────────────────────────────────────────────
section('JS Syntax');
for (const file of ['app.js', 'ws-client.js']) {
try {
execSync(`node --check ${resolve(__dirname, file)}`, { stdio: 'pipe' });
pass(`${file} parses without syntax errors`);
} catch (e) {
fail(`${file} syntax check`, e.stderr?.toString().trim() || e.message);
}
}
// ── File size budget ────────────────────────────────────────────────────────
section('File Size Budget (< 500 KB)');
for (const file of ['app.js', 'ws-client.js']) {
try {
const bytes = statSync(resolve(__dirname, file)).size;
const kb = (bytes / 1024).toFixed(1);
if (bytes < 500 * 1024) {
pass(`${file} is ${kb} KB`);
} else {
fail(`${file} exceeds 500 KB budget`, `${kb} KB`);
}
} catch (e) {
fail(`${file} size check`, e.message);
}
}
// ── JSON validation ─────────────────────────────────────────────────────────
section('JSON Files');
for (const file of ['manifest.json', 'portals.json', 'vision.json']) {
try {
const raw = readFileSync(resolve(__dirname, file), 'utf8');
JSON.parse(raw);
pass(`${file} is valid JSON`);
} catch (e) {
fail(`${file}`, e.message);
}
}
// ── HTML structure ──────────────────────────────────────────────────────────
section('HTML Structure (index.html)');
const html = (() => {
try { return readFileSync(resolve(__dirname, 'index.html'), 'utf8'); }
catch (e) { fail('index.html readable', e.message); return ''; }
})();
if (html) {
const checks = [
['DOCTYPE declaration', /<!DOCTYPE html>/i],
['<html lang> attribute', /<html[^>]+lang=/i],
['charset meta tag', /<meta[^>]+charset/i],
['viewport meta tag', /<meta[^>]+viewport/i],
['<title> tag', /<title>[^<]+<\/title>/i],
['importmap script', /<script[^>]+type="importmap"/i],
['three.js in importmap', /"three"\s*:/],
['app.js module script', /<script[^>]+type="module"[^>]+src="app\.js"/i],
['debug-toggle element', /id="debug-toggle"/],
['</html> closing tag', /<\/html>/i],
];
for (const [name, re] of checks) {
if (re.test(html)) {
pass(name);
} else {
fail(name, `pattern not found: ${re}`);
}
}
}
// ── app.js static analysis ──────────────────────────────────────────────────
section('app.js Scene Components');
const appJs = (() => {
try { return readFileSync(resolve(__dirname, 'app.js'), 'utf8'); }
catch (e) { fail('app.js readable', e.message); return ''; }
})();
if (appJs) {
const checks = [
['NEXUS.colors palette defined', /const NEXUS\s*=\s*\{/],
['THREE.Scene created', /new THREE\.Scene\(\)/],
['THREE.PerspectiveCamera created', /new THREE\.PerspectiveCamera\(/],
['THREE.WebGLRenderer created', /new THREE\.WebGLRenderer\(/],
['renderer appended to DOM', /document\.body\.appendChild\(renderer\.domElement\)/],
['animate function defined', /function animate\s*\(\)/],
['requestAnimationFrame called', /requestAnimationFrame\(animate\)/],
['renderer.render called', /renderer\.render\(scene,\s*camera\)/],
['resize handler registered', /addEventListener\(['"]resize['"]/],
['clock defined', /new THREE\.Clock\(\)/],
['star field created', /new THREE\.Points\(/],
['constellation lines built', /buildConstellationLines/],
['ws-client imported', /import.*ws-client/],
['wsClient.connect called', /wsClient\.connect\(\)/],
];
for (const [name, re] of checks) {
if (re.test(appJs)) {
pass(name);
} else {
fail(name, `pattern not found: ${re}`);
}
}
}
// ── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) {
console.log('\nSome tests failed. Fix the issues above before committing.\n');
process.exit(1);
} else {
console.log('\nAll tests passed.\n');
}

82
ws-client.js Normal file
View File

@@ -0,0 +1,82 @@
export class WebSocketClient {
constructor(url = 'wss://localhost:8080') {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.socket = null;
this.connected = false;
this.reconnectTimeout = null;
this.messageQueue = [];
}
connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
return;
}
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.messageQueue.forEach(msg => this.send(msg));
this.messageQueue = [];
window.dispatchEvent(new CustomEvent('player-joined', { detail: { id: 'system', name: 'System' } }));
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'player-joined':
window.dispatchEvent(new CustomEvent('player-joined', { detail: data }));
break;
case 'player-left':
window.dispatchEvent(new CustomEvent('player-left', { detail: data }));
break;
case 'chat-message':
window.dispatchEvent(new CustomEvent('chat-message', { detail: data }));
break;
}
};
this.socket.onclose = () => {
this.connected = false;
this.reconnect();
};
this.socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn('Max reconnection attempts reached.');
return;
}
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay));
}
send(message) {
if (this.connected) {
this.socket.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
}
}
disconnect() {
if (this.socket) {
this.socket.close();
}
}
}
// Initialize and export a singleton instance
export const wsClient = new WebSocketClient();