Download web/3d.html from RayMelius/soci2: direct link, hf CLI and curl.
- Browser
- Download file 188 kB
-
https://huggingface.co/spaces/RayMelius/soci2/resolve/main/web/3d.html
- Command line
-
hf download hf://spaces/RayMelius/soci2/web/3d.html
-
curl -L -o 3d.html https://huggingface.co/spaces/RayMelius/soci2/resolve/main/web/3d.html
188 kB
| <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()">×</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')">×</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 · Drag to orbit · Right-drag to pan · Click to inspect</div> | |
| <div id="controls-bar"> | |
| <button class="ctrl-btn" onclick="resetCamera()" title="Reset view">⌂</button> | |
| <button class="ctrl-btn" onclick="toggleTopDown()" title="Top-down view">▲</button> | |
| <button class="ctrl-btn" onclick="zoomIn()" title="Zoom in">+</button> | |
| <button class="ctrl-btn" onclick="zoomOut()" title="Zoom out">−</button> | |
| <button class="ctrl-btn" id="btn-fp" onclick="window._toggleFP()" title="First-person view">👁</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">👥</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 · Mouse — look · E — talk to NPC · 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">👁</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 · Mouse — look · 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()">×</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">🎤</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">● ${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> | |