soci2 / web /3d.html
RayMelius's picture
FPV chat: add proximity fallback when raycaster misses agents
f3c37b8
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soci 3D — Live City Simulation</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #0a0a12; font-family: 'Segoe UI', system-ui, sans-serif; }
canvas { display: block; }
#status-bar {
position: fixed; top: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 18px;
padding: 10px 20px;
background: rgba(10,10,18,0.75);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.08);
color: #e0e0e8; font-size: 13px; z-index: 100;
pointer-events: none;
}
#status-bar > * { pointer-events: auto; }
.status-live {
display: flex; align-items: center; gap: 6px;
font-weight: 600; letter-spacing: 0.5px;
}
.live-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #4ecca3; box-shadow: 0 0 8px #4ecca3;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.status-time { font-size: 15px; font-weight: 500; }
.status-weather { font-size: 15px; }
.status-agents { color: #aaa; }
.status-cost { color: #888; font-size: 12px; }
.status-spacer { flex: 1; }
#info-panel {
position: fixed; right: 16px; top: 60px;
width: 280px; max-height: calc(100vh - 80px);
background: rgba(10,10,18,0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
color: #e0e0e8; font-size: 13px;
padding: 16px; overflow-y: auto;
z-index: 100; display: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
#info-panel.visible { display: block; }
#info-panel h3 {
font-size: 16px; margin-bottom: 8px;
color: #fff; font-weight: 600;
}
#info-panel .close-btn {
position: absolute; top: 10px; right: 12px;
cursor: pointer; color: #888; font-size: 18px;
background: none; border: none;
}
#info-panel .close-btn:hover { color: #fff; }
.info-section { margin: 10px 0; }
.info-section h4 {
font-size: 11px; text-transform: uppercase;
letter-spacing: 1px; color: #888; margin-bottom: 4px;
}
.info-row {
display: flex; justify-content: space-between;
padding: 2px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.info-row .label { color: #aaa; }
.info-row .value { color: #e0e0e8; font-weight: 500; }
.need-bar {
height: 4px; background: #222; border-radius: 2px;
margin: 3px 0; overflow: hidden;
}
.need-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s; }
.memory-item {
padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 12px; color: #bbb;
}
#agent-list-panel {
position: fixed; left: 16px; top: 60px;
width: 250px; max-height: calc(100vh - 80px);
background: rgba(10,10,18,0.88);
backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
color: #e0e0e8; font-size: 12px;
padding: 12px; overflow-y: auto;
z-index: 100; display: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
#agent-list-panel.visible { display: block; }
#agent-list-panel .close-btn {
position: absolute; top: 8px; right: 10px;
cursor: pointer; color: #888; font-size: 16px;
background: none; border: none;
}
#agent-list-panel .close-btn:hover { color: #fff; }
.sort-btns { display: flex; gap: 4px; margin: 6px 0; flex-wrap: wrap; }
.sort-btn {
padding: 3px 8px; border-radius: 4px; font-size: 10px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
color: #aaa; cursor: pointer;
}
.sort-btn:hover, .sort-btn.active { background: rgba(78,204,163,0.2); color: #4ecca3; border-color: #4ecca3; }
.agent-list-entry {
display: flex; align-items: center; gap: 6px;
padding: 3px 4px; cursor: pointer; border-radius: 4px;
}
.agent-list-entry:hover { background: rgba(255,255,255,0.06); }
#zoom-hint {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.4); font-size: 12px;
pointer-events: none; z-index: 100;
transition: opacity 0.5s;
}
#controls-bar {
position: fixed; bottom: 16px; right: 16px;
display: flex; gap: 6px; z-index: 100;
}
.ctrl-btn {
width: 36px; height: 36px; border-radius: 8px;
background: rgba(10,10,18,0.75);
backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.1);
color: #ccc; font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.ctrl-btn:hover { background: rgba(30,30,50,0.85); color: #fff; }
.ctrl-btn.active { background: rgba(78,204,163,0.3); border-color: #4ecca3; color: #4ecca3; }
#player-login {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(10,10,18,0.95); border: 1px solid rgba(78,204,163,0.4);
border-radius: 12px; padding: 24px 32px; z-index: 300; display: none;
backdrop-filter: blur(16px); min-width: 280px; text-align: center;
}
#player-login h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; font-weight: 400; letter-spacing: 2px; }
#player-login input {
width: 100%; padding: 8px 12px; margin: 6px 0; border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px; background: rgba(255,255,255,0.06); color: #e0e0e8; font-size: 14px;
box-sizing: border-box;
}
#player-login select {
width: 100%; padding: 8px 12px; margin: 6px 0; border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px; background: rgba(20,20,35,0.9); color: #e0e0e8; font-size: 14px;
}
#player-login button {
margin-top: 12px; padding: 8px 24px; border: none; border-radius: 6px;
background: #4ecca3; color: #0a0a12; font-size: 14px; cursor: pointer; font-weight: 600;
}
#player-login button:hover { background: #6be0b8; }
#player-hud {
position: fixed; top: 50px; left: 12px; background: rgba(10,10,18,0.8);
border: 1px solid rgba(78,204,163,0.3); border-radius: 8px; padding: 10px 14px;
z-index: 60; display: none; font-size: 12px; color: #ccc; min-width: 160px;
backdrop-filter: blur(8px);
}
#player-hud .phud-name { color: #4ecca3; font-size: 14px; font-weight: 500; margin-bottom: 6px; }
#npc-chat {
display:none; position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
width:500px; max-width:90vw; background:rgba(10,12,20,0.92); border:1px solid rgba(78,204,163,0.3);
border-radius:12px; z-index:200; font-family:inherit; backdrop-filter:blur(8px);
}
#npc-chat .chat-header {
display:flex; align-items:center; justify-content:space-between; padding:10px 14px;
border-bottom:1px solid rgba(255,255,255,0.08);
}
#npc-chat .chat-header .chat-name { color:#4ecca3; font-size:14px; font-weight:500; }
#npc-chat .chat-header .chat-close { background:none; border:none; color:#888; cursor:pointer; font-size:18px; }
#npc-chat .chat-header .chat-close:hover { color:#fff; }
#npc-chat .chat-messages {
max-height:200px; overflow-y:auto; padding:10px 14px; font-size:13px; line-height:1.5;
}
#npc-chat .chat-messages .msg { margin:6px 0; }
#npc-chat .chat-messages .msg-player { color:#88bbff; }
#npc-chat .chat-messages .msg-npc { color:#e8d8b8; }
#npc-chat .chat-messages .msg-system { color:#888; font-style:italic; font-size:12px; }
#npc-chat .chat-input {
display:flex; align-items:center; gap:6px; padding:8px 14px;
border-top:1px solid rgba(255,255,255,0.08);
}
#npc-chat .chat-input input {
flex:1; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12);
color:#fff; padding:8px 10px; border-radius:6px; font-size:13px; outline:none;
}
#npc-chat .chat-input input:focus { border-color:rgba(78,204,163,0.4); }
#npc-chat .chat-input button {
background:rgba(78,204,163,0.15); border:1px solid rgba(78,204,163,0.3); color:#4ecca3;
padding:8px 12px; border-radius:6px; cursor:pointer; font-size:13px;
}
#npc-chat .chat-input button:hover { background:rgba(78,204,163,0.25); }
#npc-chat .chat-input .mic-btn { font-size:16px; padding:6px 10px; }
#npc-chat .chat-input .mic-btn.recording { color:#e94560; border-color:rgba(233,69,96,0.5); background:rgba(233,69,96,0.15); }
#loading {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #0a0a12; color: #e0e0e8; z-index: 200;
transition: opacity 0.8s;
}
#loading.hidden { opacity: 0; pointer-events: none; }
.loading-title { font-size: 28px; font-weight: 300; letter-spacing: 4px; margin-bottom: 12px; }
.loading-sub { font-size: 13px; color: #888; }
.loading-spinner {
width: 32px; height: 32px; border: 2px solid #333;
border-top-color: #4ecca3; border-radius: 50%;
animation: spin 1s linear infinite; margin-top: 20px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="loading">
<div class="loading-title">SOCI</div>
<div class="loading-sub">Connecting to live simulation...</div>
<div class="loading-spinner"></div>
</div>
<div id="status-bar">
<div class="status-live"><div class="live-dot"></div>LIVE</div>
<div class="status-time" id="sim-time">--:--</div>
<div class="status-weather" id="sim-weather"></div>
<div class="status-agents" id="sim-agents">0 agents</div>
<div class="status-spacer"></div>
<div class="status-cost" id="sim-cost"></div>
</div>
<div id="info-panel">
<button class="close-btn" onclick="closeInfoPanel()">&times;</button>
<div id="info-content"></div>
</div>
<div id="agent-list-panel">
<button class="close-btn" onclick="document.getElementById('agent-list-panel').classList.remove('visible')">&times;</button>
<h3 style="font-size:14px;margin:0 0 4px;color:#fff">Population</h3>
<div class="sort-btns">
<button class="sort-btn active" onclick="setAgentSort('name')">Name</button>
<button class="sort-btn" onclick="setAgentSort('age')">Age</button>
<button class="sort-btn" onclick="setAgentSort('age-desc')">Age ↓</button>
<button class="sort-btn" onclick="setAgentSort('location')">Location</button>
</div>
<div id="agent-list-content"></div>
</div>
<div id="zoom-hint">Scroll to zoom &middot; Drag to orbit &middot; Right-drag to pan &middot; Click to inspect</div>
<div id="controls-bar">
<button class="ctrl-btn" onclick="resetCamera()" title="Reset view">&#8962;</button>
<button class="ctrl-btn" onclick="toggleTopDown()" title="Top-down view">&#9650;</button>
<button class="ctrl-btn" onclick="zoomIn()" title="Zoom in">+</button>
<button class="ctrl-btn" onclick="zoomOut()" title="Zoom out">&minus;</button>
<button class="ctrl-btn" id="btn-fp" onclick="window._toggleFP()" title="First-person view">&#128065;</button>
<button class="ctrl-btn" id="btn-vr" style="display:none" title="Enter VR">VR</button>
<button class="ctrl-btn" id="btn-join" onclick="window._showJoin()" title="Join as player">JOIN</button>
<button class="ctrl-btn" onclick="toggleAgentList()" title="Agent list">&#128101;</button>
</div>
<div id="fp-crosshair" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;z-index:50">
<svg width="24" height="24"><circle cx="12" cy="12" r="3" fill="none" stroke="rgba(255,255,255,0.6)" stroke-width="1"/><line x1="12" y1="4" x2="12" y2="8" stroke="rgba(255,255,255,0.4)" stroke-width="1"/><line x1="12" y1="16" x2="12" y2="20" stroke="rgba(255,255,255,0.4)" stroke-width="1"/><line x1="4" y1="12" x2="8" y2="12" stroke="rgba(255,255,255,0.4)" stroke-width="1"/><line x1="16" y1="12" x2="20" y2="12" stroke="rgba(255,255,255,0.4)" stroke-width="1"/></svg>
</div>
<div id="fp-hint" style="display:none;position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.7);color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;z-index:50;text-align:center">
WASD — move &middot; Mouse — look &middot; E — talk to NPC &middot; ESC — exit
</div>
<div id="fp-interact-prompt" style="display:none;position:fixed;top:55%;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#4ecca3;padding:8px 18px;border-radius:8px;font-size:14px;z-index:55;pointer-events:none;border:1px solid rgba(78,204,163,0.3)">
Press <b>E</b> to talk
</div>
<div id="fp-click-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:250;cursor:pointer;display:none;align-items:center;justify-content:center">
<div style="text-align:center;color:#fff">
<div style="font-size:48px;margin-bottom:16px">&#128065;</div>
<div style="font-size:20px;font-weight:300;letter-spacing:2px">CLICK TO ENTER FIRST-PERSON</div>
<div style="font-size:13px;color:#aaa;margin-top:8px">WASD — move &middot; Mouse — look &middot; ESC — exit</div>
</div>
</div>
<div id="player-login">
<h2>JOIN SOCI CITY</h2>
<input id="player-name" type="text" placeholder="Your name..." maxlength="20">
<select id="player-gender"><option value="male">Male</option><option value="female">Female</option></select>
<select id="player-age">
<option value="20">20</option><option value="25">25</option><option value="30">30</option>
<option value="35">35</option><option value="40">40</option>
</select>
<br>
<button onclick="window._joinCity()">Enter City</button>
<button onclick="document.getElementById('player-login').style.display='none'" style="background:transparent;color:#888;border:1px solid rgba(255,255,255,0.15);margin-left:8px">Cancel</button>
</div>
<div id="player-hud">
<div class="phud-name" id="phud-name"></div>
<div id="phud-stats"></div>
</div>
<div id="npc-chat">
<div class="chat-header">
<span class="chat-name" id="chat-npc-name">NPC</span>
<button class="chat-close" onclick="endNpcChat()">&times;</button>
</div>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input">
<input type="text" id="chat-text" placeholder="Type or press mic..." autocomplete="off">
<button class="mic-btn" id="chat-mic" onclick="toggleMic()" title="Speech input">&#127908;</button>
<button onclick="sendChatMessage()">Send</button>
</div>
</div>
<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>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
// ============================================================
// CONSTANTS
// ============================================================
const WORLD_SIZE = 130;
const HALF = WORLD_SIZE / 2;
const PALETTE = {
ground: 0x5a9a5a,
groundAlt: 0x4e8e4e,
road: 0x444444,
roadLine: 0x666666,
sidewalk: 0x999988,
water: 0x4488bb,
sky: 0x87ceeb,
skyNight: 0x0a0a1e,
fog: 0x87ceeb,
fogNight: 0x0a0a1e,
};
const BLDG_COLORS = {
house: [0xf2dc7c, 0xf5c9a0, 0xe8d4a0, 0xd4e8c0, 0xf0c8a0, 0xc8d8e8],
roof: [0xcc6644, 0xbb5533, 0x8b6040, 0x996644, 0xa87050, 0x885530],
shop: [0x5bb8a9, 0xe88a7a, 0x6fa8d6, 0xd4a76a, 0xc78ab8, 0xa8c878],
apartment: [0xd8c8a8, 0xc8b898, 0xb8a888, 0xccc0b0],
office: 0x88b8d8,
tower: 0x6898c0,
hospital: 0xf0f0f0,
church: 0xc8b898,
school: 0xcc7858,
factory: 0x787878,
cinema: 0x8b3050,
park: 0x4a8a4a,
sports: 0x3a7a3a,
square: 0xaaa090,
police: 0x3a5a8a,
fire: 0xcc3333,
museum: 0xd8c8a0,
mall: [0xc0a080, 0xb0c8d8, 0xd0b8a0],
townhall: 0xc8b898,
market: [0xe8a040, 0xd09030, 0xc88030],
window: 0xa8d8f0,
windowLit: 0xffd860,
door: 0x6b4020,
tree: [0x2a7a2a, 0x3a8a3a, 0x4a9a4a, 0x358a35],
trunk: 0x6b4020,
};
const AGENT_COLORS = [
0xe94560, 0x4ecca3, 0xf0c040, 0x4e9eca, 0x9b59b6,
0xe67e22, 0x1abc9c, 0xe74c3c, 0x3498db, 0x2ecc71,
0xf39c12, 0x8e44ad, 0x16a085, 0xc0392b, 0x2980b9,
0x27ae60, 0xd35400, 0x7d3c98, 0x148f77, 0xcb4335,
];
const SKIN_COLORS = [0xf5d0a0, 0xe8c090, 0xd4a878, 0xc49060, 0xa07040, 0x805830];
const HAIR_COLORS = [0x2a1a0a, 0x4a3020, 0x1a1a1a, 0x8a6a40, 0xd4a460, 0x6a2a1a, 0xaaaaaa];
const PANTS_COLORS = [0x2a3a5a, 0x3a3a3a, 0x4a3a2a, 0x2a2a3a, 0x5a4a3a, 0x28283a];
const SKIRT_COLORS = [0xcc6688, 0x6688aa, 0xaa8866, 0x88aa66, 0xcc8844, 0x9966aa];
const SHOE_COLORS = [0x2a2a2a, 0x3a2a1a, 0x1a1a1a, 0x4a3020];
const SKY_PHASES = {
dawn: { top:'#2d1b4e', mid:'#a06840', bot:'#e8a860' },
morning: { top:'#4a90c8', mid:'#88b8d8', bot:'#c8e0f0' },
afternoon: { top:'#2878b8', mid:'#60a0d0', bot:'#88c8e8' },
evening: { top:'#1a1040', mid:'#884060', bot:'#e07840' },
night: { top:'#0e1530', mid:'#162040', bot:'#1e2850' },
};
const LOCATION_POSITIONS = {
// ── Residential — houses ──────────────────────────────────
house_elena: { x: 0.08, y: 0.19, type: 'house', label: 'Elena & Lila' },
house_marcus: { x: 0.24, y: 0.19, type: 'house', label: 'Marcus & Zoe' },
house_helen: { x: 0.62, y: 0.19, type: 'house', label: 'Helen & Alice' },
house_diana: { x: 0.78, y: 0.19, type: 'house', label: 'Diana & Marco' },
house_kai: { x: 0.08, y: 0.36, type: 'house', label: "Kai's Studio" },
house_james: { x: 0.08, y: 0.65, type: 'house', label: 'James & Theo' },
house_rosa: { x: 0.24, y: 0.65, type: 'house', label: 'Rosa & Omar' },
house_yuki: { x: 0.62, y: 0.65, type: 'house', label: 'Yuki & Devon' },
house_frank: { x: 0.78, y: 0.65, type: 'house', label: 'Frank+George+Sam' },
house_ada: { x: 0.16, y: 0.12, type: 'house', label: 'Ada & Ben' },
house_carlos: { x: 0.70, y: 0.12, type: 'house', label: 'Carlos & Mia' },
house_dara: { x: 0.16, y: 0.72, type: 'house', label: 'Dara & Leo' },
house_sven: { x: 0.70, y: 0.72, type: 'house', label: 'Sven & Hana' },
house_ivan: { x: 0.86, y: 0.12, type: 'house', label: 'Ivan & Vera' },
house_nadia: { x: 0.86, y: 0.72, type: 'house', label: 'Nadia & Rami' },
house_petra: { x: 0.08, y: 0.88, type: 'house', label: 'Petra & Tom' },
house_ling: { x: 0.24, y: 0.88, type: 'house', label: 'Ling & Jun' },
// ── Residential — apartments ──────────────────────────────
apartment_block_1: { x: 0.40, y: 0.19, type: 'apartment', label: 'Northside Apts' },
apartment_block_2: { x: 0.40, y: 0.65, type: 'apartment', label: 'Southside Apts' },
apartment_block_3: { x: 0.56, y: 0.50, type: 'apartment', label: 'Central Apts' },
apt_northeast: { x: 0.93, y: 0.22, type: 'apartment', label: 'Eastview Terrace' },
apt_northwest: { x: 0.07, y: 0.22, type: 'apartment', label: 'Hilltop Gardens' },
apt_southeast: { x: 0.93, y: 0.78, type: 'apartment', label: 'Riverside Commons' },
apt_southwest: { x: 0.07, y: 0.78, type: 'apartment', label: 'Orchard Hill Flats' },
apt_midtown: { x: 0.50, y: 0.65, type: 'apartment', label: 'Midtown Residence' },
apt_heights: { x: 0.35, y: 0.12, type: 'apartment', label: 'Sunrise Heights' },
apt_plaza: { x: 0.56, y: 0.12, type: 'apartment', label: 'Plaza Apartments' },
apt_harbor: { x: 0.62, y: 0.88, type: 'apartment', label: 'Harbor View' },
// ── Shops & food ──────────────────────────────────────────
cafe: { x: 0.35, y: 0.34, type: 'shop', label: 'The Daily Grind' },
grocery: { x: 0.65, y: 0.34, type: 'shop', label: 'Green Basket' },
bakery: { x: 0.22, y: 0.34, type: 'shop', label: 'Golden Crust' },
restaurant: { x: 0.35, y: 0.50, type: 'shop', label: "Mama Rosa's" },
bar: { x: 0.65, y: 0.50, type: 'shop', label: 'Rusty Anchor' },
gym: { x: 0.22, y: 0.50, type: 'shop', label: 'Iron & Grit' },
library: { x: 0.78, y: 0.78, type: 'shop', label: 'Public Library' },
diner: { x: 0.92, y: 0.36, type: 'shop', label: 'Blue Moon Diner' },
pharmacy: { x: 0.35, y: 0.78, type: 'shop', label: 'SociMed Pharmacy' },
bookshop: { x: 0.44, y: 0.34, type: 'shop', label: 'Chapter One' },
florist: { x: 0.56, y: 0.34, type: 'shop', label: 'Petal & Stem' },
barbershop: { x: 0.22, y: 0.42, type: 'shop', label: "Nick's Cuts" },
pizzeria: { x: 0.78, y: 0.42, type: 'shop', label: 'Napoli Slice' },
coffeehouse: { x: 0.56, y: 0.78, type: 'shop', label: 'Bean & Leaf' },
sushi_bar: { x: 0.44, y: 0.78, type: 'shop', label: 'Sakura Sushi' },
laundry: { x: 0.86, y: 0.42, type: 'shop', label: 'Clean Spin' },
electronics: { x: 0.44, y: 0.42, type: 'shop', label: 'TechZone' },
pet_shop: { x: 0.56, y: 0.42, type: 'shop', label: 'Paws & Claws' },
// ── Market stalls (open-air) ──────────────────────────────
market: { x: 0.50, y: 0.78, type: 'market', label: 'Farmers Market' },
// ── Mall ──────────────────────────────────────────────────
mall: { x: 0.78, y: 0.88, type: 'mall', label: 'City Mall' },
// ── Offices & towers ──────────────────────────────────────
office: { x: 0.50, y: 0.34, type: 'office', label: 'The Hive' },
office_tower: { x: 0.80, y: 0.34, type: 'tower', label: 'Pinnacle Tower' },
office_tech: { x: 0.65, y: 0.42, type: 'office', label: 'TechCorp HQ' },
office_media: { x: 0.35, y: 0.42, type: 'office', label: 'Media House' },
tower_2: { x: 0.92, y: 0.19, type: 'tower', label: 'Atlas Tower' },
// ── Industry ──────────────────────────────────────────────
factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' },
factory_2: { x: 0.92, y: 0.88, type: 'factory', label: 'Soci Textiles' },
// ── Public services ───────────────────────────────────────
school: { x: 0.08, y: 0.50, type: 'school', label: 'Soci School' },
hospital: { x: 0.92, y: 0.50, type: 'hospital', label: 'City Hospital' },
church: { x: 0.08, y: 0.78, type: 'church', label: "St. Mary's" },
police: { x: 0.22, y: 0.58, type: 'police', label: 'Police Station' },
fire_station: { x: 0.78, y: 0.58, type: 'fire', label: 'Fire Station' },
townhall: { x: 0.44, y: 0.50, type: 'townhall', label: 'Town Hall' },
museum: { x: 0.22, y: 0.19, type: 'museum', label: 'City Museum' },
priya_house: { x: 0.93, y: 0.42, type: 'house', label: 'Priya & Nina' },
// ── Entertainment & leisure ───────────────────────────────
cinema: { x: 0.78, y: 0.50, type: 'cinema', label: 'Starlight Cinema' },
park: { x: 0.50, y: 0.19, type: 'park', label: 'Willow Park' },
town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' },
sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
park_east: { x: 0.86, y: 0.58, type: 'park', label: 'Rose Garden' },
park_south: { x: 0.40, y: 0.88, type: 'park', label: 'Lakeside Park' },
playground: { x: 0.15, y: 0.58, type: 'park', label: 'Kids Playground' },
// ── Additional public buildings ───────────────────────────
post_office: { x: 0.30, y: 0.58, type: 'office', label: 'Post Office' },
bank: { x: 0.60, y: 0.58, type: 'office', label: 'City Bank' },
court: { x: 0.44, y: 0.58, type: 'townhall', label: 'Courthouse' },
gallery: { x: 0.15, y: 0.34, type: 'museum', label: 'Art Gallery' },
daycare: { x: 0.15, y: 0.42, type: 'school', label: 'Sunny Daycare' },
vet_clinic: { x: 0.65, y: 0.58, type: 'hospital', label: 'Vet Clinic' },
yoga_studio: { x: 0.56, y: 0.58, type: 'shop', label: 'Zen Yoga' },
pub: { x: 0.70, y: 0.42, type: 'shop', label: "The Oak Pub" },
ice_cream: { x: 0.30, y: 0.42, type: 'shop', label: 'Scoop & Joy' },
taxi_stand: { x: 0.50, y: 0.42, type: 'shop', label: 'Taxi Stand' },
// ── Cemetery ────────────────────────────────────────────────
cemetery: { x: 0.93, y: 0.92, type: 'cemetery', label: 'Eternal Rest' },
kindergarten: { x: 0.30, y: 0.12, type: 'school', label: 'Rainbow Kids' },
university: { x: 0.70, y: 0.88, type: 'office', label: 'Soci University' },
// ── Streets ───────────────────────────────────────────────
street_main: { x: 0.50, y: 0.28, type: 'square', label: 'Main Street' },
street_west: { x: 0.15, y: 0.50, type: 'square', label: 'West Avenue' },
};
function toWorld(nx, ny) {
return { x: (nx - 0.5) * WORLD_SIZE, z: (ny - 0.5) * WORLD_SIZE };
}
// ============================================================
// SCENE SETUP
// ============================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(PALETTE.sky);
scene.fog = new THREE.FogExp2(PALETTE.fog, 0.004);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// Orthographic camera for isometric feel
const aspect = window.innerWidth / window.innerHeight;
let frustum = 45;
const camera = new THREE.OrthographicCamera(
-frustum * aspect, frustum * aspect,
frustum, -frustum, 0.1, 500
);
camera.position.set(55, 70, 55);
camera.lookAt(0, 0, 0);
camera.zoom = 1;
camera.updateProjectionMatrix();
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minZoom = 0.3;
controls.maxZoom = 12;
controls.maxPolarAngle = Math.PI / 2.2;
controls.minPolarAngle = 0.3;
controls.target.set(0, 0, 0);
controls.panSpeed = 1.2;
controls.zoomSpeed = 1.8;
controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
};
// ============================================================
// LIGHTING
// ============================================================
const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x4a7a38, 0.6);
scene.add(hemiLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffeedd, 1.2);
sunLight.position.set(30, 50, 20);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.left = -80;
sunLight.shadow.camera.right = 80;
sunLight.shadow.camera.top = 80;
sunLight.shadow.camera.bottom = -80;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 200;
sunLight.shadow.bias = -0.001;
scene.add(sunLight);
// ============================================================
// GROUND
// ============================================================
const groundGeo = new THREE.PlaneGeometry(WORLD_SIZE * 1.6, WORLD_SIZE * 1.6);
const groundMat = new THREE.MeshStandardMaterial({
color: PALETTE.ground, roughness: 0.95, metalness: 0,
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.05;
ground.receiveShadow = true;
scene.add(ground);
// Subtle grid
const gridHelper = new THREE.GridHelper(WORLD_SIZE * 1.2, 60, 0x3a6a3a, 0x3a6a3a);
gridHelper.material.opacity = 0.15;
gridHelper.material.transparent = true;
gridHelper.position.y = 0.01;
scene.add(gridHelper);
// ============================================================
// ROADS
// ============================================================
function createRoad(x1, z1, x2, z2, width = 3) {
const dx = x2 - x1, dz = z2 - z1;
const len = Math.sqrt(dx * dx + dz * dz);
const angle = Math.atan2(dx, dz);
const geo = new THREE.BoxGeometry(width, 0.08, len);
const mat = new THREE.MeshStandardMaterial({ color: PALETTE.road, roughness: 0.9 });
const road = new THREE.Mesh(geo, mat);
road.position.set((x1 + x2) / 2, 0.04, (z1 + z2) / 2);
road.rotation.y = angle;
road.receiveShadow = true;
scene.add(road);
// Center line
const lineGeo = new THREE.BoxGeometry(0.15, 0.09, len - 1);
const lineMat = new THREE.MeshStandardMaterial({ color: PALETTE.roadLine, roughness: 0.8 });
const line = new THREE.Mesh(lineGeo, lineMat);
line.position.copy(road.position);
line.position.y = 0.09;
line.rotation.y = angle;
scene.add(line);
}
// Main roads
const ns = toWorld(0.5, 0);
createRoad(0, -HALF * 1.1, 0, HALF * 1.1, 4); // N-S main
createRoad(-HALF * 1.1, -7, HALF * 1.1, -7, 4); // E-W main
// Secondary horizontal roads
createRoad(-HALF * 1.1, -38, HALF * 1.1, -38, 2.5); // far north
createRoad(-HALF * 1.1, -31, HALF * 1.1, -31, 2.5); // north row
createRoad(-HALF * 1.1, -16, HALF * 1.1, -16, 2.5); // upper-mid
createRoad(-HALF * 1.1, 0, HALF * 1.1, 0, 2.5); // center
createRoad(-HALF * 1.1, 8, HALF * 1.1, 8, 2); // inner mid
createRoad(-HALF * 1.1, 15, HALF * 1.1, 15, 2.5); // lower-mid
createRoad(-HALF * 1.1, 28, HALF * 1.1, 28, 2.5); // south row
createRoad(-HALF * 1.1, 38, HALF * 1.1, 38, 2.5); // far south
// Secondary vertical roads
createRoad(-42, -HALF * 1.1, -42, HALF * 1.1, 2.5);
createRoad(-33, -HALF * 1.1, -33, HALF * 1.1, 2);
createRoad(-26, -HALF * 1.1, -26, HALF * 1.1, 2.5);
createRoad(-12, -HALF * 1.1, -12, HALF * 1.1, 2);
createRoad(6, -HALF * 1.1, 6, HALF * 1.1, 2);
createRoad(15, -HALF * 1.1, 15, HALF * 1.1, 2.5);
createRoad(28, -HALF * 1.1, 28, HALF * 1.1, 2);
createRoad(35, -HALF * 1.1, 35, HALF * 1.1, 2.5);
createRoad(42, -HALF * 1.1, 42, HALF * 1.1, 2.5);
// Extra streets for expanded city
createRoad(-HALF * 1.1, -48, HALF * 1.1, -48, 2); // far far north
createRoad(-HALF * 1.1, 48, HALF * 1.1, 48, 2); // far far south
createRoad(-52, -HALF * 1.1, -52, HALF * 1.1, 2); // far west
createRoad(52, -HALF * 1.1, 52, HALF * 1.1, 2); // far east
// ============================================================
// MATERIAL HELPERS
// ============================================================
function mat(color, opts = {}) {
return new THREE.MeshStandardMaterial({
color: color ?? 0x888888, roughness: opts.roughness ?? 0.85,
metalness: opts.metalness ?? 0.05,
flatShading: true, ...opts
});
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return h >>> 0; }
function addEdges(mesh, color = 0x000000, opacity = 0.1) {
const edges = new THREE.EdgesGeometry(mesh.geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color, opacity, transparent: true }));
mesh.add(line);
}
// ============================================================
// TREE GENERATOR
// ============================================================
function createTree(x, z, scale = 1) {
const group = new THREE.Group();
const trunkH = 1.5 * scale;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.2 * scale, 0.3 * scale, trunkH, 6),
mat(BLDG_COLORS.trunk)
);
trunk.position.y = trunkH / 2;
trunk.castShadow = true;
group.add(trunk);
const foliage = new THREE.Mesh(
new THREE.ConeGeometry(1.2 * scale, 2.5 * scale, 7),
mat(pick(BLDG_COLORS.tree))
);
foliage.position.y = trunkH + 1.0 * scale;
foliage.castShadow = true;
group.add(foliage);
// Second layer
const f2 = new THREE.Mesh(
new THREE.ConeGeometry(0.9 * scale, 2 * scale, 7),
mat(pick(BLDG_COLORS.tree))
);
f2.position.y = trunkH + 2.2 * scale;
f2.castShadow = true;
group.add(f2);
group.position.set(x, 0, z);
scene.add(group);
return group;
}
// ============================================================
// BUILDING GENERATORS
// ============================================================
const buildingMeshes = new Map();
const labelSprites = new Map();
let isNight = false;
function createLabel(text, parent, yOffset = 0) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256;
canvas.height = 64;
ctx.fillStyle = 'rgba(0,0,0,0)';
ctx.fillRect(0, 0, 256, 64);
ctx.font = 'bold 22px Segoe UI, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillText(text, 129, 33);
ctx.fillStyle = '#ffffff';
ctx.fillText(text, 128, 32);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(8, 2, 1);
sprite.position.y = yOffset;
sprite.renderOrder = 999;
parent.add(sprite);
return sprite;
}
function createOccupantBadge(parent, yOffset) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(1.5, 1.5, 1);
sprite.position.y = yOffset + 2;
sprite.renderOrder = 1000;
sprite.visible = false;
parent.add(sprite);
return { sprite, canvas, tex };
}
function updateBadge(badge, count) {
if (count <= 0) { badge.sprite.visible = false; return; }
badge.sprite.visible = true;
const ctx = badge.canvas.getContext('2d');
ctx.clearRect(0, 0, 64, 64);
ctx.beginPath();
ctx.arc(32, 32, 24, 0, Math.PI * 2);
ctx.fillStyle = '#e94560';
ctx.fill();
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.fillText(count, 32, 40);
badge.tex.needsUpdate = true;
}
function createHouse(id, locData) {
const group = new THREE.Group();
const h = hash(id);
const variant = h % 4;
const w = 3 + (h % 2), d = 3 + ((h >> 2) % 2), wallH = 2.5 + (h % 3) * 0.3;
const wallColor = BLDG_COLORS.house[h % BLDG_COLORS.house.length];
const roofColor = BLDG_COLORS.roof[h % BLDG_COLORS.roof.length];
const trimColor = ((wallColor & 0xfefefe) >> 1) + 0x404040;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(wallColor));
walls.position.y = wallH / 2;
walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls, 0x000000, 0.08);
group.add(walls);
const roofH = 1.5 + (variant === 2 ? 0.5 : 0);
if (variant === 1) {
const roofGeo = new THREE.BufferGeometry();
const hw = w / 2 + 0.25, hd = d / 2 + 0.25, rh = roofH;
const v = new Float32Array([
-hw,wallH,-hd, hw,wallH,-hd, 0,wallH+rh,-hd,
-hw,wallH,hd, hw,wallH,hd, 0,wallH+rh,hd,
-hw,wallH,-hd, -hw,wallH,hd, 0,wallH+rh,-hd, 0,wallH+rh,hd,
hw,wallH,-hd, hw,wallH,hd, 0,wallH+rh,-hd, 0,wallH+rh,hd,
]);
roofGeo.setAttribute('position', new THREE.BufferAttribute(v, 3));
roofGeo.setIndex([0,1,2, 3,5,4, 2,3,0, 2,5,3, 1,4,5, 1,5,2]);
roofGeo.computeVertexNormals();
const roof = new THREE.Mesh(roofGeo, mat(roofColor));
roof.castShadow = true; group.add(roof);
} else {
const roofGeo = new THREE.ConeGeometry(Math.max(w, d) * 0.75, roofH, 4);
roofGeo.rotateY(Math.PI / 4);
const roof = new THREE.Mesh(roofGeo, mat(roofColor));
roof.position.y = wallH + roofH / 2;
roof.castShadow = true; group.add(roof);
}
if (variant !== 3) {
const chimW = 0.35, chimH = 1.2 + (h % 3) * 0.3;
const chimney = new THREE.Mesh(new THREE.BoxGeometry(chimW, chimH, chimW), mat(0x884422));
chimney.position.set(w * 0.25, wallH + roofH * 0.5 + chimH / 2, -d * 0.2);
chimney.castShadow = true; group.add(chimney);
const chimCap = new THREE.Mesh(new THREE.BoxGeometry(chimW + 0.1, 0.08, chimW + 0.1), mat(0x666666));
chimCap.position.set(w * 0.25, wallH + roofH * 0.5 + chimH + 0.04, -d * 0.2);
group.add(chimCap);
}
const porchW = w * 0.6, porchD = 0.8;
const porchRoof = new THREE.Mesh(new THREE.BoxGeometry(porchW + 0.3, 0.08, porchD + 0.3), mat(roofColor));
porchRoof.position.set(0, wallH * 0.78, d / 2 + porchD / 2 + 0.1);
porchRoof.castShadow = true; group.add(porchRoof);
for (let s of [-1, 1]) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, wallH * 0.78, 6), mat(0xf0f0f0));
post.position.set(s * porchW / 2, wallH * 0.39, d / 2 + porchD);
group.add(post);
}
const step = new THREE.Mesh(new THREE.BoxGeometry(porchW, 0.12, porchD), mat(0xc8b8a0));
step.position.set(0, 0.06, d / 2 + porchD / 2 + 0.1);
step.receiveShadow = true; group.add(step);
const door = new THREE.Mesh(new THREE.BoxGeometry(0.6, 1.2, 0.1), mat(BLDG_COLORS.door));
door.position.set(0, 0.6, d / 2 + 0.05);
door.userData.isDoor = true; group.add(door);
const knob = new THREE.Mesh(new THREE.SphereGeometry(0.04, 6, 4), mat(0xccaa44, { metalness: 0.6 }));
knob.position.set(0.2, 0.55, d / 2 + 0.11); group.add(knob);
const winMat = mat(isNight ? BLDG_COLORS.windowLit : BLDG_COLORS.window,
isNight ? { emissive: BLDG_COLORS.windowLit, emissiveIntensity: 0.5 } : {});
for (let side of [-1, 1]) {
const win = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.5, 0.1), winMat);
win.position.set(side * (w / 2 + 0.05), wallH * 0.6, 0);
win.rotation.y = Math.PI / 2;
win.userData.isWindow = true; group.add(win);
for (let sh of [-0.35, 0.35]) {
const shutter = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.55, 0.06), mat(trimColor));
shutter.position.set(side * (w / 2 + 0.07), wallH * 0.6, sh);
group.add(shutter);
}
}
const frontWin = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.1), winMat);
frontWin.position.set(w * 0.28, wallH * 0.6, d / 2 + 0.05);
frontWin.userData.isWindow = true; group.add(frontWin);
if (variant === 3) {
const bayW = 1.2, bayD = 0.6, bayH = wallH * 0.7;
const bay = new THREE.Mesh(new THREE.BoxGeometry(bayW, bayH, bayD), mat(wallColor));
bay.position.set(-w * 0.3, bayH / 2, d / 2 + bayD / 2);
bay.castShadow = true; group.add(bay);
const bayRoof = new THREE.Mesh(new THREE.BoxGeometry(bayW + 0.15, 0.08, bayD + 0.15), mat(roofColor));
bayRoof.position.set(-w * 0.3, bayH + 0.04, d / 2 + bayD / 2); group.add(bayRoof);
for (let bx of [-0.35, 0, 0.35]) {
const bw = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.4, 0.06), winMat);
bw.position.set(-w * 0.3 + bx, bayH * 0.6, d / 2 + bayD + 0.03);
bw.userData.isWindow = true; group.add(bw);
}
}
const bed = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.15, 0.7), mat(0x8b6040));
bed.position.set(0.6, 0.08, 0); bed.userData.isFurniture = true; group.add(bed);
const mattress = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 0.6), mat(0xe8e0d0));
mattress.position.set(0.6, 0.18, 0); mattress.userData.isFurniture = true; group.add(mattress);
const pillow = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.08, 0.35), mat(0xf0f0f0));
pillow.position.set(1.1, 0.24, 0); pillow.userData.isFurniture = true; group.add(pillow);
const label = createLabel(locData.label, group, wallH + roofH + 1.5);
const badge = createOccupantBadge(group, wallH + roofH + 0.5);
group.userData = { id, type: 'house', label, badge, locData };
return group;
}
function createApartment(id, locData) {
const group = new THREE.Group();
const h = hash(id);
const variant = h % 3;
const w = 5, d = 4, wallH = 7 + (h % 4);
const wallColor = BLDG_COLORS.apartment[h % BLDG_COLORS.apartment.length];
const accentColor = wallColor - 0x181818;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(wallColor));
walls.position.y = wallH / 2;
walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
if (variant === 1 && wallH >= 9) {
const setW = w - 1, setD = d - 0.5, setH = 2.5;
const upper = new THREE.Mesh(new THREE.BoxGeometry(setW, setH, setD), mat(wallColor + 0x080808));
upper.position.y = wallH + setH / 2; upper.castShadow = true; addEdges(upper); group.add(upper);
const upperCap = new THREE.Mesh(new THREE.BoxGeometry(setW + 0.2, 0.2, setD + 0.2), mat(accentColor));
upperCap.position.y = wallH + setH; group.add(upperCap);
}
const corniceMat = mat(accentColor);
const roofCap = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4), corniceMat);
roofCap.position.y = wallH; group.add(roofCap);
const midCornice = new THREE.Mesh(new THREE.BoxGeometry(w + 0.15, 0.12, d + 0.15), corniceMat);
midCornice.position.y = wallH * 0.5; group.add(midCornice);
const baseCornice = new THREE.Mesh(new THREE.BoxGeometry(w + 0.2, 0.2, d + 0.2), corniceMat);
baseCornice.position.y = 0.1; group.add(baseCornice);
const entranceW = 2.5, entranceD = 1.2;
const canopy = new THREE.Mesh(new THREE.BoxGeometry(entranceW, 0.1, entranceD), mat(accentColor - 0x101010));
canopy.position.set(0, 2.8, d / 2 + entranceD / 2); canopy.castShadow = true; group.add(canopy);
const eDoor = new THREE.Mesh(new THREE.BoxGeometry(1.4, 2.2, 0.1), mat(0x3a3a5a));
eDoor.position.set(0, 1.1, d / 2 + 0.05); eDoor.userData.isDoor = true; group.add(eDoor);
const eGlass = new THREE.Mesh(new THREE.BoxGeometry(1.0, 1.5, 0.06),
mat(BLDG_COLORS.window, { transparent: true, opacity: 0.5, roughness: 0.2 }));
eGlass.position.set(0, 1.3, d / 2 + 0.08); eGlass.userData.isWindow = true; group.add(eGlass);
const winMat = mat(isNight ? BLDG_COLORS.windowLit : BLDG_COLORS.window,
isNight ? { emissive: BLDG_COLORS.windowLit, emissiveIntensity: 0.4 } : {});
const floors = Math.floor(wallH / 2);
const balconyFloors = variant === 0 ? [1, 3] : variant === 2 ? [0, 2, 4] : [2];
for (let f = 0; f < floors; f++) {
const fy = 1.5 + f * 2;
for (let wx = -1; wx <= 1; wx++) {
const win = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.7, 0.1), winMat);
win.position.set(wx * 1.4, fy, d / 2 + 0.05);
win.userData.isWindow = true; group.add(win);
const sill = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.06, 0.12), corniceMat);
sill.position.set(wx * 1.4, fy - 0.38, d / 2 + 0.08); group.add(sill);
}
if (balconyFloors.includes(f) && f > 0) {
const balcW = w * 0.85, balcD = 0.7;
const balcFloor = new THREE.Mesh(new THREE.BoxGeometry(balcW, 0.1, balcD), mat(0xa0a0a0));
balcFloor.position.set(0, fy - 0.45, d / 2 + balcD / 2 + 0.05); group.add(balcFloor);
const railMat = mat(0x444444, { metalness: 0.3 });
const rail = new THREE.Mesh(new THREE.BoxGeometry(balcW, 0.04, 0.04), railMat);
rail.position.set(0, fy - 0.05, d / 2 + balcD + 0.05); group.add(rail);
for (let rx = -balcW / 2 + 0.15; rx <= balcW / 2; rx += 0.3) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.35, 0.02), railMat);
bar.position.set(rx, fy - 0.25, d / 2 + balcD + 0.05); group.add(bar);
}
}
for (let side of [-1, 1]) {
const sWin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.6, 0.45), winMat);
sWin.position.set(side * (w / 2 + 0.05), fy, 0);
sWin.userData.isWindow = true; group.add(sWin);
}
}
if (variant === 2) {
for (let rx = -1.5; rx <= 1.5; rx += 3) {
const roofBox = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.8), mat(0x888888));
roofBox.position.set(rx, wallH + 0.6, 0); group.add(roofBox);
}
}
for (let bx = -1; bx <= 1; bx += 2) {
const bed = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.12, 0.5), mat(0x8b6040));
bed.position.set(bx * 1.2, 0.06, 0); bed.userData.isFurniture = true; group.add(bed);
const mattObj = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.08, 0.45), mat(0xe0d8c8));
mattObj.position.set(bx * 1.2, 0.14, 0); mattObj.userData.isFurniture = true; group.add(mattObj);
}
const totalH = variant === 1 && wallH >= 9 ? wallH + 2.5 : wallH;
const label = createLabel(locData.label, group, totalH + 2);
const badge = createOccupantBadge(group, totalH + 1);
group.userData = { id, type: 'apartment', label, badge, locData };
return group;
}
function createShop(id, locData) {
const group = new THREE.Group();
const h = hash(id);
const w = 4, d = 3.5, wallH = 3;
const wallColor = BLDG_COLORS.shop[h % BLDG_COLORS.shop.length];
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(wallColor));
walls.position.y = wallH / 2;
walls.castShadow = true;
walls.receiveShadow = true;
addEdges(walls);
group.add(walls);
// Flat roof
const roofCap = new THREE.Mesh(new THREE.BoxGeometry(w + 0.3, 0.2, d + 0.3), mat(0x666666));
roofCap.position.y = wallH;
group.add(roofCap);
// Awning
const awning = new THREE.Mesh(
new THREE.BoxGeometry(w + 0.5, 0.08, 1.2),
mat(wallColor + 0x101010, { roughness: 0.6 })
);
awning.position.set(0, wallH * 0.75, d / 2 + 0.6);
awning.castShadow = true;
group.add(awning);
// Storefront window
const storefront = new THREE.Mesh(
new THREE.BoxGeometry(w * 0.7, wallH * 0.5, 0.1),
mat(BLDG_COLORS.window, { roughness: 0.3, metalness: 0.1 })
);
storefront.position.set(0, wallH * 0.35, d / 2 + 0.05);
storefront.userData.isWindow = true;
group.add(storefront);
const label = createLabel(locData.label, group, wallH + 2);
const badge = createOccupantBadge(group, wallH + 1);
group.userData = { id, type: 'shop', label, badge, locData };
return group;
}
function createOffice(id, locData) {
const group = new THREE.Group();
const w = 5, d = 5, wallH = 6;
const frameMat = mat(0x555565, { metalness: 0.4, roughness: 0.3 });
const core = new THREE.Mesh(
new THREE.BoxGeometry(w - 0.3, wallH, d - 0.3),
mat(0x404050, { roughness: 0.4, metalness: 0.2 })
);
core.position.y = wallH / 2; core.castShadow = true; core.receiveShadow = true;
group.add(core);
const glassMat = mat(BLDG_COLORS.window, { roughness: 0.1, metalness: 0.4, opacity: 0.55, transparent: true });
for (let face = 0; face < 4; face++) {
const fw = face % 2 === 0 ? w : d;
const fd = face % 2 === 0 ? d : w;
const faceGroup = new THREE.Group();
const floors = 3;
for (let f = 0; f < floors; f++) {
const fy = 0.8 + f * 1.8;
const band = new THREE.Mesh(new THREE.BoxGeometry(fw + 0.05, 0.08, 0.08), frameMat);
band.position.set(0, fy - 0.15, fd / 2 + 0.02); faceGroup.add(band);
for (let gx = -1; gx <= 1; gx++) {
const pane = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.28, 1.4, 0.06), glassMat);
pane.position.set(gx * (fw * 0.3), fy + 0.55, fd / 2 + 0.04);
pane.userData.isWindow = true; faceGroup.add(pane);
}
for (let mx = -1.5; mx <= 1.5; mx++) {
const mullion = new THREE.Mesh(new THREE.BoxGeometry(0.06, 1.5, 0.06), frameMat);
mullion.position.set(mx * (fw * 0.2), fy + 0.55, fd / 2 + 0.05); faceGroup.add(mullion);
}
}
const topBand = new THREE.Mesh(new THREE.BoxGeometry(fw + 0.05, 0.08, 0.08), frameMat);
topBand.position.set(0, wallH - 0.1, fd / 2 + 0.02); faceGroup.add(topBand);
faceGroup.rotation.y = (face * Math.PI) / 2;
group.add(faceGroup);
}
const roofCap = new THREE.Mesh(new THREE.BoxGeometry(w + 0.3, 0.25, d + 0.3), frameMat);
roofCap.position.y = wallH; group.add(roofCap);
const parapet = new THREE.Mesh(new THREE.BoxGeometry(w + 0.35, 0.5, d + 0.35),
mat(0x555565, { metalness: 0.3 }));
parapet.position.y = wallH + 0.25;
const parapetInner = new THREE.Mesh(new THREE.BoxGeometry(w - 0.1, 0.6, d - 0.1), mat(0x555565));
parapetInner.position.y = wallH + 0.25;
const parapetCSG = parapet; group.add(parapetCSG);
const lobbyW = 2, lobbyH = 2.5, lobbyD = 1;
const lobby = new THREE.Mesh(new THREE.BoxGeometry(lobbyW, lobbyH, lobbyD),
mat(0x88aacc, { transparent: true, opacity: 0.4, roughness: 0.1, metalness: 0.3 }));
lobby.position.set(0, lobbyH / 2, d / 2 + lobbyD / 2);
lobby.userData.isWindow = true; group.add(lobby);
const lobbyFrame = new THREE.Mesh(new THREE.BoxGeometry(lobbyW + 0.15, 0.1, lobbyD + 0.15), frameMat);
lobbyFrame.position.set(0, lobbyH, d / 2 + lobbyD / 2); group.add(lobbyFrame);
const label = createLabel(locData.label, group, wallH + 2);
const badge = createOccupantBadge(group, wallH + 1);
group.userData = { id, type: 'office', label, badge, locData };
return group;
}
function createTower(id, locData) {
const group = new THREE.Group();
const w = 4.5, d = 4.5, wallH = 14;
const towerMat = mat(BLDG_COLORS.tower, { roughness: 0.25, metalness: 0.35 });
const frameMat = mat(0x445566, { metalness: 0.5, roughness: 0.3 });
const glassMat = mat(BLDG_COLORS.window, { roughness: 0.1, metalness: 0.5, opacity: 0.5, transparent: true });
const base = new THREE.Mesh(new THREE.BoxGeometry(w + 1.5, 3.5, d + 1.5), mat(BLDG_COLORS.tower - 0x101010));
base.position.y = 1.75; base.castShadow = true; base.receiveShadow = true;
addEdges(base); group.add(base);
const baseLobby = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.8, 0.8),
mat(0x88bbdd, { transparent: true, opacity: 0.4, roughness: 0.1 }));
baseLobby.position.set(0, 1.4, (d + 1.5) / 2 + 0.3);
baseLobby.userData.isWindow = true; group.add(baseLobby);
const body = new THREE.Mesh(new THREE.BoxGeometry(w, wallH - 3, d), towerMat);
body.position.y = 3.5 + (wallH - 3) / 2; body.castShadow = true; body.receiveShadow = true;
addEdges(body); group.add(body);
for (let face = 0; face < 4; face++) {
const fw = w, fd = d;
const faceGrp = new THREE.Group();
for (let f = 0; f < 5; f++) {
const fy = 4.5 + f * 2;
const strip = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.85, 1.3, 0.08), glassMat);
strip.position.set(0, fy, fd / 2 + 0.04);
strip.userData.isWindow = true; faceGrp.add(strip);
for (let mx = -1; mx <= 1; mx++) {
const mull = new THREE.Mesh(new THREE.BoxGeometry(0.06, 1.4, 0.06), frameMat);
mull.position.set(mx * (fw * 0.28), fy, fd / 2 + 0.05); faceGrp.add(mull);
}
const hBar = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.88, 0.06, 0.06), frameMat);
hBar.position.set(0, fy + 0.65, fd / 2 + 0.05); faceGrp.add(hBar);
}
faceGrp.rotation.y = (face * Math.PI) / 2;
group.add(faceGrp);
}
const deckY = wallH - 0.5;
const deckW = w + 1.2, deckD = d + 1.2;
const deckFloor = new THREE.Mesh(new THREE.BoxGeometry(deckW, 0.2, deckD), frameMat);
deckFloor.position.y = deckY; group.add(deckFloor);
const railMat = mat(0x888888, { metalness: 0.4 });
for (let side of [-1, 1]) {
const railH = new THREE.Mesh(new THREE.BoxGeometry(deckW, 0.06, 0.06), railMat);
railH.position.set(0, deckY + 0.8, side * deckD / 2); group.add(railH);
const railS = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, deckD), railMat);
railS.position.set(side * deckW / 2, deckY + 0.8, 0); group.add(railS);
for (let i = -3; i <= 3; i++) {
const post = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.8, 0.04), railMat);
post.position.set(i * 0.8, deckY + 0.4, side * deckD / 2); group.add(post);
}
}
const crownH = 3;
const crownW = w * 0.7, crownD = d * 0.7;
const crown = new THREE.Mesh(new THREE.BoxGeometry(crownW, crownH, crownD), towerMat);
crown.position.y = wallH + crownH / 2; crown.castShadow = true;
addEdges(crown); group.add(crown);
for (let face = 0; face < 4; face++) {
const cfGrp = new THREE.Group();
const cg = new THREE.Mesh(new THREE.BoxGeometry(crownW * 0.7, crownH * 0.7, 0.06), glassMat);
cg.position.set(0, wallH + crownH / 2, crownD / 2 + 0.03);
cg.userData.isWindow = true; cfGrp.add(cg);
cfGrp.rotation.y = (face * Math.PI) / 2;
group.add(cfGrp);
}
const spireH = 4;
const spire = new THREE.Mesh(new THREE.ConeGeometry(0.4, spireH, 8), mat(0x888899, { metalness: 0.6 }));
spire.position.y = wallH + crownH + spireH / 2; group.add(spire);
const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6),
mat(0xff4444, { emissive: 0xff2222, emissiveIntensity: 0.6 }));
beacon.position.y = wallH + crownH + spireH + 0.15; group.add(beacon);
const label = createLabel(locData.label, group, wallH + crownH + spireH + 2);
const badge = createOccupantBadge(group, wallH + crownH + spireH + 1);
group.userData = { id, type: 'tower', label, badge, locData };
return group;
}
function createHospital(id, locData) {
const group = new THREE.Group();
const w = 7, d = 5, wallH = 5;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(BLDG_COLORS.hospital));
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const wingW = 3, wingD = 3, wingH = 3.5;
const wing = new THREE.Mesh(new THREE.BoxGeometry(wingW, wingH, wingD), mat(0xe8e8e8));
wing.position.set(-w / 2 + wingW / 2 - 0.5, wingH / 2, -d / 2 - wingD / 2 + 0.5);
wing.castShadow = true; wing.receiveShadow = true; addEdges(wing); group.add(wing);
const winMat = mat(isNight ? BLDG_COLORS.windowLit : BLDG_COLORS.window,
isNight ? { emissive: BLDG_COLORS.windowLit, emissiveIntensity: 0.3 } : {});
for (let f = 0; f < 2; f++) {
for (let wx = -2; wx <= 2; wx++) {
const win = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.1), winMat);
win.position.set(wx * 1.2, 1.5 + f * 2, d / 2 + 0.05);
win.userData.isWindow = true; group.add(win);
}
}
const crossMat = mat(0xcc2222);
const crossH = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.4, 0.1), crossMat);
crossH.position.set(0, wallH - 0.5, d / 2 + 0.06); group.add(crossH);
const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.5, 0.1), crossMat);
crossV.position.set(0, wallH - 0.5, d / 2 + 0.06); group.add(crossV);
const canopyW = 4, canopyD = 2.5;
const canopy = new THREE.Mesh(new THREE.BoxGeometry(canopyW, 0.12, canopyD), mat(0xdddddd));
canopy.position.set(0, 3, d / 2 + canopyD / 2); canopy.castShadow = true; group.add(canopy);
for (let s of [-1, 1]) {
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 3, 6), mat(0x888888));
pole.position.set(s * (canopyW / 2 - 0.3), 1.5, d / 2 + canopyD - 0.3); group.add(pole);
}
const emergSign = new THREE.Mesh(new THREE.BoxGeometry(2, 0.4, 0.08),
mat(0xcc2222, { emissive: 0xaa1111, emissiveIntensity: 0.5 }));
emergSign.position.set(0, 3.3, d / 2 + canopyD / 2); group.add(emergSign);
const roofCap = new THREE.Mesh(new THREE.BoxGeometry(w + 0.3, 0.25, d + 0.3), mat(0xcccccc));
roofCap.position.y = wallH; group.add(roofCap);
const heliR = 1.5;
const heliPad = new THREE.Mesh(new THREE.CylinderGeometry(heliR, heliR, 0.05, 16), mat(0xaaaaaa));
heliPad.position.set(w / 4, wallH + 0.15, 0); group.add(heliPad);
const heliCircle = new THREE.Mesh(
new THREE.RingGeometry(heliR - 0.15, heliR, 16),
mat(0xeeee22, { side: THREE.DoubleSide }));
heliCircle.rotation.x = -Math.PI / 2;
heliCircle.position.set(w / 4, wallH + 0.2, 0); group.add(heliCircle);
const hBar = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.04, 0.15), mat(0xeeee22));
hBar.position.set(w / 4, wallH + 0.2, 0); group.add(hBar);
const hBar2 = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.04, 1.2), mat(0xeeee22));
hBar2.position.set(w / 4, wallH + 0.2, 0); group.add(hBar2);
const label = createLabel(locData.label, group, wallH + 2);
const badge = createOccupantBadge(group, wallH + 1);
group.userData = { id, type: 'hospital', label, badge, locData };
return group;
}
function createChurch(id, locData) {
const group = new THREE.Group();
const w = 4, d = 6, wallH = 4.5;
const stoneMat = mat(BLDG_COLORS.church);
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), stoneMat);
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const roofGeo = new THREE.BufferGeometry();
const hw = w / 2 + 0.2, hd = d / 2 + 0.2, rh = 2.2;
const v = new Float32Array([
-hw,wallH,-hd, hw,wallH,-hd, 0,wallH+rh,-hd,
-hw,wallH,hd, hw,wallH,hd, 0,wallH+rh,hd,
-hw,wallH,-hd, -hw,wallH,hd, 0,wallH+rh,-hd, 0,wallH+rh,hd,
hw,wallH,-hd, hw,wallH,hd, 0,wallH+rh,-hd, 0,wallH+rh,hd,
]);
roofGeo.setAttribute('position', new THREE.BufferAttribute(v, 3));
roofGeo.setIndex([0,1,2, 3,5,4, 2,3,0, 2,5,3, 1,4,5, 1,5,2]);
roofGeo.computeVertexNormals();
const roof = new THREE.Mesh(roofGeo, mat(BLDG_COLORS.roof[0]));
roof.castShadow = true; group.add(roof);
for (let bz = -1.5; bz <= 1.5; bz += 1.5) {
for (let side of [-1, 1]) {
const buttress = new THREE.Mesh(new THREE.BoxGeometry(0.3, wallH * 0.8, 0.5), stoneMat);
buttress.position.set(side * (w / 2 + 0.15), wallH * 0.4, bz);
buttress.castShadow = true; group.add(buttress);
const buttTop = new THREE.Mesh(new THREE.ConeGeometry(0.25, 0.6, 4), stoneMat);
buttTop.position.set(side * (w / 2 + 0.15), wallH * 0.8 + 0.3, bz);
buttTop.rotation.y = Math.PI / 4; group.add(buttTop);
}
}
const roseR = 0.7;
const roseFrame = new THREE.Mesh(new THREE.TorusGeometry(roseR, 0.06, 8, 16),
mat(0x888878, { metalness: 0.3 }));
roseFrame.position.set(0, wallH * 0.7, d / 2 + 0.06); group.add(roseFrame);
const roseGlass = new THREE.Mesh(new THREE.CircleGeometry(roseR - 0.05, 16),
mat(0x4466aa, { transparent: true, opacity: 0.6, emissive: 0x223355, emissiveIntensity: 0.2, side: THREE.DoubleSide }));
roseGlass.position.set(0, wallH * 0.7, d / 2 + 0.07);
roseGlass.userData.isWindow = true; group.add(roseGlass);
for (let i = 0; i < 8; i++) {
const ang = (i / 8) * Math.PI * 2;
const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.03, roseR * 1.6, 0.03), mat(0x888878));
spoke.position.set(0, wallH * 0.7, d / 2 + 0.065);
spoke.rotation.z = ang; group.add(spoke);
}
const doorW = 1.2, doorH = 2;
const doorArch = new THREE.Mesh(new THREE.BoxGeometry(doorW, doorH, 0.15), mat(BLDG_COLORS.door));
doorArch.position.set(0, doorH / 2, d / 2 + 0.05); doorArch.userData.isDoor = true; group.add(doorArch);
const archTop = new THREE.Mesh(new THREE.CylinderGeometry(doorW / 2, doorW / 2, 0.15, 12, 1, false, 0, Math.PI),
mat(BLDG_COLORS.door));
archTop.rotation.x = Math.PI / 2; archTop.rotation.z = Math.PI;
archTop.position.set(0, doorH, d / 2 + 0.05); group.add(archTop);
const stBase = new THREE.Mesh(new THREE.BoxGeometry(1.8, 2.5, 1.8), stoneMat);
stBase.position.set(0, wallH + 1.25, -d / 2 + 1);
stBase.castShadow = true; group.add(stBase);
for (let corner of [[-0.7,-0.7],[0.7,-0.7],[-0.7,0.7],[0.7,0.7]]) {
const pin = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.8, 4), mat(BLDG_COLORS.roof[1]));
pin.position.set(corner[0], wallH + 2.9, -d / 2 + 1 + corner[1]);
pin.rotation.y = Math.PI / 4; group.add(pin);
}
const steeple = new THREE.Mesh(new THREE.ConeGeometry(0.7, 4.5, 4), mat(BLDG_COLORS.roof[1]));
steeple.position.set(0, wallH + rh + 2.5, -d / 2 + 1);
steeple.rotation.y = Math.PI / 4;
steeple.castShadow = true; group.add(steeple);
const crossBar1 = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.8, 0.06), mat(0xccaa44, { metalness: 0.5 }));
crossBar1.position.set(0, wallH + rh + 5.1, -d / 2 + 1); group.add(crossBar1);
const crossBar2 = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.06, 0.06), mat(0xccaa44, { metalness: 0.5 }));
crossBar2.position.set(0, wallH + rh + 5.2, -d / 2 + 1); group.add(crossBar2);
const label = createLabel(locData.label, group, wallH + rh + 6);
const badge = createOccupantBadge(group, wallH + rh + 5);
group.userData = { id, type: 'church', label, badge, locData };
return group;
}
function createSchool(id, locData) {
const group = new THREE.Group();
const w = 6, d = 5, wallH = 3.5;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(BLDG_COLORS.school));
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const entW = 2, entH = wallH + 1.5, entD = 0.6;
const entrance = new THREE.Mesh(new THREE.BoxGeometry(entW, entH, entD), mat(BLDG_COLORS.school - 0x101010));
entrance.position.set(0, entH / 2, d / 2 + entD / 2 - 0.1);
entrance.castShadow = true; group.add(entrance);
const pediment = new THREE.Mesh(new THREE.ConeGeometry(entW * 0.6, 1, 3), mat(BLDG_COLORS.school - 0x101010));
pediment.position.set(0, entH + 0.3, d / 2 + entD / 2 - 0.1);
pediment.scale.z = 0.3; pediment.rotation.y = Math.PI / 6; group.add(pediment);
const door = new THREE.Mesh(new THREE.BoxGeometry(1.2, 2, 0.1), mat(BLDG_COLORS.door));
door.position.set(0, 1, d / 2 + entD - 0.05); door.userData.isDoor = true; group.add(door);
const clockR = 0.4;
const clockFace = new THREE.Mesh(new THREE.CircleGeometry(clockR, 16),
mat(0xf8f8f0, { side: THREE.DoubleSide }));
clockFace.position.set(0, entH - 0.5, d / 2 + entD + 0.01); group.add(clockFace);
const clockRim = new THREE.Mesh(new THREE.TorusGeometry(clockR, 0.04, 8, 16), mat(0x444444));
clockRim.position.set(0, entH - 0.5, d / 2 + entD + 0.01); group.add(clockRim);
const hourHand = new THREE.Mesh(new THREE.BoxGeometry(0.04, clockR * 0.5, 0.02), mat(0x222222));
hourHand.position.set(0, entH - 0.5 + clockR * 0.2, d / 2 + entD + 0.02); group.add(hourHand);
const minHand = new THREE.Mesh(new THREE.BoxGeometry(0.03, clockR * 0.7, 0.02), mat(0x222222));
minHand.position.set(clockR * 0.15, entH - 0.5, d / 2 + entD + 0.02);
minHand.rotation.z = -Math.PI / 3; group.add(minHand);
const winMat = mat(isNight ? BLDG_COLORS.windowLit : BLDG_COLORS.window,
isNight ? { emissive: BLDG_COLORS.windowLit, emissiveIntensity: 0.3 } : {});
for (let i = -2; i <= 2; i++) {
if (Math.abs(i) < 1) continue;
const win = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.8, 0.1), winMat);
win.position.set(i * 1.1, wallH * 0.6, d / 2 + 0.05);
win.userData.isWindow = true; group.add(win);
const sill = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.06, 0.12), mat(0xaa6848));
sill.position.set(i * 1.1, wallH * 0.6 - 0.43, d / 2 + 0.08); group.add(sill);
}
for (let side of [-1, 1]) {
for (let sz = -1; sz <= 1; sz++) {
const sWin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.7, 0.5), winMat);
sWin.position.set(side * (w / 2 + 0.05), wallH * 0.6, sz * 1.3);
sWin.userData.isWindow = true; group.add(sWin);
}
}
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 4, 6), mat(0x888888));
pole.position.set(w / 2 + 0.8, 2, d / 2 - 1); group.add(pole);
const flag = new THREE.Mesh(new THREE.PlaneGeometry(1, 0.6), mat(0xcc3333, { side: THREE.DoubleSide }));
flag.position.set(w / 2 + 0.8 + 0.5, 3.5, d / 2 - 1); group.add(flag);
const roofCap = new THREE.Mesh(new THREE.BoxGeometry(w + 0.3, 0.2, d + 0.3), mat(BLDG_COLORS.school - 0x151515));
roofCap.position.y = wallH; group.add(roofCap);
const label = createLabel(locData.label, group, entH + 2);
const badge = createOccupantBadge(group, entH + 1);
group.userData = { id, type: 'school', label, badge, locData };
return group;
}
function createFactory(id, locData) {
const group = new THREE.Group();
const w = 7, d = 5, wallH = 4.5;
const factMat = mat(BLDG_COLORS.factory);
const metalMat = mat(0x666666, { metalness: 0.4, roughness: 0.4 });
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), factMat);
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const sawTeeth = 3, toothW = w / sawTeeth, toothH = 1.2;
for (let i = 0; i < sawTeeth; i++) {
const tx = -w / 2 + toothW / 2 + i * toothW;
const geo = new THREE.BufferGeometry();
const hw = toothW / 2, hd = d / 2;
const v = new Float32Array([
-hw, wallH, -hd, hw, wallH, -hd, hw, wallH, hd, -hw, wallH, hd,
-hw, wallH + toothH, -hd, hw, wallH + toothH, -hd,
-hw, wallH + toothH, hd, hw, wallH, hd,
]);
geo.setAttribute('position', new THREE.BufferAttribute(v, 3));
geo.setIndex([0,1,5,0,5,4, 3,6,4,3,4,0, 1,2,7,1,7,5, 4,5,7,4,7,6, 3,2,1,3,1,0]);
geo.computeVertexNormals();
const tooth = new THREE.Mesh(geo, metalMat);
tooth.position.x = tx; tooth.castShadow = true; group.add(tooth);
const skylight = new THREE.Mesh(new THREE.PlaneGeometry(toothW * 0.6, d * 0.6),
mat(0x88bbdd, { transparent: true, opacity: 0.4, side: THREE.DoubleSide }));
skylight.position.set(tx, wallH + toothH * 0.5 + 0.01, 0);
skylight.rotation.x = -Math.PI / 2 + 0.3;
skylight.userData.isWindow = true; group.add(skylight);
}
const chimney = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.65, 5, 8), mat(0x555555));
chimney.position.set(w / 2 - 1, wallH + 2.5, -d / 2 + 1);
chimney.castShadow = true; group.add(chimney);
const chimRing1 = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.06, 6, 12), metalMat);
chimRing1.position.set(w / 2 - 1, wallH + 4.5, -d / 2 + 1);
chimRing1.rotation.x = Math.PI / 2; group.add(chimRing1);
const chimney2 = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.5, 4, 8), mat(0x555555));
chimney2.position.set(w / 2 - 2.8, wallH + 2, -d / 2 + 1);
chimney2.castShadow = true; group.add(chimney2);
const dockW = 3, dockD = 1.5;
const dockFloor = new THREE.Mesh(new THREE.BoxGeometry(dockW, 0.8, dockD), mat(0x606060));
dockFloor.position.set(-w / 4, 0.4, d / 2 + dockD / 2); group.add(dockFloor);
const dockRoof = new THREE.Mesh(new THREE.BoxGeometry(dockW + 0.5, 0.1, dockD + 0.5), metalMat);
dockRoof.position.set(-w / 4, 3.5, d / 2 + dockD / 2); dockRoof.castShadow = true; group.add(dockRoof);
for (let s of [-1, 1]) {
const dPole = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 3, 6), metalMat);
dPole.position.set(-w / 4 + s * dockW / 2, 2, d / 2 + dockD); group.add(dPole);
}
const rollDoor = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.8, 0.1), mat(0x888888, { metalness: 0.3 }));
rollDoor.position.set(-w / 4, 2.2, d / 2 + 0.05); rollDoor.userData.isDoor = true; group.add(rollDoor);
const pipeMat = mat(0x999900, { metalness: 0.3 });
const pipe1 = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, wallH + 1, 8), pipeMat);
pipe1.position.set(-w / 2 + 0.3, wallH / 2, -d / 2 + 0.3); group.add(pipe1);
const pipe2 = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 3, 8), pipeMat);
pipe2.rotation.z = Math.PI / 2;
pipe2.position.set(-w / 2 + 1.8, wallH * 0.7, -d / 2 + 0.3); group.add(pipe2);
const label = createLabel(locData.label, group, wallH + toothH + 5);
const badge = createOccupantBadge(group, wallH + toothH + 4);
group.userData = { id, type: 'factory', label, badge, locData };
return group;
}
function createCinema(id, locData) {
const group = new THREE.Group();
const w = 5.5, d = 5, wallH = 5;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(BLDG_COLORS.cinema));
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const facadeH = 2;
const facade = new THREE.Mesh(new THREE.BoxGeometry(w + 0.8, facadeH, 0.3), mat(0x6b2040));
facade.position.set(0, wallH + facadeH / 2, d / 2); facade.castShadow = true; group.add(facade);
const marquee = new THREE.Mesh(new THREE.BoxGeometry(w + 1.2, 1, 1),
mat(0xddcc44, { emissive: 0xaa8833, emissiveIntensity: 0.4 }));
marquee.position.set(0, wallH * 0.75, d / 2 + 0.5); group.add(marquee);
const marqueeBottom = new THREE.Mesh(new THREE.BoxGeometry(w + 1.3, 0.08, 1.1),
mat(0xccbb33, { emissive: 0x887722, emissiveIntensity: 0.3 }));
marqueeBottom.position.set(0, wallH * 0.75 - 0.5, d / 2 + 0.5); group.add(marqueeBottom);
const bulbMat = mat(0xffee88, { emissive: 0xffdd44, emissiveIntensity: 0.6 });
for (let bx = -w / 2 - 0.3; bx <= w / 2 + 0.3; bx += 0.5) {
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.06, 4, 4), bulbMat);
bulb.position.set(bx, wallH * 0.75 + 0.52, d / 2 + 1.02); group.add(bulb);
const bulb2 = new THREE.Mesh(new THREE.SphereGeometry(0.06, 4, 4), bulbMat);
bulb2.position.set(bx, wallH * 0.75 - 0.52, d / 2 + 1.02); group.add(bulb2);
}
const doors = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.5, 0.1), mat(0x4a2030));
doors.position.set(0, 1.25, d / 2 + 0.06); doors.userData.isDoor = true; group.add(doors);
for (let dx of [-0.5, 0.5]) {
const dGlass = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.8, 0.06),
mat(BLDG_COLORS.window, { transparent: true, opacity: 0.4 }));
dGlass.position.set(dx, 1.4, d / 2 + 0.09); dGlass.userData.isWindow = true; group.add(dGlass);
}
const boothW = 1, boothH = 2.2, boothD = 1;
const booth = new THREE.Mesh(new THREE.BoxGeometry(boothW, boothH, boothD), mat(0x8b3050));
booth.position.set(w / 2 + boothW / 2 + 0.3, boothH / 2, d / 2 - 0.5);
booth.castShadow = true; group.add(booth);
const boothWin = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.5, 0.6),
mat(BLDG_COLORS.window, { transparent: true, opacity: 0.5 }));
boothWin.position.set(w / 2 + 0.3, boothH * 0.6, d / 2 - 0.5);
boothWin.userData.isWindow = true; group.add(boothWin);
const boothRoof = new THREE.Mesh(new THREE.BoxGeometry(boothW + 0.2, 0.1, boothD + 0.2), mat(0x6b2040));
boothRoof.position.set(w / 2 + boothW / 2 + 0.3, boothH + 0.05, d / 2 - 0.5); group.add(boothRoof);
const label = createLabel(locData.label, group, wallH + facadeH + 2);
const badge = createOccupantBadge(group, wallH + facadeH + 1);
group.userData = { id, type: 'cinema', label, badge, locData };
return group;
}
function createPark(id, locData) {
const group = new THREE.Group();
const benchMat = mat(0x8b6040);
const parkGround = new THREE.Mesh(new THREE.BoxGeometry(8, 0.1, 6), mat(BLDG_COLORS.park));
parkGround.position.y = 0.05; parkGround.receiveShadow = true; group.add(parkGround);
const pathMat = mat(0xccbbaa);
const mainPath = new THREE.Mesh(new THREE.BoxGeometry(1, 0.12, 6), pathMat);
mainPath.position.y = 0.06; group.add(mainPath);
const crossPath = new THREE.Mesh(new THREE.BoxGeometry(8, 0.12, 0.8), pathMat);
crossPath.position.y = 0.06; group.add(crossPath);
const gazR = 1.2, gazH = 2.8, gazPoles = 6;
const gazebo = new THREE.Group();
const gazFloor = new THREE.Mesh(new THREE.CylinderGeometry(gazR + 0.1, gazR + 0.1, 0.12, gazPoles), mat(0xc0b090));
gazFloor.position.y = 0.2; gazebo.add(gazFloor);
for (let i = 0; i < gazPoles; i++) {
const ang = (i / gazPoles) * Math.PI * 2;
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, gazH, 6), mat(0xf0f0f0));
pole.position.set(Math.cos(ang) * gazR, gazH / 2 + 0.2, Math.sin(ang) * gazR);
gazebo.add(pole);
}
const gazRoof = new THREE.Mesh(new THREE.ConeGeometry(gazR + 0.4, 1.2, gazPoles), mat(0x885540));
gazRoof.position.y = gazH + 0.8; gazRoof.castShadow = true; gazebo.add(gazRoof);
const gazRail = new THREE.Mesh(new THREE.TorusGeometry(gazR, 0.04, 4, gazPoles), mat(0xf0f0f0));
gazRail.rotation.x = Math.PI / 2; gazRail.position.y = 0.7; gazebo.add(gazRail);
gazebo.position.set(-2.5, 0, 0); group.add(gazebo);
const flowerColors = [0xff6688, 0xffaa44, 0xcc66cc, 0xff4444, 0xffdd44];
for (let fb = 0; fb < 2; fb++) {
const fbz = fb === 0 ? -2 : 2;
const bed = new THREE.Mesh(new THREE.BoxGeometry(2, 0.15, 0.8), mat(0x3a6a2a));
bed.position.set(2.5, 0.12, fbz); group.add(bed);
const border = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.2, 0.9), mat(0x8b7050));
border.position.set(2.5, 0.06, fbz); group.add(border);
for (let fx = 0; fx < 6; fx++) {
const fc = flowerColors[(fb * 6 + fx) % flowerColors.length];
const flower = new THREE.Mesh(new THREE.SphereGeometry(0.08, 4, 4), mat(fc));
flower.position.set(2.5 + (fx - 2.5) * 0.35, 0.28, fbz + (Math.random() - 0.5) * 0.4);
group.add(flower);
const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.12, 4), mat(0x2a6a2a));
stem.position.set(flower.position.x, 0.2, flower.position.z); group.add(stem);
}
}
for (let i = 0; i < 5; i++) {
const tx = (Math.random() - 0.5) * 6;
const tz = (Math.random() - 0.5) * 4;
if (Math.abs(tx) > 1 && Math.abs(tx + 2.5) > 1.5) {
const t = createTree(0, 0, 0.35 + Math.random() * 0.25);
t.position.set(tx, 0, tz);
scene.remove(t); group.add(t);
}
}
for (let bPos of [[1.5, 1.2], [1.5, -1.2]]) {
const bGroup = new THREE.Group();
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.08, 0.45), benchMat);
seat.position.y = 0.45; bGroup.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.5, 0.06), benchMat);
back.position.set(0, 0.65, -0.2); bGroup.add(back);
for (let lx of [-0.6, 0.6]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.45, 0.35), mat(0x444444));
leg.position.set(lx, 0.22, 0); bGroup.add(leg);
}
bGroup.position.set(bPos[0], 0, bPos[1]);
group.add(bGroup);
}
const label = createLabel(locData.label, group, 5);
const badge = createOccupantBadge(group, 4);
group.userData = { id, type: 'park', label, badge, locData };
return group;
}
function createSportsField(id, locData) {
const group = new THREE.Group();
const field = new THREE.Mesh(
new THREE.BoxGeometry(8, 0.1, 6),
mat(BLDG_COLORS.sports)
);
field.position.y = 0.05;
field.receiveShadow = true;
group.add(field);
// White lines
const lineMat = mat(0xffffff);
const outline = new THREE.Mesh(new THREE.BoxGeometry(7.5, 0.12, 0.08), lineMat);
outline.position.set(0, 0.06, 2.5);
group.add(outline);
const outline2 = outline.clone();
outline2.position.z = -2.5;
group.add(outline2);
const outline3 = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.12, 5), lineMat);
outline3.position.set(3.75, 0.06, 0);
group.add(outline3);
const outline4 = outline3.clone();
outline4.position.x = -3.75;
group.add(outline4);
// Center line
const center = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.12, 5), lineMat);
center.position.set(0, 0.06, 0);
group.add(center);
const label = createLabel(locData.label, group, 3);
const badge = createOccupantBadge(group, 2);
group.userData = { id, type: 'sports', label, badge, locData };
return group;
}
function createPolice(id, locData) {
const group = new THREE.Group();
const w = 5, d = 4, wallH = 3.5;
const policeMat = mat(BLDG_COLORS.police);
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), policeMat);
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const roof = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.25, d + 0.4), mat(0x2a4a6a));
roof.position.y = wallH; group.add(roof);
const parapet = new THREE.Mesh(new THREE.BoxGeometry(w + 0.5, 0.4, 0.15), mat(0x2a4a6a));
parapet.position.set(0, wallH + 0.2, d / 2 + 0.1); group.add(parapet);
const stripe = new THREE.Mesh(new THREE.BoxGeometry(w + 0.1, 0.5, 0.1), mat(0x4488cc));
stripe.position.set(0, wallH * 0.88, d / 2 + 0.06); group.add(stripe);
const lowerStripe = new THREE.Mesh(new THREE.BoxGeometry(w + 0.1, 0.15, 0.1), mat(0x4488cc));
lowerStripe.position.set(0, 0.5, d / 2 + 0.06); group.add(lowerStripe);
const entW = 1.8;
const canopy = new THREE.Mesh(new THREE.BoxGeometry(entW + 1, 0.1, 1.2), mat(0x2a4a6a));
canopy.position.set(0, 2.5, d / 2 + 0.6); canopy.castShadow = true; group.add(canopy);
const door = new THREE.Mesh(new THREE.BoxGeometry(entW, 2.2, 0.1), mat(0x2a3a5a));
door.position.set(0, 1.1, d / 2 + 0.06); door.userData.isDoor = true; group.add(door);
const dGlass = new THREE.Mesh(new THREE.BoxGeometry(entW * 0.7, 1.5, 0.06),
mat(BLDG_COLORS.window, { transparent: true, opacity: 0.4 }));
dGlass.position.set(0, 1.3, d / 2 + 0.09); dGlass.userData.isWindow = true; group.add(dGlass);
for (let sx of [-1.8, 1.8]) {
const win = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.8, 0.1), mat(BLDG_COLORS.window, { roughness: 0.3 }));
win.position.set(sx, wallH * 0.6, d / 2 + 0.06); win.userData.isWindow = true; group.add(win);
const bars = new THREE.Group();
for (let bx = -0.25; bx <= 0.25; bx += 0.12) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.8, 0.02), mat(0x444444));
bar.position.set(bx, 0, 0.06); bars.add(bar);
}
bars.position.set(sx, wallH * 0.6, d / 2 + 0.06); group.add(bars);
}
const badgeR = 0.5;
const shield = new THREE.Mesh(new THREE.CircleGeometry(badgeR, 6),
mat(0xccaa44, { metalness: 0.5, side: THREE.DoubleSide }));
shield.position.set(0, wallH * 0.65, d / 2 + 0.12); group.add(shield);
const star = new THREE.Mesh(new THREE.CircleGeometry(badgeR * 0.5, 5),
mat(0x4488cc, { side: THREE.DoubleSide }));
star.position.set(0, wallH * 0.65, d / 2 + 0.13); group.add(star);
const label = createLabel(locData.label, group, wallH + 2);
const badge = createOccupantBadge(group, wallH + 1);
group.userData = { id, type: 'police', label, badge, locData };
return group;
}
function createFireStation(id, locData) {
const group = new THREE.Group();
const w = 5.5, d = 5, wallH = 4;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(BLDG_COLORS.fire));
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const roof = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.3, d + 0.4), mat(0x881818));
roof.position.y = wallH; group.add(roof);
const garageDoor = new THREE.Mesh(new THREE.BoxGeometry(3, 2.8, 0.12), mat(0x555555));
garageDoor.position.set(0, 1.4, d / 2 + 0.06); group.add(garageDoor);
const towerH = 7;
const tower = new THREE.Mesh(new THREE.BoxGeometry(1.2, towerH, 1.2), mat(0xaa2222));
tower.position.set(w / 2 - 0.8, towerH / 2, -d / 2 + 0.8); tower.castShadow = true; group.add(tower);
const towerCap = new THREE.Mesh(new THREE.ConeGeometry(1, 1.5, 4), mat(0x881818));
towerCap.position.set(w / 2 - 0.8, towerH + 0.75, -d / 2 + 0.8); towerCap.rotation.y = Math.PI / 4; group.add(towerCap);
const label = createLabel(locData.label, group, towerH + 3);
const badge = createOccupantBadge(group, wallH + 1);
group.userData = { id, type: 'fire', label, badge, locData };
return group;
}
function createMuseum(id, locData) {
const group = new THREE.Group();
const w = 6, d = 5, wallH = 4.5;
const museumMat = mat(BLDG_COLORS.museum);
const colMat = mat(0xd8c898);
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), museumMat);
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const porticoD = 1.5, porticoH = wallH + 0.5;
const porticoRoof = new THREE.Mesh(new THREE.BoxGeometry(w + 0.8, 0.25, porticoD + 0.3), museumMat);
porticoRoof.position.set(0, porticoH, d / 2 + porticoD / 2); porticoRoof.castShadow = true; group.add(porticoRoof);
const pedGeo = new THREE.BufferGeometry();
const pw = w / 2 + 0.4, ph = 1.8;
const pv = new Float32Array([
-pw, porticoH + 0.25, d / 2, pw, porticoH + 0.25, d / 2, 0, porticoH + 0.25 + ph, d / 2,
-pw, porticoH + 0.25, d / 2 + porticoD + 0.3, pw, porticoH + 0.25, d / 2 + porticoD + 0.3, 0, porticoH + 0.25 + ph, d / 2 + porticoD + 0.3,
]);
pedGeo.setAttribute('position', new THREE.BufferAttribute(pv, 3));
pedGeo.setIndex([0,1,2, 3,5,4, 0,2,5,0,5,3, 1,4,5,1,5,2]);
pedGeo.computeVertexNormals();
const pediment = new THREE.Mesh(pedGeo, museumMat);
pediment.castShadow = true; group.add(pediment);
const cols = 5;
for (let i = 0; i < cols; i++) {
const cx = (i - (cols - 1) / 2) * (w / (cols - 1));
const col = new THREE.Group();
const colBase = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.3, 0.5), colMat);
colBase.position.y = 0.5; col.add(colBase);
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.22, porticoH - 0.8, 12), colMat);
shaft.position.y = porticoH / 2 + 0.2; col.add(shaft);
const capital = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.25, 0.5), colMat);
capital.position.y = porticoH - 0.15; col.add(capital);
col.position.set(cx, 0, d / 2 + porticoD - 0.2);
col.castShadow = true; group.add(col);
}
for (let s = 0; s < 3; s++) {
const stepW = w + 1.5 - s * 0.3, stepD = 0.5;
const step = new THREE.Mesh(new THREE.BoxGeometry(stepW, 0.2, stepD), mat(0xc8c0a0));
step.position.set(0, 0.1 + s * 0.2, d / 2 + porticoD + 0.5 - s * 0.3);
step.receiveShadow = true; group.add(step);
}
const door = new THREE.Mesh(new THREE.BoxGeometry(1.5, 2.5, 0.1), mat(BLDG_COLORS.door));
door.position.set(0, 1.55, d / 2 + 0.05); door.userData.isDoor = true; group.add(door);
const winMat = mat(isNight ? BLDG_COLORS.windowLit : BLDG_COLORS.window,
isNight ? { emissive: BLDG_COLORS.windowLit, emissiveIntensity: 0.3 } : {});
for (let side of [-1, 1]) {
for (let sz = -1; sz <= 1; sz++) {
const sWin = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.2, 0.6), winMat);
sWin.position.set(side * (w / 2 + 0.05), wallH * 0.55, sz * 1.3);
sWin.userData.isWindow = true; group.add(sWin);
}
}
const roofCap = new THREE.Mesh(new THREE.BoxGeometry(w + 0.3, 0.2, d + 0.3), mat(0xc0b890));
roofCap.position.y = wallH; group.add(roofCap);
const domeR = 1.2;
const dome = new THREE.Mesh(new THREE.SphereGeometry(domeR, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2),
mat(0xa8b8a0, { metalness: 0.2 }));
dome.position.y = wallH + 0.2; dome.castShadow = true; group.add(dome);
const label = createLabel(locData.label, group, porticoH + ph + 2);
const badge = createOccupantBadge(group, porticoH + ph + 1);
group.userData = { id, type: 'museum', label, badge, locData };
return group;
}
function createMall(id, locData) {
const group = new THREE.Group();
const h = hash(id);
const w = 8, d = 6, wallH = 5;
const clr = Array.isArray(BLDG_COLORS.mall) ? BLDG_COLORS.mall[h % BLDG_COLORS.mall.length] : BLDG_COLORS.mall;
const frameMat = mat(0x666666, { metalness: 0.3 });
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(clr));
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const roof = new THREE.Mesh(new THREE.BoxGeometry(w + 0.5, 0.3, d + 0.5), frameMat);
roof.position.y = wallH; group.add(roof);
const atriumW = 3, atriumH = 2.5, atriumD = 2;
const atriumGlass = mat(0x88ccee, { transparent: true, opacity: 0.35, roughness: 0.1, metalness: 0.3 });
const atrium = new THREE.Mesh(new THREE.BoxGeometry(atriumW, atriumH, atriumD), atriumGlass);
atrium.position.set(0, wallH + atriumH / 2, 0);
atrium.userData.isWindow = true; group.add(atrium);
const atriumFrame = new THREE.Mesh(new THREE.BoxGeometry(atriumW + 0.1, 0.08, atriumD + 0.1), frameMat);
atriumFrame.position.set(0, wallH + atriumH, 0); group.add(atriumFrame);
for (let mx of [-1, 0, 1]) {
const mull = new THREE.Mesh(new THREE.BoxGeometry(0.05, atriumH, 0.05), frameMat);
mull.position.set(mx * (atriumW / 3), wallH + atriumH / 2, atriumD / 2 + 0.02); group.add(mull);
const mullB = mull.clone();
mullB.position.z = -atriumD / 2 - 0.02; group.add(mullB);
}
const glassMat = mat(BLDG_COLORS.window, { roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.6 });
const glass = new THREE.Mesh(new THREE.BoxGeometry(w * 0.85, wallH * 0.55, 0.08), glassMat);
glass.position.set(0, wallH * 0.4, d / 2 + 0.05); glass.userData.isWindow = true; group.add(glass);
for (let mx = -3; mx <= 3; mx++) {
const mull = new THREE.Mesh(new THREE.BoxGeometry(0.05, wallH * 0.6, 0.05), frameMat);
mull.position.set(mx * (w * 0.12), wallH * 0.4, d / 2 + 0.06); group.add(mull);
}
const entrW = 3, entrH = 3;
const canopy = new THREE.Mesh(new THREE.BoxGeometry(entrW + 1, 0.1, 2), frameMat);
canopy.position.set(0, entrH, d / 2 + 1); canopy.castShadow = true; group.add(canopy);
const doors = new THREE.Mesh(new THREE.BoxGeometry(entrW, entrH, 0.1), mat(0x3a3a4a));
doors.position.set(0, entrH / 2, d / 2 + 0.05); doors.userData.isDoor = true; group.add(doors);
for (let dx of [-0.8, 0, 0.8]) {
const dGlass = new THREE.Mesh(new THREE.BoxGeometry(0.6, entrH * 0.7, 0.06), glassMat);
dGlass.position.set(dx, entrH * 0.45, d / 2 + 0.08);
dGlass.userData.isWindow = true; group.add(dGlass);
}
for (let sx of [-2.8, 2.8]) {
const awning = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.08, 1), mat(0xcc8844, { roughness: 0.6 }));
awning.position.set(sx, wallH * 0.65, d / 2 + 0.5); awning.castShadow = true; group.add(awning);
}
for (let side of [-1, 1]) {
for (let sz = -1; sz <= 1; sz++) {
const sWin = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.2, 0.8), glassMat);
sWin.position.set(side * (w / 2 + 0.04), wallH * 0.5, sz * 1.5);
sWin.userData.isWindow = true; group.add(sWin);
}
}
const label = createLabel(locData.label, group, wallH + atriumH + 2);
const badge = createOccupantBadge(group, wallH + atriumH + 1);
group.userData = { id, type: 'mall', label, badge, locData };
return group;
}
function createTownHall(id, locData) {
const group = new THREE.Group();
const w = 6, d = 5, wallH = 5;
const walls = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, d), mat(BLDG_COLORS.townhall));
walls.position.y = wallH / 2; walls.castShadow = true; walls.receiveShadow = true;
addEdges(walls); group.add(walls);
const domeR = 2;
const dome = new THREE.Mesh(new THREE.SphereGeometry(domeR, 16, 12, 0, Math.PI * 2, 0, Math.PI / 2), mat(0x88a888));
dome.position.y = wallH; dome.castShadow = true; group.add(dome);
const clockTower = new THREE.Mesh(new THREE.BoxGeometry(1.2, 3, 1.2), mat(0xb8a888));
clockTower.position.set(0, wallH + domeR + 1.5, 0); clockTower.castShadow = true; group.add(clockTower);
const spire = new THREE.Mesh(new THREE.ConeGeometry(0.5, 2, 4), mat(0x88a888));
spire.position.set(0, wallH + domeR + 4, 0); spire.rotation.y = Math.PI / 4; group.add(spire);
for (let cx of [-2, -0.7, 0.7, 2]) {
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.25, wallH * 0.8, 8), mat(0xc0b090));
col.position.set(cx, wallH * 0.4, d / 2 + 0.35); col.castShadow = true; group.add(col);
}
const steps = new THREE.Mesh(new THREE.BoxGeometry(w + 1.5, 0.4, 2), mat(0xb8b0a0));
steps.position.set(0, 0.2, d / 2 + 1.2); steps.receiveShadow = true; group.add(steps);
const label = createLabel(locData.label, group, wallH + domeR + 6);
const badge = createOccupantBadge(group, wallH + domeR + 5);
group.userData = { id, type: 'townhall', label, badge, locData };
return group;
}
function createMarket(id, locData) {
const group = new THREE.Group();
const plaza = new THREE.Mesh(new THREE.BoxGeometry(8, 0.08, 6), mat(0xb8a888));
plaza.position.y = 0.04; plaza.receiveShadow = true; group.add(plaza);
const stallColors = [0xe8a040, 0xcc4444, 0x44aa66, 0x4488cc, 0xcc8844, 0xaa44aa];
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 3; col++) {
const sx = (col - 1) * 2.5, sz = (row - 0.5) * 2.5;
const stall = new THREE.Group();
const counter = new THREE.Mesh(new THREE.BoxGeometry(1.8, 1, 1.2), mat(0x8b6040));
counter.position.y = 0.5; stall.add(counter);
const canopy = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.06, 1.6), mat(stallColors[(row * 3 + col) % stallColors.length]));
canopy.position.y = 2.2; canopy.castShadow = true; stall.add(canopy);
const pole1 = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.2, 4), mat(0x666666));
pole1.position.set(-0.8, 1.1, -0.6); stall.add(pole1);
const pole2 = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.2, 4), mat(0x666666));
pole2.position.set(0.8, 1.1, -0.6); stall.add(pole2);
stall.position.set(sx, 0, sz);
group.add(stall);
}
}
const label = createLabel(locData.label, group, 4);
const badge = createOccupantBadge(group, 3);
group.userData = { id, type: 'market', label, badge, locData };
return group;
}
function createSquare(id, locData) {
const group = new THREE.Group();
const stoneMat = mat(0x999988);
const plaza = new THREE.Mesh(new THREE.BoxGeometry(7, 0.1, 7), mat(BLDG_COLORS.square));
plaza.position.y = 0.05; plaza.receiveShadow = true; group.add(plaza);
for (let ring = 0; ring < 2; ring++) {
const r = 2.5 + ring * 1.2;
const border = new THREE.Mesh(new THREE.TorusGeometry(r, 0.06, 4, 24), mat(0x888878));
border.rotation.x = Math.PI / 2; border.position.y = 0.12; group.add(border);
}
const basinOuter = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 1.7, 0.6, 16), stoneMat);
basinOuter.position.y = 0.3; group.add(basinOuter);
const basinInner = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.3, 0.3, 16), mat(0x5588aa, { roughness: 0.2 }));
basinInner.position.y = 0.45; group.add(basinInner);
const waterTop = new THREE.Mesh(new THREE.CylinderGeometry(1.25, 1.25, 0.05, 16),
mat(PALETTE.water, { roughness: 0.15, metalness: 0.15 }));
waterTop.position.y = 0.55; group.add(waterTop);
const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.35, 1.5, 8), stoneMat);
pedestal.position.y = 1.3; group.add(pedestal);
const upperBasin = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.5, 0.25, 12), stoneMat);
upperBasin.position.y = 2.15; group.add(upperBasin);
const upperWater = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.04, 12),
mat(PALETTE.water, { roughness: 0.15, metalness: 0.15 }));
upperWater.position.y = 2.3; group.add(upperWater);
const finial = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6), stoneMat);
finial.position.y = 2.55; group.add(finial);
const benchMat = mat(0x8b6040);
for (let i = 0; i < 4; i++) {
const ang = (i / 4) * Math.PI * 2 + Math.PI / 4;
const br = 2.8;
const bGroup = new THREE.Group();
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.08, 0.45), benchMat);
seat.position.y = 0.45; bGroup.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.5, 0.06), benchMat);
back.position.set(0, 0.65, -0.2); bGroup.add(back);
for (let lx of [-0.6, 0.6]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.45, 0.4), mat(0x444444));
leg.position.set(lx, 0.22, 0); bGroup.add(leg);
}
bGroup.position.set(Math.cos(ang) * br, 0, Math.sin(ang) * br);
bGroup.rotation.y = -ang + Math.PI / 2;
group.add(bGroup);
}
for (let i = 0; i < 4; i++) {
const ang = (i / 4) * Math.PI * 2;
const lamp = new THREE.Group();
const lPole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 2.5, 6), mat(0x444444));
lPole.position.y = 1.25; lamp.add(lPole);
const lTop = new THREE.Mesh(new THREE.SphereGeometry(0.1, 6, 4),
mat(0xffeedd, { emissive: 0xffd860, emissiveIntensity: isNight ? 0.6 : 0 }));
lTop.position.y = 2.5; lamp.add(lTop);
lamp.position.set(Math.cos(ang) * 2, 0, Math.sin(ang) * 2);
group.add(lamp);
}
const label = createLabel(locData.label, group, 5);
const badge = createOccupantBadge(group, 4);
group.userData = { id, type: 'square', label, badge, locData };
return group;
}
function createCemetery(id, locData) {
const group = new THREE.Group();
const plot = new THREE.Mesh(
new THREE.BoxGeometry(8, 0.08, 7),
mat(0x3a5a3a, { roughness: 0.95 })
);
plot.position.y = 0.04;
plot.receiveShadow = true;
group.add(plot);
const fence = mat(0x333333);
for (let side of [-1, 1]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.8, 7), fence);
rail.position.set(side * 4, 0.4, 0);
group.add(rail);
}
for (let side of [-1, 1]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(8, 0.8, 0.06), fence);
rail.position.set(0, 0.4, side * 3.5);
group.add(rail);
}
const stoneMat = mat(0xaaaaaa);
const cemeteryGraves = [];
for (let row = 0; row < 2; row++) {
for (let col = -2; col <= 2; col++) {
const stone = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.7, 0.12), stoneMat);
stone.position.set(col * 1.3, 0.43, -1.5 + row * 3);
group.add(stone);
cemeteryGraves.push(stone);
}
}
const label = createLabel(locData.label, group, 3);
const badge = createOccupantBadge(group, 2);
group.userData = { id, type: 'cemetery', label, badge, locData, graves: cemeteryGraves };
return group;
}
// Building factory dispatch
function createBuilding(id, locData) {
const type = locData.type;
let bldg;
switch (type) {
case 'house': bldg = createHouse(id, locData); break;
case 'apartment': bldg = createApartment(id, locData); break;
case 'shop': bldg = createShop(id, locData); break;
case 'office': bldg = createOffice(id, locData); break;
case 'tower': bldg = createTower(id, locData); break;
case 'hospital': bldg = createHospital(id, locData); break;
case 'church': bldg = createChurch(id, locData); break;
case 'school': bldg = createSchool(id, locData); break;
case 'factory': bldg = createFactory(id, locData); break;
case 'cinema': bldg = createCinema(id, locData); break;
case 'park': bldg = createPark(id, locData); break;
case 'sports': bldg = createSportsField(id, locData); break;
case 'square': bldg = createSquare(id, locData); break;
case 'police': bldg = createPolice(id, locData); break;
case 'fire': bldg = createFireStation(id, locData); break;
case 'museum': bldg = createMuseum(id, locData); break;
case 'mall': bldg = createMall(id, locData); break;
case 'townhall': bldg = createTownHall(id, locData); break;
case 'market': bldg = createMarket(id, locData); break;
case 'cemetery': bldg = createCemetery(id, locData); break;
default: bldg = createShop(id, locData); break;
}
addFurniture(id, bldg, locData);
const pos = toWorld(locData.x, locData.y);
bldg.position.set(pos.x, 0, pos.z);
scene.add(bldg);
buildingMeshes.set(id, bldg);
return bldg;
}
const FOOD_LOCS = new Set(['restaurant','bar','cafe','diner','bakery','sushi_bar','pizzeria','coffeehouse','pub','ice_cream']);
const DESK_LOCS = new Set(['office','office_tech','office_media','school','library','townhall','post_office','bank','court','daycare','kindergarten','university']);
const SEAT_LOCS = new Set(['cinema','church','museum']);
const SITTING_LOCS = new Set([...FOOD_LOCS, ...DESK_LOCS, ...SEAT_LOCS]);
function addFurniture(id, group, locData) {
const tableMat = mat(0x8b6040);
const chairMat = mat(0x6a5030);
const deskMat = mat(0xa09080);
function addF(mesh) { mesh.userData.isFurniture = true; group.add(mesh); }
if (FOOD_LOCS.has(id)) {
for (let i = -1; i <= 1; i++) {
const table = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.4, 0.7), tableMat);
table.position.set(i * 1.2, 0.2, 0);
addF(table);
for (let s of [-0.5, 0.5]) {
const chair = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.25, 0.3), chairMat);
chair.position.set(i * 1.2 + s, 0.125, 0.5);
addF(chair);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, 0.05), chairMat);
back.position.set(i * 1.2 + s, 0.35, 0.65);
addF(back);
}
}
} else if (DESK_LOCS.has(id)) {
for (let i = -1; i <= 1; i += 2) {
const desk = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.45, 0.6), deskMat);
desk.position.set(i * 1.3, 0.225, 0);
addF(desk);
const ch = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.25, 0.3), chairMat);
ch.position.set(i * 1.3, 0.125, 0.5);
addF(ch);
}
} else if (SEAT_LOCS.has(id)) {
for (let row = 0; row < 2; row++) {
for (let col = -1; col <= 1; col++) {
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.25, 0.35), chairMat);
seat.position.set(col * 0.7, 0.125, -0.5 + row * 0.8);
addF(seat);
const sb = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.35, 0.05), chairMat);
sb.position.set(col * 0.7, 0.35, -0.5 + row * 0.8 + 0.18);
addF(sb);
}
}
}
}
// ============================================================
// PLACE ALL BUILDINGS
// ============================================================
const BLDG_SIZES = {
house: [4,4], apartment: [6,5], shop: [5,4], office: [6,6], tower: [5,5],
hospital: [8,6], church: [5,7], school: [7,6], factory: [8,6], cinema: [6,6],
park: [8,8], sports: [10,8], square: [8,8], police: [6,5], fire: [6,6],
museum: [8,7], mall: [9,7], townhall: [8,7], market: [9,7], cemetery: [9,8],
};
const obstacles = [];
for (const [id, loc] of Object.entries(LOCATION_POSITIONS)) {
if (loc.type === 'street') continue;
createBuilding(id, loc);
const pos = toWorld(loc.x, loc.y);
const sz = BLDG_SIZES[loc.type] || [5, 4];
const margin = 0.6;
obstacles.push({ x: pos.x, z: pos.z, hw: sz[0] / 2 + margin, hd: sz[1] / 2 + margin });
}
// Scatter trees along roads
const treePositions = [];
for (let i = 0; i < 80; i++) {
const tx = (Math.random() - 0.5) * WORLD_SIZE * 1.1;
const tz = (Math.random() - 0.5) * WORLD_SIZE * 1.1;
let tooClose = false;
for (const [, loc] of Object.entries(LOCATION_POSITIONS)) {
const p = toWorld(loc.x, loc.y);
if (Math.abs(tx - p.x) < 5 && Math.abs(tz - p.z) < 5) { tooClose = true; break; }
}
if (Math.abs(tx) < 2.5 || Math.abs(tz + 7) < 2.5) tooClose = true; // avoid main roads
if (!tooClose) {
createTree(tx, tz, 0.3 + Math.random() * 0.4);
treePositions.push({ x: tx, z: tz });
}
}
// ============================================================
// TEXTURE HELPERS
// ============================================================
function celestialTex(color) {
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d');
const col = '#' + new THREE.Color(color).getHexString();
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
g.addColorStop(0, col);
g.addColorStop(0.25, col + 'cc');
g.addColorStop(0.6, col + '33');
g.addColorStop(1, col + '00');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
}
// ============================================================
// STREET LAMPS
// ============================================================
const streetLamps = [];
const lampBulbMat = new THREE.MeshStandardMaterial({ color: 0xffeebb, emissive: 0xffd860, emissiveIntensity: 0, roughness: 0.3 });
const lampGlowTex = celestialTex(0xffd860);
function createStreetLamp(x, z) {
const group = new THREE.Group();
const pole = new THREE.Mesh(
new THREE.CylinderGeometry(0.06, 0.08, 3.5, 6),
mat(0x444444, { metalness: 0.4 })
);
pole.position.y = 1.75;
group.add(pole);
const arm = new THREE.Mesh(
new THREE.BoxGeometry(0.8, 0.05, 0.05),
mat(0x444444, { metalness: 0.4 })
);
arm.position.set(0.35, 3.4, 0);
group.add(arm);
const bulb = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 6, 4),
lampBulbMat.clone()
);
bulb.position.set(0.7, 3.3, 0);
group.add(bulb);
const glow = new THREE.Sprite(
new THREE.SpriteMaterial({ map: lampGlowTex, transparent: true, opacity: 0, depthTest: false })
);
glow.scale.set(3, 3, 1);
glow.position.set(0.7, 3.3, 0);
group.add(glow);
group.position.set(x, 0, z);
scene.add(group);
streetLamps.push({ group, bulb, glow });
}
// Place lamps along main roads
for (let i = -6; i <= 6; i++) {
createStreetLamp(i * 10, -5);
createStreetLamp(i * 10, -9);
createStreetLamp(2, i * 10);
createStreetLamp(-2, i * 10);
}
for (let i = -5; i <= 5; i++) {
createStreetLamp(i * 12, -16);
createStreetLamp(i * 12, 15);
createStreetLamp(i * 12, 28);
createStreetLamp(-26, i * 12);
createStreetLamp(28, i * 12);
}
// A few actual PointLights for night illumination (placed at key intersections)
const nightLights = [];
const nightLightPositions = [
[0, 6, 0], [0, 6, -14], [0, 6, 14], [-25, 6, 0], [25, 6, 0],
[-25, 6, -14], [25, 6, -14], [-25, 6, 14], [25, 6, 14],
[0, 6, -35], [0, 6, 35],
];
for (const [x, y, z] of nightLightPositions) {
const pl = new THREE.PointLight(0xffd860, 0, 25, 1.2);
pl.position.set(x, y, z);
scene.add(pl);
nightLights.push(pl);
}
// ============================================================
// SKY ENVIRONMENT — mountains, sun, moon, clouds, stars
// ============================================================
let currentPhase = 'morning';
let simHour = 10;
// Sky gradient background (canvas texture)
const skyCanvas = document.createElement('canvas');
skyCanvas.width = 2; skyCanvas.height = 512;
const skyTex = new THREE.CanvasTexture(skyCanvas);
function updateSkyGradient(phase) {
const p = SKY_PHASES[phase] || SKY_PHASES.morning;
const ctx = skyCanvas.getContext('2d');
const g = ctx.createLinearGradient(0, 0, 0, 512);
g.addColorStop(0, p.top);
g.addColorStop(0.45, p.mid);
g.addColorStop(1, p.bot);
ctx.fillStyle = g;
ctx.fillRect(0, 0, 2, 512);
skyTex.needsUpdate = true;
scene.background = skyTex;
}
updateSkyGradient('morning');
// Mountains ring — smooth, distant, ethereal ridges
const mountainGroup = new THREE.Group();
const MTN_COLOR = 0x4a5a6a;
function makeMountainRidge(radius, baseHeight, peakHeight, segments, yOffset, colorHex, opacity) {
const pts = [];
const segCount = segments;
for (let i = 0; i <= segCount; i++) {
const t = i / segCount;
const ang = t * Math.PI * 2;
const noise1 = Math.sin(ang * 3.7 + 1.2) * 0.3 + Math.sin(ang * 7.1 + 4.5) * 0.15;
const noise2 = Math.sin(ang * 5.3 + 2.8) * 0.2;
const h = baseHeight + (peakHeight - baseHeight) * (0.5 + noise1 + noise2);
pts.push(new THREE.Vector3(Math.cos(ang) * radius, Math.max(h, baseHeight * 0.7), Math.sin(ang) * radius));
}
const shape = new THREE.BufferGeometry();
const verts = [], indices = [];
for (let i = 0; i <= segCount; i++) {
const p = pts[i % pts.length];
verts.push(p.x, 0, p.z);
verts.push(p.x, p.y, p.z);
}
for (let i = 0; i < segCount; i++) {
const a = i * 2, b = a + 1, c = a + 2, d = a + 3;
indices.push(a, b, c, b, d, c);
}
shape.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
shape.setIndex(indices);
shape.computeVertexNormals();
const m = new THREE.Mesh(shape, new THREE.MeshStandardMaterial({
color: colorHex, transparent: true, opacity: opacity,
side: THREE.DoubleSide, flatShading: false, fog: true,
depthWrite: opacity > 0.7
}));
m.position.y = yOffset;
m.receiveShadow = true;
return m;
}
// Three layered ridges — back (farthest, palest) to front
const ridge1 = makeMountainRidge(85, 3, 18, 128, 0, 0x6a7a8a, 0.35);
const ridge2 = makeMountainRidge(75, 2, 14, 128, 0, 0x5a6a7a, 0.50);
const ridge3 = makeMountainRidge(66, 1, 10, 128, 0, 0x4a5a6a, 0.70);
mountainGroup.add(ridge1, ridge2, ridge3);
// Snow-capped highlight on the tallest ridge (sprite ring for ethereal look)
for (let i = 0; i < 20; i++) {
const ang = (i / 20) * Math.PI * 2 + Math.random() * 0.3;
const r = 85 + Math.random() * 2;
const snowTex = celestialTex('#e8e8f0');
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: snowTex, transparent: true, opacity: 0.25 }));
const h = 12 + Math.random() * 6;
sp.position.set(Math.cos(ang) * r, h, Math.sin(ang) * r);
sp.scale.set(8, 4, 1);
mountainGroup.add(sp);
}
scene.add(mountainGroup);
// Sun
const sunSprite = new THREE.Sprite(
new THREE.SpriteMaterial({ map: celestialTex(0xffdd44), transparent: true, depthTest: false })
);
sunSprite.scale.set(14, 14, 1);
sunSprite.renderOrder = -1;
scene.add(sunSprite);
// Moon with phases
const moonGeo = new THREE.SphereGeometry(4, 32, 32);
const moonMat = new THREE.MeshBasicMaterial({ color: 0xeeeeff, transparent: true, opacity: 0.95 });
const moonMesh = new THREE.Mesh(moonGeo, moonMat);
moonMesh.renderOrder = -1;
moonMesh.visible = false;
scene.add(moonMesh);
const moonShadowGeo = new THREE.SphereGeometry(4.05, 32, 32);
const moonShadowMat = new THREE.MeshBasicMaterial({ color: 0x0e1530, transparent: true, opacity: 0.95, depthWrite: false });
const moonShadow = new THREE.Mesh(moonShadowGeo, moonShadowMat);
moonShadow.renderOrder = -0.5;
moonShadow.visible = false;
scene.add(moonShadow);
const moonGlow = new THREE.Sprite(
new THREE.SpriteMaterial({ map: celestialTex(0xaabbee), transparent: true, depthTest: false })
);
moonGlow.scale.set(22, 22, 1);
moonGlow.renderOrder = -2;
moonGlow.visible = false;
scene.add(moonGlow);
let moonPhaseDay = 0;
function getMoonPhase(day) {
const phase = (day % 28) / 28;
return phase;
}
function updateMoonPhase(day) {
const phase = getMoonPhase(day);
const offset = Math.cos(phase * Math.PI * 2) * 4.5;
moonShadow.position.copy(moonMesh.position);
moonShadow.position.x += offset;
moonShadow.visible = moonMesh.visible;
moonGlow.material.opacity = moonMesh.visible ? (0.15 + 0.45 * (0.5 + 0.5 * Math.cos(phase * Math.PI * 2))) : 0;
}
// Stars
const starCount = 500;
const starPos = new Float32Array(starCount * 3);
const starSizes = new Float32Array(starCount);
for (let i = 0; i < starCount; i++) {
const th = Math.random() * Math.PI * 2;
const ph = Math.random() * Math.PI * 0.5;
const r = 90;
starPos[i*3] = Math.cos(th) * Math.sin(ph) * r;
starPos[i*3+1] = Math.cos(ph) * r + 15;
starPos[i*3+2] = Math.sin(th) * Math.sin(ph) * r;
starSizes[i] = 0.6 + Math.random() * 1.2;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(starSizes, 1));
const starPoints = new THREE.Points(starGeo, new THREE.PointsMaterial({
color: 0xffffff, size: 1.2, transparent: true, opacity: 0.9,
sizeAttenuation: false
}));
starPoints.visible = false;
scene.add(starPoints);
// Clouds
const clouds = [];
for (let ci = 0; ci < 7; ci++) {
const cg = new THREE.Group();
const nPuffs = 3 + Math.floor(Math.random() * 4);
const cMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, metalness: 0, transparent: true, opacity: 0.55, depthWrite: false, flatShading: true });
for (let j = 0; j < nPuffs; j++) {
const r = 1.5 + Math.random() * 2.2;
const pf = new THREE.Mesh(new THREE.SphereGeometry(r, 8, 6), cMat.clone());
pf.scale.y = 0.25;
pf.position.set((Math.random() - 0.5) * 6, (Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 3);
cg.add(pf);
}
cg.position.set((Math.random() - 0.5) * 130, 26 + Math.random() * 16, (Math.random() - 0.5) * 130);
cg.userData.speed = 0.004 + Math.random() * 0.008;
scene.add(cg);
clouds.push(cg);
}
// ============================================================
// WEATHER EFFECTS — rain, snow, lightning, storm clouds
// ============================================================
let currentWeather = 'sunny';
const stormClouds = [];
for (let ci = 0; ci < 18; ci++) {
const cg = new THREE.Group();
const nPuffs = 4 + Math.floor(Math.random() * 5);
for (let j = 0; j < nPuffs; j++) {
const pf = new THREE.Mesh(
new THREE.SphereGeometry(2.0 + Math.random() * 2.8, 7, 5),
new THREE.MeshStandardMaterial({ color: 0x556070, roughness: 1, metalness: 0, transparent: true, opacity: 0.85, flatShading: true })
);
pf.scale.y = 0.25;
pf.position.set((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 0.6, (Math.random() - 0.5) * 6);
cg.add(pf);
}
cg.position.set((Math.random() - 0.5) * 140, 17 + Math.random() * 8, (Math.random() - 0.5) * 140);
cg.userData.speed = 0.007 + Math.random() * 0.012;
cg.visible = false;
scene.add(cg);
stormClouds.push(cg);
}
const RAIN_COUNT = 4000;
const rainGeo = new THREE.BufferGeometry();
const rainPositions = new Float32Array(RAIN_COUNT * 3);
const rainVelocities = new Float32Array(RAIN_COUNT);
for (let i = 0; i < RAIN_COUNT; i++) {
rainPositions[i*3] = (Math.random() - 0.5) * 120;
rainPositions[i*3+1] = Math.random() * 40;
rainPositions[i*3+2] = (Math.random() - 0.5) * 120;
rainVelocities[i] = 0.45 + Math.random() * 0.35;
}
rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3));
const rainMat = new THREE.PointsMaterial({ color: 0x8899cc, size: 0.1, transparent: true, opacity: 0.45 });
const rainSystem = new THREE.Points(rainGeo, rainMat);
rainSystem.visible = false;
scene.add(rainSystem);
const SNOW_COUNT = 2000;
const snowGeo = new THREE.BufferGeometry();
const snowPositions = new Float32Array(SNOW_COUNT * 3);
const snowDriftX = new Float32Array(SNOW_COUNT);
const snowDriftZ = new Float32Array(SNOW_COUNT);
for (let i = 0; i < SNOW_COUNT; i++) {
snowPositions[i*3] = (Math.random() - 0.5) * 120;
snowPositions[i*3+1] = Math.random() * 35;
snowPositions[i*3+2] = (Math.random() - 0.5) * 120;
snowDriftX[i] = (Math.random() - 0.5) * 0.012;
snowDriftZ[i] = (Math.random() - 0.5) * 0.012;
}
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3));
const snowMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.2, transparent: true, opacity: 0.85 });
const snowSystem = new THREE.Points(snowGeo, snowMat);
snowSystem.visible = false;
scene.add(snowSystem);
const lightningLight = new THREE.PointLight(0xccddff, 0, 200);
lightningLight.position.set(0, 50, 0);
scene.add(lightningLight);
let lightningTimer = 0;
let nextLightningAt = 0;
function updateWeather(weather) {
currentWeather = (weather || 'sunny').toLowerCase();
const isSunny = currentWeather === 'sunny' || currentWeather === 'clear';
const isCloudy = currentWeather === 'cloudy';
const isRainy = currentWeather === 'rainy';
const isStormy = currentWeather === 'stormy';
const isSnowy = currentWeather === 'snowy' || currentWeather === 'snow';
const isFoggy = currentWeather === 'foggy';
const precipitating = isRainy || isStormy || isSnowy;
if (sunSprite.visible) {
sunSprite.material.opacity = isSunny ? 1.0 : isCloudy ? 0.3 : precipitating ? 0.05 : isFoggy ? 0.35 : 0.8;
const ss = isSunny ? 16 : isCloudy ? 10 : precipitating ? 6 : 14;
sunSprite.scale.set(ss, ss, 1);
}
if (!isNight) {
const wm = isSunny ? 1.0 : isCloudy ? 0.55 : isRainy ? 0.3 : isStormy ? 0.2 : isSnowy ? 0.4 : isFoggy ? 0.35 : 1.0;
sunLight.intensity *= wm;
ambientLight.intensity = Math.max(ambientLight.intensity * (0.5 + wm * 0.5), 0.08);
hemiLight.intensity *= (0.6 + wm * 0.4);
}
const fairVis = !isSunny && !(precipitating);
const fairOp = isCloudy ? 0.7 : 0.45;
clouds.forEach(c => {
c.visible = fairVis || isCloudy;
if (!isNight) c.children.forEach(p => { p.material.opacity = fairOp; });
});
const showStorm = isCloudy || precipitating;
const sc = isStormy ? 0x3a3a50 : isRainy ? 0x4a5560 : isSnowy ? 0x8890a0 : 0x6a7580;
const so = isStormy ? 0.95 : isRainy ? 0.88 : isSnowy ? 0.8 : 0.7;
stormClouds.forEach(c => {
c.visible = showStorm;
c.children.forEach(p => { p.material.color.set(sc); p.material.opacity = so; });
});
rainSystem.visible = isRainy || isStormy;
rainMat.opacity = isStormy ? 0.65 : 0.4;
snowSystem.visible = isSnowy;
if (isFoggy && !isNight) {
scene.fog = new THREE.FogExp2(0xaaaaaa, 0.018);
} else if (isStormy && !isNight) {
scene.fog = new THREE.FogExp2(0x333340, 0.009);
} else if (isRainy && !isNight) {
scene.fog = new THREE.FogExp2(0x556678, 0.007);
} else if (isSnowy && !isNight) {
scene.fog = new THREE.FogExp2(0x99a0a8, 0.006);
}
if (isSnowy && !isNight) ground.material.color.set(0xd8dce0);
}
// Position sun/moon based on hour
function updateCelestials(hour) {
// Sun: rises 6, peaks 12, sets 18
const sp = (hour - 6) / 12;
if (sp > -0.05 && sp < 1.05) {
const a = Math.max(0, Math.min(1, sp)) * Math.PI;
sunSprite.position.set(Math.cos(a) * 75, Math.sin(a) * 55 + 3, -65);
sunSprite.visible = true;
sunLight.position.set(Math.cos(a) * 40, Math.sin(a) * 50 + 5, -30);
} else {
sunSprite.visible = false;
}
// Moon: rises ~19, peaks ~0, sets ~5
const mh = ((hour - 19 + 24) % 24);
if (hour >= 18 || hour <= 6) {
const ma = (mh / 11) * Math.PI;
const mx = -Math.cos(ma) * 65;
const my = Math.sin(ma) * 50 + 8;
const mz = -55;
moonMesh.position.set(mx, my, mz);
moonMesh.visible = true;
moonGlow.position.set(mx, my, mz - 1);
moonGlow.visible = true;
updateMoonPhase(moonPhaseDay);
} else {
moonMesh.visible = false;
moonShadow.visible = false;
moonGlow.visible = false;
}
starPoints.visible = hour >= 18 || hour <= 5;
starPoints.material.opacity = (hour >= 20 || hour <= 4) ? 0.95 : 0.6;
}
updateCelestials(10);
// Full time-of-day updater (replaces old updateLighting)
function updateTimeOfDay(phase, hour) {
currentPhase = phase;
simHour = hour;
isNight = phase === 'night' || phase === 'evening';
updateSkyGradient(phase);
updateCelestials(hour);
// Mountain tint — fade ridges for time of day
const mc = isNight ? 0x141828 : (phase === 'evening' ? 0x3a2838 : (phase === 'dawn' ? 0x4a3848 : MTN_COLOR));
mountainGroup.children.forEach(m => {
if (m.material && m.material.color) m.material.color.set(mc);
if (m.isSprite && m.material) m.material.opacity = isNight ? 0.05 : 0.25;
});
// Cloud tint
const cc = isNight ? 0x1a1a30 : (phase === 'evening' ? 0xe8a070 : (phase === 'dawn' ? 0xd8a080 : 0xffffff));
const co = isNight ? 0.18 : 0.5;
clouds.forEach(c => c.children.forEach(p => { p.material.color.set(cc); p.material.opacity = co; }));
// Moon shadow matches sky so it blends naturally
const moonSkyColors = { night: 0x0e1530, evening: 0x1a1040, dawn: 0x2d1b4e, morning: 0x4a90c8, afternoon: 0x2878b8 };
moonShadowMat.color.set(moonSkyColors[phase] || 0x0e1530);
// Lighting
if (isNight) {
hemiLight.intensity = 0.45; hemiLight.color.set(0x4466aa);
ambientLight.intensity = 0.35;
sunLight.intensity = 0.25; sunLight.color.set(0x6688bb);
ground.material.color.set(0x1e401e);
gridHelper.material.opacity = 0.08;
scene.fog = new THREE.FogExp2(0x101830, 0.003);
} else if (phase === 'evening') {
hemiLight.intensity = 0.35; hemiLight.color.set(0xcc8855);
ambientLight.intensity = 0.2;
sunLight.intensity = 0.6; sunLight.color.set(0xff8844);
ground.material.color.set(0x3a6a30);
gridHelper.material.opacity = 0.08;
scene.fog = new THREE.FogExp2(0x442222, 0.005);
} else if (phase === 'dawn') {
hemiLight.intensity = 0.4; hemiLight.color.set(0xcc9966);
ambientLight.intensity = 0.25;
sunLight.intensity = 0.7; sunLight.color.set(0xffaa66);
ground.material.color.set(0x3a6a30);
gridHelper.material.opacity = 0.1;
scene.fog = new THREE.FogExp2(0x886644, 0.004);
} else {
hemiLight.intensity = 0.6; hemiLight.color.set(0x87ceeb);
ambientLight.intensity = 0.3;
sunLight.intensity = 1.2; sunLight.color.set(0xffeedd);
ground.material.color.set(PALETTE.ground);
gridHelper.material.opacity = 0.15;
scene.fog = new THREE.FogExp2(PALETTE.fog, 0.004);
}
// Street lamps: on at night/evening, off during day
const lampsOn = isNight || phase === 'evening';
for (const lamp of streetLamps) {
lamp.bulb.material.emissiveIntensity = lampsOn ? 2.0 : 0;
lamp.bulb.material.color.set(lampsOn ? 0xffd860 : 0xffeebb);
lamp.glow.material.opacity = lampsOn ? 0.6 : 0;
}
for (const nl of nightLights) {
nl.intensity = lampsOn ? 2.5 : 0;
}
// Window glow + building transparency at night
scene.traverse(o => {
if (o.userData?.isWindow && o.material) {
if (isNight) {
o.material.color.set(BLDG_COLORS.windowLit);
o.material.emissive = new THREE.Color(BLDG_COLORS.windowLit);
o.material.emissiveIntensity = 0.8;
} else {
o.material.color.set(BLDG_COLORS.window);
o.material.emissive = new THREE.Color(0x000000);
o.material.emissiveIntensity = 0;
}
}
});
const deepNight = phase === 'night';
for (const [id, bldg] of buildingMeshes) {
if (id === interiorBuildingId) continue;
const btype = bldg.userData?.type;
const isResidential = btype === 'house' || btype === 'apartment';
if (!isResidential) continue;
bldg.traverse(child => {
if (child.isMesh && !child.userData?.isWindow && !child.userData?.isDoor) {
if (deepNight) {
child.material.transparent = true;
child.material.opacity = 0.3;
child.material.depthWrite = false;
} else {
child.material.transparent = false;
child.material.opacity = 1.0;
child.material.depthWrite = true;
}
}
});
}
}
// Parse simulation time string → { hour, phase }
function parseSimTime(timeStr) {
const hm = timeStr.match(/(\d{1,2}):(\d{2})/);
const hour = hm ? parseInt(hm[1]) : 12;
const tm = timeStr.match(/\((\w+)\)/);
let phase = tm ? tm[1].trim() : 'morning';
if (!SKY_PHASES[phase]) {
if (hour >= 5 && hour < 7) phase = 'dawn';
else if (hour >= 7 && hour < 12) phase = 'morning';
else if (hour >= 12 && hour < 17) phase = 'afternoon';
else if (hour >= 17 && hour < 20) phase = 'evening';
else phase = 'night';
}
return { hour, phase };
}
// ============================================================
// AGENTS (3D Figures)
// ============================================================
const agentMeshes = new Map();
const agentTargetPositions = new Map();
let agentIdxCounter = 0;
const agentIdxMap = {};
function getAgentIdx(id) {
if (!(id in agentIdxMap)) agentIdxMap[id] = agentIdxCounter++;
return agentIdxMap[id];
}
function createLimb(length, radius, color) {
const g = new THREE.Group();
const m = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius * 0.85, length, 6),
mat(color)
);
m.position.y = -length / 2;
m.castShadow = true;
g.add(m);
return g;
}
const FEMALE_NAMES = new Set([
'elena','lila','helen','diana','priya','rosa','yuki','nina','zoe','alice',
'ada','mia','dara','hana','vera','nadia','petra','ling','maya','sophie',
'anya','clara','elsa','greta','irene','kira','marta','olga','rita','tanya',
'viola','xena','zara','bianca','dina','fiona','hazel','jenna',
]);
function createAgentMesh(agentId, agentData) {
const group = new THREE.Group();
const idx = getAgentIdx(agentId);
const h = hash(agentId);
const shirtColor = AGENT_COLORS[idx % AGENT_COLORS.length];
const skinColor = SKIN_COLORS[h % SKIN_COLORS.length];
const hairColor = HAIR_COLORS[(h >> 3) % HAIR_COLORS.length];
const genderStr = (agentData?.gender || '').toLowerCase();
const isFemale = genderStr === 'female' || (!genderStr && FEMALE_NAMES.has(agentId.toLowerCase()));
const wearsSkirt = isFemale;
const bottomColor = wearsSkirt
? SKIRT_COLORS[(h >> 8) % SKIRT_COLORS.length]
: PANTS_COLORS[(h >> 8) % PANTS_COLORS.length];
const shoeColor = SHOE_COLORS[(h >> 10) % SHOE_COLORS.length];
const S = 0.6;
// --- Head ---
const head = new THREE.Mesh(new THREE.SphereGeometry(0.22 * S, 8, 6), mat(skinColor));
head.position.y = 1.58 * S;
head.castShadow = true;
group.add(head);
// Hair
const hairGeo = (h & 1)
? new THREE.SphereGeometry(0.24 * S, 8, 6, 0, Math.PI * 2, 0, Math.PI * 0.55)
: new THREE.BoxGeometry(0.38 * S, 0.13 * S, 0.36 * S);
const hair = new THREE.Mesh(hairGeo, mat(hairColor));
hair.position.y = ((h & 1) ? 1.65 : 1.70) * S;
group.add(hair);
// --- Torso (shirt) ---
const torso = new THREE.Mesh(
new THREE.BoxGeometry(0.38 * S, 0.48 * S, 0.20 * S),
mat(shirtColor)
);
torso.position.y = 1.18 * S;
torso.castShadow = true;
group.add(torso);
// --- Arms (pivoted at shoulder) ---
const armLen = 0.42 * S, armR = 0.055 * S;
const leftArm = createLimb(armLen, armR, skinColor);
leftArm.position.set(-0.24 * S, 1.38 * S, 0);
group.add(leftArm);
const rightArm = createLimb(armLen, armR, skinColor);
rightArm.position.set(0.24 * S, 1.38 * S, 0);
group.add(rightArm);
// Sleeve cuffs (shirt-colored upper arm overlay)
const sleeveLen = armLen * 0.45;
const leftSleeve = new THREE.Mesh(
new THREE.CylinderGeometry(armR * 1.3, armR * 1.15, sleeveLen, 6),
mat(shirtColor)
);
leftSleeve.position.y = -sleeveLen / 2;
leftArm.add(leftSleeve);
const rightSleeve = leftSleeve.clone();
rightArm.add(rightSleeve);
// --- Legs ---
const legLen = 0.50 * S, legR = 0.065 * S;
const hipW = 0.09 * S, hipY = 0.92 * S;
let leftLeg, rightLeg;
if (wearsSkirt) {
// Skirt piece
const skirt = new THREE.Mesh(
new THREE.CylinderGeometry(0.10 * S, 0.28 * S, 0.30 * S, 8),
mat(bottomColor)
);
skirt.position.y = 0.78 * S;
group.add(skirt);
// Shorter visible legs below skirt
const visLen = 0.28 * S;
leftLeg = createLimb(visLen, legR * 0.9, skinColor);
leftLeg.position.set(-hipW, 0.62 * S, 0);
rightLeg = createLimb(visLen, legR * 0.9, skinColor);
rightLeg.position.set(hipW, 0.62 * S, 0);
} else {
// Full-length pants legs
leftLeg = createLimb(legLen, legR, bottomColor);
leftLeg.position.set(-hipW, hipY, 0);
rightLeg = createLimb(legLen, legR, bottomColor);
rightLeg.position.set(hipW, hipY, 0);
}
group.add(leftLeg);
group.add(rightLeg);
// --- Shoes ---
const shoeGeo = new THREE.BoxGeometry(0.10 * S, 0.05 * S, 0.16 * S);
const shoeMat = mat(shoeColor);
const leftShoe = new THREE.Mesh(shoeGeo, shoeMat);
leftShoe.position.set(-hipW, 0.025 * S, 0.02 * S);
group.add(leftShoe);
const rightShoe = new THREE.Mesh(shoeGeo, shoeMat);
rightShoe.position.set(hipW, 0.025 * S, 0.02 * S);
group.add(rightShoe);
// --- Name label ---
const nc = document.createElement('canvas');
nc.width = 256; nc.height = 48;
const nctx = nc.getContext('2d');
nctx.font = 'bold 20px sans-serif';
nctx.textAlign = 'center';
nctx.fillStyle = '#000000';
nctx.fillText(agentData?.name || agentId, 129, 27);
nctx.fillStyle = '#ffffff';
nctx.fillText(agentData?.name || agentId, 128, 26);
const nameTex = new THREE.CanvasTexture(nc);
nameTex.minFilter = THREE.LinearFilter;
const nameSprite = new THREE.Sprite(
new THREE.SpriteMaterial({ map: nameTex, transparent: true, depthTest: false })
);
nameSprite.scale.set(4, 0.75, 1);
nameSprite.position.y = 1.9 * S;
nameSprite.renderOrder = 998;
group.add(nameSprite);
group.userData = {
id: agentId, type: 'agent', data: agentData, nameSprite,
leftArm, rightArm, leftLeg, rightLeg,
wearsSkirt, isMoving: false,
};
scene.add(group);
agentMeshes.set(agentId, group);
return group;
}
const OUTDOOR_LOCS = new Set(['park','park_east','park_south','playground','town_square','sports_field',
'street_main','street_west','market','cemetery']);
function getAgentScenePosition(agentId, locationId, agents) {
const loc = LOCATION_POSITIONS[locationId] || dynamicLocations[locationId];
if (!loc) return { x: 0, y: 0, z: 0 };
const pos = toWorld(loc.x, loc.y);
const agentsHere = Object.entries(agents).filter(([, a]) => a.location === locationId);
const myIdx = agentsHere.findIndex(([id]) => id === agentId);
const count = agentsHere.length;
const isOutdoor = OUTDOOR_LOCS.has(locationId) || loc.type === 'park' || loc.type === 'square' || loc.type === 'sports';
let ox = 0, oz = 0;
if (count > 1 && myIdx >= 0) {
const angle = (myIdx / count) * Math.PI * 2;
const radius = isOutdoor ? Math.min(3, 1.0 + count * 0.2) : Math.min(1.5, 0.4 + count * 0.12);
ox = Math.cos(angle) * radius;
oz = Math.sin(angle) * radius;
}
if (isOutdoor) {
return { x: pos.x + ox, y: 0, z: pos.z + oz };
}
return { x: pos.x + ox, y: 0, z: pos.z + oz };
}
const dynamicLocations = {};
// ============================================================
// WEBSOCKET & STATE
// ============================================================
let simState = {};
let wsConnected = false;
let ws = null;
let isDemoMode = false;
const isEmbedded = window.parent !== window;
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws/stream`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
wsConnected = true;
document.getElementById('loading').classList.add('hidden');
document.querySelector('.live-dot').style.background = '#4ecca3';
document.querySelector('.live-dot').style.boxShadow = '0 0 8px #4ecca3';
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'tick' && msg.state) {
handleStateUpdate(msg);
}
} catch (err) { /* ignore parse errors */ }
};
ws.onclose = () => {
wsConnected = false;
document.querySelector('.live-dot').style.background = '#e94560';
document.querySelector('.live-dot').style.boxShadow = '0 0 8px #e94560';
if (!isDemoMode) setTimeout(connectWebSocket, 3000);
};
ws.onerror = () => ws.close();
}
function handleStateUpdate(msg) {
simState = msg.state;
const agents = simState.agents || {};
const locations = simState.locations || {};
// Forward state to parent when embedded
if (isEmbedded) window.parent.postMessage({ type: 'state-update', state: simState, time: msg.time }, '*');
// Update time display
document.getElementById('sim-time').textContent = msg.time || '';
const weather = simState.weather || simState.clock?.weather || '';
const weatherIcons = { sunny: '☀️', clear: '☀️', cloudy: '☁️', rainy: '🌧️', stormy: '⛈️', foggy: '🌫️', snowy: '🌨️', snow: '🌨️' };
document.getElementById('sim-weather').textContent = weatherIcons[weather] || '';
document.getElementById('sim-agents').textContent = `${Object.keys(agents).length} agents`;
const cost = simState.llm_total_cost ?? simState.cost;
if (cost !== undefined) {
document.getElementById('sim-cost').textContent = `$${parseFloat(cost).toFixed(4)}`;
}
// Update sky / lighting from simulation time + weather
const { hour, phase } = parseSimTime(msg.time || '');
updateTimeOfDay(phase, hour);
updateWeather(weather);
// Register dynamic locations
for (const [locId, locData] of Object.entries(locations)) {
if (!LOCATION_POSITIONS[locId] && !dynamicLocations[locId]) {
let h = hash(locId);
dynamicLocations[locId] = {
x: 0.05 + ((h >>> 0) % 18) / 18 * 0.90,
y: 0.05 + ((h >>> 4) % 16) / 16 * 0.90,
type: locData.zone === 'residential' ? 'house' : 'shop',
label: (locData.name || locId).slice(0, 16),
};
createBuilding(locId, dynamicLocations[locId]);
}
}
// Update building occupancy
for (const [locId, bldg] of buildingMeshes) {
const occupantCount = Object.values(agents).filter(a => a.location === locId).length;
if (bldg.userData.badge) updateBadge(bldg.userData.badge, occupantCount);
}
// Update/create agents
const seenAgents = new Set();
for (const [agentId, agentData] of Object.entries(agents)) {
seenAgents.add(agentId);
let mesh = agentMeshes.get(agentId);
if (!mesh) {
mesh = createAgentMesh(agentId, agentData);
const startPos = getAgentScenePosition(agentId, agentData.location, agents);
mesh.position.set(startPos.x, startPos.y, startPos.z);
}
mesh.userData.data = agentData;
const targetPos = getAgentScenePosition(agentId, agentData.location, agents);
agentTargetPositions.set(agentId, targetPos);
}
// Remove departed agents
for (const [agentId, mesh] of agentMeshes) {
if (!seenAgents.has(agentId)) {
scene.remove(mesh);
agentMeshes.delete(agentId);
agentTargetPositions.delete(agentId);
}
}
// Update info panel if agent selected
if (selectedId && agents[selectedId]) updateAgentInfo(selectedId, agents[selectedId]);
}
// updateLighting removed — replaced by updateTimeOfDay(phase, hour)
// ============================================================
// CLICK / SELECTION
// ============================================================
let selectedId = null;
let interiorBuildingId = null;
function enterInterior(buildingId) {
exitInterior();
interiorBuildingId = buildingId;
const bldg = buildingMeshes.get(buildingId);
if (!bldg) return;
bldg.traverse(child => {
if (!child.isMesh) return;
if (child.userData?.isFurniture || child.userData?.isDoor) return;
child.userData._savedOpacity = child.material.opacity;
child.userData._savedTransparent = child.material.transparent;
child.userData._savedDepthWrite = child.material.depthWrite;
child.material.transparent = true;
if (child.userData?.isWindow) {
child.material.opacity = 0.25;
} else {
child.material.opacity = 0.08;
child.material.depthWrite = false;
}
});
smoothZoomTo(bldg.position, 8);
}
function exitInterior() {
if (!interiorBuildingId) return;
const bldg = buildingMeshes.get(interiorBuildingId);
if (bldg) {
bldg.traverse(child => {
if (child.isMesh && child.userData._savedOpacity !== undefined) {
child.material.opacity = child.userData._savedOpacity;
child.material.transparent = child.userData._savedTransparent;
child.material.depthWrite = child.userData._savedDepthWrite ?? true;
delete child.userData._savedOpacity;
delete child.userData._savedTransparent;
delete child.userData._savedDepthWrite;
}
});
}
interiorBuildingId = null;
}
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let mouseDownPos = new THREE.Vector2();
let isDragging = false;
renderer.domElement.addEventListener('pointerdown', (e) => {
mouseDownPos.set(e.clientX, e.clientY);
isDragging = false;
});
renderer.domElement.addEventListener('pointermove', (e) => {
const dx = e.clientX - mouseDownPos.x;
const dy = e.clientY - mouseDownPos.y;
if (Math.sqrt(dx * dx + dy * dy) > 5) isDragging = true;
});
renderer.domElement.addEventListener('pointerup', (e) => {
if (isDragging) return;
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Check agents first
const agentObjects = [];
for (const [, mesh] of agentMeshes) agentObjects.push(...mesh.children);
const agentHits = raycaster.intersectObjects(agentObjects, false);
if (agentHits.length > 0) {
let parent = agentHits[0].object;
while (parent.parent && !parent.userData?.id) parent = parent.parent;
if (parent.userData?.id && parent.userData.type === 'agent') {
selectAgent(parent.userData.id);
smoothZoomTo(parent.position, 4);
return;
}
}
// Check buildings
const buildingObjects = [];
for (const [, mesh] of buildingMeshes) buildingObjects.push(...mesh.children);
const buildingHits = raycaster.intersectObjects(buildingObjects, false);
if (buildingHits.length > 0) {
let parent = buildingHits[0].object;
while (parent.parent && !parent.userData?.id) parent = parent.parent;
if (parent.userData?.id) {
selectBuilding(parent.userData.id);
enterInterior(parent.userData.id);
return;
}
}
// Click on empty space: deselect and exit interior
exitInterior();
closeInfoPanel();
});
// Double-click zoom
renderer.domElement.addEventListener('dblclick', (e) => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObject(ground, false);
if (hits.length > 0) {
smoothZoomTo(hits[0].point, camera.zoom < 3 ? 5 : 1);
}
});
function selectAgent(agentId) {
selectedId = agentId;
const data = simState.agents?.[agentId];
if (data) updateAgentInfo(agentId, data);
document.getElementById('info-panel').classList.add('visible');
if (window.parent !== window) window.parent.postMessage({ type: 'agent-select', agentId }, '*');
}
function selectBuilding(buildingId) {
selectedId = buildingId;
const agents = simState.agents || {};
const here = Object.entries(agents).filter(([, a]) => a.location === buildingId);
const loc = LOCATION_POSITIONS[buildingId] || dynamicLocations[buildingId];
const locInfo = simState.locations?.[buildingId];
let html = `<h3>${loc?.label || buildingId}</h3>`;
html += `<div class="info-section">`;
html += `<h4>Location</h4>`;
html += `<div class="info-row"><span class="label">Type</span><span class="value">${loc?.type || '?'}</span></div>`;
if (locInfo?.description) {
html += `<div style="color:#aaa;margin-top:4px;font-size:12px">${locInfo.description}</div>`;
}
html += `</div>`;
html += `<div class="info-section"><h4>Occupants (${here.length})</h4>`;
if (here.length === 0) html += `<div style="color:#666">Empty</div>`;
for (const [id, a] of here) {
const idx = getAgentIdx(id);
const color = '#' + AGENT_COLORS[idx % AGENT_COLORS.length].toString(16).padStart(6, '0');
html += `<div style="padding:3px 0;cursor:pointer;display:flex;align-items:center;gap:6px" onclick="window._selectAgent('${id}')">`;
html += `<span style="width:8px;height:8px;border-radius:50%;background:${color};display:inline-block"></span>`;
html += `<span>${a.name || id}</span>`;
html += `<span style="color:#888;font-size:11px;margin-left:auto">${a.state || ''}</span>`;
html += `</div>`;
}
html += `</div>`;
document.getElementById('info-content').innerHTML = html;
document.getElementById('info-panel').classList.add('visible');
}
window._selectAgent = (id) => selectAgent(id);
function updateAgentInfo(agentId, data) {
const idx = getAgentIdx(agentId);
const color = '#' + AGENT_COLORS[idx % AGENT_COLORS.length].toString(16).padStart(6, '0');
let html = `<h3 style="display:flex;align-items:center;gap:8px">`;
html += `<span style="width:12px;height:12px;border-radius:50%;background:${color}"></span>`;
html += `${data.name || agentId}</h3>`;
html += `<div class="info-section">`;
html += `<h4>Status</h4>`;
html += `<div class="info-row"><span class="label">Location</span><span class="value">${data.location_name || data.location || '?'}</span></div>`;
const isDead = data.state === 'deceased';
if (isDead) {
html += `<div class="info-row"><span class="label">State</span><span class="value" style="color:#e94560">Deceased</span></div>`;
if (data.deathDate) html += `<div class="info-row"><span class="label">Death</span><span class="value">${data.deathDate}</span></div>`;
if (data.deathAge) html += `<div class="info-row"><span class="label">Age at death</span><span class="value">${data.deathAge}</span></div>`;
} else {
html += `<div class="info-row"><span class="label">State</span><span class="value">${data.state || '?'}</span></div>`;
html += `<div class="info-row"><span class="label">Action</span><span class="value">${data.current_action || data.action || data.state || '?'}</span></div>`;
if (data.mood !== undefined) {
html += `<div class="info-row"><span class="label">Mood</span><span class="value">${typeof data.mood === 'number' ? (data.mood * 100).toFixed(0) + '%' : data.mood}</span></div>`;
}
}
if (data.age !== undefined && !isDead) {
html += `<div class="info-row"><span class="label">Age</span><span class="value">${data.age}</span></div>`;
}
if (data.gender) {
html += `<div class="info-row"><span class="label">Gender</span><span class="value">${data.gender}</span></div>`;
}
if (data.occupation && !isDead) {
html += `<div class="info-row"><span class="label">Occupation</span><span class="value">${data.occupation}</span></div>`;
}
if (data.lifePhase && !isDead) {
html += `<div class="info-row"><span class="label">Life Phase</span><span class="value">${data.lifePhase}</span></div>`;
}
const parents = data.parents || [];
if (parents.length > 0) {
html += `<div class="info-row"><span class="label">Parents</span><span class="value">${parents.join(', ')}</span></div>`;
}
html += `</div>`;
// Life Plan
const lifePlan = data.plan || [];
if (lifePlan.length > 0) {
html += `<div class="info-section"><h4>Life Plan</h4>`;
for (const item of lifePlan) {
const text = typeof item === 'string' ? item : `${item.time || ''} ${item.activity || item.action || ''}`;
html += `<div style="font-size:12px;color:#4ecca3;padding:2px 0">&#9679; ${text}</div>`;
}
html += `</div>`;
}
// Needs (hide for deceased)
const needs = data.needs || {};
if (!isDead && Object.keys(needs).length > 0) {
html += `<div class="info-section"><h4>Needs</h4>`;
for (const [need, val] of Object.entries(needs)) {
const pct = typeof val === 'number' ? val * 100 : parseFloat(val) * 100;
const barColor = pct > 60 ? '#4ecca3' : pct > 30 ? '#f0c040' : '#e94560';
html += `<div style="display:flex;align-items:center;gap:6px;margin:2px 0">`;
html += `<span style="width:60px;font-size:11px;color:#aaa">${need}</span>`;
html += `<div class="need-bar" style="flex:1"><div class="need-bar-fill" style="width:${pct}%;background:${barColor}"></div></div>`;
html += `<span style="font-size:11px;color:#888">${pct.toFixed(0)}%</span>`;
html += `</div>`;
}
html += `</div>`;
}
// Relationships
const rels = data.relationships || [];
if (rels.length > 0) {
html += `<div class="info-section"><h4>Relationships</h4>`;
for (const rel of rels.slice(0, 6)) {
html += `<div class="info-row"><span class="label">${rel.name || rel.target || '?'}</span>`;
html += `<span class="value" style="font-size:11px">${rel.type || ''} ${rel.closeness !== undefined ? `(${(rel.closeness * 100).toFixed(0)}%)` : ''}</span></div>`;
}
html += `</div>`;
}
// Long-term Memory
const memories = data.recent_memories || data.memories || [];
if (memories.length > 0) {
html += `<div class="info-section"><h4>Long-term Memory</h4>`;
for (const mem of memories.slice(-10).reverse()) {
const text = typeof mem === 'string' ? mem : mem.description || mem.text || JSON.stringify(mem);
html += `<div class="memory-item">${text}</div>`;
}
html += `</div>`;
}
document.getElementById('info-content').innerHTML = html;
}
// ============================================================
// CAMERA HELPERS
// ============================================================
let cameraAnimating = false;
let cameraAnimStart = 0;
let cameraAnimDuration = 800;
let cameraStartTarget = new THREE.Vector3();
let cameraEndTarget = new THREE.Vector3();
let cameraStartZoom = 1;
let cameraEndZoom = 1;
function smoothZoomTo(targetPos, zoomLevel) {
cameraAnimating = true;
cameraAnimStart = performance.now();
cameraStartTarget.copy(controls.target);
cameraEndTarget.set(targetPos.x, 0, targetPos.z);
cameraStartZoom = camera.zoom;
cameraEndZoom = zoomLevel;
}
function easeInOut(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function updateCameraAnimation() {
if (!cameraAnimating) return;
const elapsed = performance.now() - cameraAnimStart;
let t = Math.min(elapsed / cameraAnimDuration, 1);
t = easeInOut(t);
controls.target.lerpVectors(cameraStartTarget, cameraEndTarget, t);
camera.zoom = cameraStartZoom + (cameraEndZoom - cameraStartZoom) * t;
camera.updateProjectionMatrix();
if (t >= 1) cameraAnimating = false;
}
window.resetCamera = () => smoothZoomTo({ x: 0, z: 0 }, 1);
window.toggleTopDown = () => {
const isTopish = camera.position.y > 90;
if (isTopish) {
camera.position.set(55, 70, 55);
} else {
camera.position.set(0.1, 100, 0.1);
}
camera.lookAt(controls.target);
};
window.zoomIn = () => {
camera.zoom = Math.min(camera.zoom * 1.5, 12);
camera.updateProjectionMatrix();
};
window.zoomOut = () => {
camera.zoom = Math.max(camera.zoom / 1.5, 0.3);
camera.updateProjectionMatrix();
};
window.closeInfoPanel = () => {
selectedId = null;
exitInterior();
document.getElementById('info-panel').classList.remove('visible');
};
let agentSortMode = 'name';
function setAgentSort(mode) {
agentSortMode = mode;
document.querySelectorAll('#agent-list-panel .sort-btn').forEach(b => b.classList.remove('active'));
document.querySelector(`.sort-btn[onclick="setAgentSort('${mode}')"]`)?.classList.add('active');
renderAgentListPanel();
}
function toggleAgentList() {
const panel = document.getElementById('agent-list-panel');
panel.classList.toggle('visible');
if (panel.classList.contains('visible')) renderAgentListPanel();
}
function renderAgentListPanel() {
const agents = simState.agents || {};
let entries = Object.entries(agents).filter(([,a]) => a.state !== 'deceased');
switch (agentSortMode) {
case 'age': entries.sort((a,b) => (a[1].age ?? 0) - (b[1].age ?? 0)); break;
case 'age-desc': entries.sort((a,b) => (b[1].age ?? 0) - (a[1].age ?? 0)); break;
case 'location': entries.sort((a,b) => (a[1].location||'').localeCompare(b[1].location||'')); break;
default: entries.sort((a,b) => (a[1].name||'').localeCompare(b[1].name||''));
}
const deadCount = Object.values(agents).filter(a => a.state === 'deceased').length;
let html = `<div style="color:#888;font-size:11px;margin-bottom:6px">${entries.length} alive${deadCount > 0 ? `, ${deadCount} deceased` : ''}</div>`;
for (const [id, a] of entries) {
const idx = getAgentIdx(id);
const color = '#' + AGENT_COLORS[idx % AGENT_COLORS.length].toString(16).padStart(6, '0');
const gi = (a.age != null && a.age < 6) ? '👶' : (a.age < 18) ? '🧒' : a.gender === 'female' ? '👩' : a.gender === 'male' ? '👨' : '🧑';
html += `<div class="agent-list-entry" onclick="selectAgent('${id}')">`;
html += `<span style="width:6px;height:6px;border-radius:50%;background:${color};flex-shrink:0"></span>`;
html += `<span>${gi}</span>`;
html += `<span style="color:${color};flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${a.name||id}</span>`;
html += `<span style="color:#888;font-size:10px">${a.age ?? '?'}</span>`;
html += `</div>`;
}
document.getElementById('agent-list-content').innerHTML = html;
}
// ============================================================
// DETAIL LEVEL BY ZOOM
// ============================================================
function updateDetailLevel() {
const z = camera.zoom;
const showLabels = z > 0.6;
const showNames = z > 1.5;
const showBadges = z > 0.8;
for (const [, bldg] of buildingMeshes) {
if (bldg.userData.label) bldg.userData.label.visible = showLabels;
if (bldg.userData.badge?.sprite) bldg.userData.badge.sprite.visible = showBadges && bldg.userData.badge.sprite.visible;
}
for (const [, mesh] of agentMeshes) {
if (mesh.userData.nameSprite) mesh.userData.nameSprite.visible = showNames;
}
}
controls.addEventListener('change', updateDetailLevel);
// ============================================================
// ANIMATION LOOP
// ============================================================
const clock = new THREE.Clock();
let frameCount = 0;
const _interactRay = new THREE.Raycaster();
_interactRay.far = 15;
function updateInteractPrompt() {
const prompt = document.getElementById('fp-interact-prompt');
if (chatTargetId) { prompt.style.display = 'none'; return; }
if (!fpControls.isLocked) { prompt.style.display = 'none'; return; }
// Try raycaster first
_interactRay.setFromCamera(new THREE.Vector2(0, 0), fpCamera);
const objs = [];
for (const [, m] of agentMeshes) objs.push(m);
const hits = _interactRay.intersectObjects(objs, true);
if (hits.length > 0) {
let p = hits[0].object;
while (p.parent && !p.userData?.id) p = p.parent;
if (p.userData?.id && p.userData.type === 'agent') {
const name = p.userData.data?.name || p.userData.id;
prompt.innerHTML = `Press <b>E</b> to talk to <b>${name}</b>`;
prompt.style.display = 'block';
return;
}
}
// Fallback: proximity check
let best = null, bestDist = 6;
const cam = fpCamera.position;
for (const [id, m] of agentMeshes) {
if (id === 'player') continue;
const d = cam.distanceTo(m.position);
if (d < bestDist) { bestDist = d; best = m; }
}
if (best) {
const name = best.userData.data?.name || best.userData.id;
prompt.innerHTML = `Press <b>E</b> to talk to <b>${name}</b>`;
prompt.style.display = 'block';
return;
}
prompt.style.display = 'none';
}
renderer.setAnimationLoop(animate);
function animate() {
const dt = clock.getDelta();
frameCount++;
if (fpMode) {
updateFPMovement();
updateNPCFacing();
updatePlayerPosition();
if (frameCount % 10 === 0) updateInteractPrompt();
} else {
controls.update();
updateCameraAnimation();
}
// Smooth agent movement
for (const [agentId, mesh] of agentMeshes) {
if (mesh.userData.chatFrozen) continue;
const target = agentTargetPositions.get(agentId);
if (target) {
const dx = target.x - mesh.position.x;
const dz = target.z - mesh.position.z;
const dist0 = Math.sqrt(dx * dx + dz * dz);
const speed = 0.12;
if (dist0 > speed) {
let mx = (dx / dist0) * speed;
let mz = (dz / dist0) * speed;
// Exclude buildings the agent is inside or heading to
const relevant = obstacles.filter(ob => {
const curIn = Math.abs(mesh.position.x - ob.x) < ob.hw && Math.abs(mesh.position.z - ob.z) < ob.hd;
const tgtIn = Math.abs(target.x - ob.x) < ob.hw && Math.abs(target.z - ob.z) < ob.hd;
return !curIn && !tgtIn;
});
const tryMove = (px, pz) => {
for (const ob of relevant) {
if (Math.abs(px - ob.x) < ob.hw && Math.abs(pz - ob.z) < ob.hd) return false;
}
return true;
};
const nx = mesh.position.x + mx;
const nz = mesh.position.z + mz;
if (tryMove(nx, nz)) {
mesh.position.x = nx;
mesh.position.z = nz;
} else if (tryMove(nx, mesh.position.z)) {
mesh.position.x = nx;
} else if (tryMove(mesh.position.x, nz)) {
mesh.position.z = nz;
} else {
// Slide perpendicular — try both sides
const perpX = -dz / dist0 * speed;
const perpZ = dx / dist0 * speed;
const side = (hash(agentId) % 2 === 0) ? 1 : -1;
const sx1 = mesh.position.x + perpX * side;
const sz1 = mesh.position.z + perpZ * side;
const sx2 = mesh.position.x - perpX * side;
const sz2 = mesh.position.z - perpZ * side;
if (tryMove(sx1, sz1)) {
mesh.position.x = sx1;
mesh.position.z = sz1;
} else if (tryMove(sx2, sz2)) {
mesh.position.x = sx2;
mesh.position.z = sz2;
} else {
// Last resort — move directly, don't freeze
mesh.position.x += mx;
mesh.position.z += mz;
}
}
} else {
mesh.position.x = target.x;
mesh.position.z = target.z;
}
if (dist0 > 0.05) {
mesh.rotation.y = Math.atan2(
target.x - mesh.position.x,
target.z - mesh.position.z
);
}
}
mesh.visible = true;
// Sleeping, sitting, walking, or idle animation
const agentState = mesh.userData.data?.state || '';
const agentLoc = mesh.userData.data?.location || '';
const isSleeping = agentState === 'sleeping';
const dx2 = target ? target.x - mesh.position.x : 0;
const dz2 = target ? target.z - mesh.position.z : 0;
const dist = Math.sqrt(dx2 * dx2 + dz2 * dz2);
const moving = dist > 0.15;
const atLocation = dist < 0.3;
const isSitting = !moving && atLocation && SITTING_LOCS.has(agentLoc);
const isLyingDown = isSleeping && atLocation;
if (isLyingDown) {
mesh.rotation.x = 0;
mesh.rotation.z = Math.PI / 2;
mesh.position.y = 0.30;
if (mesh.userData.leftLeg) mesh.userData.leftLeg.rotation.x = 0.1;
if (mesh.userData.rightLeg) mesh.userData.rightLeg.rotation.x = -0.1;
if (mesh.userData.leftArm) mesh.userData.leftArm.rotation.x = 0.3;
if (mesh.userData.rightArm) mesh.userData.rightArm.rotation.x = 0.3;
} else if (isSitting) {
mesh.rotation.x = 0;
mesh.rotation.z = 0;
mesh.position.y = 0.0;
if (mesh.userData.leftLeg) mesh.userData.leftLeg.rotation.x = -Math.PI / 2;
if (mesh.userData.rightLeg) mesh.userData.rightLeg.rotation.x = -Math.PI / 2;
if (mesh.userData.leftArm) mesh.userData.leftArm.rotation.x = -0.3;
if (mesh.userData.rightArm) mesh.userData.rightArm.rotation.x = -0.3;
} else {
mesh.rotation.x = 0;
mesh.rotation.z = 0;
const walkPhase = frameCount * 0.12 + hash(agentId) * 0.5;
const swing = moving ? Math.sin(walkPhase) * 0.7 : 0;
if (mesh.userData.leftLeg) mesh.userData.leftLeg.rotation.x = swing;
if (mesh.userData.rightLeg) mesh.userData.rightLeg.rotation.x = -swing;
if (mesh.userData.leftArm) mesh.userData.leftArm.rotation.x = -swing * 0.5;
if (mesh.userData.rightArm) mesh.userData.rightArm.rotation.x = swing * 0.5;
mesh.position.y = moving
? Math.abs(Math.sin(walkPhase * 2)) * 0.04
: Math.sin(frameCount * 0.02 + hash(agentId) * 0.1) * 0.015;
}
}
// Subtle tree sway
if (frameCount % 2 === 0) {
scene.traverse((obj) => {
if (obj.geometry?.type === 'ConeGeometry' && obj.parent?.parent === scene) {
obj.rotation.z = Math.sin(frameCount * 0.01 + obj.position.x) * 0.02;
}
});
}
// Rain animation
if (rainSystem.visible) {
const rp = rainGeo.attributes.position.array;
const windX = currentWeather === 'stormy' ? 0.06 : 0.012;
for (let i = 0; i < RAIN_COUNT; i++) {
rp[i*3+1] -= rainVelocities[i];
rp[i*3] += windX;
if (rp[i*3+1] < 0) {
rp[i*3] = (Math.random() - 0.5) * 120;
rp[i*3+1] = 35 + Math.random() * 10;
rp[i*3+2] = (Math.random() - 0.5) * 120;
}
}
rainGeo.attributes.position.needsUpdate = true;
}
// Snow animation
if (snowSystem.visible) {
const sp = snowGeo.attributes.position.array;
for (let i = 0; i < SNOW_COUNT; i++) {
sp[i*3] += snowDriftX[i] + Math.sin(frameCount * 0.008 + i) * 0.004;
sp[i*3+1] -= 0.025 + Math.random() * 0.008;
sp[i*3+2] += snowDriftZ[i];
if (sp[i*3+1] < 0) {
sp[i*3] = (Math.random() - 0.5) * 120;
sp[i*3+1] = 28 + Math.random() * 10;
sp[i*3+2] = (Math.random() - 0.5) * 120;
}
}
snowGeo.attributes.position.needsUpdate = true;
}
// Lightning flashes
if (currentWeather === 'stormy') {
if (frameCount >= nextLightningAt) {
lightningLight.intensity = 10 + Math.random() * 15;
lightningLight.position.set((Math.random() - 0.5) * 60, 45, (Math.random() - 0.5) * 60);
lightningTimer = 2 + Math.floor(Math.random() * 5);
nextLightningAt = frameCount + 90 + Math.floor(Math.random() * 250);
}
if (lightningTimer > 0) {
lightningTimer--;
lightningLight.intensity *= 0.45;
if (lightningTimer <= 0) lightningLight.intensity = 0;
}
} else {
lightningLight.intensity = 0;
}
// Cloud drift
for (const cloud of clouds) {
cloud.position.x += cloud.userData.speed;
if (cloud.position.x > 70) cloud.position.x = -70;
}
for (const cloud of stormClouds) {
if (!cloud.visible) continue;
cloud.position.x += cloud.userData.speed;
if (cloud.position.x > 70) cloud.position.x = -70;
}
renderer.render(scene, fpMode ? fpCamera : camera);
}
// ============================================================
// RESIZE
// ============================================================
window.addEventListener('resize', () => {
const w = window.innerWidth, h = window.innerHeight;
const a = w / h;
camera.left = -frustum * a;
camera.right = frustum * a;
camera.top = frustum;
camera.bottom = -frustum;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
// ============================================================
// FIRST-PERSON / VR MODE
// ============================================================
let fpMode = false;
const fpCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
fpCamera.position.set(0, 1.8, 10);
const fpControls = new PointerLockControls(fpCamera, renderer.domElement);
const fpMoveState = { forward: false, backward: false, left: false, right: false };
const fpVelocity = new THREE.Vector3();
const fpDirection = new THREE.Vector3();
const FP_SPEED = 12;
const FP_HEIGHT = 0.9;
let fpClock = new THREE.Clock();
fpControls.addEventListener('lock', () => {
document.getElementById('fp-crosshair').style.display = 'block';
document.getElementById('fp-hint').style.display = 'block';
setTimeout(() => { document.getElementById('fp-hint').style.display = 'none'; }, 5000);
});
fpControls.addEventListener('unlock', () => {
document.getElementById('fp-crosshair').style.display = 'none';
if (fpMode && !chatTargetId) {
document.getElementById('fp-click-overlay').style.display = 'flex';
}
});
function enterFPMode() {
fpMode = true;
fpCamera.position.set(0, FP_HEIGHT, 10);
fpCamera.aspect = window.innerWidth / window.innerHeight;
fpCamera.updateProjectionMatrix();
controls.enabled = false;
fpClock.start();
document.getElementById('btn-fp').classList.add('active');
document.getElementById('btn-fp').title = 'Exit first-person';
const overlay = document.getElementById('fp-click-overlay');
overlay.style.display = 'flex';
overlay.onclick = () => {
overlay.style.display = 'none';
fpControls.lock();
};
}
function exitFPMode() {
if (chatTargetId) endNpcChat();
fpMode = false;
fpControls.unlock();
controls.enabled = true;
fpClock.stop();
document.getElementById('fp-crosshair').style.display = 'none';
document.getElementById('fp-hint').style.display = 'none';
document.getElementById('fp-click-overlay').style.display = 'none';
document.getElementById('btn-fp').classList.remove('active');
document.getElementById('btn-fp').title = 'First-person view';
}
window._toggleFP = () => {
if (fpMode) exitFPMode(); else enterFPMode();
};
function updateFPMovement() {
if (!fpMode) return;
const delta = Math.min(fpClock.getDelta(), 0.1);
fpDirection.z = Number(fpMoveState.forward) - Number(fpMoveState.backward);
fpDirection.x = Number(fpMoveState.right) - Number(fpMoveState.left);
fpDirection.normalize();
fpVelocity.x = fpDirection.x * FP_SPEED * delta;
fpVelocity.z = fpDirection.z * FP_SPEED * delta;
fpControls.moveRight(fpVelocity.x);
fpControls.moveForward(fpVelocity.z);
fpCamera.position.y = FP_HEIGHT;
// Keep within world bounds
fpCamera.position.x = Math.max(-HALF, Math.min(HALF, fpCamera.position.x));
fpCamera.position.z = Math.max(-HALF, Math.min(HALF, fpCamera.position.z));
}
// FP interaction: E key to interact with nearest agent/building
let chatTargetId = null;
let chatMessages = [];
let speechRecognition = null;
let isRecording = false;
function findNearestAgent() {
let best = null, bestDist = 6;
const cam = fpCamera.position;
for (const [id, m] of agentMeshes) {
if (id === 'player') continue;
const d = cam.distanceTo(m.position);
if (d < bestDist) { bestDist = d; best = id; }
}
return best;
}
function fpInteract() {
if (!fpMode) return;
if (chatTargetId) { endNpcChat(); return; }
const ray = new THREE.Raycaster();
ray.setFromCamera(new THREE.Vector2(0, 0), fpCamera);
ray.far = 15;
const agentObjs = [];
for (const [, m] of agentMeshes) agentObjs.push(m);
const hits = ray.intersectObjects(agentObjs, true);
if (hits.length > 0) {
let p = hits[0].object;
while (p.parent && !p.userData?.id) p = p.parent;
if (p.userData?.id && p.userData.type === 'agent') {
startNpcChat(p.userData.id);
return;
}
}
// Fallback: nearest agent within 6 units
const nearest = findNearestAgent();
if (nearest) { startNpcChat(nearest); return; }
const bldgObjs = [];
for (const [, m] of buildingMeshes) bldgObjs.push(m);
const bHits = ray.intersectObjects(bldgObjs, true);
if (bHits.length > 0) {
let p = bHits[0].object;
while (p.parent && !p.userData?.id) p = p.parent;
if (p.userData?.id) {
selectBuilding(p.userData.id);
enterInterior(p.userData.id);
}
}
}
function startNpcChat(agentId) {
chatTargetId = agentId;
chatMessages = [];
const data = demoAgents[agentId] || agentMeshes.get(agentId)?.userData?.data || {};
const name = data.name || agentId;
document.getElementById('chat-npc-name').textContent = name;
document.getElementById('chat-messages').innerHTML = '';
document.getElementById('chat-text').value = '';
document.getElementById('fp-interact-prompt').style.display = 'none';
document.getElementById('fp-click-overlay').style.display = 'none';
document.getElementById('npc-chat').style.display = 'block';
fpControls.unlock();
const mesh = agentMeshes.get(agentId);
if (mesh) mesh.userData.chatFrozen = true;
addChatMsg('system', `${name} stops and turns to face you.`);
setTimeout(() => document.getElementById('chat-text').focus(), 100);
}
function endNpcChat() {
if (isRecording) toggleMic();
const prevTarget = chatTargetId;
chatTargetId = null;
chatMessages = [];
if (prevTarget) {
const mesh = agentMeshes.get(prevTarget);
if (mesh) mesh.userData.chatFrozen = false;
}
document.getElementById('npc-chat').style.display = 'none';
if (fpMode) {
const overlay = document.getElementById('fp-click-overlay');
overlay.style.display = 'flex';
overlay.onclick = () => { overlay.style.display = 'none'; fpControls.lock(); };
}
}
function addChatMsg(type, text) {
chatMessages.push({ type, text });
const div = document.getElementById('chat-messages');
const cls = type === 'player' ? 'msg-player' : type === 'npc' ? 'msg-npc' : 'msg-system';
const label = type === 'player' ? 'You' : type === 'npc' ? (demoAgents[chatTargetId]?.name || chatTargetId) : '';
div.innerHTML += `<div class="msg ${cls}">${label ? `<b>${label}:</b> ` : ''}${text}</div>`;
div.scrollTop = div.scrollHeight;
}
const NPC_GREETINGS = ['Hello!', 'Hi there!', 'Hey!', 'Good to see you!', 'What brings you here?'];
const NPC_RESPONSES = [
"That's interesting!", "I see what you mean.", "Tell me more!",
"I was just thinking about that.", "Hmm, let me think...",
"What a lovely day it is!", "I've been busy lately.",
"Have you met everyone in town?", "The weather is nice today.",
"I enjoy living in this city.", "Things have been going well.",
"I appreciate you stopping by!", "That's a good point.",
];
function generateNpcResponse(playerMsg) {
const agent = demoAgents[chatTargetId];
if (!agent) return NPC_RESPONSES[Math.floor(Math.random() * NPC_RESPONSES.length)];
const lower = (playerMsg || '').toLowerCase();
if (lower.includes('name') || lower.includes('who are you'))
return `I'm ${agent.name}. Nice to meet you!`;
if (lower.includes('age') || lower.includes('old'))
return `I'm ${agent.age} years old.`;
if (lower.includes('job') || lower.includes('work') || lower.includes('occupation'))
return `I work as a ${agent.occupation || 'citizen'}.`;
if (lower.includes('how are') || lower.includes('feeling'))
return agent.mood > 0.6 ? "I'm doing great, thanks!" : "I've been better, honestly.";
if (lower.includes('bye') || lower.includes('goodbye') || lower.includes('see you'))
return "Goodbye! See you around!";
if (lower.includes('hello') || lower.includes('hi'))
return NPC_GREETINGS[Math.floor(Math.random() * NPC_GREETINGS.length)];
return NPC_RESPONSES[Math.floor(Math.random() * NPC_RESPONSES.length)];
}
function sendChatMessage() {
const input = document.getElementById('chat-text');
const text = input.value.trim();
if (!text || !chatTargetId) return;
input.value = '';
addChatMsg('player', text);
if (chatMessages.length === 1) {
addChatMsg('npc', NPC_GREETINGS[Math.floor(Math.random() * NPC_GREETINGS.length)]);
}
setTimeout(() => addChatMsg('npc', generateNpcResponse(text)), 400 + Math.random() * 600);
}
document.getElementById('chat-text').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sendChatMessage(); }
e.stopPropagation();
});
document.getElementById('chat-text').addEventListener('keyup', (e) => e.stopPropagation());
function toggleMic() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) { addChatMsg('system', 'Speech recognition not supported in this browser.'); return; }
const btn = document.getElementById('chat-mic');
if (isRecording) {
isRecording = false;
btn.classList.remove('recording');
if (speechRecognition) { speechRecognition.stop(); speechRecognition = null; }
return;
}
isRecording = true;
btn.classList.add('recording');
speechRecognition = new SpeechRecognition();
speechRecognition.continuous = false;
speechRecognition.interimResults = true;
speechRecognition.lang = navigator.language || 'en-US';
const input = document.getElementById('chat-text');
speechRecognition.onresult = (ev) => {
let transcript = '';
for (let i = ev.resultIndex; i < ev.results.length; i++) {
transcript += ev.results[i][0].transcript;
}
input.value = transcript;
if (ev.results[ev.results.length - 1].isFinal) {
sendChatMessage();
isRecording = false;
btn.classList.remove('recording');
}
};
speechRecognition.onerror = () => { isRecording = false; btn.classList.remove('recording'); };
speechRecognition.onend = () => { isRecording = false; btn.classList.remove('recording'); };
speechRecognition.start();
}
// WebXR support
if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-vr').then(supported => {
if (supported) {
const vrBtn = document.getElementById('btn-vr');
vrBtn.style.display = '';
vrBtn.onclick = () => {
navigator.xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor', 'hand-tracking'] }).then(session => {
renderer.xr.enabled = true;
renderer.xr.setSession(session);
fpCamera.position.set(0, FP_HEIGHT, 10);
session.addEventListener('end', () => { renderer.xr.enabled = false; });
});
};
}
});
}
// NPC facing: make nearby agents turn toward the player in FP mode
function updateNPCFacing() {
if (!fpMode) return;
const pp = fpCamera.position;
for (const [id, mesh] of agentMeshes) {
if (id === chatTargetId) {
const dx = pp.x - mesh.position.x;
const dz = pp.z - mesh.position.z;
mesh.rotation.y = Math.atan2(dx, dz);
mesh.userData.chatFrozen = true;
continue;
}
if (mesh.userData.chatFrozen) { mesh.userData.chatFrozen = false; }
const dx = pp.x - mesh.position.x;
const dz = pp.z - mesh.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < 8 && dist > 0.3) {
mesh.rotation.y = Math.atan2(dx, dz);
}
}
}
// Resize handler for FP camera
window.addEventListener('resize', () => {
fpCamera.aspect = window.innerWidth / window.innerHeight;
fpCamera.updateProjectionMatrix();
});
// ============================================================
// PLAYER CHARACTER
// ============================================================
let playerData = null;
let playerMesh = null;
window._showJoin = () => {
document.getElementById('player-login').style.display = 'block';
};
window._joinCity = () => {
const name = document.getElementById('player-name').value.trim() || 'Player';
const gender = document.getElementById('player-gender').value;
const age = parseInt(document.getElementById('player-age').value) || 25;
document.getElementById('player-login').style.display = 'none';
playerData = { name, gender, age, location: 'town_square', state: 'exploring', needs: { hunger: 0.8, energy: 0.9, social: 0.6, fun: 0.7 } };
const playerAgentData = { ...playerData, occupation: 'Explorer', lifePhase: 'playing' };
playerMesh = createAgentMesh('player', playerAgentData);
const sq = LOCATION_POSITIONS['town_square'];
const pos = toWorld(sq.x, sq.y);
playerMesh.position.set(pos.x, 0, pos.z);
document.getElementById('player-hud').style.display = 'block';
document.getElementById('phud-name').textContent = name;
updatePlayerHUD();
document.getElementById('btn-join').textContent = 'PLAYING';
document.getElementById('btn-join').classList.add('active');
enterFPMode();
fpCamera.position.set(pos.x, FP_HEIGHT, pos.z + 2);
};
function updatePlayerHUD() {
if (!playerData) return;
const n = playerData.needs;
let html = '';
for (const [k, v] of Object.entries(n)) {
const pct = (v * 100).toFixed(0);
const color = v > 0.6 ? '#4ecca3' : v > 0.3 ? '#f0c040' : '#e94560';
html += `<div style="display:flex;align-items:center;gap:4px;margin:2px 0">`;
html += `<span style="width:50px;color:#aaa">${k}</span>`;
html += `<div style="flex:1;height:6px;background:rgba(255,255,255,0.1);border-radius:3px"><div style="width:${pct}%;height:100%;background:${color};border-radius:3px"></div></div>`;
html += `<span style="width:28px;text-align:right;color:#888">${pct}%</span></div>`;
}
document.getElementById('phud-stats').innerHTML = html;
}
function updatePlayerPosition() {
if (!playerData || !playerMesh || !fpMode) return;
playerMesh.position.set(fpCamera.position.x, 0, fpCamera.position.z);
playerMesh.rotation.y = fpCamera.rotation.y;
playerMesh.visible = false;
}
// ============================================================
// KEYBOARD
// ============================================================
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (chatTargetId) { endNpcChat(); return; } if (fpMode) { exitFPMode(); return; } closeInfoPanel(); }
if (fpMode) {
if (e.key === 'w' || e.key === 'W' || e.key === 'ArrowUp') fpMoveState.forward = true;
if (e.key === 's' || e.key === 'S' || e.key === 'ArrowDown') fpMoveState.backward = true;
if (e.key === 'a' || e.key === 'A' || e.key === 'ArrowLeft') fpMoveState.left = true;
if (e.key === 'd' || e.key === 'D' || e.key === 'ArrowRight') fpMoveState.right = true;
if (e.key === 'e' || e.key === 'E') fpInteract();
return;
}
if (e.key === 'r' || e.key === 'R') resetCamera();
if (e.key === '+' || e.key === '=') zoomIn();
if (e.key === '-') zoomOut();
});
document.addEventListener('keyup', (e) => {
if (e.key === 'w' || e.key === 'W' || e.key === 'ArrowUp') fpMoveState.forward = false;
if (e.key === 's' || e.key === 'S' || e.key === 'ArrowDown') fpMoveState.backward = false;
if (e.key === 'a' || e.key === 'A' || e.key === 'ArrowLeft') fpMoveState.left = false;
if (e.key === 'd' || e.key === 'D' || e.key === 'ArrowRight') fpMoveState.right = false;
});
// ============================================================
// FALLBACK: REST polling if WebSocket isn't available
// ============================================================
async function pollFallback() {
if (wsConnected || isDemoMode) return;
try {
const resp = await fetch('/api/city');
if (resp.ok) {
const data = await resp.json();
const clk = data.clock || {};
const timeStr = clk.day ? `Day ${clk.day}, ${clk.time_str || '??:??'} (${(clk.time_of_day || 'morning').replace(/^./, c => c.toUpperCase())})` : '';
handleStateUpdate({ type: 'tick', state: data, time: timeStr });
document.getElementById('loading').classList.add('hidden');
}
} catch (e) { /* retry */ }
}
// ============================================================
// BOOT
// ============================================================
if (isEmbedded) {
document.getElementById('status-bar').style.display = 'none';
document.getElementById('controls-bar').style.display = 'none';
const hint = document.getElementById('zoom-hint');
if (hint) hint.style.display = 'none';
window.addEventListener('message', (e) => {
if (e.data?.type === 'zoom-in') zoomIn();
if (e.data?.type === 'zoom-out') zoomOut();
if (e.data?.type === 'reset-camera') resetCamera();
if (e.data?.type === 'set-speed') setDemoSpeed(e.data.multiplier);
if (e.data?.type === 'toggle-fp') window._toggleFP();
if (e.data?.type === 'show-join') window._showJoin();
});
}
connectWebSocket();
setInterval(pollFallback, 3000);
updateDetailLevel();
let demoSpeedMultiplier = 1.0;
let demoPaused = false;
function setDemoSpeed(mult) {
if (mult === 0) { demoPaused = true; return; }
demoPaused = false;
demoSpeedMultiplier = mult;
}
// Auto-dismiss loading overlay; show demo agents if no server
setTimeout(() => {
const el = document.getElementById('loading');
if (el && !el.classList.contains('hidden')) {
document.querySelector('.loading-sub').textContent = 'No server detected — showing static city';
setTimeout(() => {
el.classList.add('hidden');
if (!wsConnected) { isDemoMode = true; spawnDemoAgents(); }
}, 1500);
}
}, 4000);
function spawnDemoAgents() {
const NAMES_F = ['Elena','Helen','Diana','Priya','Rosa','Yuki','Lila','Zoe','Nina','Ada','Mia','Dara','Hana','Vera','Nadia','Petra','Ling','Alice','Maya','Sophie','Anya','Clara','Elsa','Greta','Irene','Kira','Marta','Olga','Rita','Tanya','Viola','Xena','Zara','Bianca','Dina','Fiona','Hazel','Jenna','Layla','Sasha','Eva','Chloe','Amber','Ruby','Ivy','Luna','Nora','Aria'];
const NAMES_M = ['Marcus','Kai','James','Frank','Omar','Theo','Ben','Carlos','Leo','Sven','Ivan','Rami','Tom','Jun','Marco','Devon','George','Sam','Felix','Alex','Boris','Dimitri','Farid','Hamid','Jake','Lukas','Nico','Paolo','Quinn','Stefan','Ulrich','Walter','Yusuf','Arnaud','Cyril','Emilio','Gustav','Igor','Erik','Hans','Oleg','Rolf','Dante','Hugo','Max','Owen','Noah','Liam'];
const OCCUPATIONS = ['Teacher','Engineer','Doctor','Artist','Chef','Writer','Nurse','Lawyer','Merchant','Farmer','Builder','Driver','Scientist','Musician','Guard','Clerk','Designer','Mechanic','Baker','Tailor'];
const residentialLocs = Object.entries(LOCATION_POSITIONS)
.filter(([, v]) => v.type === 'house' || v.type === 'apartment').map(([k]) => k);
const publicLocs = Object.keys(LOCATION_POSITIONS).filter(k => !k.startsWith('street') && k !== 'cemetery');
const ADULT_ONLY_TYPES = new Set(['office', 'factory', 'tower', 'hospital']);
const childSafeLocs = publicLocs.filter(k => !ADULT_ONLY_TYPES.has(LOCATION_POSITIONS[k]?.type));
const workLocs = Object.keys(LOCATION_POSITIONS).filter(k => {
const t = LOCATION_POSITIONS[k]?.type;
return t === 'office' || t === 'shop' || t === 'factory' || t === 'tower' || t === 'hospital' || t === 'school';
});
const demoAgents = {};
const agentHome = {};
const agentLife = {};
const agentMemories = {};
let nextId = 1;
function makeAgent(name, gender, age, homeLoc) {
const id = name.toLowerCase();
const occ = age >= 18 ? OCCUPATIONS[hash(id) % OCCUPATIONS.length] : (age >= 6 ? 'Student' : 'Child');
const lifePhase = age < 3 ? 'baby' : age < 6 ? 'kindergarten' : age < 18 ? 'school' : age < 23 ? 'university' : age < 65 ? 'working' : 'retired';
agentHome[id] = homeLoc;
agentLife[id] = { age, gender, partner: null, children: [], parents: [], lifePhase, occupation: occ, pregnant: false, pregnancyTimer: 0, alive: true };
agentMemories[id] = [`Born in Soci City`];
demoAgents[id] = {
name, location: homeLoc, state: 'idle', gender,
age, occupation: occ, lifePhase, parents: [],
needs: { hunger: 0.6 + Math.random() * 0.3, energy: 0.5 + Math.random() * 0.4, social: 0.4 + Math.random() * 0.5, fun: 0.4 + Math.random() * 0.4 },
recent_memories: agentMemories[id],
relationships: [],
};
return id;
}
// Create 100 agents with diverse ages
const allIds = [];
for (let i = 0; i < 50; i++) {
const fn = NAMES_F[i % NAMES_F.length] + (i >= NAMES_F.length ? String(Math.floor(i/NAMES_F.length)+1) : '');
const age = 3 + Math.floor(Math.random() * 80);
const home = residentialLocs[i % residentialLocs.length];
allIds.push(makeAgent(fn, 'female', age, home));
}
for (let i = 0; i < 50; i++) {
const mn = NAMES_M[i % NAMES_M.length] + (i >= NAMES_M.length ? String(Math.floor(i/NAMES_M.length)+1) : '');
const age = 3 + Math.floor(Math.random() * 80);
const home = residentialLocs[(i+14) % residentialLocs.length];
allIds.push(makeAgent(mn, 'male', age, home));
}
// Pre-marry some adults
const singleF = allIds.filter(id => agentLife[id].gender === 'female' && agentLife[id].age >= 20 && !agentLife[id].partner);
const singleM = allIds.filter(id => agentLife[id].gender === 'male' && agentLife[id].age >= 20 && !agentLife[id].partner);
const marriageCount = Math.min(singleF.length, singleM.length, 15);
for (let i = 0; i < marriageCount; i++) {
const f = singleF[i], m = singleM[i];
agentLife[f].partner = m;
agentLife[m].partner = f;
agentHome[m] = agentHome[f];
demoAgents[f].relationships.push({ name: demoAgents[m].name, type: 'spouse', closeness: 0.85 });
demoAgents[m].relationships.push({ name: demoAgents[f].name, type: 'spouse', closeness: 0.85 });
agentMemories[f].push(`Married ${demoAgents[m].name}`);
agentMemories[m].push(`Married ${demoAgents[f].name}`);
}
let demoDay = 1;
let demoMinute = 630;
let demoMonth = 4; // Start April 1st
let demoDayOfMonth = 1;
let demoYear = 2026;
function getSeason() {
if (demoMonth >= 3 && demoMonth <= 5) return 'spring';
if (demoMonth >= 6 && demoMonth <= 8) return 'summer';
if (demoMonth >= 9 && demoMonth <= 11) return 'autumn';
return 'winter';
}
const DAYS_IN_MONTH = [0,31,28,31,30,31,30,31,31,30,31,30,31];
function advanceCalendar() {
demoDayOfMonth++;
if (demoDayOfMonth > DAYS_IN_MONTH[demoMonth]) {
demoDayOfMonth = 1;
demoMonth++;
if (demoMonth > 12) { demoMonth = 1; demoYear++; ageAllAgents(); }
}
}
function ageAllAgents() {
for (const id of allIds) {
if (!agentLife[id]?.alive) continue;
agentLife[id].age++;
demoAgents[id].age = agentLife[id].age;
const a = agentLife[id].age;
if (a === 3) { agentLife[id].lifePhase = 'kindergarten'; agentLife[id].occupation = 'Child'; }
if (a === 6) { agentLife[id].lifePhase = 'school'; agentLife[id].occupation = 'Student'; agentMemories[id].push('Started school'); }
if (a === 18) { agentLife[id].lifePhase = 'university'; agentLife[id].occupation = 'Student'; agentMemories[id].push('Graduated high school'); }
if (a === 23) { agentLife[id].lifePhase = 'working'; agentLife[id].occupation = OCCUPATIONS[hash(id) % OCCUPATIONS.length]; agentMemories[id].push(`Started career as ${agentLife[id].occupation}`); }
if (a === 65) { agentLife[id].lifePhase = 'retired'; agentMemories[id].push('Retired'); }
demoAgents[id].occupation = agentLife[id].occupation;
demoAgents[id].lifePhase = agentLife[id].lifePhase;
}
}
let currentDemoWeather = 'sunny';
function pickWeather() {
const season = getSeason();
const r = Math.random();
if (r < 0.75) return 'sunny';
if (season === 'winter') {
if (r < 0.82) return 'cloudy';
if (r < 0.92) return 'snowy';
if (r < 0.97) return 'foggy';
return 'stormy';
}
if (r < 0.84) return 'cloudy';
if (r < 0.94) return 'rainy';
if (r < 0.97) return 'foggy';
return 'stormy';
}
let tickCount = 0;
const deadAgents = [];
const MONTH_NAMES = ['','Яну','Фев','Мар','Апр','Май','Юни','Юли','Авг','Сеп','Окт','Ное','Дек'];
function demoTimeStr() {
const hh = Math.floor(demoMinute / 60);
const mm = demoMinute % 60;
const phase = hh >= 5 && hh < 7 ? 'dawn' : hh >= 7 && hh < 12 ? 'morning' : hh >= 12 && hh < 17 ? 'afternoon' : hh >= 17 && hh < 20 ? 'evening' : 'night';
return `Day ${demoDay} (${demoDayOfMonth} ${MONTH_NAMES[demoMonth]} ${demoYear}), ${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')} (${phase})`;
}
function getLifePlan(id) {
const l = agentLife[id];
if (!l) return [];
const plan = [];
plan.push(l.age < 3 ? 'Baby at home' : l.age < 6 ? 'Kindergarten (3-5)' : l.age < 18 ? 'School (6-18)' : l.age < 23 ? 'University (18-23)' : l.age < 65 ? `Working as ${l.occupation}` : 'Retired');
if (l.age >= 18 && !l.partner) plan.push('Looking for a partner');
if (l.partner) plan.push(`Married to ${demoAgents[l.partner]?.name || l.partner}`);
if (l.pregnant) plan.push('Expecting a baby!');
if (l.children.length > 0) plan.push(`${l.children.length} child(ren)`);
if (l.age >= 65) plan.push('Enjoying retirement');
return plan;
}
function lifeCycleTick(hh) {
const aliveIds = allIds.filter(id => agentLife[id]?.alive);
tickCount++;
// Aging is now handled by calendar (ageAllAgents on Jan 1)
// Marriage: every 4 ticks, try to match single adults
if (tickCount % 4 === 0) {
const sf = aliveIds.filter(id => agentLife[id].gender === 'female' && agentLife[id].age >= 18 && !agentLife[id].partner);
const sm = aliveIds.filter(id => agentLife[id].gender === 'male' && agentLife[id].age >= 18 && !agentLife[id].partner);
if (sf.length > 0 && sm.length > 0 && Math.random() < 0.3) {
const f = sf[Math.floor(Math.random() * sf.length)];
const m = sm[Math.floor(Math.random() * sm.length)];
agentLife[f].partner = m;
agentLife[m].partner = f;
agentHome[m] = agentHome[f];
demoAgents[f].relationships.push({ name: demoAgents[m].name, type: 'spouse', closeness: 0.8 + Math.random() * 0.2 });
demoAgents[m].relationships.push({ name: demoAgents[f].name, type: 'spouse', closeness: 0.8 + Math.random() * 0.2 });
agentMemories[f].push(`Married ${demoAgents[m].name} (Day ${demoDay})`);
agentMemories[m].push(`Married ${demoAgents[f].name} (Day ${demoDay})`);
}
}
// Divorce: rare
if (tickCount % 8 === 0 && Math.random() < 0.05) {
const married = aliveIds.filter(id => agentLife[id].partner);
if (married.length > 0) {
const who = married[Math.floor(Math.random() * married.length)];
const ex = agentLife[who].partner;
if (ex && agentLife[ex]) {
agentLife[who].partner = null;
agentLife[ex].partner = null;
agentMemories[who].push(`Divorced ${demoAgents[ex]?.name} (Day ${demoDay})`);
agentMemories[ex].push(`Divorced ${demoAgents[who]?.name} (Day ${demoDay})`);
const newHome = residentialLocs[Math.floor(Math.random() * residentialLocs.length)];
agentHome[ex] = newHome;
}
}
}
// Pregnancy & birth (cap population at 120 alive)
const aliveCount = aliveIds.length;
const canBirth = aliveCount < 120;
for (const id of aliveIds) {
const l = agentLife[id];
if (canBirth && l.gender === 'female' && l.partner && l.age >= 18 && l.age <= 42 && !l.pregnant && l.children.length < 3) {
if (Math.random() < 0.003) {
l.pregnant = true;
l.pregnancyTimer = 9;
agentMemories[id].push(`Became pregnant (Day ${demoDay})`);
}
}
if (l.pregnant) {
l.pregnancyTimer--;
if (l.pregnancyTimer <= 0) {
l.pregnant = false;
const babyGender = Math.random() < 0.5 ? 'female' : 'male';
const namePool = babyGender === 'female' ? NAMES_F : NAMES_M;
const babyName = namePool[Math.floor(Math.random() * namePool.length)] + String(nextId++);
const babyId = makeAgent(babyName, babyGender, 0, agentHome[id]);
allIds.push(babyId);
l.children.push(babyId);
agentLife[babyId].parents = [id, l.partner].filter(Boolean);
demoAgents[babyId].parents = agentLife[babyId].parents.map(p => demoAgents[p]?.name || p);
if (l.partner && agentLife[l.partner]) agentLife[l.partner].children.push(babyId);
agentMemories[id].push(`Gave birth to ${babyName} (Day ${demoDay})`);
if (l.partner) agentMemories[l.partner].push(`Baby ${babyName} was born (Day ${demoDay})`);
}
}
}
// Illness & death: agents 70+ have increasing chance
if (tickCount % 5 === 0) {
for (const id of aliveIds) {
const a = agentLife[id].age;
if (a >= 70) {
const deathChance = (a - 65) * 0.004;
if (Math.random() < deathChance) {
if (demoAgents[id].state !== 'hospitalized') {
demoAgents[id].location = 'hospital';
demoAgents[id].state = 'hospitalized';
agentMemories[id].push(`Hospitalized with serious illness (Day ${demoDay})`);
} else {
agentLife[id].alive = false;
demoAgents[id].location = 'cemetery';
demoAgents[id].state = 'deceased';
demoAgents[id].deathDate = `${demoDayOfMonth} ${MONTH_NAMES[demoMonth]} ${demoYear}`;
demoAgents[id].deathAge = a;
demoAgents[id].needs = null;
deadAgents.push({ id, name: demoAgents[id].name, age: a, day: demoDay });
agentMemories[id].push(`Passed away at age ${a} (${demoDayOfMonth} ${MONTH_NAMES[demoMonth]} ${demoYear})`);
if (agentLife[id].partner) {
const p = agentLife[id].partner;
agentLife[p].partner = null;
agentMemories[p].push(`${demoAgents[id].name} passed away (Day ${demoDay})`);
}
// Remove from active after a few ticks
setTimeout(() => { delete demoAgents[id]; }, 12000);
}
}
}
}
}
// Update memories in agent data
for (const id of allIds) {
if (demoAgents[id]) {
demoAgents[id].recent_memories = (agentMemories[id] || []).slice(-8);
demoAgents[id].plan = getLifePlan(id);
}
}
}
handleStateUpdate({
type: 'tick', time: demoTimeStr(),
state: { agents: demoAgents, locations: {}, weather: currentDemoWeather }
});
document.getElementById('sim-agents').textContent = `${Object.keys(demoAgents).length} agents (demo)`;
function demoTick() {
const interval = Math.max(100, 2500 * demoSpeedMultiplier);
setTimeout(demoTick, interval);
if (wsConnected || demoPaused) return;
demoMinute += 20;
if (demoMinute >= 1440) { demoMinute -= 1440; demoDay++; moonPhaseDay++; advanceCalendar(); }
const hh = Math.floor(demoMinute / 60);
if (demoMinute % 60 === 0 && hh === 6) {
currentDemoWeather = pickWeather();
}
const w = currentDemoWeather;
const badW = w === 'rainy' || w === 'stormy' || w === 'snowy';
const isNightTime = hh >= 22 || hh < 6;
const isLateEvening = hh >= 20 && hh < 22;
const agents = Object.keys(demoAgents).filter(id => agentLife[id]?.alive);
// Life cycle events
lifeCycleTick(hh);
// Location-based on age/lifePhase
for (const who of agents) {
const l = agentLife[who];
if (!l || !l.alive) continue;
if (l.age < 3) { demoAgents[who].location = agentHome[who]; continue; }
}
if (isNightTime) {
for (const who of agents) {
if (!agentLife[who]?.alive || demoAgents[who].state === 'deceased') continue;
const home = agentHome[who] || residentialLocs[hash(who) % residentialLocs.length];
demoAgents[who].location = home;
demoAgents[who].state = 'sleeping';
}
} else if (isLateEvening) {
const goHomeCount = Math.floor(agents.length * 0.6);
for (let i = 0; i < goHomeCount; i++) {
const who = agents[Math.floor(Math.random() * agents.length)];
if (!agentLife[who]?.alive) continue;
const home = agentHome[who] || residentialLocs[hash(who) % residentialLocs.length];
demoAgents[who].location = home;
demoAgents[who].state = 'resting';
}
} else if (badW) {
const stayInCount = Math.floor(agents.length * 0.7);
for (let i = 0; i < stayInCount; i++) {
const who = agents[Math.floor(Math.random() * agents.length)];
if (!agentLife[who]?.alive) continue;
const home = agentHome[who] || residentialLocs[hash(who) % residentialLocs.length];
demoAgents[who].location = home;
demoAgents[who].state = 'sheltering';
}
} else {
// Daytime: agents go to age-appropriate locations
for (const who of agents) {
const l = agentLife[who];
if (!l || !l.alive) continue;
if (Math.random() > 0.15) continue;
if (l.age >= 3 && l.age < 6) {
demoAgents[who].location = 'kindergarten';
demoAgents[who].state = 'playing';
} else if (l.age >= 6 && l.age < 18) {
demoAgents[who].location = hh >= 8 && hh < 15 ? 'school' : childSafeLocs[Math.floor(Math.random() * childSafeLocs.length)];
demoAgents[who].state = hh >= 8 && hh < 15 ? 'studying' : 'idle';
} else if (l.age >= 18 && l.age < 23) {
demoAgents[who].location = hh >= 9 && hh < 16 ? 'university' : childSafeLocs[Math.floor(Math.random() * childSafeLocs.length)];
demoAgents[who].state = hh >= 9 && hh < 16 ? 'studying' : 'idle';
} else if (l.age >= 23 && l.age < 65) {
if (hh >= 9 && hh < 17) {
demoAgents[who].location = workLocs[hash(who) % workLocs.length];
demoAgents[who].state = 'working';
} else {
demoAgents[who].location = publicLocs[Math.floor(Math.random() * publicLocs.length)];
demoAgents[who].state = 'idle';
}
} else {
demoAgents[who].location = publicLocs[Math.floor(Math.random() * publicLocs.length)];
demoAgents[who].state = 'idle';
}
}
}
handleStateUpdate({
type: 'tick', time: demoTimeStr(),
state: { agents: demoAgents, locations: {}, weather: w }
});
document.getElementById('sim-agents').textContent = `${agents.length} agents (demo)` + (deadAgents.length > 0 ? ` | ${deadAgents.length} deceased` : '');
}
setTimeout(demoTick, 2500);
}
// Hide zoom hint after a few seconds
setTimeout(() => {
const hint = document.getElementById('zoom-hint');
if (hint) hint.style.opacity = '0';
}, 8000);
</script>
</body>
</html>