1547 lines
67 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>✦ Buba World ✦</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
:root{--pink:#FFB5C8;--lavender:#D4B8FF;--teal:#A8E6CF;--peach:#FFDAB9;--gold:#FFE5A0;--blue:#B8D4FF;--mint:#C5E8D0;--text:#555;--text-dim:#888;}
html,body{width:100%;height:100%;overflow:hidden;background:#FFE4EC;font-family:'Space Grotesk',sans-serif;color:var(--text);cursor:crosshair;}
canvas{display:block;}
#container{position:relative;width:100%;height:100%;}
.hud{position:absolute;pointer-events:none;z-index:10;}
.hud-interactive{pointer-events:auto;}
#hud-tl{top:20px;left:20px;}
#hud-tl h1{font-size:22px;font-weight:700;letter-spacing:1.5px;background:linear-gradient(135deg,var(--pink),var(--lavender),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.05));}
#hud-tl .status-row{display:flex;align-items:center;gap:8px;margin-top:6px;font-size:12px;font-family:'JetBrains Mono',monospace;color:var(--text-dim);}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--teal);box-shadow:0 0 6px var(--teal);transition:all .3s;}
.status-dot.idle{background:#ccc;box-shadow:none;}
#current-task{max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;color:var(--text-dim);margin-top:4px;font-family:'JetBrains Mono',monospace;}
#hud-tr{top:20px;right:20px;text-align:right;}
#clock{font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:300;color:var(--lavender);text-shadow:0 1px 3px rgba(212,184,255,0.3);}
#session-count{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-dim);margin-top:4px;}
#hud-bl{bottom:20px;left:20px;display:flex;gap:10px;flex-wrap:wrap;max-width:420px;}
.stat-card{background:rgba(255,255,255,0.55);border:1px solid rgba(212,184,255,0.2);border-radius:14px;padding:12px 16px;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);min-width:88px;box-shadow:0 2px 12px rgba(0,0,0,0.04);}
.stat-card .label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:4px;font-weight:600;}
.stat-card .value{font-family:'JetBrains Mono',monospace;font-size:22px;font-weight:700;}
.stat-card .value.pink{color:var(--pink);}
.stat-card .value.teal{color:#7BCBA2;}
.stat-card .value.lavender{color:var(--lavender);}
.stat-card .value.gold{color:#E8C862;}
#hud-br{bottom:20px;right:20px;width:300px;max-height:220px;}
#activity-title{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:6px;font-weight:700;}
#activity-feed{display:flex;flex-direction:column;gap:4px;max-height:195px;overflow-y:auto;scrollbar-width:none;}
#activity-feed::-webkit-scrollbar{display:none;}
.activity-item{background:rgba(255,255,255,0.5);border-left:3px solid var(--teal);border-radius:0 10px 10px 0;padding:6px 10px;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text);line-height:1.4;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);animation:fadeIn .3s ease;box-shadow:0 1px 6px rgba(0,0,0,0.03);}
.activity-item.tool_call{border-left-color:var(--teal);}
.activity-item.user_message{border-left-color:var(--pink);}
.activity-item.buba_reply{border-left-color:var(--lavender);}
.activity-item .type-tag{font-size:9px;font-weight:700;text-transform:uppercase;margin-bottom:2px;}
.activity-item.tool_call .type-tag{color:#7BCBA2;}
.activity-item.user_message .type-tag{color:var(--pink);}
.activity-item.buba_reply .type-tag{color:var(--lavender);}
@keyframes fadeIn{from{opacity:0;transform:translateX(10px);}to{opacity:1;transform:translateX(0);}}
#controls-hint{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-family:'JetBrains Mono',monospace;font-size:11px;color:rgba(100,100,100,0.6);background:rgba(255,255,255,0.45);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:8px 20px;border-radius:20px;letter-spacing:0.5px;pointer-events:none;z-index:10;white-space:nowrap;}
#interact-prompt{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) translateY(60px);font-family:'Space Grotesk',sans-serif;font-size:15px;font-weight:600;color:#555;background:rgba(255,255,255,0.7);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);padding:10px 24px;border-radius:16px;border:1px solid rgba(212,184,255,0.3);z-index:20;pointer-events:none;opacity:0;transition:opacity 0.2s;box-shadow:0 4px 20px rgba(0,0,0,0.06);}
#interact-prompt.visible{opacity:1;}
#project-panel{position:absolute;top:0;right:-420px;width:400px;height:100%;background:rgba(255,255,255,0.72);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);z-index:30;transition:right 0.35s cubic-bezier(0.4,0,0.2,1);padding:40px 28px;overflow-y:auto;border-left:1px solid rgba(212,184,255,0.15);box-shadow:-8px 0 40px rgba(0,0,0,0.06);}
#project-panel.open{right:0;}
#project-panel .close-hint{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-dim);margin-bottom:20px;letter-spacing:0.5px;}
#project-panel .proj-name{font-size:28px;font-weight:700;margin-bottom:6px;line-height:1.2;}
#project-panel .proj-status{display:inline-block;font-size:11px;font-weight:600;padding:4px 12px;border-radius:20px;margin-bottom:14px;letter-spacing:0.5px;}
#project-panel .proj-status.active{background:rgba(168,230,207,0.3);color:#5DAF70;}
#project-panel .proj-status.complete{background:rgba(212,184,255,0.3);color:#9B7ED8;}
#project-panel .proj-desc{font-size:14px;color:var(--text);line-height:1.6;margin-bottom:20px;}
#project-panel .section-title{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text-dim);margin:16px 0 8px;font-weight:700;}
#project-panel .agent-list{display:flex;flex-direction:column;gap:6px;}
#project-panel .agent-item{background:rgba(168,230,207,0.15);border-radius:10px;padding:8px 12px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text);}
#project-panel .activity-list{display:flex;flex-direction:column;gap:4px;}
#project-panel .proj-activity{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-dim);padding:4px 0;border-bottom:1px solid rgba(0,0,0,0.04);}
#project-panel .start-working-btn{margin-top:16px;padding:12px 24px;background:linear-gradient(135deg,var(--teal),var(--blue));color:#fff;border:none;border-radius:12px;font-family:'Space Grotesk',sans-serif;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.2s;letter-spacing:0.5px;width:100%;}
#project-panel .start-working-btn:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(168,230,207,0.4);}
#project-panel .work-status{margin-top:12px;padding:16px;background:rgba(168,230,207,0.12);border-radius:12px;border:1px solid rgba(168,230,207,0.2);display:none;}
#project-panel .work-status.visible{display:block;}
#project-panel .work-status h4{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--teal);margin-bottom:8px;}
#project-panel .work-status .timer{font-family:'JetBrains Mono',monospace;font-size:24px;color:var(--text);font-weight:700;}
#project-panel .work-status .stop-btn{margin-top:8px;padding:6px 16px;background:rgba(255,181,200,0.2);color:var(--pink);border:1px solid var(--pink);border-radius:8px;font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer;transition:all 0.2s;}
#project-panel .work-status .stop-btn:hover{background:var(--pink);color:#fff;}
#loading{position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(135deg,#FFE4EC,#E8DFFF,#DDE8FF);display:flex;align-items:center;justify-content:center;z-index:100;transition:opacity .8s;flex-direction:column;gap:12px;}
#loading.hidden{opacity:0;pointer-events:none;}
#loading .spinner{width:36px;height:36px;border:3px solid rgba(212,184,255,0.3);border-top-color:var(--lavender);border-radius:50%;animation:spin 1s linear infinite;}
#loading .load-text{font-size:13px;color:var(--lavender);font-weight:600;letter-spacing:1px;}
#loading .load-progress{width:200px;height:4px;background:rgba(212,184,255,0.2);border-radius:2px;overflow:hidden;margin-top:4px;}
#loading .load-progress-bar{height:100%;background:var(--lavender);border-radius:2px;width:0%;transition:width 0.3s;}
@keyframes spin{to{transform:rotate(360deg);}}
/* New Project Modal */
#new-project-btn{position:absolute;bottom:70px;left:50%;transform:translateX(-50%);z-index:15;pointer-events:auto;padding:10px 24px;background:linear-gradient(135deg,var(--pink),var(--lavender));color:#fff;border:none;border-radius:20px;font-family:'Space Grotesk',sans-serif;font-size:13px;font-weight:600;cursor:pointer;letter-spacing:0.5px;box-shadow:0 4px 20px rgba(212,184,255,0.3);transition:all 0.2s;}
#new-project-btn:hover{transform:translateX(-50%) translateY(-2px);box-shadow:0 6px 28px rgba(212,184,255,0.5);}
#new-project-modal{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.3);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:50;display:none;align-items:center;justify-content:center;}
#new-project-modal.open{display:flex;}
#new-project-modal .modal-content{background:rgba(255,255,255,0.9);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border-radius:24px;padding:36px;width:380px;box-shadow:0 12px 60px rgba(0,0,0,0.1);border:1px solid rgba(212,184,255,0.2);}
#new-project-modal h2{font-size:22px;font-weight:700;margin-bottom:20px;background:linear-gradient(135deg,var(--pink),var(--lavender));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
#new-project-modal label{display:block;font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:6px;font-weight:700;}
#new-project-modal input,#new-project-modal select{width:100%;padding:10px 14px;border:1px solid rgba(212,184,255,0.3);border-radius:12px;font-family:'Space Grotesk',sans-serif;font-size:14px;color:var(--text);background:rgba(255,255,255,0.6);margin-bottom:16px;outline:none;transition:border-color 0.2s;}
#new-project-modal input:focus,#new-project-modal select:focus{border-color:var(--lavender);}
#new-project-modal .color-picker{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;}
#new-project-modal .color-swatch{width:32px;height:32px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:all 0.2s;}
#new-project-modal .color-swatch.selected{border-color:var(--text);transform:scale(1.15);}
#new-project-modal .modal-actions{display:flex;gap:10px;margin-top:8px;}
#new-project-modal .modal-actions button{flex:1;padding:10px;border-radius:12px;font-family:'Space Grotesk',sans-serif;font-size:13px;font-weight:600;cursor:pointer;transition:all 0.2s;}
#new-project-modal .btn-create{background:linear-gradient(135deg,var(--teal),var(--blue));color:#fff;border:none;}
#new-project-modal .btn-create:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(168,230,207,0.4);}
#new-project-modal .btn-cancel{background:transparent;color:var(--text-dim);border:1px solid rgba(0,0,0,0.1);}
#new-project-modal .btn-cancel:hover{background:rgba(0,0,0,0.03);}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="container">
<div id="loading">
<div class="spinner"></div>
<div class="load-text">✦ entering buba world ✦</div>
<div class="load-progress"><div class="load-progress-bar" id="load-bar"></div></div>
</div>
<div class="hud" id="hud-tl">
<h1>✦ Buba World ✦</h1>
<div class="status-row"><div class="status-dot" id="status-dot"></div><span id="status-text">Initializing...</span></div>
<div id="current-task"></div>
</div>
<div class="hud" id="hud-tr">
<div id="clock">00:00:00</div>
<div id="session-count">— sessions today</div>
</div>
<div class="hud" id="hud-bl">
<div class="stat-card"><div class="label">Sessions</div><div class="value pink" id="stat-sessions"></div></div>
<div class="stat-card"><div class="label">Sub-Agents</div><div class="value teal" id="stat-subagents"></div></div>
<div class="stat-card"><div class="label">Active</div><div class="value lavender" id="stat-active"></div></div>
<div class="stat-card"><div class="label">Today</div><div class="value gold" id="stat-today"></div></div>
</div>
<div class="hud hud-interactive" id="hud-br">
<div id="activity-title">✦ Activity Feed</div>
<div id="activity-feed"></div>
</div>
<button id="new-project-btn">✦ New Project</button>
<div id="controls-hint">WASD Move &nbsp;&nbsp; Mouse Look &nbsp;&nbsp; E Interact &nbsp;&nbsp; Shift Run &nbsp;&nbsp; Space Jump</div>
<div id="interact-prompt"></div>
<div id="project-panel">
<div class="close-hint">Press E or ESC to close</div>
<div class="proj-name" id="panel-name"></div>
<div class="proj-status" id="panel-status"></div>
<div class="proj-desc" id="panel-desc"></div>
<div class="section-title">Sub-Agents Working</div>
<div class="agent-list" id="panel-agents"></div>
<div class="section-title">Recent Activity</div>
<div class="activity-list" id="panel-activity"></div>
<button class="start-working-btn" id="start-working-btn">⚡ Start Working</button>
<div class="work-status" id="work-status">
<h4>✦ Working Session Active</h4>
<div class="timer" id="work-timer">00:00:00</div>
<button class="stop-btn" id="stop-working-btn">■ Stop</button>
</div>
</div>
<div id="new-project-modal">
<div class="modal-content">
<h2>✦ New Project</h2>
<label>Project Name</label>
<input type="text" id="np-name" placeholder="My Awesome Project" maxlength="30">
<label>Building Style</label>
<select id="np-style">
<option value="A">Style A — Classic</option>
<option value="B">Style B — Townhouse</option>
<option value="C">Style C — Modern</option>
<option value="D">Style D — Tower</option>
<option value="E">Style E — Complex</option>
<option value="F">Style F — Industrial</option>
<option value="G">Style G — Grand</option>
<option value="H">Style H — Skyscraper</option>
</select>
<label>Color Theme</label>
<div class="color-picker" id="np-colors"></div>
<div class="modal-actions">
<button class="btn-cancel" id="np-cancel">Cancel</button>
<button class="btn-create" id="np-create">✦ Create</button>
</div>
</div>
</div>
</div>
<script>
window.__errors = [];
window.onerror = (m,s,l,c,e) => { window.__errors.push('ERR: '+m+' @ '+s+':'+l); };
window.addEventListener('unhandledrejection', (ev) => { window.__errors.push('REJECT: ' + (ev.reason ? (ev.reason.message || ev.reason.stack || String(ev.reason)) : 'unknown')); });
</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
/* ── constants ────────────────────────── */
const C = {
pink:0xFFB5C8, lavender:0xD4B8FF, teal:0xA8E6CF,
peach:0xFFDAB9, gold:0xFFE5A0, blue:0xB8D4FF,
coral:0xFFB8A8, mint:0xC5E8D0, white:0xFFFFFF
};
const KAYKIT_BASE = '/assets/kaykit/';
const MODEL_SCALE = 2.5; // KayKit models are ~3-4 units tall, scale to ~8-10
const BUILDING_DEFS = [
{ name:'MCP Factory', color:'#A8E6CF', hex:0xA8E6CF, desc:'The production pipeline. 30+ MCP servers built here.', pos:[-18,0,-14], height:8, type:'factory', model:'building_H' },
{ name:'SOLVR', color:'#FFE5A0', hex:0xFFE5A0, desc:'White Glove client project. $20K, 6-week build.', pos:[16,0,-16], height:6, type:'cottage', model:'building_E' },
{ name:'CannaBri', color:'#B8E6C8', hex:0xB8E6C8, desc:'Cannabis processing site for CannaBri NY.', pos:[-20,0,10], height:5, type:'greenhouse', model:'building_C' },
{ name:'TheNicheQuiz', color:'#FFDAB9', hex:0xFFDAB9, desc:'AI-powered niche discovery quiz platform.', pos:[20,0,8], height:5.5, type:'wizard', model:'building_D' },
{ name:'CREdispo', color:'#B8D4FF', hex:0xB8D4FF, desc:'Commercial real estate disposition app.', pos:[12,0,20], height:5, type:'warehouse', model:'building_F' },
{ name:'OpenClaw', color:'#FFB5C8', hex:0xFFB5C8, desc:"Buba's home base. The core Clawdbot platform.", pos:[0,0,0], height:7, type:'hub', model:'building_A' },
{ name:'OSKV Coaching', color:'#D4B8FF', hex:0xD4B8FF, desc:'Content coaching for Oliver & Kevin.', pos:[-12,0,20], height:4.5, type:'studio', model:'building_B' }
];
const COLOR_OPTIONS = [
{ name:'Teal', hex:'#A8E6CF', threeHex:0xA8E6CF },
{ name:'Pink', hex:'#FFB5C8', threeHex:0xFFB5C8 },
{ name:'Lavender', hex:'#D4B8FF', threeHex:0xD4B8FF },
{ name:'Peach', hex:'#FFDAB9', threeHex:0xFFDAB9 },
{ name:'Gold', hex:'#FFE5A0', threeHex:0xFFE5A0 },
{ name:'Blue', hex:'#B8D4FF', threeHex:0xB8D4FF },
{ name:'Coral', hex:'#FFB8A8', threeHex:0xFFB8A8 },
{ name:'Mint', hex:'#C5E8D0', threeHex:0xC5E8D0 },
];
const ISLAND_RADIUS = 42;
const INTERACT_DIST = 6;
const WALK_SPEED = 8;
const RUN_SPEED = 16;
const JUMP_VEL = 7;
const GRAVITY = 18;
/* ── state ────────────────────────────── */
let scene, camera, renderer, composer, gameClock;
let bubaGroup, bubaInner, bubaAura;
let buildings = {};
let subagentMeshes = {};
let clouds = [], trees = [], flowers = [], smokeParticles = [];
let dashState = null;
let gltfModelCache = {}; // cached loaded gltf scenes
let animatedCars = []; // cars with drive animations
// Working session state
let workingOnProject = null;
let workStartTime = null;
let workTimerInterval = null;
// Custom projects from localStorage
let customProjects = JSON.parse(localStorage.getItem('bubaCustomProjects') || '[]');
// Character state
const player = { x:0, y:0, z:14, vy:0, grounded:true, facing:Math.PI };
const keys = { w:false, a:false, s:false, d:false, shift:false, space:false };
// Camera orbit
let camAzimuth = Math.PI, camElevation = 0.6;
let camDist = 28;
const CAM_MIN_DIST = 8, CAM_MAX_DIST = 30;
const CAM_MIN_EL = 0.05, CAM_MAX_EL = 1.2;
const camSmooth = new THREE.Vector3();
// Interaction
let nearestBuilding = null;
let panelOpen = false;
let panelBuilding = null;
/* ── GLTF Loading ─────────────────────── */
const loadingManager = new THREE.LoadingManager();
const gltfLoader = new GLTFLoader(loadingManager);
let totalToLoad = 0;
let loadedCount = 0;
loadingManager.onProgress = (url, loaded, total) => {
const bar = document.getElementById('load-bar');
if (bar) bar.style.width = Math.round((loaded/total)*100) + '%';
};
function loadModel(name) {
return new Promise((resolve, reject) => {
if (gltfModelCache[name]) {
resolve(gltfModelCache[name].clone());
return;
}
const url = KAYKIT_BASE + name + '.gltf';
gltfLoader.load(url, (gltf) => {
gltfModelCache[name] = gltf.scene;
resolve(gltf.scene.clone());
}, undefined, (err) => {
console.warn(`Failed to load model ${name}:`, err);
reject(err);
});
});
}
/* ── init ─────────────────────────────── */
async function init() {
gameClock = new THREE.Clock();
scene = new THREE.Scene();
// Sky sphere with gradient
const skyCanvas = document.createElement('canvas');
skyCanvas.width = 2; skyCanvas.height = 512;
const skyCtx = skyCanvas.getContext('2d');
const grad = skyCtx.createLinearGradient(0,0,0,512);
grad.addColorStop(0,'#DDE8FF');
grad.addColorStop(0.35,'#E8DFFF');
grad.addColorStop(0.7,'#FFE4EC');
grad.addColorStop(1,'#FFF0F5');
skyCtx.fillStyle = grad;
skyCtx.fillRect(0,0,2,512);
const skyTex = new THREE.CanvasTexture(skyCanvas);
const skySphere = new THREE.Mesh(
new THREE.SphereGeometry(250,32,32),
new THREE.MeshBasicMaterial({ map:skyTex, side:THREE.BackSide })
);
scene.add(skySphere);
camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 500);
renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.getElementById('container').prepend(renderer.domElement);
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight), 0.25, 0.5, 0.85));
createLights();
createIsland();
createClouds();
createTrees();
createFlowers();
createPond();
// Load all KayKit buildings
const buildPromises = BUILDING_DEFS.map(d => createBuilding(d));
await Promise.all(buildPromises);
// Load custom projects
for (const cp of customProjects) {
await createBuilding(cp);
}
// Roads connecting buildings to hub
await createRoads();
// Cars
await createCars();
// Props
await createProps();
createBuba();
// Input
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('wheel', onWheel, { passive:false });
renderer.domElement.addEventListener('click', onClick);
window.addEventListener('resize', onResize);
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
// New project UI
setupNewProjectUI();
document.getElementById('loading').classList.add('hidden');
fetchData();
setInterval(fetchData, 5000);
setInterval(updateClock, 1000);
updateClock();
animate();
}
/* ── lights ────────────────────────────── */
function createLights() {
scene.add(new THREE.HemisphereLight(0xFFE4EC, 0xC5E8D0, 0.8));
const dir = new THREE.DirectionalLight(0xFFF5EE, 1.0);
dir.position.set(25,40,20);
scene.add(dir);
scene.add(new THREE.AmbientLight(0xFFFAFA, 0.6));
const fill = new THREE.DirectionalLight(0xFFDAB9, 0.3);
fill.position.set(-10,-5,10);
scene.add(fill);
}
/* ── island ────────────────────────────── */
function createIsland() {
// Main top
const top = new THREE.Mesh(
new THREE.CylinderGeometry(ISLAND_RADIUS, ISLAND_RADIUS-2, 2.5, 48),
new THREE.MeshStandardMaterial({ color:C.mint, roughness:0.85 })
);
top.position.y = -1.25;
scene.add(top);
// Edge dirt ring
const edge = new THREE.Mesh(
new THREE.TorusGeometry(ISLAND_RADIUS-1, 1.2, 12, 48),
new THREE.MeshStandardMaterial({ color:0xD4A574, roughness:0.8 })
);
edge.rotation.x = Math.PI/2;
edge.position.y = -0.3;
scene.add(edge);
// Bottom
const bot = new THREE.Mesh(
new THREE.CylinderGeometry(ISLAND_RADIUS-2, ISLAND_RADIUS*0.5, 6, 48),
new THREE.MeshStandardMaterial({ color:0xD4A574, roughness:0.9 })
);
bot.position.y = -5.5;
scene.add(bot);
const rock = new THREE.Mesh(
new THREE.CylinderGeometry(ISLAND_RADIUS*0.45, ISLAND_RADIUS*0.3, 4, 32),
new THREE.MeshStandardMaterial({ color:0xC49A6C, roughness:0.9 })
);
rock.position.y = -10;
scene.add(rock);
// Grass patches
for (let i=0; i<20; i++) {
const a = Math.random()*Math.PI*2, r = Math.random()*(ISLAND_RADIUS-6);
const patch = new THREE.Mesh(
new THREE.CircleGeometry(1+Math.random()*2.5, 12),
new THREE.MeshStandardMaterial({ color:0xD4F0DC, roughness:0.9 })
);
patch.rotation.x = -Math.PI/2;
patch.position.set(Math.cos(a)*r, 0.02, Math.sin(a)*r);
scene.add(patch);
}
}
/* ── clouds ────────────────────────────── */
function createClouds() {
const positions = [[-40,30,-25],[30,35,-20],[-15,38,35],[45,32,25],[-35,40,15]];
const mat = new THREE.MeshStandardMaterial({ color:0xFFFFFF, roughness:0.9 });
positions.forEach(([x,y,z]) => {
const g = new THREE.Group();
const offsets = [[0,0,0,3],[-2.5,0.3,0.5,2.3],[2.5,0.2,-0.3,2.5],[-1,0.9,0,2],[1,0.8,0.5,1.8],[0,-0.2,1,2]];
offsets.forEach(o => {
const s = new THREE.Mesh(new THREE.SphereGeometry(o[3],10,10), mat);
s.position.set(o[0],o[1],o[2]);
s.scale.set(1,0.55,1);
g.add(s);
});
g.position.set(x,y,z);
g.userData = { baseX:x, speed:0.3+Math.random()*0.5, range:12+Math.random()*10 };
scene.add(g);
clouds.push(g);
});
}
/* ── trees ─────────────────────────────── */
function createTrees() {
const positions = [[-28,0,-8],[24,0,-22],[-10,0,-26],[26,0,18],[-26,0,-20],[30,0,-4],[-24,0,28],[18,0,28]];
positions.forEach(p => {
const g = new THREE.Group();
const trunkH = 2+Math.random()*1.5;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.3,0.45,trunkH,8),
new THREE.MeshStandardMaterial({ color:0xA0785A, roughness:0.8 })
);
trunk.position.y = trunkH/2;
g.add(trunk);
const canopyMat = new THREE.MeshStandardMaterial({ color: 0x7DD4A0 + Math.floor(Math.random()*0x202020), roughness:0.7 });
const cs = 1.5+Math.random()*0.8;
const c1 = new THREE.Mesh(new THREE.SphereGeometry(cs,10,10), canopyMat);
c1.position.y = trunkH+cs*0.6;
g.add(c1);
const c2 = new THREE.Mesh(new THREE.SphereGeometry(cs*0.75,8,8), canopyMat);
c2.position.set(-0.5,trunkH+cs*0.2,0.4);
g.add(c2);
const c3 = new THREE.Mesh(new THREE.SphereGeometry(cs*0.7,8,8), canopyMat);
c3.position.set(0.5,trunkH+cs*0.3,-0.3);
g.add(c3);
g.position.set(p[0],p[1],p[2]);
scene.add(g);
trees.push(g);
});
}
/* ── flowers ───────────────────────────── */
function createFlowers() {
const cols = [C.pink,C.lavender,C.peach,C.coral,C.gold,0xFFFFFF,0xFF9EBC,0xB8D4FF];
for (let i=0; i<40; i++) {
const a = Math.random()*Math.PI*2, r = 5+Math.random()*(ISLAND_RADIUS-10);
const x = Math.cos(a)*r, z = Math.sin(a)*r;
let skip = false;
for (const bd of BUILDING_DEFS) { if (Math.hypot(x-bd.pos[0],z-bd.pos[2])<5) { skip=true; break; } }
if (skip) continue;
const g = new THREE.Group();
const stemH = 0.4+Math.random()*0.3;
g.add((()=>{const _oa0=new THREE.Mesh(
new THREE.CylinderGeometry(0.03,0.03,stemH,4),
new THREE.MeshStandardMaterial({ color:0x6DBF7A })
);_oa0.position.set(0,stemH/2,0);return _oa0;})());
const col = cols[Math.floor(Math.random()*cols.length)];
const bloom = new THREE.Mesh(
new THREE.SphereGeometry(0.1+Math.random()*0.08,8,8),
new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.15 })
);
bloom.position.y = stemH+0.05;
g.add(bloom);
g.position.set(x,0,z);
scene.add(g);
flowers.push(g);
}
}
/* ── pond ──────────────────────────────── */
function createPond() {
const pond = new THREE.Mesh(
new THREE.CircleGeometry(4,32),
new THREE.MeshStandardMaterial({ color:0xA0D8EF, roughness:0.1, transparent:true, opacity:0.55 })
);
pond.rotation.x = -Math.PI/2;
pond.position.set(10,0.05,-8);
scene.add(pond);
const pedge = new THREE.Mesh(
new THREE.TorusGeometry(4,0.25,8,32),
new THREE.MeshStandardMaterial({ color:0x9BCFAB, roughness:0.7 })
);
pedge.rotation.x = Math.PI/2;
pedge.position.set(10,0.06,-8);
scene.add(pedge);
}
/* ── KayKit roads ──────────────────────── */
async function createRoads() {
// Load road models
let roadStraight, roadJunction, roadTSplit, roadCorner, roadCornerCurved;
try {
[roadStraight, roadJunction, roadTSplit, roadCorner, roadCornerCurved] = await Promise.all([
loadModel('road_straight'),
loadModel('road_junction'),
loadModel('road_tsplit'),
loadModel('road_corner'),
loadModel('road_corner_curved'),
]);
} catch(e) {
console.warn('Road models failed to load, using dotted paths fallback');
createPathsFallback();
return;
}
const ROAD_SCALE = 5;
const ROAD_TILE_SIZE = 2 * ROAD_SCALE; // each tile is ~2 units wide, scaled up
// Place a junction at the hub center
const junctionClone = roadJunction.clone();
junctionClone.scale.setScalar(ROAD_SCALE);
junctionClone.position.set(0, 0.01, 0);
scene.add(junctionClone);
// For each building, lay road_straight tiles from hub toward building
for (const def of BUILDING_DEFS) {
if (def.type === 'hub') continue;
const bx = def.pos[0], bz = def.pos[2];
const dist = Math.sqrt(bx*bx + bz*bz);
const angle = Math.atan2(bx, bz);
const numTiles = Math.floor(dist / ROAD_TILE_SIZE) - 1;
for (let i = 1; i <= numTiles; i++) {
const t = (i * ROAD_TILE_SIZE) / dist;
const tx = bx * t;
const tz = bz * t;
try {
const tile = roadStraight.clone();
tile.scale.setScalar(ROAD_SCALE);
tile.position.set(tx, 0.01, tz);
tile.rotation.y = angle;
scene.add(tile);
} catch(e) { /* skip */ }
}
// T-split near building
try {
const tsplit = roadTSplit.clone();
tsplit.scale.setScalar(ROAD_SCALE);
const endT = ((numTiles + 1) * ROAD_TILE_SIZE) / dist;
tsplit.position.set(bx * Math.min(endT, 0.85), 0.01, bz * Math.min(endT, 0.85));
tsplit.rotation.y = angle;
scene.add(tsplit);
} catch(e) { /* skip */ }
}
}
/* ── fallback dotted paths ─────────────── */
function createPathsFallback() {
const discMat = new THREE.MeshStandardMaterial({ color:0xEDE4D8, roughness:0.9 });
BUILDING_DEFS.forEach(def => {
if (def.type === 'hub') return;
const steps = Math.ceil(Math.hypot(def.pos[0],def.pos[2])/2.5);
for (let i=1; i<steps; i++) {
const t = i/steps;
const disc = new THREE.Mesh(new THREE.CircleGeometry(0.35,8), discMat);
disc.rotation.x = -Math.PI/2;
disc.position.set(def.pos[0]*t, 0.03, def.pos[2]*t);
scene.add(disc);
}
});
}
/* ── KayKit cars ───────────────────────── */
async function createCars() {
const carModels = ['car_sedan', 'car_taxi', 'car_hatchback'];
const CAR_SCALE = 4.5;
// Place static cars
const carPlacements = [
{ model:'car_sedan', pos:[8, 0.01, -12], rot: 0.3 },
{ model:'car_taxi', pos:[-14, 0.01, -4], rot: 1.8 },
{ model:'car_hatchback', pos:[18, 0.01, 14], rot: -0.5 },
{ model:'car_sedan', pos:[-8, 0.01, 16], rot: 2.5 },
];
for (const cp of carPlacements) {
try {
const car = await loadModel(cp.model);
car.scale.setScalar(CAR_SCALE);
car.position.set(cp.pos[0], cp.pos[1], cp.pos[2]);
car.rotation.y = cp.rot;
scene.add(car);
} catch(e) {
console.warn(`Failed to load car ${cp.model}`);
}
}
// Animated cars — 2 cars driving in circles around the hub
const driveRadius1 = 14;
const driveRadius2 = 22;
try {
const car1 = await loadModel('car_taxi');
car1.scale.setScalar(CAR_SCALE);
car1.position.set(driveRadius1, 0.01, 0);
scene.add(car1);
animatedCars.push({ mesh: car1, radius: driveRadius1, speed: 0.15, offset: 0 });
const car2 = await loadModel('car_hatchback');
car2.scale.setScalar(CAR_SCALE);
car2.position.set(driveRadius2, 0.01, 0);
scene.add(car2);
animatedCars.push({ mesh: car2, radius: driveRadius2, speed: -0.1, offset: Math.PI });
} catch(e) {
console.warn('Animated car load failed');
}
}
/* ── KayKit props ──────────────────────── */
async function createProps() {
const PROP_SCALE = 4.5;
// Benches near buildings
const benchPositions = [
[-15, 0.01, -10], [14, 0.01, -12], [-17, 0.01, 14],
[16, 0.01, 12], [5, 0.01, 5], [-6, 0.01, -5]
];
for (const bp of benchPositions) {
try {
const bench = await loadModel('bench');
bench.scale.setScalar(PROP_SCALE);
bench.position.set(bp[0], bp[1], bp[2]);
bench.rotation.y = Math.random() * Math.PI * 2;
scene.add(bench);
} catch(e) {}
}
// Streetlights along roads
const streetlightPositions = [
[-9, 0.01, -7], [8, 0.01, -8], [-10, 0.01, 5],
[6, 0.01, 10], [-6, 0.01, 10], [10, 0.01, -2],
[-15, 0.01, 0], [0, 0.01, -10]
];
for (const sp of streetlightPositions) {
try {
const light = await loadModel('streetlight');
light.scale.setScalar(PROP_SCALE);
light.position.set(sp[0], sp[1], sp[2]);
light.rotation.y = Math.random() * Math.PI * 2;
scene.add(light);
} catch(e) {}
}
// Bushes scattered
const bushPositions = [
[-22, 0.01, -5], [22, 0.01, -10], [-8, 0.01, -20],
[15, 0.01, 22], [-18, 0.01, 22], [28, 0.01, 0],
[-28, 0.01, 14], [4, 0.01, -18]
];
for (const bp of bushPositions) {
try {
const bush = await loadModel('bush');
bush.scale.setScalar(PROP_SCALE * (0.8 + Math.random() * 0.6));
bush.position.set(bp[0], bp[1], bp[2]);
bush.rotation.y = Math.random() * Math.PI * 2;
scene.add(bush);
} catch(e) {}
}
// Fire hydrants
const hydrantPositions = [[-12, 0.01, -8], [10, 0.01, 4], [18, 0.01, -6], [-5, 0.01, 14]];
for (const hp of hydrantPositions) {
try {
const hydrant = await loadModel('firehydrant');
hydrant.scale.setScalar(PROP_SCALE);
hydrant.position.set(hp[0], hp[1], hp[2]);
scene.add(hydrant);
} catch(e) {}
}
// Trash cans
const trashPositions = [[-16, 0.01, -12], [13, 0.01, -14], [8, 0.01, 18], [-10, 0.01, 18]];
for (const tp of trashPositions) {
try {
const modelName = Math.random() > 0.5 ? 'trash_A' : 'trash_B';
const trash = await loadModel(modelName);
trash.scale.setScalar(PROP_SCALE);
trash.position.set(tp[0], tp[1], tp[2]);
scene.add(trash);
} catch(e) {}
}
// Dumpsters near factory
try {
const dumpster = await loadModel('dumpster');
dumpster.scale.setScalar(PROP_SCALE);
dumpster.position.set(-21, 0.01, -17);
dumpster.rotation.y = 0.5;
scene.add(dumpster);
} catch(e) {}
// Traffic lights at a couple intersections
const trafficPositions = [[-4, 0.01, -4], [4, 0.01, 4]];
for (const tp of trafficPositions) {
try {
const tl = await loadModel('trafficlight_A');
tl.scale.setScalar(PROP_SCALE);
tl.position.set(tp[0], tp[1], tp[2]);
tl.rotation.y = Math.random() * Math.PI;
scene.add(tl);
} catch(e) {}
}
}
/* ── building creation (KayKit + fallback) ─── */
async function createBuilding(def) {
const group = new THREE.Group();
group.position.set(def.pos[0], 0, def.pos[2]);
group.userData = { name:def.name, def, baseHeight:def.height, active:false, glowIntensity:0, isCustom:!!def.isCustom };
const mat = (c, extra) => new THREE.MeshStandardMaterial({ color:c, roughness:0.6, metalness:0.05, ...extra });
// Plot disc
const plot = new THREE.Mesh(new THREE.CylinderGeometry(4.5,4.5,0.3,24), new THREE.MeshStandardMaterial({ color:0xB5DCC0, roughness:0.8 }));
plot.position.y = -0.15;
group.add(plot);
// Try to load KayKit GLTF model
const modelName = def.model || 'building_A';
let modelLoaded = false;
try {
const gltfScene = await loadModel(modelName + '_withoutBase');
gltfScene.scale.setScalar(MODEL_SCALE);
gltfScene.position.y = 0;
// Apply color tint overlay to each mesh
gltfScene.traverse((child) => {
if (child.isMesh && child.material) {
// Clone material so we don't share across buildings
child.material = child.material.clone();
// Add a subtle emissive color tint matching the project
child.material.emissive = new THREE.Color(def.hex);
child.material.emissiveIntensity = 0.08;
}
});
group.add(gltfScene);
group.userData.gltfModel = gltfScene;
modelLoaded = true;
// Compute model height for label placement
const box = new THREE.Box3().setFromObject(gltfScene);
const modelHeight = box.max.y;
group.userData.modelHeight = modelHeight;
} catch(e) {
console.warn(`KayKit model ${modelName} failed, using procedural fallback for ${def.name}`);
// Fallback: create a simple procedural building
createProceduralFallback(group, def);
group.userData.modelHeight = def.height;
}
// Building label sprite
const labelHeight = (group.userData.modelHeight || def.height) + 2;
const lc = document.createElement('canvas'); lc.width=256; lc.height=64;
const lx = lc.getContext('2d');
lx.font = 'bold 22px "Space Grotesk",sans-serif';
lx.textAlign = 'center'; lx.fillStyle = '#555';
lx.shadowColor = 'rgba(0,0,0,0.1)'; lx.shadowBlur = 4;
lx.fillText(def.name, 128, 38);
const labelSprite = new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(lc),transparent:true,opacity:0.85,depthTest:false}));
labelSprite.scale.set(7,1.8,1);
labelSprite.position.y = labelHeight;
group.add(labelSprite);
// Point light
const light = new THREE.PointLight(def.hex, 0.5, 15);
light.position.y = labelHeight - 1;
group.add(light);
group.userData.light = light;
// Special decorations for OpenClaw hub
if (def.type === 'hub') {
// Glowing orb above hub
const orb = new THREE.Mesh(
new THREE.SphereGeometry(1.0, 20, 20),
new THREE.MeshStandardMaterial({color:0xFFC8D8,roughness:0.3,emissive:0xFFB5C8,emissiveIntensity:0.4})
);
orb.position.y = labelHeight + 1.5;
group.add(orb);
group.userData.orb = orb;
// Heart sprite
const hc = document.createElement('canvas'); hc.width=64; hc.height=64;
const hx = hc.getContext('2d'); hx.font='48px sans-serif'; hx.textAlign='center'; hx.fillStyle='#FFB5C8'; hx.fillText('♡',32,46);
const hSprite = new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(hc),transparent:true,opacity:0.6,depthTest:false}));
hSprite.scale.set(1.5,1.5,1); hSprite.position.y = labelHeight + 3;
group.add(hSprite);
}
// Factory smoke particles
if (def.type === 'factory') {
const smokeY = group.userData.modelHeight || def.height;
for (let i=0;i<5;i++) {
const smoke = new THREE.Mesh(
new THREE.SphereGeometry(0.2+Math.random()*0.2,8,8),
new THREE.MeshStandardMaterial({color:0xFFFFFF,transparent:true,opacity:0.4,roughness:0.9})
);
smoke.position.set(1.5+(Math.random()-0.5)*0.3, smokeY+0.5+i*0.5, (Math.random()-0.5)*0.3);
smoke.userData = { baseY:smokeY, speed:0.3+Math.random()*0.5, offset:Math.random()*Math.PI*2 };
group.add(smoke);
smokeParticles.push(smoke);
}
}
scene.add(group);
buildings[def.name] = group;
}
/* ── procedural fallback building ──────── */
function createProceduralFallback(group, def) {
const mat = (c, extra) => new THREE.MeshStandardMaterial({ color:c, roughness:0.6, metalness:0.05, ...extra });
// Simple cylinder building
const body = new THREE.Mesh(
new THREE.CylinderGeometry(2.5, 2.8, def.height, 16),
mat(def.hex)
);
body.position.y = def.height / 2;
group.add(body);
// Roof dome
const roof = new THREE.Mesh(
new THREE.SphereGeometry(2.5, 16, 12, 0, Math.PI*2, 0, Math.PI/2),
mat(new THREE.Color(def.hex).multiplyScalar(0.85))
);
roof.position.y = def.height;
group.add(roof);
// Windows
for (let i = 0; i < 4; i++) {
const a = (i/4) * Math.PI * 2;
const win = new THREE.Mesh(
new THREE.CircleGeometry(0.4, 12),
mat(0xFFF8DC)
);
win.position.set(Math.cos(a)*2.51, def.height*0.6, Math.sin(a)*2.51);
win.lookAt(new THREE.Vector3(Math.cos(a)*5, def.height*0.6, Math.sin(a)*5));
group.add(win);
}
}
/* ── buba character ────────────────────── */
function createBuba() {
bubaGroup = new THREE.Group();
bubaInner = new THREE.Group();
bubaGroup.add(bubaInner);
const skin = 0xFFE8D6, hair = 0xB08050, bodyCol = C.pink;
const m = (c,e) => new THREE.MeshStandardMaterial({color:c,roughness:0.5,...e});
// Head
const head = new THREE.Mesh(new THREE.SphereGeometry(1.6,24,24), m(skin));
head.scale.set(1,0.92,0.95); head.position.y = 2.8;
bubaInner.add(head);
bubaInner.userData = { head };
// Eyes
const eyeW = m(0xFFFFFF,{roughness:0.2});
const irisM = m(0x7DD4B0,{emissive:0xA8E6CF,emissiveIntensity:0.2,roughness:0.3});
const pupilM = m(0x222222,{roughness:0.2});
const specM = m(0xFFFFFF,{emissive:0xFFFFFF,emissiveIntensity:0.5});
function eye(x) {
bubaInner.add((()=>{const _oa1=new THREE.Mesh(new THREE.SphereGeometry(0.46,16,16),eyeW);_oa1.position.set(x,2.55,1.3);_oa1.scale.set(1,1.15,0.5);return _oa1;})());
bubaInner.add((()=>{const _oa2=new THREE.Mesh(new THREE.SphereGeometry(0.3,14,14),irisM);_oa2.position.set(x,2.5,1.52);_oa2.scale.set(1,1.1,0.5);return _oa2;})());
bubaInner.add((()=>{const _oa3=new THREE.Mesh(new THREE.SphereGeometry(0.15,10,10),pupilM);_oa3.position.set(x,2.48,1.6);_oa3.scale.set(1,1.1,0.5);return _oa3;})());
bubaInner.add((()=>{const _oa4=new THREE.Mesh(new THREE.SphereGeometry(0.09,8,8),specM);_oa4.position.set(x+0.1,2.65,1.6);return _oa4;})());
bubaInner.add((()=>{const _oa5=new THREE.Mesh(new THREE.SphereGeometry(0.05,6,6),specM);_oa5.position.set(x-0.05,2.58,1.62);return _oa5;})());
}
eye(-0.5); eye(0.5);
// Mouth
const mouth = new THREE.Mesh(new THREE.TorusGeometry(0.15,0.04,8,12,Math.PI), m(0xCC8888));
mouth.position.set(0,2.05,1.42); mouth.rotation.x = 0.2;
bubaInner.add(mouth);
// Blush
const blushM = m(0xFFAAAA,{transparent:true,opacity:0.4,roughness:0.8});
bubaInner.add((()=>{const _oa6=new THREE.Mesh(new THREE.SphereGeometry(0.25,10,10),blushM);_oa6.position.set(-0.95,2.25,1.2);_oa6.scale.set(1.2,0.7,0.3);return _oa6;})());
bubaInner.add((()=>{const _oa7=new THREE.Mesh(new THREE.SphereGeometry(0.25,10,10),blushM);_oa7.position.set(0.95,2.25,1.2);_oa7.scale.set(1.2,0.7,0.3);return _oa7;})());
// Hair
const hm = m(hair,{roughness:0.7});
[[0,4.15,0,0.9,0.6,0.8],[-0.5,4.1,0.2,0.7,0.55,0.6],[0.5,4.1,0.2,0.7,0.55,0.6],[0,4.0,-0.5,0.8,0.5,0.7],
[-1.3,3.2,0,0.5,0.8,0.6],[1.3,3.2,0,0.5,0.8,0.6],[-1.1,3.6,-0.3,0.55,0.6,0.5],[1.1,3.6,-0.3,0.55,0.6,0.5],
[0,3.5,-1.2,1.0,0.7,0.5],[-0.6,3.3,-1.0,0.6,0.6,0.5],[0.6,3.3,-1.0,0.6,0.6,0.5],[0,3.7,1.1,1.1,0.35,0.4]
].forEach(h => {
const p = new THREE.Mesh(new THREE.SphereGeometry(0.5,10,10),hm);
p.position.set(h[0],h[1],h[2]); p.scale.set(h[3],h[4],h[5]);
bubaInner.add(p);
});
// Glasses
const gm = m(0x666666,{roughness:0.4,metalness:0.2});
bubaInner.add((()=>{const _oa8=new THREE.Mesh(new THREE.TorusGeometry(0.5,0.05,12,24),gm);_oa8.position.set(-0.5,2.55,1.35);return _oa8;})());
bubaInner.add((()=>{const _oa9=new THREE.Mesh(new THREE.TorusGeometry(0.5,0.05,12,24),gm);_oa9.position.set(0.5,2.55,1.35);return _oa9;})());
const bridge = new THREE.Mesh(new THREE.CylinderGeometry(0.035,0.035,0.4,6),gm);
bridge.rotation.z = Math.PI/2; bridge.position.set(0,2.55,1.5); bubaInner.add(bridge);
const tL = new THREE.Mesh(new THREE.CylinderGeometry(0.03,0.03,1.0,4),gm);
tL.rotation.x = Math.PI/2; tL.position.set(-0.95,2.55,0.8); bubaInner.add(tL);
const tR = new THREE.Mesh(new THREE.CylinderGeometry(0.03,0.03,1.0,4),gm);
tR.rotation.x = Math.PI/2; tR.position.set(0.95,2.55,0.8); bubaInner.add(tR);
// Body
const bm = m(bodyCol);
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.65,0.55,1.0,10), bm);
body.position.y = 0.9; bubaInner.add(body);
const bodyTop = new THREE.Mesh(new THREE.SphereGeometry(0.65,10,6,0,Math.PI*2,0,Math.PI/2), bm);
bodyTop.position.y = 1.4; bubaInner.add(bodyTop);
// Arms
const armL = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.15,0.7,8), bm);
armL.position.set(-0.9,1.1,0); armL.rotation.z = 0.5; bubaInner.add(armL);
bubaInner.add((()=>{const _oa10=new THREE.Mesh(new THREE.SphereGeometry(0.13,8,8),m(skin));_oa10.position.set(-1.2,0.85,0);return _oa10;})());
const armR = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.15,0.7,8), bm);
armR.position.set(0.9,1.1,0); armR.rotation.z = -0.5; bubaInner.add(armR);
bubaInner.add((()=>{const _oa11=new THREE.Mesh(new THREE.SphereGeometry(0.13,8,8),m(skin));_oa11.position.set(1.2,0.85,0);return _oa11;})());
bubaInner.userData.armL = armL;
bubaInner.userData.armR = armR;
// Legs
const legL = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.16,0.45,8), bm);
legL.position.set(-0.3,0.22,0); bubaInner.add(legL);
const legR = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.16,0.45,8), bm);
legR.position.set(0.3,0.22,0); bubaInner.add(legR);
bubaInner.userData.legL = legL;
bubaInner.userData.legR = legR;
// Shoes
const sm = m(C.coral);
const shL = new THREE.Mesh(new THREE.SphereGeometry(0.17,8,8),sm);
shL.position.set(-0.3,-0.02,0.05); shL.scale.set(1,0.7,1.3); bubaInner.add(shL);
const shR = new THREE.Mesh(new THREE.SphereGeometry(0.17,8,8),sm);
shR.position.set(0.3,-0.02,0.05); shR.scale.set(1,0.7,1.3); bubaInner.add(shR);
bubaInner.userData.shoeL = shL;
bubaInner.userData.shoeR = shR;
// Aura ring
bubaAura = new THREE.Mesh(
new THREE.TorusGeometry(1.8,0.12,8,32),
new THREE.MeshStandardMaterial({color:C.pink,emissive:C.pink,emissiveIntensity:0.3,transparent:true,opacity:0.2})
);
bubaAura.rotation.x = Math.PI/2; bubaAura.position.y = -0.2;
bubaGroup.add(bubaAura);
// Label
const lc = document.createElement('canvas'); lc.width=256; lc.height=80;
const lx = lc.getContext('2d');
lx.font = 'bold 42px "Space Grotesk",sans-serif'; lx.textAlign='center';
lx.fillStyle='#FFB5C8'; lx.shadowColor='rgba(255,181,200,0.4)'; lx.shadowBlur=10;
lx.fillText('✦ BUBA ✦',128,55);
const label = new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(lc),transparent:true,depthTest:false}));
label.scale.set(5,1.6,1); label.position.y = 5.5;
bubaGroup.add(label);
bubaGroup.position.set(player.x, player.y, player.z);
scene.add(bubaGroup);
}
/* ── sub-agent NPCs ────────────────────── */
function createSubAgent(id, position) {
const g = new THREE.Group();
g.position.set(position.x, 0, position.z);
g.scale.setScalar(0.6);
const skin=0xFFE8D6, bodyCol=C.teal;
const m = (c,e) => new THREE.MeshStandardMaterial({color:c,roughness:0.5,...e});
const head = new THREE.Mesh(new THREE.SphereGeometry(1.3,20,20),m(skin));
head.scale.set(1,0.9,0.93); head.position.y=2.6; g.add(head);
const ew=m(0xFFFFFF,{roughness:0.2}), ir=m(0x7DD4B0,{emissive:0xA8E6CF,emissiveIntensity:0.15});
[[-0.4],[0.4]].forEach(([x])=>{
g.add((()=>{const _oa12=new THREE.Mesh(new THREE.SphereGeometry(0.35,12,12),ew);_oa12.position.set(x,2.4,1.05);_oa12.scale.set(1,1.1,0.5);return _oa12;})());
g.add((()=>{const _oa13=new THREE.Mesh(new THREE.SphereGeometry(0.22,10,10),ir);_oa13.position.set(x,2.35,1.2);_oa13.scale.set(1,1.1,0.5);return _oa13;})());
});
const hm = m(0x7DD4B0,{roughness:0.6});
[[0,3.6,0,0.7,0.5,0.65],[-0.4,3.5,0.2,0.55,0.45,0.5],[0.4,3.5,0.2,0.55,0.45,0.5],[0,3.4,-0.6,0.7,0.4,0.5]].forEach(h=>{
const p=new THREE.Mesh(new THREE.SphereGeometry(0.4,8,8),hm);
p.position.set(h[0],h[1],h[2]); p.scale.set(h[3],h[4],h[5]); g.add(p);
});
const bm = m(bodyCol);
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.42,0.8,8),bm);
body.position.y=0.9; g.add(body);
const legL=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.1,0.35,6),bm); legL.position.set(-0.2,0.32,0); g.add(legL);
const legR=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.1,0.35,6),bm); legR.position.set(0.2,0.32,0); g.add(legR);
const nc=document.createElement('canvas'); nc.width=128; nc.height=40;
const nx=nc.getContext('2d'); nx.font='bold 16px "JetBrains Mono",monospace'; nx.textAlign='center'; nx.fillStyle='#7BCBA2'; nx.fillText(id,64,26);
const ns=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(nc),transparent:true,depthTest:false}));
ns.scale.set(3,1,1); ns.position.y=4.5; g.add(ns);
const pGeo = new THREE.BufferGeometry();
const pCount = 8;
const pPos = new Float32Array(pCount*3);
for(let i=0;i<pCount;i++){ pPos[i*3]=(Math.random()-0.5)*1.5; pPos[i*3+1]=4+Math.random()*2; pPos[i*3+2]=(Math.random()-0.5)*1.5; }
pGeo.setAttribute('position',new THREE.BufferAttribute(pPos,3));
const sparkles = new THREE.Points(pGeo,new THREE.PointsMaterial({color:0xFFE5A0,size:0.2,transparent:true,opacity:0.7,sizeAttenuation:true}));
g.add(sparkles);
g.userData = { id, spawnTime:gameClock.getElapsedTime(), active:true, sparkles };
scene.add(g);
subagentMeshes[id] = g;
}
function removeSubAgent(id) {
const mesh = subagentMeshes[id];
if(mesh){ scene.remove(mesh); mesh.traverse(c=>{ if(c.geometry)c.geometry.dispose(); if(c.material){if(c.material.map)c.material.map.dispose();c.material.dispose();} }); delete subagentMeshes[id]; }
}
/* ── New Project UI ────────────────────── */
function setupNewProjectUI() {
const modal = document.getElementById('new-project-modal');
const btn = document.getElementById('new-project-btn');
const cancelBtn = document.getElementById('np-cancel');
const createBtn = document.getElementById('np-create');
const colorsDiv = document.getElementById('np-colors');
let selectedColor = COLOR_OPTIONS[0];
// Populate color swatches
COLOR_OPTIONS.forEach((c, i) => {
const swatch = document.createElement('div');
swatch.className = 'color-swatch' + (i === 0 ? ' selected' : '');
swatch.style.background = c.hex;
swatch.title = c.name;
swatch.addEventListener('click', () => {
colorsDiv.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
selectedColor = c;
});
colorsDiv.appendChild(swatch);
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
modal.classList.add('open');
});
cancelBtn.addEventListener('click', () => {
modal.classList.remove('open');
});
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.remove('open');
});
createBtn.addEventListener('click', async () => {
const name = document.getElementById('np-name').value.trim();
if (!name) { document.getElementById('np-name').focus(); return; }
const style = document.getElementById('np-style').value;
// Find empty spot
const spot = findEmptySpot();
const newDef = {
name: name,
color: selectedColor.hex,
hex: selectedColor.threeHex,
desc: 'Custom project — ' + name,
pos: [spot.x, 0, spot.z],
height: 6,
type: 'custom',
model: 'building_' + style,
isCustom: true,
createdAt: Date.now()
};
// Save to localStorage
customProjects.push(newDef);
localStorage.setItem('bubaCustomProjects', JSON.stringify(customProjects));
// Create the building in scene
await createBuilding(newDef);
// Close modal and reset
modal.classList.remove('open');
document.getElementById('np-name').value = '';
});
// Work panel buttons
document.getElementById('start-working-btn').addEventListener('click', () => {
if (!panelBuilding) return;
startWorking(panelBuilding);
});
document.getElementById('stop-working-btn').addEventListener('click', () => {
stopWorking();
});
}
function findEmptySpot() {
const usedPositions = [...BUILDING_DEFS, ...customProjects].map(d => ({ x: d.pos[0], z: d.pos[2] }));
// Try spiral outward from center
for (let r = 10; r < ISLAND_RADIUS - 8; r += 8) {
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
const x = Math.cos(a) * r;
const z = Math.sin(a) * r;
let tooClose = false;
for (const p of usedPositions) {
if (Math.hypot(x - p.x, z - p.z) < 10) { tooClose = true; break; }
}
if (!tooClose) return { x, z };
}
}
// Fallback
return { x: (Math.random()-0.5)*30, z: (Math.random()-0.5)*30 };
}
function startWorking(projectName) {
workingOnProject = projectName;
workStartTime = Date.now();
document.getElementById('start-working-btn').style.display = 'none';
const ws = document.getElementById('work-status');
ws.classList.add('visible');
if (workTimerInterval) clearInterval(workTimerInterval);
workTimerInterval = setInterval(() => {
if (!workStartTime) return;
const elapsed = Date.now() - workStartTime;
const h = Math.floor(elapsed / 3600000);
const m = Math.floor((elapsed % 3600000) / 60000);
const s = Math.floor((elapsed % 60000) / 1000);
document.getElementById('work-timer').textContent =
String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}, 1000);
}
function stopWorking() {
workingOnProject = null;
workStartTime = null;
if (workTimerInterval) { clearInterval(workTimerInterval); workTimerInterval = null; }
document.getElementById('start-working-btn').style.display = '';
document.getElementById('work-status').classList.remove('visible');
document.getElementById('work-timer').textContent = '00:00:00';
}
/* ── input ─────────────────────────────── */
function onKeyDown(e) {
// Don't capture when modal is open
if (document.getElementById('new-project-modal').classList.contains('open')) {
if (e.key === 'Escape') document.getElementById('new-project-modal').classList.remove('open');
return;
}
const k = e.key.toLowerCase();
if (k==='w') keys.w=true;
else if (k==='a') keys.a=true;
else if (k==='s') keys.s=true;
else if (k==='d') keys.d=true;
else if (k==='shift') keys.shift=true;
else if (k===' ') { e.preventDefault(); keys.space=true; }
else if (k==='e') handleInteract();
else if (k==='escape') closePanel();
}
function onKeyUp(e) {
const k = e.key.toLowerCase();
if (k==='w') keys.w=false;
else if (k==='a') keys.a=false;
else if (k==='s') keys.s=false;
else if (k==='d') keys.d=false;
else if (k==='shift') keys.shift=false;
else if (k===' ') keys.space=false;
}
function onMouseMove(e) {
if (panelOpen) return;
const sens = 0.003;
camAzimuth -= e.movementX * sens;
camElevation = Math.max(CAM_MIN_EL, Math.min(CAM_MAX_EL, camElevation - e.movementY * sens));
}
function onWheel(e) {
e.preventDefault();
camDist = Math.max(CAM_MIN_DIST, Math.min(CAM_MAX_DIST, camDist + e.deltaY * 0.02));
}
function onClick() {
if (!document.pointerLockElement) {
renderer.domElement.requestPointerLock();
}
}
document.addEventListener('pointerlockchange', ()=>{
document.body.style.cursor = document.pointerLockElement ? 'none' : 'crosshair';
});
/* ── interaction ───────────────────────── */
function handleInteract() {
if (panelOpen) { closePanel(); return; }
if (nearestBuilding) openPanel(nearestBuilding);
}
function openPanel(bName) {
panelOpen = true;
panelBuilding = bName;
// Find definition from default or custom
const def = BUILDING_DEFS.find(d => d.name === bName) || customProjects.find(d => d.name === bName);
if (!def) return;
document.getElementById('panel-name').textContent = def.name;
document.getElementById('panel-name').style.color = def.color;
document.getElementById('panel-desc').textContent = def.desc;
// Status
const statusEl = document.getElementById('panel-status');
const hasActiveAgent = dashState?.subagents?.some(sa => sa.active);
statusEl.textContent = hasActiveAgent ? '● Active' : '○ Idle';
statusEl.className = 'proj-status ' + (hasActiveAgent ? 'active' : 'complete');
// Agents
const agentsEl = document.getElementById('panel-agents');
agentsEl.innerHTML = '';
if (dashState?.subagents) {
const agents = dashState.subagents.filter(sa => sa.active).slice(0,5);
if (agents.length === 0) agentsEl.innerHTML = '<div style="font-size:11px;color:#aaa;font-family:JetBrains Mono,monospace;">No active sub-agents</div>';
agents.forEach(sa => {
const div = document.createElement('div');
div.className = 'agent-item';
div.textContent = `🤖 ${sa.id}${(sa.task||'working...').slice(0,60)}`;
agentsEl.appendChild(div);
});
}
// Activity
const actEl = document.getElementById('panel-activity');
actEl.innerHTML = '';
if (dashState?.activity) {
dashState.activity.slice(0,8).forEach(a => {
const div = document.createElement('div');
div.className = 'proj-activity';
div.textContent = `${a.type==='tool_call'?'✦':a.type==='user_message'?'♡':'☆'} ${(a.content||'').slice(0,70)}`;
actEl.appendChild(div);
});
}
// Work status
if (workingOnProject === bName) {
document.getElementById('start-working-btn').style.display = 'none';
document.getElementById('work-status').classList.add('visible');
} else {
document.getElementById('start-working-btn').style.display = '';
document.getElementById('work-status').classList.remove('visible');
}
document.getElementById('project-panel').classList.add('open');
}
function closePanel() {
panelOpen = false;
panelBuilding = null;
document.getElementById('project-panel').classList.remove('open');
}
/* ── data ──────────────────────────────── */
let prevSubagentIds = new Set();
async function fetchData() {
try {
const res = await fetch('/api/state');
if (!res.ok) return;
dashState = await res.json();
updateHUD();
updateSubAgents();
} catch(e){}
}
function updateHUD() {
if (!dashState) return;
const s = dashState.stats||{}, b = dashState.buba||{};
const isActive = b.status === 'active';
document.getElementById('status-dot').className = 'status-dot'+(isActive?'':' idle');
document.getElementById('status-text').textContent = isActive ? '✦ Active' : 'Idle';
document.getElementById('current-task').textContent = b.currentTask||'';
document.getElementById('stat-sessions').textContent = s.totalSessions??'—';
document.getElementById('stat-subagents').textContent = s.totalSubagents??'—';
document.getElementById('stat-active').textContent = (s.activeSessions??0)+'/'+(s.activeSubagents??0);
document.getElementById('stat-today').textContent = s.todaysSessions??'—';
document.getElementById('session-count').textContent = (s.todaysSessions||0)+' sessions today';
const feed = document.getElementById('activity-feed');
const activities = dashState.activity||[];
feed.innerHTML = '';
activities.slice(0,5).forEach(a => {
const div = document.createElement('div');
div.className = 'activity-item '+(a.type||'');
const typeLabel = a.type==='tool_call'?'✦ Tool':a.type==='user_message'?'♡ Message':'☆ Reply';
div.innerHTML = `<div class="type-tag">${typeLabel}</div>${(a.content||'').slice(0,80)}`;
feed.appendChild(div);
});
}
function updateSubAgents() {
if (!dashState) return;
const currentIds = new Set((dashState.subagents||[]).filter(s=>s.active).map(s=>s.id));
for (const id of prevSubagentIds) { if(!currentIds.has(id)) removeSubAgent(id); }
for (const sa of (dashState.subagents||[]).filter(s=>s.active)) {
if (!subagentMeshes[sa.id]) {
const bKeys = Object.keys(buildings);
const target = buildings[bKeys[Math.floor(Math.random()*bKeys.length)]];
const pos = target.position.clone();
pos.x += (Math.random()-0.5)*8; pos.z += (Math.random()-0.5)*8;
createSubAgent(sa.id, pos);
}
}
prevSubagentIds = currentIds;
}
/* ── clock ─────────────────────────────── */
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent = [now.getHours(),now.getMinutes(),now.getSeconds()].map(n=>String(n).padStart(2,'0')).join(':');
}
/* ── resize ────────────────────────────── */
function onResize() {
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight);
composer.setSize(innerWidth,innerHeight);
}
/* ── animation loop ────────────────────── */
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(gameClock.getDelta(), 0.05);
const t = gameClock.getElapsedTime();
// ── player movement ──
if (!panelOpen) {
const speed = keys.shift ? RUN_SPEED : WALK_SPEED;
const forward = new THREE.Vector3(-Math.sin(camAzimuth), 0, -Math.cos(camAzimuth));
const right = new THREE.Vector3(forward.z, 0, -forward.x);
const moveDir = new THREE.Vector3();
if (keys.w) moveDir.add(forward);
if (keys.s) moveDir.sub(forward);
if (keys.a) moveDir.sub(right);
if (keys.d) moveDir.add(right);
const isMoving = moveDir.lengthSq() > 0.001;
if (isMoving) {
moveDir.normalize();
player.x += moveDir.x * speed * dt;
player.z += moveDir.z * speed * dt;
player.facing = Math.atan2(moveDir.x, moveDir.z);
}
// Jump
if (keys.space && player.grounded) {
player.vy = JUMP_VEL;
player.grounded = false;
}
// Gravity
player.vy -= GRAVITY * dt;
player.y += player.vy * dt;
if (player.y <= 0) { player.y = 0; player.vy = 0; player.grounded = true; }
// Island boundary
const dist = Math.sqrt(player.x*player.x + player.z*player.z);
if (dist > ISLAND_RADIUS - 3) {
const angle = Math.atan2(player.z, player.x);
player.x = Math.cos(angle)*(ISLAND_RADIUS-3);
player.z = Math.sin(angle)*(ISLAND_RADIUS-3);
}
// Update Buba
bubaGroup.position.x = player.x;
bubaGroup.position.z = player.z;
bubaGroup.position.y = player.y;
const targetRot = player.facing;
let currentRot = bubaInner.rotation.y;
let diff = targetRot - currentRot;
while(diff > Math.PI) diff -= Math.PI*2;
while(diff < -Math.PI) diff += Math.PI*2;
bubaInner.rotation.y += diff * Math.min(1, 10*dt);
// ── procedural animation ──
const isRunning = isMoving && keys.shift;
const animSpeed = isRunning ? 14 : 8;
const bobAmt = isMoving ? (isRunning ? 0.25 : 0.15) : 0.05;
const bobFreq = isMoving ? animSpeed : 1.5;
bubaInner.position.y = Math.abs(Math.sin(t*bobFreq)) * bobAmt;
bubaInner.rotation.x = isRunning ? 0.12 : 0;
const legSwing = isMoving ? Math.sin(t*animSpeed)*0.5 : 0;
if (bubaInner.userData.legL) {
bubaInner.userData.legL.rotation.x = legSwing;
bubaInner.userData.legR.rotation.x = -legSwing;
bubaInner.userData.shoeL.position.z = 0.05 + Math.sin(t*animSpeed)*0.12;
bubaInner.userData.shoeR.position.z = 0.05 - Math.sin(t*animSpeed)*0.12;
}
if (bubaInner.userData.armL) {
bubaInner.userData.armL.rotation.x = isMoving ? -legSwing*0.6 : 0;
bubaInner.userData.armR.rotation.x = isMoving ? legSwing*0.6 : 0;
}
if (!isMoving && bubaInner.userData.head) {
bubaInner.userData.head.position.y = 2.8 + Math.sin(t*1.2)*0.05;
}
// ── building proximity ──
nearestBuilding = null;
let nearestDist = Infinity;
for (const [name, bg] of Object.entries(buildings)) {
const dx = player.x - bg.position.x, dz = player.z - bg.position.z;
const d = Math.sqrt(dx*dx + dz*dz);
if (d < INTERACT_DIST && d < nearestDist) {
nearestDist = d;
nearestBuilding = name;
}
}
const prompt = document.getElementById('interact-prompt');
if (nearestBuilding && !panelOpen) {
prompt.textContent = `Press E — ${nearestBuilding}`;
prompt.classList.add('visible');
} else {
prompt.classList.remove('visible');
}
}
// ── camera ──
const camTarget = new THREE.Vector3(player.x, player.y+3, player.z);
camSmooth.lerp(camTarget, Math.min(1, 6*dt));
const cx = camSmooth.x + Math.sin(camAzimuth)*Math.cos(camElevation)*camDist;
const cy = camSmooth.y + Math.sin(camElevation)*camDist;
const cz = camSmooth.z + Math.cos(camAzimuth)*Math.cos(camElevation)*camDist;
camera.position.set(cx, Math.max(cy, 1.5), cz);
camera.lookAt(camSmooth);
// ── aura ──
if (bubaAura) {
bubaAura.material.opacity = 0.15 + Math.sin(t*1.5)*0.05;
bubaAura.scale.setScalar(1 + Math.sin(t*1.5)*0.04);
bubaAura.rotation.z = t*0.3;
}
// ── clouds ──
clouds.forEach(c => {
c.position.x = c.userData.baseX + Math.sin(t*0.05*c.userData.speed)*c.userData.range;
c.position.y += Math.sin(t*0.3+c.position.x)*0.001;
});
// ── smoke ──
smokeParticles.forEach(s => {
s.position.y = s.userData.baseY + Math.abs(Math.sin(t*s.userData.speed+s.userData.offset))*2;
s.position.x += Math.sin(t*0.5+s.userData.offset)*0.003;
s.material.opacity = 0.35 - (s.position.y-s.userData.baseY)*0.1;
if (s.material.opacity<0) s.material.opacity=0;
});
// ── trees sway ──
trees.forEach(tr => { tr.rotation.z = Math.sin(t*0.5+tr.position.x)*0.02; });
// ── buildings breathe + glow ──
for (const [name, bg] of Object.entries(buildings)) {
const breathe = 1 + Math.sin(t*0.8+bg.position.x*0.3)*0.015;
bg.scale.set(breathe, breathe, breathe);
const isNear = nearestBuilding === name;
const targetGlow = isNear ? 1 : 0;
bg.userData.glowIntensity += (targetGlow - bg.userData.glowIntensity) * Math.min(1, 5*dt);
const glow = bg.userData.glowIntensity;
if (bg.userData.light) bg.userData.light.intensity = 0.5 + glow*1.5 + Math.sin(t*1.2+bg.position.x)*0.15;
if (bg.userData.orb) {
bg.userData.orb.scale.setScalar(1+Math.sin(t*2)*0.08);
bg.userData.orb.material.emissiveIntensity = 0.3+Math.sin(t*1.5)*0.15;
}
// Highlight GLTF models when near
if (bg.userData.gltfModel) {
bg.userData.gltfModel.traverse((child) => {
if (child.isMesh && child.material) {
child.material.emissiveIntensity = 0.08 + glow * 0.15;
}
});
}
}
// ── animated cars ──
animatedCars.forEach(car => {
const angle = t * car.speed + car.offset;
car.mesh.position.x = Math.cos(angle) * car.radius;
car.mesh.position.z = Math.sin(angle) * car.radius;
car.mesh.position.y = 0.01;
car.mesh.rotation.y = -angle + Math.PI / 2;
});
// ── sub-agent bob ──
Object.values(subagentMeshes).forEach(sa => {
sa.position.y = Math.sin(t*1.8+sa.userData.spawnTime)*0.2 + 0.3;
sa.rotation.y = Math.sin(t*0.4+sa.userData.spawnTime)*0.3;
if (sa.userData.sparkles) {
const pos = sa.userData.sparkles.geometry.attributes.position.array;
for (let i=0; i<pos.length/3; i++) {
pos[i*3+1] += 0.02;
if (pos[i*3+1] > 7) { pos[i*3+1] = 4; pos[i*3]=(Math.random()-0.5)*1.5; pos[i*3+2]=(Math.random()-0.5)*1.5; }
}
sa.userData.sparkles.geometry.attributes.position.needsUpdate = true;
sa.userData.sparkles.material.opacity = 0.5+Math.sin(t*3)*0.3;
}
});
composer.render();
}
/* ── start ─────────────────────────────── */
init();
</script>
</body>
</html>