| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Soci 3D — Live City Simulation</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { overflow: hidden; background: #0a0a12; font-family: 'Segoe UI', system-ui, sans-serif; } |
| canvas { display: block; } |
| |
| #status-bar { |
| position: fixed; top: 0; left: 0; right: 0; |
| display: flex; align-items: center; gap: 18px; |
| padding: 10px 20px; |
| background: rgba(10,10,18,0.75); |
| backdrop-filter: blur(12px); |
| border-bottom: 1px solid rgba(255,255,255,0.08); |
| color: #e0e0e8; font-size: 13px; z-index: 100; |
| pointer-events: none; |
| } |
| #status-bar > * { pointer-events: auto; } |
| .status-live { |
| display: flex; align-items: center; gap: 6px; |
| font-weight: 600; letter-spacing: 0.5px; |
| } |
| .live-dot { |
| width: 8px; height: 8px; border-radius: 50%; |
| background: #4ecca3; box-shadow: 0 0 8px #4ecca3; |
| animation: pulse 2s ease-in-out infinite; |
| } |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; transform: scale(1); } |
| 50% { opacity: 0.5; transform: scale(0.8); } |
| } |
| .status-time { font-size: 15px; font-weight: 500; } |
| .status-weather { font-size: 15px; } |
| .status-agents { color: #aaa; } |
| .status-cost { color: #888; font-size: 12px; } |
| .status-spacer { flex: 1; } |
| |
| #info-panel { |
| position: fixed; right: 16px; top: 60px; |
| width: 280px; max-height: calc(100vh - 80px); |
| background: rgba(10,10,18,0.85); |
| backdrop-filter: blur(16px); |
| border: 1px solid rgba(255,255,255,0.1); |
| border-radius: 12px; |
| color: #e0e0e8; font-size: 13px; |
| padding: 16px; overflow-y: auto; |
| z-index: 100; display: none; |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5); |
| } |
| #info-panel.visible { display: block; } |
| #info-panel h3 { |
| font-size: 16px; margin-bottom: 8px; |
| color: #fff; font-weight: 600; |
| } |
| #info-panel .close-btn { |
| position: absolute; top: 10px; right: 12px; |
| cursor: pointer; color: #888; font-size: 18px; |
| background: none; border: none; |
| } |
| #info-panel .close-btn:hover { color: #fff; } |
| .info-section { margin: 10px 0; } |
| .info-section h4 { |
| font-size: 11px; text-transform: uppercase; |
| letter-spacing: 1px; color: #888; margin-bottom: 4px; |
| } |
| .info-row { |
| display: flex; justify-content: space-between; |
| padding: 2px 0; border-bottom: 1px solid rgba(255,255,255,0.04); |
| } |
| .info-row .label { color: #aaa; } |
| .info-row .value { color: #e0e0e8; font-weight: 500; } |
| .need-bar { |
| height: 4px; background: #222; border-radius: 2px; |
| margin: 3px 0; overflow: hidden; |
| } |
| .need-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s; } |
| .memory-item { |
| padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.04); |
| font-size: 12px; color: #bbb; |
| } |
| |
| #agent-list-panel { |
| position: fixed; left: 16px; top: 60px; |
| width: 250px; max-height: calc(100vh - 80px); |
| background: rgba(10,10,18,0.88); |
| backdrop-filter: blur(16px); |
| border: 1px solid rgba(255,255,255,0.1); |
| border-radius: 12px; |
| color: #e0e0e8; font-size: 12px; |
| padding: 12px; overflow-y: auto; |
| z-index: 100; display: none; |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5); |
| } |
| #agent-list-panel.visible { display: block; } |
| #agent-list-panel .close-btn { |
| position: absolute; top: 8px; right: 10px; |
| cursor: pointer; color: #888; font-size: 16px; |
| background: none; border: none; |
| } |
| #agent-list-panel .close-btn:hover { color: #fff; } |
| .sort-btns { display: flex; gap: 4px; margin: 6px 0; flex-wrap: wrap; } |
| .sort-btn { |
| padding: 3px 8px; border-radius: 4px; font-size: 10px; |
| background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); |
| color: #aaa; cursor: pointer; |
| } |
| .sort-btn:hover, .sort-btn.active { background: rgba(78,204,163,0.2); color: #4ecca3; border-color: #4ecca3; } |
| .agent-list-entry { |
| display: flex; align-items: center; gap: 6px; |
| padding: 3px 4px; cursor: pointer; border-radius: 4px; |
| } |
| .agent-list-entry:hover { background: rgba(255,255,255,0.06); } |
| |
| #zoom-hint { |
| position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); |
| color: rgba(255,255,255,0.4); font-size: 12px; |
| pointer-events: none; z-index: 100; |
| transition: opacity 0.5s; |
| } |
| |
| #controls-bar { |
| position: fixed; bottom: 16px; right: 16px; |
| display: flex; gap: 6px; z-index: 100; |
| } |
| .ctrl-btn { |
| width: 36px; height: 36px; border-radius: 8px; |
| background: rgba(10,10,18,0.75); |
| backdrop-filter: blur(12px); |
| border: 1px solid rgba(255,255,255,0.1); |
| color: #ccc; font-size: 16px; cursor: pointer; |
| display: flex; align-items: center; justify-content: center; |
| transition: all 0.2s; |
| } |
| .ctrl-btn:hover { background: rgba(30,30,50,0.85); color: #fff; } |
| .ctrl-btn.active { background: rgba(78,204,163,0.3); border-color: #4ecca3; color: #4ecca3; } |
| |
| #player-login { |
| position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); |
| background: rgba(10,10,18,0.95); border: 1px solid rgba(78,204,163,0.4); |
| border-radius: 12px; padding: 24px 32px; z-index: 300; display: none; |
| backdrop-filter: blur(16px); min-width: 280px; text-align: center; |
| } |
| #player-login h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; font-weight: 400; letter-spacing: 2px; } |
| #player-login input { |
| width: 100%; padding: 8px 12px; margin: 6px 0; border: 1px solid rgba(255,255,255,0.15); |
| border-radius: 6px; background: rgba(255,255,255,0.06); color: #e0e0e8; font-size: 14px; |
| box-sizing: border-box; |
| } |
| #player-login select { |
| width: 100%; padding: 8px 12px; margin: 6px 0; border: 1px solid rgba(255,255,255,0.15); |
| border-radius: 6px; background: rgba(20,20,35,0.9); color: #e0e0e8; font-size: 14px; |
| } |
| #player-login button { |
| margin-top: 12px; padding: 8px 24px; border: none; border-radius: 6px; |
| background: #4ecca3; color: #0a0a12; font-size: 14px; cursor: pointer; font-weight: 600; |
| } |
| #player-login button:hover { background: #6be0b8; } |
| #player-hud { |
| position: fixed; top: 50px; left: 12px; background: rgba(10,10,18,0.8); |
| border: 1px solid rgba(78,204,163,0.3); border-radius: 8px; padding: 10px 14px; |
| z-index: 60; display: none; font-size: 12px; color: #ccc; min-width: 160px; |
| backdrop-filter: blur(8px); |
| } |
| #player-hud .phud-name { color: #4ecca3; font-size: 14px; font-weight: 500; margin-bottom: 6px; } |
| #npc-chat { |
| display:none; position:fixed; bottom:20px; left:50%; transform:translateX(-50%); |
| width:500px; max-width:90vw; background:rgba(10,12,20,0.92); border:1px solid rgba(78,204,163,0.3); |
| border-radius:12px; z-index:200; font-family:inherit; backdrop-filter:blur(8px); |
| } |
| #npc-chat .chat-header { |
| display:flex; align-items:center; justify-content:space-between; padding:10px 14px; |
| border-bottom:1px solid rgba(255,255,255,0.08); |
| } |
| #npc-chat .chat-header .chat-name { color:#4ecca3; font-size:14px; font-weight:500; } |
| #npc-chat .chat-header .chat-close { background:none; border:none; color:#888; cursor:pointer; font-size:18px; } |
| #npc-chat .chat-header .chat-close:hover { color:#fff; } |
| #npc-chat .chat-messages { |
| max-height:200px; overflow-y:auto; padding:10px 14px; font-size:13px; line-height:1.5; |
| } |
| #npc-chat .chat-messages .msg { margin:6px 0; } |
| #npc-chat .chat-messages .msg-player { color:#88bbff; } |
| #npc-chat .chat-messages .msg-npc { color:#e8d8b8; } |
| #npc-chat .chat-messages .msg-system { color:#888; font-style:italic; font-size:12px; } |
| #npc-chat .chat-input { |
| display:flex; align-items:center; gap:6px; padding:8px 14px; |
| border-top:1px solid rgba(255,255,255,0.08); |
| } |
| #npc-chat .chat-input input { |
| flex:1; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12); |
| color:#fff; padding:8px 10px; border-radius:6px; font-size:13px; outline:none; |
| } |
| #npc-chat .chat-input input:focus { border-color:rgba(78,204,163,0.4); } |
| #npc-chat .chat-input button { |
| background:rgba(78,204,163,0.15); border:1px solid rgba(78,204,163,0.3); color:#4ecca3; |
| padding:8px 12px; border-radius:6px; cursor:pointer; font-size:13px; |
| } |
| #npc-chat .chat-input button:hover { background:rgba(78,204,163,0.25); } |
| #npc-chat .chat-input .mic-btn { font-size:16px; padding:6px 10px; } |
| #npc-chat .chat-input .mic-btn.recording { color:#e94560; border-color:rgba(233,69,96,0.5); background:rgba(233,69,96,0.15); } |
| |
| #loading { |
| position: fixed; inset: 0; |
| display: flex; flex-direction: column; |
| align-items: center; justify-content: center; |
| background: #0a0a12; color: #e0e0e8; z-index: 200; |
| transition: opacity 0.8s; |
| } |
| #loading.hidden { opacity: 0; pointer-events: none; } |
| .loading-title { font-size: 28px; font-weight: 300; letter-spacing: 4px; margin-bottom: 12px; } |
| .loading-sub { font-size: 13px; color: #888; } |
| .loading-spinner { |
| width: 32px; height: 32px; border: 2px solid #333; |
| border-top-color: #4ecca3; border-radius: 50%; |
| animation: spin 1s linear infinite; margin-top: 20px; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| </style> |
| </head> |
| <body> |
|
|
| <div id="loading"> |
| <div class="loading-title">SOCI</div> |
| <div class="loading-sub">Connecting to live simulation...</div> |
| <div class="loading-spinner"></div> |
| </div> |
|
|
| <div id="status-bar"> |
| <div class="status-live"><div class="live-dot"></div>LIVE</div> |
| <div class="status-time" id="sim-time">--:--</div> |
| <div class="status-weather" id="sim-weather"></div> |
| <div class="status-agents" id="sim-agents">0 agents</div> |
| <div class="status-spacer"></div> |
| <div class="status-cost" id="sim-cost"></div> |
| </div> |
|
|
| <div id="info-panel"> |
| <button class="close-btn" onclick="closeInfoPanel()">×</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'; |
| |
| |
| |
| |
| 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 = { |
| |
| 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' }, |
| |
| |
| 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' }, |
| |
| |
| 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: { x: 0.50, y: 0.78, type: 'market', label: 'Farmers Market' }, |
| |
| |
| mall: { x: 0.78, y: 0.88, type: 'mall', label: 'City Mall' }, |
| |
| |
| 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' }, |
| |
| |
| factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' }, |
| factory_2: { x: 0.92, y: 0.88, type: 'factory', label: 'Soci Textiles' }, |
| |
| |
| 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' }, |
| |
| |
| 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' }, |
| |
| |
| 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: { 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' }, |
| |
| |
| 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 }; |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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(); |
| |
| |
| 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, |
| }; |
| |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| |
| const ns = toWorld(0.5, 0); |
| createRoad(0, -HALF * 1.1, 0, HALF * 1.1, 4); |
| createRoad(-HALF * 1.1, -7, HALF * 1.1, -7, 4); |
| |
| |
| createRoad(-HALF * 1.1, -38, HALF * 1.1, -38, 2.5); |
| createRoad(-HALF * 1.1, -31, HALF * 1.1, -31, 2.5); |
| createRoad(-HALF * 1.1, -16, HALF * 1.1, -16, 2.5); |
| createRoad(-HALF * 1.1, 0, HALF * 1.1, 0, 2.5); |
| createRoad(-HALF * 1.1, 8, HALF * 1.1, 8, 2); |
| createRoad(-HALF * 1.1, 15, HALF * 1.1, 15, 2.5); |
| createRoad(-HALF * 1.1, 28, HALF * 1.1, 28, 2.5); |
| createRoad(-HALF * 1.1, 38, HALF * 1.1, 38, 2.5); |
| |
| |
| 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); |
| |
| |
| createRoad(-HALF * 1.1, -48, HALF * 1.1, -48, 2); |
| createRoad(-HALF * 1.1, 48, HALF * 1.1, 48, 2); |
| createRoad(-52, -HALF * 1.1, -52, HALF * 1.1, 2); |
| createRoad(52, -HALF * 1.1, 52, HALF * 1.1, 2); |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| } |
| } |
| } |
| |
| |
| |
| |
| 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 }); |
| } |
| |
| |
| 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; |
| if (!tooClose) { |
| createTree(tx, tz, 0.3 + Math.random() * 0.4); |
| treePositions.push({ x: tx, z: tz }); |
| } |
| } |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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 }); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| let currentPhase = 'morning'; |
| let simHour = 10; |
| |
| |
| 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'); |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| function updateCelestials(hour) { |
| |
| 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; |
| } |
| |
| 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); |
| |
| |
| function updateTimeOfDay(phase, hour) { |
| currentPhase = phase; |
| simHour = hour; |
| isNight = phase === 'night' || phase === 'evening'; |
| |
| updateSkyGradient(phase); |
| updateCelestials(hour); |
| |
| |
| 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; |
| }); |
| |
| |
| 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; })); |
| |
| |
| const moonSkyColors = { night: 0x0e1530, evening: 0x1a1040, dawn: 0x2d1b4e, morning: 0x4a90c8, afternoon: 0x2878b8 }; |
| moonShadowMat.color.set(moonSkyColors[phase] || 0x0e1530); |
| |
| |
| 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); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| } |
| }); |
| } |
| } |
| |
| |
| 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 }; |
| } |
| |
| |
| |
| |
| 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; |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| const legLen = 0.50 * S, legR = 0.065 * S; |
| const hipW = 0.09 * S, hipY = 0.92 * S; |
| let leftLeg, rightLeg; |
| |
| if (wearsSkirt) { |
| |
| 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); |
| |
| 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 { |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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 = {}; |
| |
| |
| |
| |
| 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) { } |
| }; |
| |
| 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 || {}; |
| |
| |
| if (isEmbedded) window.parent.postMessage({ type: 'state-update', state: simState, time: msg.time }, '*'); |
| |
| |
| 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)}`; |
| } |
| |
| |
| const { hour, phase } = parseSimTime(msg.time || ''); |
| updateTimeOfDay(phase, hour); |
| updateWeather(weather); |
| |
| |
| 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]); |
| } |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| for (const [agentId, mesh] of agentMeshes) { |
| if (!seenAgents.has(agentId)) { |
| scene.remove(mesh); |
| agentMeshes.delete(agentId); |
| agentTargetPositions.delete(agentId); |
| } |
| } |
| |
| |
| if (selectedId && agents[selectedId]) updateAgentInfo(selectedId, agents[selectedId]); |
| } |
| |
| |
| |
| |
| |
| |
| 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); |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| exitInterior(); |
| closeInfoPanel(); |
| }); |
| |
| |
| 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>`; |
| |
| |
| 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>`; |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| 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; } |
| |
| |
| _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; |
| } |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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; |
| |
| |
| 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 { |
| |
| 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 { |
| |
| 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; |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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; |
| } |
| }); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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); |
| }); |
| |
| |
| |
| |
| 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; |
| |
| |
| fpCamera.position.x = Math.max(-HALF, Math.min(HALF, fpCamera.position.x)); |
| fpCamera.position.z = Math.max(-HALF, Math.min(HALF, fpCamera.position.z)); |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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; }); |
| }); |
| }; |
| } |
| }); |
| } |
| |
| |
| 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); |
| } |
| } |
| } |
| |
| |
| window.addEventListener('resize', () => { |
| fpCamera.aspect = window.innerWidth / window.innerHeight; |
| fpCamera.updateProjectionMatrix(); |
| }); |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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; |
| }); |
| |
| |
| |
| |
| 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) { } |
| } |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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)); |
| } |
| |
| |
| 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; |
| 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++; |
| |
| |
| |
| |
| 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})`); |
| } |
| } |
| |
| |
| 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; |
| } |
| } |
| } |
| |
| |
| 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})`); |
| } |
| } |
| } |
| |
| |
| 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})`); |
| } |
| |
| setTimeout(() => { delete demoAgents[id]; }, 12000); |
| } |
| } |
| } |
| } |
| } |
| |
| |
| 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); |
| |
| |
| lifeCycleTick(hh); |
| |
| |
| 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 { |
| |
| 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); |
| } |
| |
| |
| setTimeout(() => { |
| const hint = document.getElementById('zoom-hint'); |
| if (hint) hint.style.opacity = '0'; |
| }, 8000); |
| </script> |
| </body> |
| </html> |
|
|