1547 lines
67 KiB
HTML
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 │ Mouse Look │ E Interact │ Shift Run │ 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>
|