Download web/index.html from RayMelius/soci2: direct link, hf CLI and curl.
- Browser
- Download file 178 kB
-
https://huggingface.co/spaces/RayMelius/soci2/resolve/main/web/index.html
- Command line
-
hf download hf://spaces/RayMelius/soci2/web/index.html
-
curl -L -o index.html https://huggingface.co/spaces/RayMelius/soci2/resolve/main/web/index.html
178 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Soci City — Population Simulator</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: #1a1a2e; | |
| color: #e0e0e0; | |
| overflow: hidden; | |
| height: 100vh; | |
| } | |
| #header { | |
| background: #16213e; | |
| padding: 10px 20px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| border-bottom: 2px solid #0f3460; | |
| height: 50px; | |
| } | |
| #header h1 { font-size: 18px; color: #e94560; letter-spacing: 2px; } | |
| #header .info { display: flex; gap: 20px; font-size: 13px; color: #a0a0c0; align-items: center; } | |
| #header .info span { display: flex; align-items: center; gap: 6px; } | |
| #weekend-badge { | |
| display: none; align-items: center; gap: 5px; | |
| background: linear-gradient(135deg, #f0c040, #e67e22); | |
| color: #1a1a2e; font-weight: 800; font-size: 11px; | |
| padding: 3px 10px; border-radius: 12px; | |
| letter-spacing: 1px; text-transform: uppercase; | |
| box-shadow: 0 0 10px rgba(240,192,64,0.5); | |
| animation: weekend-pulse 2s ease-in-out infinite; | |
| } | |
| @keyframes weekend-pulse { | |
| 0%, 100% { box-shadow: 0 0 8px rgba(240,192,64,0.4); } | |
| 50% { box-shadow: 0 0 18px rgba(240,192,64,0.8); } | |
| } | |
| .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } | |
| .dot.green { background: #4ecca3; } .dot.yellow { background: #f0c040; } .dot.red { background: #e94560; } | |
| #main { display: flex; flex-direction: row; height: calc(100vh - 50px); } | |
| #viewport-3d { flex: 1; position: relative; min-width: 0; } | |
| #viewport-3d iframe { width: 100%; height: 100%; border: none; display: block; } | |
| #canvas-container { display: none; } | |
| #cityCanvas { display: none; } | |
| /* RESIZE HANDLE */ | |
| #sidebar-resizer { | |
| width: 5px; cursor: col-resize; background: #0f3460; | |
| flex-shrink: 0; transition: background 0.2s; | |
| } | |
| #sidebar-resizer:hover, #sidebar-resizer.active { background: #4ecca3; } | |
| /* RIGHT DATA PANEL (resizable) */ | |
| #sidebar { | |
| width: 320px; min-width: 200px; max-width: 600px; | |
| background: #16213e; border-left: 2px solid #0f3460; | |
| display: flex; flex-direction: column; overflow: hidden; | |
| flex-shrink: 0; | |
| } | |
| .sidebar-tabs { | |
| display: flex; border-bottom: 2px solid #0f3460; flex-shrink: 0; | |
| } | |
| .sidebar-tab { | |
| flex: 1; padding: 8px 4px; text-align: center; font-size: 11px; font-weight: bold; | |
| color: #666; cursor: pointer; border-bottom: 2px solid transparent; | |
| transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px; | |
| } | |
| .sidebar-tab:hover { color: #a0a0c0; background: rgba(255,255,255,0.02); } | |
| .sidebar-tab.active { color: #4ecca3; border-bottom-color: #4ecca3; } | |
| .tab-content { display: none; flex: 1; overflow-y: auto; } | |
| .tab-content.active { display: flex; flex-direction: column; } | |
| #agent-detail { padding: 12px; flex: 1; overflow-y: auto; } | |
| #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; } | |
| #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 6px; } | |
| .bar-container { margin: 3px 0; } | |
| .bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; } | |
| .bar-bg { height: 6px; background: #0f3460; border-radius: 3px; overflow: hidden; margin-top: 1px; } | |
| .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; } | |
| .bar-fill.green { background: #4ecca3; } .bar-fill.yellow { background: #f0c040; } | |
| .bar-fill.red { background: #e94560; } .bar-fill.blue { background: #4e9eca; } | |
| .bar-fill.purple { background: #9b59b6; } .bar-fill.orange { background: #e67e22; } | |
| .bar-fill.pink { background: #e91e90; } | |
| .memory-item { font-size: 11px; color: #c0c0d0; padding: 3px 0; border-bottom: 1px solid #0f346030; } | |
| .memory-time { color: #666; font-size: 10px; } | |
| .agent-list-item { | |
| font-size: 11px; padding: 5px 6px; cursor: pointer; display: flex; align-items: center; gap: 6px; | |
| border-bottom: 1px solid #0f346020; transition: background 0.15s; | |
| } | |
| .agent-list-item:hover { background: rgba(78,204,163,0.08); } | |
| .agent-list-item .agent-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } | |
| .agent-list-item .agent-info { flex: 1; min-width: 0; } | |
| .agent-list-item .agent-name { font-weight: 600; } | |
| .agent-list-item .agent-action { color: #666; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| #conversations-panel { padding: 0; flex: 1; overflow-y: auto; min-width: 0; } | |
| .conv-card { | |
| margin: 8px; padding: 10px; background: #1a1a2e; border-radius: 6px; | |
| border: 1px solid #0f3460; | |
| } | |
| .conv-card.active-conv { border-color: #4ecca3; } | |
| .conv-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } | |
| .conv-topic { font-size: 12px; color: #4ecca3; font-weight: 600; } | |
| .conv-badge { font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold; text-transform: uppercase; } | |
| .conv-badge.live { background: #4ecca3; color: #1a1a2e; } | |
| .conv-badge.ended { background: #333; color: #888; } | |
| .conv-participants { font-size: 10px; color: #888; margin-bottom: 6px; } | |
| .conv-turn { padding: 4px 0; font-size: 11px; line-height: 1.4; } | |
| .conv-speaker { font-weight: 600; } | |
| .conv-message { color: #d0d0e0; } | |
| .conv-empty { padding: 20px; text-align: center; color: #555; font-size: 12px; } | |
| #event-log-panel { padding: 8px 12px; flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; } | |
| .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; } | |
| .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; } | |
| .event-line.event { color: #e94560; } .event-line.time { color: #666; font-weight: bold; margin-top: 6px; } | |
| .event-line.romance { color: #e91e90; } | |
| .event-line.move { color: #4e9eca; } | |
| .event-line.reflect { color: #9b59b6; } | |
| #tooltip { | |
| position: absolute; background: #16213eee; border: 1px solid #4ecca3; | |
| border-radius: 6px; padding: 8px 12px; font-size: 12px; | |
| pointer-events: none; display: none; z-index: 100; max-width: 280px; | |
| } | |
| #toast-container { | |
| position: absolute; bottom: 12px; left: 12px; z-index: 200; | |
| display: flex; flex-direction: column-reverse; gap: 6px; | |
| pointer-events: none; max-width: 360px; | |
| } | |
| .toast { | |
| background: #16213eee; border-radius: 6px; padding: 8px 14px; | |
| font-size: 12px; color: #e0e0e0; border-left: 3px solid #4ecca3; | |
| animation: toastIn 0.3s ease, toastOut 0.5s ease 4.5s forwards; | |
| backdrop-filter: blur(4px); line-height: 1.4; | |
| } | |
| .toast.romance { border-left-color: #e91e90; } | |
| .toast.event { border-left-color: #e94560; } | |
| .toast.conv { border-left-color: #f0c040; } | |
| .toast.gossip { border-left-color: #9b59b6; } | |
| @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } } | |
| #llm-model { cursor: pointer; user-select: none; } | |
| #llm-model:hover { color: #fff; } | |
| #llm-popup { | |
| display: none; position: fixed; z-index: 9999; | |
| background: #0d1b2e; border: 1px solid #1a3a6e; | |
| border-radius: 8px; padding: 6px 0; min-width: 200px; | |
| box-shadow: 0 6px 24px rgba(0,0,0,0.6); | |
| font-size: 13px; | |
| } | |
| #llm-popup .llm-pop-title { | |
| padding: 6px 14px 8px; color: #4ecca3; font-size: 11px; | |
| font-weight: 700; letter-spacing: 1px; border-bottom: 1px solid #0f3460; | |
| margin-bottom: 4px; | |
| } | |
| #llm-popup .llm-opt { | |
| display: flex; align-items: center; gap: 10px; flex-wrap: wrap; | |
| padding: 7px 14px; cursor: pointer; color: #c8c8d8; | |
| transition: background 0.15s; | |
| } | |
| #llm-popup .llm-opt:hover { background: #0f3460; color: #fff; } | |
| #llm-popup .llm-opt.active { color: #4ecca3; } | |
| #llm-popup .llm-opt .llm-check { width: 12px; text-align: center; font-size: 10px; } | |
| .section-header { | |
| font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600; | |
| display: flex; align-items: center; gap: 6px; | |
| } | |
| .section-header::after { content: ''; flex: 1; height: 1px; background: #0f3460; } | |
| .rel-item { font-size: 11px; padding: 4px 0; border-bottom: 1px solid #0f346020; cursor: pointer; } | |
| .rel-item:hover { background: rgba(78,204,163,0.05); } | |
| .rel-name { font-weight: 600; color: #d0d0e0; } | |
| .rel-bars { display: flex; gap: 4px; margin-top: 2px; } | |
| .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; } | |
| .rel-mini-fill { height: 100%; border-radius: 2px; } | |
| .controls { display: flex; align-items: center; gap: 4px; } | |
| .ctrl-btn { | |
| background: #0f3460; border: 1px solid #1a3a6e; color: #a0a0c0; | |
| padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px; | |
| transition: all 0.15s; | |
| } | |
| .ctrl-btn:hover { background: #1a4a80; color: #fff; } | |
| .ctrl-btn.active { background: #4ecca3; color: #1a1a2e; border-color: #4ecca3; } | |
| .ctrl-btn.paused { background: #e94560; color: #fff; border-color: #e94560; } | |
| .speed-label { font-size: 10px; color: #666; margin-left: 2px; } | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: #1a1a2e; } | |
| ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; } | |
| /* ── Player / Auth UI ────────────────────────────────────────────── */ | |
| .modal-overlay { | |
| position: fixed; inset: 0; background: rgba(0,0,0,0.72); | |
| display: flex; align-items: center; justify-content: center; z-index: 1000; | |
| } | |
| .modal-box { | |
| background: #0f1b35; border: 1px solid #1a3a6e; border-radius: 8px; | |
| padding: 24px 28px; min-width: 300px; max-width: 420px; width: 90%; | |
| color: #e0e0e0; | |
| } | |
| .modal-box h2 { color: #4ecca3; margin: 0 0 16px; font-size: 18px; } | |
| .modal-box label { display: block; font-size: 11px; color: #888; margin: 10px 0 3px; } | |
| .modal-box input, .modal-box textarea, .modal-box select { | |
| width: 100%; box-sizing: border-box; background: #0a1628; border: 1px solid #1a3a6e; | |
| color: #e0e0e0; padding: 7px 10px; border-radius: 4px; font-size: 13px; | |
| } | |
| .modal-box textarea { resize: vertical; min-height: 60px; font-family: inherit; } | |
| .modal-box input[type=range] { padding: 4px 0; border: none; background: none; } | |
| .modal-row { display: flex; gap: 10px; } | |
| .modal-row > * { flex: 1; } | |
| .modal-actions { display: flex; gap: 10px; margin-top: 18px; justify-content: flex-end; } | |
| .btn-primary { | |
| background: #4ecca3; color: #0a1628; border: none; padding: 8px 18px; | |
| border-radius: 4px; cursor: pointer; font-weight: 700; font-size: 13px; | |
| } | |
| .btn-primary:hover { background: #3ab88e; } | |
| .btn-secondary { | |
| background: transparent; color: #a0a0c0; border: 1px solid #1a3a6e; | |
| padding: 8px 18px; border-radius: 4px; cursor: pointer; font-size: 13px; | |
| } | |
| .btn-secondary:hover { color: #e0e0e0; border-color: #4ecca3; } | |
| .btn-danger { | |
| background: transparent; color: #e94560; border: 1px solid #e94560; | |
| padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 11px; | |
| } | |
| .auth-switch { text-align: center; margin-top: 12px; font-size: 12px; color: #666; } | |
| .auth-switch a { color: #4ecca3; cursor: pointer; text-decoration: underline; } | |
| .auth-error { color: #e94560; font-size: 12px; margin-top: 8px; } | |
| #player-panel { | |
| background: rgba(78,204,163,0.06); border: 1px solid rgba(78,204,163,0.2); | |
| border-radius: 6px; margin: 8px; padding: 10px 12px; display: none; | |
| } | |
| #player-panel .pp-header { | |
| display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; | |
| } | |
| #player-panel .pp-title { color: #4ecca3; font-size: 11px; font-weight: 700; letter-spacing: 1px; } | |
| #player-panel .pp-name { color: #e0e0e0; font-size: 13px; font-weight: 600; } | |
| #player-panel .pp-loc { color: #888; font-size: 10px; } | |
| .pp-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } | |
| .pp-btn { | |
| background: #0f3460; color: #a0a0c0; border: 1px solid #1a3a6e; | |
| padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; | |
| } | |
| .pp-btn:hover { background: #1a4a80; color: #e0e0e0; } | |
| .pp-move { display: flex; gap: 4px; margin-top: 6px; align-items: center; } | |
| .pp-move select { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 4px 6px; border-radius: 3px; font-size: 11px; } | |
| .pp-move button { background: #4ecca3; color: #0a1628; border: none; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 11px; font-weight: 700; } | |
| /* Chat panel */ | |
| #chat-panel { | |
| border-top: 1px solid #1a3a6e; margin-top: 10px; padding-top: 10px; display: none; | |
| } | |
| #chat-panel .chat-header { color: #4ecca3; font-size: 11px; font-weight: 700; margin-bottom: 6px; display: flex; justify-content: space-between; } | |
| #chat-messages { max-height: 180px; overflow-y: auto; font-size: 12px; margin-bottom: 8px; } | |
| .chat-msg-you { color: #4ecca3; margin: 3px 0; } | |
| .chat-msg-npc { color: #e0e0e0; margin: 3px 0; } | |
| .chat-msg-thinking { color: #666; font-style: italic; margin: 3px 0; } | |
| #chat-input-row { display: flex; gap: 6px; } | |
| #chat-input { flex: 1; background: #0a1628; color: #e0e0e0; border: 1px solid #1a3a6e; padding: 6px 8px; border-radius: 3px; font-size: 12px; font-family: inherit; } | |
| #chat-send { background: #4ecca3; color: #0a1628; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-weight: 700; font-size: 12px; } | |
| .slider-val { font-size: 12px; color: #4ecca3; min-width: 20px; text-align: right; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="header"> | |
| <h1>SOCI CITY</h1> | |
| <div class="info"> | |
| <span id="clock">Day 1, 06:00</span> | |
| <span id="weekend-badge">🏖️ Weekend</span> | |
| <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span> | |
| <span id="agent-count"><span class="dot green"></span> 0 agents</span> | |
| <span id="conv-count">0 convos</span> | |
| <span id="llm-model" title="Active LLM model">⚡ —</span> | |
| <span class="controls"> | |
| <button class="ctrl-btn" id="btn-pause" onclick="togglePause()" title="Pause/Resume">⏯</button> | |
| <button class="ctrl-btn" id="btn-slow" onclick="setSpeed(3.0)" title="Slow">🐢</button> | |
| <button class="ctrl-btn active" id="btn-1x" onclick="setSpeed(1.0)" title="Normal">1x</button> | |
| <button class="ctrl-btn" id="btn-2x" onclick="setSpeed(0.5)" title="2x speed">2x</button> | |
| <button class="ctrl-btn" id="btn-5x" onclick="setSpeed(0.2)" title="5x speed">5x</button> | |
| <button class="ctrl-btn" id="btn-10x" onclick="setSpeed(0.1)" title="10x speed">10x</button> | |
| <button class="ctrl-btn" id="btn-50x" onclick="setSpeed(0.02)" title="50x speed">50x</button> | |
| <span class="speed-label" id="speed-label">1x</span> | |
| <span style="color:#1a3a6e;margin:0 4px">│</span> | |
| <span style="font-size:10px;color:#666;white-space:nowrap" title="LLM call probability: controls how often agents use AI reasoning vs. routine behaviour. At 45% with Gemini free tier ≈ 10h daily runtime.">🧠</span> | |
| <input type="range" id="llm-prob-slider" min="0" max="100" value="10" step="5" | |
| style="width:64px;height:6px;accent-color:#4ecca3;cursor:pointer;vertical-align:middle;" | |
| oninput="onLlmProbSlider(this.value)" title="LLM usage probability"> | |
| <span id="llm-prob-label" style="font-size:10px;color:#4ecca3;min-width:28px;text-align:right;">10%</span> | |
| <span style="color:#1a3a6e;margin:0 4px">│</span> | |
| <button class="ctrl-btn" onclick="send3d('zoom-in')" title="Zoom In (scroll in 3D view)">+</button> | |
| <button class="ctrl-btn" onclick="send3d('zoom-out')" title="Zoom Out (scroll in 3D view)">-</button> | |
| <button class="ctrl-btn" onclick="send3d('reset-camera')" title="Reset 3D camera">Fit</button> | |
| <span style="color:#1a3a6e;margin:0 4px">│</span> | |
| <button class="ctrl-btn" id="btn-fp-main" onclick="send3d('toggle-fp')" title="First-person view">👁 FP</button> | |
| <button class="ctrl-btn" id="btn-join-main" onclick="send3d('show-join')" title="Join as player">JOIN</button> | |
| </span> | |
| <span id="api-calls">API: 0</span> | |
| <span id="cost">$0.00</span> | |
| <span id="status"><span class="dot yellow"></span> Connecting...</span> | |
| </div> | |
| </div> | |
| <div id="main"> | |
| <div id="viewport-3d"> | |
| <iframe id="city3d" allow="autoplay; pointerlock; xr-spatial-tracking; fullscreen"></iframe> | |
| <script>document.getElementById('city3d').src = location.protocol === 'file:' ? '3d.html' : '/3d';</script> | |
| <div id="tooltip" style="z-index:200"></div> | |
| <div id="toast-container"></div> | |
| <div id="llm-popup"><div class="llm-pop-title">SWITCH LLM</div></div> | |
| </div> | |
| <div id="sidebar-resizer"></div> | |
| <div id="sidebar"> | |
| <div class="sidebar-tabs"> | |
| <div class="sidebar-tab active" data-tab="agents" onclick="switchTab('agents')">Agents</div> | |
| <div class="sidebar-tab" data-tab="conversations" onclick="switchTab('conversations')">Chat</div> | |
| <div class="sidebar-tab" data-tab="events" onclick="switchTab('events')">Events</div> | |
| </div> | |
| <div id="tab-agents" class="tab-content active"> | |
| <!-- Player panel (shown when logged in) --> | |
| <div id="player-panel"> | |
| <div class="pp-header"> | |
| <span class="pp-title">YOU</span> | |
| <button class="btn-danger" onclick="authLogout()" style="padding:2px 8px;font-size:10px">Logout</button> | |
| </div> | |
| <div id="pp-name" class="pp-name">Loading...</div> | |
| <div id="pp-loc" class="pp-loc"></div> | |
| <div class="pp-move"> | |
| <select id="pp-move-select"></select> | |
| <button onclick="playerMove()">Go</button> | |
| </div> | |
| <div class="pp-actions"> | |
| <button class="pp-btn" onclick="openProfileEditor()">Edit Profile</button> | |
| <button class="pp-btn" onclick="openPlansModal()">My Plans</button> | |
| <button class="pp-btn" onclick="openStockExModal()" style="background:#1a3a2e;border-color:#2a6a4e">StockEx</button> | |
| </div> | |
| </div> | |
| <div id="agent-detail"></div> | |
| </div> | |
| <div id="tab-conversations" class="tab-content"> | |
| <div id="conversations-panel"></div> | |
| </div> | |
| <div id="tab-events" class="tab-content"> | |
| <div id="event-log-panel"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Login / Register modal --> | |
| <div id="login-modal" class="modal-overlay" style="display:none"> | |
| <div class="modal-box"> | |
| <h2 id="auth-title">Welcome to Soci City</h2> | |
| <div id="auth-error" class="auth-error" style="display:none"></div> | |
| <label>Username</label> | |
| <input type="text" id="auth-username" placeholder="Your name" autocomplete="username"> | |
| <label>Password</label> | |
| <input type="password" id="auth-password" placeholder="Password" autocomplete="current-password"> | |
| <div class="modal-actions"> | |
| <button class="btn-secondary" onclick="closeLoginModal()">Skip</button> | |
| <button class="btn-primary" id="auth-btn" onclick="authSubmit()">Login</button> | |
| </div> | |
| <div class="auth-switch" id="auth-switch-text"> | |
| No account? <a onclick="toggleAuthMode()">Register</a> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Agent Profile Editor modal --> | |
| <div id="profile-modal" class="modal-overlay" style="display:none"> | |
| <div class="modal-box"> | |
| <h2>Your Agent Profile</h2> | |
| <div class="modal-row"> | |
| <div><label>Name</label><input type="text" id="pe-name" placeholder="Your name"></div> | |
| <div><label>Age</label><input type="number" id="pe-age" min="16" max="90" value="30"></div> | |
| </div> | |
| <div class="modal-row"> | |
| <div style="flex:1"> | |
| <label>Gender</label> | |
| <select id="pe-gender" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px"> | |
| <option value="unknown">Prefer not to say</option> | |
| <option value="male">Male</option> | |
| <option value="female">Female</option> | |
| <option value="nonbinary">Non-binary</option> | |
| </select> | |
| </div> | |
| <div style="flex:1"> | |
| <label>Occupation</label> | |
| <input type="text" id="pe-occupation" placeholder="e.g. Artist, Engineer, Chef" style="width:100%"> | |
| </div> | |
| </div> | |
| <label>Background (how you describe yourself)</label> | |
| <textarea id="pe-background" rows="3" placeholder="A few sentences about yourself..."></textarea> | |
| <label>Extraversion: <span id="pe-extra-val" class="slider-val">5</span></label> | |
| <input type="range" id="pe-extraversion" min="1" max="10" value="5" oninput="document.getElementById('pe-extra-val').textContent=this.value"> | |
| <label>Agreeableness: <span id="pe-agree-val" class="slider-val">7</span></label> | |
| <input type="range" id="pe-agreeableness" min="1" max="10" value="7" oninput="document.getElementById('pe-agree-val').textContent=this.value"> | |
| <label>Openness: <span id="pe-open-val" class="slider-val">6</span></label> | |
| <input type="range" id="pe-openness" min="1" max="10" value="6" oninput="document.getElementById('pe-open-val').textContent=this.value"> | |
| <div class="modal-actions"> | |
| <button class="btn-secondary" onclick="document.getElementById('profile-modal').style.display='none'">Cancel</button> | |
| <button class="btn-primary" onclick="saveProfile()">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- My Plans modal --> | |
| <div id="plans-modal" class="modal-overlay" style="display:none"> | |
| <div class="modal-box"> | |
| <h2>My Plans</h2> | |
| <div id="plans-list" style="font-size:12px;color:#a0a0c0;margin-bottom:12px;min-height:40px"></div> | |
| <label>Add a plan</label> | |
| <input type="text" id="plans-input" placeholder="e.g. Go to the cafe and meet someone" onkeydown="if(event.key==='Enter')addPlan()"> | |
| <div class="modal-actions"> | |
| <button class="btn-secondary" onclick="document.getElementById('plans-modal').style.display='none'">Close</button> | |
| <button class="btn-primary" onclick="addPlan()">Add</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- StockEx Trading modal --> | |
| <div id="stockex-modal" class="modal-overlay" style="display:none"> | |
| <div class="modal-box" style="max-width:480px"> | |
| <h2 style="color:#4ecca3">StockEx Trading</h2> | |
| <div id="stockex-status" style="font-size:11px;color:#888;margin-bottom:8px"></div> | |
| <div id="stockex-portfolio" style="font-size:11px;color:#a0a0c0;margin-bottom:10px;max-height:120px;overflow-y:auto"></div> | |
| <div class="modal-row"> | |
| <div style="flex:1"> | |
| <label>Member ID</label> | |
| <input type="text" id="sx-member" placeholder="USR02" value="USR02" style="text-transform:uppercase"> | |
| </div> | |
| <div style="flex:1"> | |
| <label>Symbol</label> | |
| <select id="sx-symbol" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px"> | |
| <option>Loading...</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="modal-row"> | |
| <div style="flex:1"> | |
| <label>Side</label> | |
| <select id="sx-side" style="width:100%;padding:6px 8px;background:#1e1e30;border:1px solid #3a3a5c;border-radius:6px;color:#e0e0f0;font-size:13px"> | |
| <option value="BUY">BUY</option> | |
| <option value="SELL">SELL</option> | |
| </select> | |
| </div> | |
| <div style="flex:1"> | |
| <label>Quantity</label> | |
| <input type="number" id="sx-qty" min="1" value="100"> | |
| </div> | |
| <div style="flex:1"> | |
| <label>Price</label> | |
| <input type="number" id="sx-price" step="0.01" min="0.01" value="0"> | |
| </div> | |
| </div> | |
| <div id="sx-market-info" style="font-size:10px;color:#555;margin:4px 0"></div> | |
| <div id="sx-result" style="font-size:11px;margin:6px 0;min-height:18px"></div> | |
| <div class="modal-actions"> | |
| <button class="btn-secondary" onclick="document.getElementById('stockex-modal').style.display='none'">Close</button> | |
| <button class="btn-secondary" onclick="loadStockExPortfolio()">Refresh</button> | |
| <button class="btn-primary" onclick="submitStockExOrder()" style="background:#1a6a3e">Place Order</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================================ | |
| // SIDEBAR RESIZER | |
| // ============================================================ | |
| (function() { | |
| const resizer = document.getElementById('sidebar-resizer'); | |
| const sidebar = document.getElementById('sidebar'); | |
| let isResizing = false; | |
| resizer.addEventListener('mousedown', (e) => { | |
| isResizing = true; | |
| resizer.classList.add('active'); | |
| document.body.style.cursor = 'col-resize'; | |
| document.body.style.userSelect = 'none'; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isResizing) return; | |
| const newWidth = window.innerWidth - e.clientX; | |
| sidebar.style.width = Math.max(200, Math.min(600, newWidth)) + 'px'; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (!isResizing) return; | |
| isResizing = false; | |
| resizer.classList.remove('active'); | |
| document.body.style.cursor = ''; | |
| document.body.style.userSelect = ''; | |
| }); | |
| })(); | |
| // ============================================================ | |
| // CONFIG | |
| // ============================================================ | |
| const API_BASE = window.location.origin + '/api'; | |
| const POLL_INTERVAL = 2000; | |
| const HORIZON = 0.14; | |
| // --- CITY LAYOUT --- | |
| // World dimensions — 1:1 with canvas (zoom handled separately) | |
| const WORLD_W = 1.0; | |
| const WORLD_H = 1.0; | |
| const MIN_ZOOM = 0.5; | |
| const MAX_ZOOM = 10.0; | |
| // Road network — full grid connecting every building | |
| const ROADS = [ | |
| // === Horizontal roads (full width) === | |
| { x1: 0.03, y1: 0.22, x2: 0.97, y2: 0.22, width: 10, name: 'North Access' }, | |
| { x1: 0.03, y1: 0.28, x2: 0.97, y2: 0.28, width: 14, name: 'North Road' }, | |
| { x1: 0.03, y1: 0.43, x2: 0.97, y2: 0.43, width: 10, name: 'Mid Road' }, | |
| { x1: 0.03, y1: 0.58, x2: 0.97, y2: 0.58, width: 14, name: 'South Road' }, | |
| { x1: 0.03, y1: 0.72, x2: 0.97, y2: 0.72, width: 10, name: 'South Connector' }, | |
| { x1: 0.03, y1: 0.80, x2: 0.97, y2: 0.80, width: 10, name: 'South Access' }, | |
| // === Vertical roads (full height) === | |
| { x1: 0.08, y1: 0.20, x2: 0.08, y2: 0.82, width: 7 }, | |
| { x1: 0.15, y1: 0.20, x2: 0.15, y2: 0.82, width: 10 }, | |
| { x1: 0.30, y1: 0.20, x2: 0.30, y2: 0.82, width: 8 }, | |
| { x1: 0.50, y1: 0.13, x2: 0.50, y2: 0.90, width: 16, name: 'Main Street' }, | |
| { x1: 0.70, y1: 0.20, x2: 0.70, y2: 0.82, width: 8 }, | |
| { x1: 0.85, y1: 0.20, x2: 0.85, y2: 0.82, width: 10 }, | |
| { x1: 0.92, y1: 0.20, x2: 0.92, y2: 0.82, width: 7 }, | |
| ]; | |
| // Slot grid for procedurally generated houses — all slots in VALID city ground (y≥0.20). | |
| // Horizon is at y=0.14; sky/mountain occupy y<0.20. Slots cover the full map width. | |
| const GEN_HOUSE_SLOTS = [ | |
| // North strip: gaps between named north houses (y=0.19) and commercial row (y=0.34) | |
| {x:0.04,y:0.21},{x:0.16,y:0.21},{x:0.30,y:0.21},{x:0.54,y:0.21},{x:0.70,y:0.21},{x:0.86,y:0.21},{x:0.94,y:0.21}, | |
| {x:0.04,y:0.27},{x:0.16,y:0.27},{x:0.30,y:0.27},{x:0.44,y:0.27},{x:0.56,y:0.27},{x:0.70,y:0.27},{x:0.84,y:0.27},{x:0.94,y:0.27}, | |
| // West column: left edge clear of school (x=0.08, y=0.50) | |
| {x:0.03,y:0.36},{x:0.03,y:0.42},{x:0.03,y:0.56},{x:0.03,y:0.62}, | |
| // East column: right edge clear of hospital (x=0.92, y=0.50) | |
| {x:0.96,y:0.36},{x:0.96,y:0.42},{x:0.96,y:0.56},{x:0.96,y:0.62}, | |
| // Interior mid-north gaps (between commercial strip rows, y=0.40-0.46) | |
| {x:0.14,y:0.40},{x:0.44,y:0.40},{x:0.72,y:0.40},{x:0.84,y:0.40}, | |
| // Interior mid-south gaps (y=0.54-0.62) | |
| {x:0.14,y:0.56},{x:0.30,y:0.56},{x:0.44,y:0.56},{x:0.70,y:0.56},{x:0.82,y:0.56}, | |
| // South strip: gaps between south named houses (y=0.65) and south zone buildings (y=0.78) | |
| {x:0.04,y:0.66},{x:0.30,y:0.66},{x:0.54,y:0.66},{x:0.70,y:0.66},{x:0.86,y:0.66},{x:0.94,y:0.66}, | |
| {x:0.04,y:0.72},{x:0.16,y:0.72},{x:0.30,y:0.72},{x:0.46,y:0.72},{x:0.56,y:0.72},{x:0.70,y:0.72},{x:0.84,y:0.72},{x:0.94,y:0.72}, | |
| // Bottom fringe (y=0.82) | |
| {x:0.14,y:0.82},{x:0.30,y:0.82},{x:0.44,y:0.82},{x:0.56,y:0.82},{x:0.70,y:0.82},{x:0.86,y:0.82},{x:0.96,y:0.82}, | |
| ]; | |
| // Building positions — spread across larger grid | |
| const LOCATION_POSITIONS = { | |
| // === RESIDENTIAL — Row 1: North houses === | |
| house_elena: { x: 0.08, y: 0.19, type: 'house', label: 'Elena & Lila' }, | |
| house_marcus: { x: 0.24, y: 0.19, type: 'house', label: 'Marcus & Zoe' }, | |
| house_helen: { x: 0.62, y: 0.19, type: 'house', label: 'Helen & Alice' }, | |
| house_diana: { x: 0.78, y: 0.19, type: 'house', label: 'Diana & Marco' }, | |
| // Row 2: Middle houses | |
| house_kai: { x: 0.08, y: 0.36, type: 'house', label: "Kai's Studio" }, | |
| house_priya: { x: 0.92, y: 0.36, type: 'house', label: 'Priya & Nina' }, | |
| // Row 3: South houses | |
| 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' }, | |
| // Apartment blocks | |
| 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' }, | |
| // === COMMERCIAL === | |
| 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' }, | |
| cinema: { x: 0.78, y: 0.50, type: 'cinema', label: 'Starlight Cinema' }, | |
| // === WORK === | |
| 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' }, | |
| factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' }, | |
| school: { x: 0.08, y: 0.50, type: 'school', label: 'Soci School' }, | |
| hospital: { x: 0.92, y: 0.50, type: 'hospital', label: 'City Hospital' }, | |
| // === PUBLIC === | |
| park: { x: 0.50, y: 0.19, type: 'park', label: 'Willow Park' }, | |
| gym: { x: 0.22, y: 0.50, type: 'public', label: 'Iron & Grit' }, | |
| library: { x: 0.78, y: 0.78, type: 'public', label: 'Public Library' }, | |
| church: { x: 0.08, y: 0.78, type: 'church', label: "St. Mary's" }, | |
| 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' }, | |
| // === NEW BUILDINGS (corner apartments + east-side commercial) === | |
| 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' }, | |
| 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' }, | |
| // === STREETS === | |
| street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' }, | |
| street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' }, | |
| street_east: { x: 0.85, y: 0.43, type: 'street', label: 'East Ave' }, | |
| street_west: { x: 0.15, y: 0.43, type: 'street', label: 'West Ave' }, | |
| }; | |
| const AGENT_COLORS = [ | |
| '#e94560','#4ecca3','#f0c040','#4e9eca','#9b59b6', | |
| '#e67e22','#1abc9c','#e74c3c','#3498db','#2ecc71', | |
| '#f39c12','#8e44ad','#16a085','#c0392b','#2980b9', | |
| '#27ae60','#d35400','#7d3c98','#148f77','#cb4335', | |
| ]; | |
| const WEATHER_ICONS = { | |
| sunny:'\u2600\uFE0F', clear:'\u2600\uFE0F', cloudy:'\u2601\uFE0F', | |
| rainy:'\uD83C\uDF27\uFE0F', stormy:'\u26C8\uFE0F', foggy:'\uD83C\uDF2B\uFE0F', | |
| snowy:'🌨️', snow:'🌨️', | |
| }; | |
| const SKY = { | |
| dawn: { top:'#2d1b4e', bot:'#e8a860', stars:false, sun:'low' }, | |
| morning: { top:'#4a90c8', bot:'#c8e0f0', stars:false, sun:'mid' }, | |
| afternoon: { top:'#2878b8', bot:'#88c8e8', stars:false, sun:'high' }, | |
| evening: { top:'#1a1040', bot:'#e07840', stars:false, sun:'low' }, | |
| night: { top:'#060610', bot:'#101028', stars:true, sun:'moon' }, | |
| }; | |
| const GROUND_TINT = { | |
| dawn: { base:'#3a5a2a', shade: 0.7 }, | |
| morning: { base:'#4a7a38', shade: 1.0 }, | |
| afternoon: { base:'#4a7a38', shade: 1.0 }, | |
| evening: { base:'#3a4a28', shade: 0.6 }, | |
| night: { base:'#0e1a0e', shade: 0.25 }, | |
| }; | |
| // ============================================================ | |
| // STATE | |
| // ============================================================ | |
| let canvas, ctx; | |
| let locations = {}; | |
| let agents = {}; | |
| let agentPositions = {}; | |
| let agentTargets = {}; | |
| let selectedAgentId = null; | |
| let eventLog = []; | |
| let conversationData = { active: [], recent: [] }; | |
| let connected = false; | |
| let hoveredAgent = null; | |
| let currentTimeOfDay = 'morning'; | |
| let currentWeather = 'sunny'; | |
| let animFrame = 0; | |
| let raindrops = []; | |
| let clouds = []; | |
| let stars = []; | |
| let activeTab = 'agents'; | |
| let agentIdxMap = {}; | |
| // Pan & zoom state | |
| let panX = 0, panY = 0; | |
| let zoom = 1.0; | |
| // Movement tracking — previous location + road waypoints for smooth walking | |
| let agentPrevLocations = {}; // {id: locationId} last known location | |
| let agentWaypoints = {}; // {id: [{x,y},...]} L-shaped road waypoint queue | |
| let agentFacingRight = {}; // {id: bool} last known horizontal facing direction | |
| let agentMovingUp = {}; // {id: bool} true when moving toward top of screen (back view) | |
| let locCrowdCount = {}; // {locationId: count} — used by drawPerson to scale agents | |
| let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0; | |
| // Rectangle-zoom state | |
| let rectZoomMode = false, isRectDragging = false, rectStart = null, rectEnd = null; | |
| let _blockNextClick = false; | |
| // Tree / decorations cache | |
| let trees = []; | |
| let streetLamps = []; | |
| function initParticles() { | |
| for (let i = 0; i < 120; i++) | |
| raindrops.push({x:Math.random(), y:Math.random(), speed:0.012+Math.random()*0.015, len:8+Math.random()*10}); | |
| for (let i = 0; i < 5; i++) | |
| clouds.push({x:Math.random(), y:0.02+Math.random()*0.08, w:60+Math.random()*50, speed:0.00005+Math.random()*0.0001}); | |
| for (let i = 0; i < 70; i++) | |
| stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28}); | |
| // Scatter trees in green spaces between buildings | |
| const treeZones = [ | |
| {cx:0.50, cy:0.19, rx:0.10, ry:0.03, count:8}, // park | |
| {cx:0.22, cy:0.78, rx:0.06, ry:0.03, count:5}, // sports field | |
| {cx:0.50, cy:0.50, rx:0.04, ry:0.03, count:4}, // town square | |
| {cx:0.08, cy:0.78, rx:0.03, ry:0.02, count:3}, // church garden | |
| {cx:0.50, cy:0.85, rx:0.15, ry:0.03, count:5}, // south green | |
| ]; | |
| for (const z of treeZones) { | |
| for (let i = 0; i < z.count; i++) { | |
| trees.push({ | |
| x: z.cx + (Math.random()-0.5)*2*z.rx, | |
| y: z.cy + (Math.random()-0.5)*2*z.ry, | |
| size: 6 + Math.random()*5, | |
| type: Math.random() > 0.3 ? 'round' : 'pine', | |
| }); | |
| } | |
| } | |
| // Street lamps along roads | |
| for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.16 + i*0.10 }); | |
| for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.28 }); | |
| for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.58 }); | |
| for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.15, y: 0.25 + i*0.18 }); | |
| for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.85, y: 0.25 + i*0.18 }); | |
| } | |
| // ============================================================ | |
| // TABS | |
| // ============================================================ | |
| function switchTab(tab) { | |
| activeTab = tab; | |
| document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); | |
| document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + tab)); | |
| if (tab === 'conversations') fetchConversations(); | |
| } | |
| // ============================================================ | |
| // CANVAS SETUP | |
| // ============================================================ | |
| function initCanvas() { | |
| // 2D canvas disabled — 3D viewport in iframe handles rendering | |
| } | |
| function resizeCanvas() { | |
| const c = document.getElementById('canvas-container'); | |
| canvas.width = c.clientWidth; | |
| canvas.height = c.clientHeight; | |
| } | |
| // World size — base is canvas size; zoom applied via ctx.scale | |
| function worldW() { return canvas.width; } | |
| function worldH() { return canvas.height; } | |
| function maxPanX() { return Math.max(0, canvas.width * (1 - 1 / zoom)); } | |
| function maxPanY() { return Math.max(0, canvas.height * (1 - 1 / zoom)); } | |
| function onPanSlider() { | |
| const sx = document.getElementById('pan-x'); | |
| const sy = document.getElementById('pan-y'); | |
| panX = (sx.value / 100) * maxPanX(); | |
| panY = (sy.value / 100) * maxPanY(); | |
| } | |
| function syncSliders() { | |
| const sx = document.getElementById('pan-x'); | |
| const sy = document.getElementById('pan-y'); | |
| if (sx) sx.value = maxPanX() > 0 ? (panX / maxPanX()) * 100 : 0; | |
| if (sy) sy.value = maxPanY() > 0 ? (panY / maxPanY()) * 100 : 0; | |
| } | |
| function onCanvasDragStart(e) { | |
| if (e.button !== 0) return; | |
| if (rectZoomMode) { | |
| const r = canvas.getBoundingClientRect(); | |
| rectStart = {x: e.clientX - r.left, y: e.clientY - r.top}; | |
| rectEnd = {...rectStart}; | |
| isRectDragging = true; | |
| return; | |
| } | |
| isDragging = true; | |
| dragStartX = e.clientX; dragStartY = e.clientY; | |
| dragPanStartX = panX; dragPanStartY = panY; | |
| } | |
| function onCanvasDrag(e) { | |
| if (isRectDragging) { | |
| const r = canvas.getBoundingClientRect(); | |
| rectEnd = {x: e.clientX - r.left, y: e.clientY - r.top}; | |
| return; | |
| } | |
| if (!isDragging) return; | |
| const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY; | |
| if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return; | |
| panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx / zoom)); | |
| panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy / zoom)); | |
| syncSliders(); | |
| } | |
| function onCanvasDragEnd() { | |
| if (isRectDragging) { | |
| isRectDragging = false; | |
| if (rectStart && rectEnd) { | |
| const rw = Math.abs(rectEnd.x - rectStart.x), rh = Math.abs(rectEnd.y - rectStart.y); | |
| if (rw > 10 && rh > 10) { applyRectZoom(); _blockNextClick = true; } | |
| } | |
| rectStart = null; rectEnd = null; | |
| rectZoomMode = false; | |
| const btn = document.getElementById('btn-rect-zoom'); | |
| if (btn) btn.classList.remove('active'); | |
| canvas.style.cursor = 'default'; | |
| return; | |
| } | |
| isDragging = false; | |
| } | |
| // ============================================================ | |
| // ZOOM FUNCTIONS | |
| // ============================================================ | |
| function onCanvasWheel(e) { | |
| e.preventDefault(); | |
| const r = canvas.getBoundingClientRect(); | |
| const sx = e.clientX - r.left, sy = e.clientY - r.top; | |
| zoomAround(e.deltaY < 0 ? 1.15 : 1 / 1.15, sx, sy); | |
| } | |
| function zoomAround(factor, sx, sy) { | |
| const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor)); | |
| if (newZoom === zoom) return; | |
| // World point under screen pixel (sx, sy): wx = sx/zoom + panX | |
| const wx = sx / zoom + panX, wy = sy / zoom + panY; | |
| zoom = newZoom; | |
| panX = Math.max(0, Math.min(maxPanX(), wx - sx / zoom)); | |
| panY = Math.max(0, Math.min(maxPanY(), wy - sy / zoom)); | |
| syncSliders(); | |
| } | |
| function zoomBy(factor) { zoomAround(factor, canvas.width / 2, canvas.height / 2); } | |
| function zoomFit() { zoom = 1.0; panX = 0; panY = 0; syncSliders(); } | |
| function applyRectZoom() { | |
| if (!rectStart || !rectEnd) return; | |
| const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y); | |
| const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y); | |
| const rw = x2 - x1, rh = y2 - y1; | |
| if (rw < 10 || rh < 10) return; | |
| // Convert screen rect corners to world coords | |
| const wx1 = x1 / zoom + panX, wy1 = y1 / zoom + panY; | |
| const wx2 = x2 / zoom + panX, wy2 = y2 / zoom + panY; | |
| const newZoom = Math.min(MAX_ZOOM, Math.min(canvas.width / (wx2 - wx1), canvas.height / (wy2 - wy1))); | |
| zoom = Math.max(MIN_ZOOM, newZoom); | |
| panX = Math.max(0, Math.min(maxPanX(), wx1)); | |
| panY = Math.max(0, Math.min(maxPanY(), wy1)); | |
| syncSliders(); | |
| } | |
| function toggleRectZoom() { | |
| rectZoomMode = !rectZoomMode; | |
| const btn = document.getElementById('btn-rect-zoom'); | |
| if (btn) btn.classList.toggle('active', rectZoomMode); | |
| if (canvas) canvas.style.cursor = rectZoomMode ? 'crosshair' : 'default'; | |
| } | |
| function animate() { | |
| animFrame++; | |
| for (const [id, target] of Object.entries(agentTargets)) { | |
| if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; } | |
| const p = agentPositions[id]; | |
| const agent = agents[id]; | |
| // Consume road waypoint queue (L-shaped routing) | |
| const wps = agentWaypoints[id]; | |
| if (wps && wps.length > 0) { | |
| const d = Math.hypot(p.x - wps[0].x, p.y - wps[0].y); | |
| if (d < 4) { wps.shift(); if (wps.length === 0) delete agentWaypoints[id]; } | |
| } | |
| const dest = (agentWaypoints[id] && agentWaypoints[id].length) ? agentWaypoints[id][0] : target; | |
| // Moving agents travel slower so the walk is visible; others snap faster | |
| const isMoving = agent && (agent.state === 'moving'); | |
| const lerpRate = isMoving ? 0.022 : 0.07; | |
| const prevX = p.x, prevY = p.y; | |
| p.x += (dest.x - p.x) * lerpRate; | |
| p.y += (dest.y - p.y) * lerpRate; | |
| // Track facing direction from actual pixel delta — reliable for all path types | |
| const mdx = p.x - prevX, mdy = p.y - prevY; | |
| if (Math.abs(mdx) > 0.1 || Math.abs(mdy) > 0.1) { | |
| if (Math.abs(mdy) > Math.abs(mdx)) { | |
| agentMovingUp[id] = mdy < 0; // moving up = back view | |
| } else { | |
| agentFacingRight[id] = mdx > 0; // moving horizontally = profile | |
| agentMovingUp[id] = false; | |
| } | |
| } | |
| } | |
| draw(); | |
| requestAnimationFrame(animate); | |
| } | |
| // ============================================================ | |
| // MAIN DRAW | |
| // ============================================================ | |
| function draw() { | |
| if (!ctx) return; | |
| const W = worldW(), H = worldH(); | |
| const cW = canvas.width, cH = canvas.height; | |
| // Background fill (covers any gap between sky and zoomed ground) | |
| const skyCfg = SKY[currentTimeOfDay] || SKY.morning; | |
| ctx.fillStyle = skyCfg.bot; | |
| ctx.fillRect(0, 0, cW, cH); | |
| // Sky drawn at canvas size (no zoom transform — always fills background) | |
| drawSky(cW, cH); | |
| ctx.save(); | |
| ctx.scale(zoom, zoom); | |
| ctx.translate(-panX, -panY); | |
| drawGround(W, H); | |
| drawZones(W, H); | |
| drawWeather(W, H); | |
| drawRoads(W, H); | |
| drawSidewalks(W, H); | |
| drawTrees(W, H); | |
| // Draw buildings — known positions + auto-generated houses | |
| const allPositions = getEffectivePositions(); | |
| for (const [id, pos] of Object.entries(allPositions)) { | |
| if (pos.type !== 'street') drawBuilding(id, pos, W, H); | |
| } | |
| drawStreetLamps(W, H); | |
| // Compute agent positions | |
| const byLoc = {}; | |
| for (const [id, a] of Object.entries(agents)) { | |
| const loc = a.location || 'house_elena'; | |
| if (!byLoc[loc]) byLoc[loc] = []; | |
| byLoc[loc].push({id, ...a}); | |
| } | |
| for (const [id, a] of Object.entries(agents)) { | |
| computeAgentTarget(id, a, getAgentIdx(id), byLoc, W, H); | |
| } | |
| drawCoupleLines(W, H); | |
| drawConversationBubbles(W, H); | |
| for (const [id, a] of Object.entries(agents)) { | |
| drawPerson(id, a, getAgentIdx(id), W, H); | |
| } | |
| if (currentWeather === 'foggy') { | |
| ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`; | |
| ctx.fillRect(0, 0, W, H); | |
| } | |
| ctx.restore(); | |
| // Rect-zoom selection overlay (screen coords, after restore) | |
| if (isRectDragging && rectStart && rectEnd) { | |
| const x1 = Math.min(rectStart.x, rectEnd.x), y1 = Math.min(rectStart.y, rectEnd.y); | |
| const x2 = Math.max(rectStart.x, rectEnd.x), y2 = Math.max(rectStart.y, rectEnd.y); | |
| ctx.fillStyle = 'rgba(78,204,163,0.08)'; | |
| ctx.fillRect(x1, y1, x2 - x1, y2 - y1); | |
| ctx.strokeStyle = 'rgba(78,204,163,0.9)'; | |
| ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); | |
| ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| ctx.setLineDash([]); | |
| } | |
| // Zoom level indicator | |
| if (zoom !== 1.0 || rectZoomMode) { | |
| ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; | |
| const label = rectZoomMode ? '⬚ draw rect to zoom' : `${zoom.toFixed(1)}x`; | |
| const tw = ctx.measureText(label).width + 14; | |
| ctx.fillStyle = 'rgba(15,52,96,0.75)'; | |
| ctx.fillRect(cW - tw - 4, cH - 22, tw + 4, 18); | |
| ctx.fillStyle = rectZoomMode ? '#f0c040' : '#4ecca3'; | |
| ctx.fillText(label, cW - 6, cH - 6); | |
| } | |
| } | |
| // Auto-compute positions for generated houses not in LOCATION_POSITIONS | |
| let _genPosCache = {}; | |
| function getEffectivePositions() { | |
| const result = {...LOCATION_POSITIONS}; | |
| for (const locId of Object.keys(locations)) { | |
| if (!result[locId]) { | |
| if (!_genPosCache[locId]) { | |
| let h = 0; | |
| for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0; | |
| const genMatch = locId.match(/house_gen_(\d+)/); | |
| if (genMatch) { | |
| // Deterministic slot — no jitter so houses never drift into sky/mountain | |
| const slotIdx = (parseInt(genMatch[1]) - 1) % GEN_HOUSE_SLOTS.length; | |
| const sl = GEN_HOUSE_SLOTS[slotIdx]; | |
| _genPosCache[locId] = { | |
| x: sl.x, | |
| y: sl.y, | |
| type: 'house', | |
| label: (locations[locId]?.name || locId).slice(0, 14), | |
| }; | |
| } else { | |
| // Unknown location: spread evenly across the whole map via hash | |
| _genPosCache[locId] = { | |
| x: 0.05 + ((h >>> 0) % 18) / 18 * 0.90, | |
| y: 0.05 + ((h >>> 4) % 16) / 16 * 0.90, | |
| type: 'house', | |
| label: (locations[locId]?.name || locId).slice(0, 14), | |
| }; | |
| } | |
| } | |
| result[locId] = _genPosCache[locId]; | |
| } | |
| } | |
| return result; | |
| } | |
| function getAgentIdx(id) { | |
| if (!(id in agentIdxMap)) agentIdxMap[id] = Object.keys(agentIdxMap).length; | |
| return agentIdxMap[id]; | |
| } | |
| // ============================================================ | |
| // SKY | |
| // ============================================================ | |
| function drawSky(W, H) { | |
| // Sky is always drawn at canvas size (before pan transform) | |
| const s = SKY[currentTimeOfDay] || SKY.morning; | |
| const hLine = H * HORIZON; | |
| const grad = ctx.createLinearGradient(0, 0, 0, hLine); | |
| grad.addColorStop(0, s.top); | |
| grad.addColorStop(1, s.bot); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(0, 0, W, hLine); | |
| if (s.stars) { | |
| for (const st of stars) { | |
| const tw = 0.3 + 0.7 * Math.abs(Math.sin(animFrame * 0.025 + st.tw)); | |
| ctx.fillStyle = `rgba(255,255,240,${tw})`; | |
| ctx.beginPath(); ctx.arc(st.x*W, st.y*H, st.size, 0, 6.28); ctx.fill(); | |
| } | |
| } | |
| if (s.sun === 'moon') drawMoon(W*0.82, hLine*0.35, 18); | |
| else if (s.sun === 'high') drawSun(W*0.78, hLine*0.25, 16); | |
| else if (s.sun === 'mid') drawSun(W*0.80, hLine*0.45, 16); | |
| else if (s.sun === 'low') drawSun(W*0.82, hLine*0.7, 16); | |
| // Mountains along the horizon | |
| drawMountains(W, hLine); | |
| } | |
| function drawMountains(W, hLine) { | |
| const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening'; | |
| const isDawn = currentTimeOfDay === 'dawn'; | |
| // Far mountains (larger, darker) | |
| const farPeaks = [ | |
| {x:0.00,h:0.55},{x:0.08,h:0.85},{x:0.18,h:0.65},{x:0.28,h:0.95}, | |
| {x:0.38,h:0.70},{x:0.48,h:1.0},{x:0.58,h:0.75},{x:0.68,h:0.90}, | |
| {x:0.78,h:0.80},{x:0.88,h:0.70},{x:0.98,h:0.60},{x:1.05,h:0.50}, | |
| ]; | |
| ctx.fillStyle = isDark ? '#0a0e18' : (isDawn ? '#4a3858' : '#5a6878'); | |
| ctx.beginPath(); | |
| ctx.moveTo(0, hLine); | |
| for (const p of farPeaks) { | |
| ctx.lineTo(p.x * W, hLine - p.h * hLine * 0.6); | |
| } | |
| ctx.lineTo(W, hLine); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Near mountains (smaller, lighter) | |
| const nearPeaks = [ | |
| {x:-0.02,h:0.30},{x:0.05,h:0.55},{x:0.14,h:0.40},{x:0.22,h:0.65}, | |
| {x:0.32,h:0.45},{x:0.42,h:0.60},{x:0.52,h:0.50},{x:0.60,h:0.70}, | |
| {x:0.70,h:0.55},{x:0.80,h:0.50},{x:0.90,h:0.62},{x:1.02,h:0.35}, | |
| ]; | |
| ctx.fillStyle = isDark ? '#141828' : (isDawn ? '#5a4868' : '#6a7888'); | |
| ctx.beginPath(); | |
| ctx.moveTo(0, hLine); | |
| for (const p of nearPeaks) { | |
| ctx.lineTo(p.x * W, hLine - p.h * hLine * 0.45); | |
| } | |
| ctx.lineTo(W, hLine); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Snow caps on far peaks (daytime only) | |
| if (!isDark) { | |
| ctx.fillStyle = isDawn ? 'rgba(220,200,220,0.5)' : 'rgba(240,245,255,0.6)'; | |
| for (const p of farPeaks) { | |
| if (p.h > 0.75) { | |
| const px = p.x * W; | |
| const py = hLine - p.h * hLine * 0.6; | |
| ctx.beginPath(); | |
| ctx.moveTo(px, py); | |
| ctx.lineTo(px - 12, py + hLine * 0.06); | |
| ctx.lineTo(px + 12, py + hLine * 0.06); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| } | |
| } | |
| // Haze at base of mountains | |
| const hazeGrad = ctx.createLinearGradient(0, hLine - 15, 0, hLine + 5); | |
| hazeGrad.addColorStop(0, 'rgba(0,0,0,0)'); | |
| hazeGrad.addColorStop(1, isDark ? 'rgba(10,15,25,0.5)' : (isDawn ? 'rgba(180,140,120,0.3)' : 'rgba(140,160,180,0.25)')); | |
| ctx.fillStyle = hazeGrad; | |
| ctx.fillRect(0, hLine - 15, W, 20); | |
| } | |
| function drawSun(x, y, r) { | |
| const glow = ctx.createRadialGradient(x, y, r*0.5, x, y, r*4); | |
| glow.addColorStop(0, 'rgba(255,220,100,0.35)'); | |
| glow.addColorStop(1, 'rgba(255,220,100,0)'); | |
| ctx.fillStyle = glow; ctx.fillRect(x-r*4, y-r*4, r*8, r*8); | |
| ctx.save(); ctx.translate(x, y); ctx.rotate(animFrame*0.005); | |
| for (let i = 0; i < 8; i++) { ctx.rotate(Math.PI/4); ctx.fillStyle='rgba(255,220,100,0.25)'; ctx.fillRect(-1.5,r+3,3,8); } | |
| ctx.restore(); | |
| ctx.fillStyle='#ffe066'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill(); | |
| } | |
| function drawMoon(x, y, r) { | |
| const glow = ctx.createRadialGradient(x,y,r*0.5,x,y,r*3); | |
| glow.addColorStop(0,'rgba(200,210,240,0.15)'); | |
| glow.addColorStop(1,'rgba(200,210,240,0)'); | |
| ctx.fillStyle=glow; ctx.fillRect(x-r*3,y-r*3,r*6,r*6); | |
| ctx.fillStyle='#d8dff0'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill(); | |
| ctx.fillStyle=SKY.night.top; ctx.beginPath(); ctx.arc(x+7,y-2,r*0.85,0,6.28); ctx.fill(); | |
| } | |
| // ============================================================ | |
| // GROUND | |
| // ============================================================ | |
| function drawGround(W, H) { | |
| const hLine = (canvas.height * HORIZON); | |
| const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning; | |
| const bc = hexToRgb(gt.base); | |
| const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening'; | |
| const isDawn = currentTimeOfDay === 'dawn'; | |
| // Base gradient — three-band ground for depth | |
| const grad = ctx.createLinearGradient(0, hLine, 0, H); | |
| grad.addColorStop(0, `rgb(${bc.r+28},${bc.g+40},${bc.b+12})`); | |
| grad.addColorStop(0.4, `rgb(${bc.r+10},${bc.g+20},${bc.b+5})`); | |
| grad.addColorStop(1, `rgb(${Math.max(0,bc.r-20)},${Math.max(0,bc.g-20)},${Math.max(0,bc.b-14)})`); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(0, hLine, W, H - hLine); | |
| // Horizontal band of slightly different shade — mid-ground variation | |
| if (!isDark) { | |
| const bandY = hLine + (H - hLine) * 0.5; | |
| const bandGrad = ctx.createLinearGradient(0, bandY - 12, 0, bandY + 12); | |
| bandGrad.addColorStop(0, 'rgba(0,0,0,0)'); | |
| bandGrad.addColorStop(0.5, isDawn ? 'rgba(180,120,60,0.06)' : 'rgba(80,130,40,0.06)'); | |
| bandGrad.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = bandGrad; | |
| ctx.fillRect(0, bandY - 12, W, 24); | |
| } | |
| // Rain/storm overlay | |
| if (currentWeather === 'rainy' || currentWeather === 'stormy') { | |
| ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`; | |
| ctx.fillRect(0, hLine, W, H - hLine); | |
| } | |
| // Scattered grass tufts for texture | |
| ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.14)`; | |
| for (let i = 0; i < 120; i++) { | |
| const gx = (i * 37 + 13) % W; | |
| const gy = hLine + 10 + ((i * 53 + 7) % (H - hLine - 15)); | |
| ctx.fillRect(gx, gy, 2, 3); | |
| } | |
| // Horizon blend | |
| const horizGrad = ctx.createLinearGradient(0, hLine - 4, 0, hLine + 8); | |
| const s = SKY[currentTimeOfDay] || SKY.morning; | |
| horizGrad.addColorStop(0, s.bot); | |
| horizGrad.addColorStop(1, gt.base); | |
| ctx.fillStyle = horizGrad; | |
| ctx.fillRect(0, hLine - 4, W, 12); | |
| } | |
| function hexToRgb(hex) { | |
| const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); | |
| return {r,g,b}; | |
| } | |
| // ============================================================ | |
| // ZONE COLORS — colored neighborhood tints | |
| // ============================================================ | |
| function drawZones(W, H) { | |
| const isDark = currentTimeOfDay === 'night' || currentTimeOfDay === 'evening'; | |
| const isDawn = currentTimeOfDay === 'dawn'; | |
| const zones = [ | |
| // Park — bright grass green | |
| { cx: 0.50, cy: 0.19, rx: 0.11, ry: 0.055, c: isDark ? '#1a4a18' : (isDawn ? '#4a7a30' : '#7ecf50'), a: isDark ? 0.22 : 0.35 }, | |
| // Sports field — turf green | |
| { cx: 0.22, cy: 0.78, rx: 0.07, ry: 0.055, c: isDark ? '#1a3818' : '#5ab838', a: isDark ? 0.20 : 0.30 }, | |
| // Town square — warm cobblestone gold | |
| { cx: 0.50, cy: 0.50, rx: 0.07, ry: 0.045, c: isDark ? '#3a3010' : '#e8c86a', a: isDark ? 0.18 : 0.32 }, | |
| // Commercial row — warm terracotta orange | |
| { cx: 0.48, cy: 0.42, rx: 0.32, ry: 0.075, c: isDark ? '#2a1808' : '#e8b880', a: isDark ? 0.15 : 0.20 }, | |
| // North residential — soft warm cream | |
| { cx: 0.48, cy: 0.20, rx: 0.44, ry: 0.060, c: isDark ? '#28201a' : '#eedcb0', a: isDark ? 0.13 : 0.22 }, | |
| // South residential — light sandy beige | |
| { cx: 0.48, cy: 0.66, rx: 0.44, ry: 0.065, c: isDark ? '#28201a' : '#e8d8a0', a: isDark ? 0.13 : 0.22 }, | |
| // Office / business district — cool steel blue | |
| { cx: 0.66, cy: 0.34, rx: 0.22, ry: 0.075, c: isDark ? '#182030' : '#b0cce8', a: isDark ? 0.18 : 0.24 }, | |
| // Industrial — brownish rust | |
| { cx: 0.91, cy: 0.63, rx: 0.07, ry: 0.08, c: isDark ? '#201408' : '#c89860', a: isDark ? 0.20 : 0.28 }, | |
| // Hospital — clean pale cyan | |
| { cx: 0.91, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#182828' : '#b8e8e0', a: isDark ? 0.18 : 0.26 }, | |
| // School — warm learning yellow | |
| { cx: 0.08, cy: 0.50, rx: 0.07, ry: 0.055, c: isDark ? '#28200a' : '#f0e890', a: isDark ? 0.16 : 0.26 }, | |
| // Church garden — soft lavender-green | |
| { cx: 0.08, cy: 0.78, rx: 0.06, ry: 0.045, c: isDark ? '#1a2030' : '#d0c8e8', a: isDark ? 0.18 : 0.28 }, | |
| ]; | |
| for (const z of zones) { | |
| const grd = ctx.createRadialGradient(z.cx*W, z.cy*H, 0, z.cx*W, z.cy*H, Math.max(z.rx*W, z.ry*H)); | |
| grd.addColorStop(0, z.c); | |
| grd.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.globalAlpha = z.a; | |
| ctx.fillStyle = grd; | |
| ctx.beginPath(); | |
| ctx.ellipse(z.cx*W, z.cy*H, z.rx*W, z.ry*H, 0, 0, 6.28); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1.0; | |
| } | |
| // ============================================================ | |
| // WEATHER | |
| // ============================================================ | |
| function drawWeather(W, H) { | |
| const w = currentWeather; | |
| if (w==='cloudy'||w==='rainy'||w==='stormy') { | |
| const op = w==='stormy'?0.65:(w==='rainy'?0.45:0.30); | |
| for (const c of clouds) { | |
| c.x+=c.speed; if(c.x>1.15) c.x=-0.15; | |
| drawCloud(c.x*W, c.y*H, c.w, op); | |
| } | |
| } | |
| if (w==='rainy'||w==='stormy') { | |
| ctx.strokeStyle = w==='stormy'?'rgba(180,200,255,0.5)':'rgba(150,180,220,0.35)'; | |
| ctx.lineWidth=1; | |
| for (const r of raindrops) { | |
| r.y+=r.speed; if(r.y>1){r.y=-0.03;r.x=Math.random();} | |
| const rx=r.x*W, ry=r.y*H; | |
| ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx-2,ry+r.len); ctx.stroke(); | |
| } | |
| } | |
| if (w==='stormy'&&animFrame%120<3) { | |
| ctx.fillStyle=`rgba(255,255,255,${0.12+Math.random()*0.08})`; | |
| ctx.fillRect(0,0,W,H); | |
| } | |
| } | |
| function drawCloud(x,y,w,op) { | |
| ctx.fillStyle=`rgba(200,210,220,${op})`; | |
| const h=w*0.32; | |
| ctx.beginPath(); ctx.ellipse(x,y,w*0.5,h*0.6,0,0,6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(x-w*0.25,y+h*0.15,w*0.3,h*0.45,0,0,6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(x+w*0.28,y+h*0.1,w*0.28,h*0.4,0,0,6.28); ctx.fill(); | |
| } | |
| // ============================================================ | |
| // ROADS — drawn as realistic asphalt with lane markings | |
| // ============================================================ | |
| function drawRoads(W, H) { | |
| const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; | |
| const asphalt = isDark ? '#2a2820' : '#6b6560'; | |
| const asphaltEdge = isDark ? '#1a1815' : '#555048'; | |
| for (const road of ROADS) { | |
| const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H; | |
| const w = road.width || 10; | |
| const dx = x2-x1, dy = y2-y1; | |
| const len = Math.hypot(dx, dy); | |
| const nx = -dy/len * w/2, ny = dx/len * w/2; | |
| // Road surface | |
| ctx.fillStyle = asphalt; | |
| ctx.beginPath(); | |
| ctx.moveTo(x1+nx, y1+ny); | |
| ctx.lineTo(x2+nx, y2+ny); | |
| ctx.lineTo(x2-nx, y2-ny); | |
| ctx.lineTo(x1-nx, y1-ny); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Edge lines | |
| ctx.strokeStyle = asphaltEdge; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(x1+nx,y1+ny); ctx.lineTo(x2+nx,y2+ny); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(x1-nx,y1-ny); ctx.lineTo(x2-nx,y2-ny); ctx.stroke(); | |
| // Center dashed line (for wider roads) | |
| if (w >= 12) { | |
| ctx.strokeStyle = isDark ? 'rgba(200,180,80,0.25)' : 'rgba(255,220,100,0.6)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.setLineDash([8, 10]); | |
| ctx.beginPath(); | |
| ctx.moveTo((x1+x2)/2 - (x2-x1)*0.48 + (x2-x1)*0.02, (y1+y2)/2 - (y2-y1)*0.48 + (y2-y1)*0.02); | |
| ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| } | |
| } | |
| // Sidewalks next to roads | |
| function drawSidewalks(W, H) { | |
| const isDark = currentTimeOfDay==='night'; | |
| ctx.fillStyle = isDark ? 'rgba(90,85,75,0.3)' : 'rgba(180,175,160,0.35)'; | |
| for (const road of ROADS) { | |
| const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H; | |
| const w = (road.width || 10) + 6; | |
| const dx = x2-x1, dy = y2-y1; | |
| const len = Math.hypot(dx, dy); | |
| const nx = -dy/len * w/2, ny = dx/len * w/2; | |
| const sw = 3; // sidewalk width | |
| // Draw thin sidewalk strips | |
| ctx.fillRect( | |
| Math.min(x1+nx, x2+nx) - sw/2, | |
| Math.min(y1+ny, y2+ny) - sw/2, | |
| Math.abs(x2+nx - x1-nx) + sw, | |
| Math.abs(y2+ny - y1-ny) + sw | |
| ); | |
| } | |
| } | |
| // ============================================================ | |
| // TREES | |
| // ============================================================ | |
| function drawTrees(W, H) { | |
| const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; | |
| for (const t of trees) { | |
| const tx = t.x * W, ty = t.y * H; | |
| // Trunk | |
| ctx.fillStyle = isDark ? '#3a2a15' : '#6b4226'; | |
| ctx.fillRect(tx-2, ty-2, 4, 10); | |
| // Canopy | |
| if (t.type === 'pine') { | |
| ctx.fillStyle = isDark ? '#1a3a18' : '#2a7a28'; | |
| ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 6); ctx.lineTo(tx - t.size*0.6, ty); ctx.lineTo(tx + t.size*0.6, ty); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = isDark ? '#1e4a1c' : '#3a8a30'; | |
| ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 2); ctx.lineTo(tx - t.size*0.4, ty - 3); ctx.lineTo(tx + t.size*0.4, ty - 3); ctx.closePath(); ctx.fill(); | |
| } else { | |
| ctx.fillStyle = isDark ? '#1a4018' : '#3a8a30'; | |
| ctx.beginPath(); ctx.arc(tx, ty - t.size*0.5, t.size*0.7, 0, 6.28); ctx.fill(); | |
| ctx.fillStyle = isDark ? '#2a5028' : '#55aa45'; | |
| ctx.beginPath(); ctx.arc(tx - 2, ty - t.size*0.6, t.size*0.35, 0, 6.28); ctx.fill(); | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // STREET LAMPS | |
| // ============================================================ | |
| function drawStreetLamps(W, H) { | |
| const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; | |
| for (const l of streetLamps) { | |
| const lx = l.x*W + 12, ly = l.y*H; | |
| ctx.fillStyle = '#555'; ctx.fillRect(lx-1, ly-14, 2, 14); | |
| ctx.fillStyle = '#666'; ctx.fillRect(lx-3, ly-16, 6, 3); | |
| if (isDark) { | |
| const glow = ctx.createRadialGradient(lx, ly-14, 2, lx, ly-14, 30); | |
| glow.addColorStop(0, 'rgba(255,220,130,0.25)'); | |
| glow.addColorStop(1, 'rgba(255,220,130,0)'); | |
| ctx.fillStyle = glow; | |
| ctx.fillRect(lx-30, ly-44, 60, 60); | |
| ctx.fillStyle = 'rgba(255,220,130,0.8)'; | |
| ctx.beginPath(); ctx.arc(lx, ly-14, 2, 0, 6.28); ctx.fill(); | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // BUILDINGS | |
| // ============================================================ | |
| function drawBuilding(id, pos, W, H) { | |
| const x = pos.x*W, y = pos.y*H; | |
| const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening'; | |
| const loc = locations[id] || {}; | |
| const occ = (loc.occupants || []).length; | |
| if (pos.type === 'house') drawHouse(x, y, isDark, id); | |
| else if (pos.type === 'shop') drawShop(x, y, isDark); | |
| else if (pos.type === 'office') drawOffice(x, y, isDark); | |
| else if (pos.type === 'tower') drawTower(x, y, isDark); | |
| else if (pos.type === 'park') drawPark(x, y, isDark); | |
| else if (pos.type === 'public') drawPublicBuilding(x, y, isDark); | |
| else if (pos.type === 'factory') drawFactory(x, y, isDark); | |
| else if (pos.type === 'school') drawSchool(x, y, isDark); | |
| else if (pos.type === 'hospital') drawHospital(x, y, isDark); | |
| else if (pos.type === 'church') drawChurch(x, y, isDark); | |
| else if (pos.type === 'cinema') drawCinema(x, y, isDark); | |
| else if (pos.type === 'apartment') drawApartment(x, y, isDark); | |
| else if (pos.type === 'square') drawSquare(x, y, isDark); | |
| else if (pos.type === 'sports') drawSportsField(x, y, isDark); | |
| // Label | |
| if (pos.type !== 'park' && pos.type !== 'square' && pos.type !== 'sports') { | |
| const label = pos.label || id; | |
| const short = label.length > 16 ? label.slice(0, 14) + '..' : label; | |
| ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| const ly = pos.type === 'house' ? y + 18 : (pos.type === 'tower' ? y + 35 : y + 25); | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1); | |
| ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly); | |
| } | |
| // Occupant count badge | |
| if (occ > 0) { | |
| const byOff = pos.type === 'house' ? 14 : (pos.type === 'tower' ? 32 : (pos.type === 'church' ? 40 : 18)); | |
| const bx = x + (pos.type === 'house' ? 22 : 28), by = y - byOff; | |
| ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill(); | |
| ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText(occ.toString(), bx, by); | |
| } | |
| } | |
| // --- 2.5D PERSPECTIVE HELPERS --- | |
| const ISO_DX = 6; // horizontal offset for side face | |
| const ISO_DY = 4; // vertical offset for top face | |
| function draw25DBox(x, y, w, h, frontColor, sideColor, topColor, dk) { | |
| // Front face | |
| ctx.fillStyle = dk ? dim(frontColor, 0.4) : frontColor; | |
| ctx.fillRect(x - w/2, y - h, w, h); | |
| // Right side face (perspective) | |
| ctx.fillStyle = dk ? dim(sideColor, 0.35) : sideColor; | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2, y - h); | |
| ctx.lineTo(x + w/2 + ISO_DX, y - h - ISO_DY); | |
| ctx.lineTo(x + w/2 + ISO_DX, y - ISO_DY); | |
| ctx.lineTo(x + w/2, y); | |
| ctx.closePath(); ctx.fill(); | |
| // Top face | |
| ctx.fillStyle = dk ? dim(topColor, 0.45) : topColor; | |
| ctx.beginPath(); | |
| ctx.moveTo(x - w/2, y - h); | |
| ctx.lineTo(x - w/2 + ISO_DX, y - h - ISO_DY); | |
| ctx.lineTo(x + w/2 + ISO_DX, y - h - ISO_DY); | |
| ctx.lineTo(x + w/2, y - h); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| function drawHouse(x, y, dk, id) { | |
| const w = 36, h = 24; | |
| const hues = [ | |
| {wall:'#c8a882', side:'#a88862', roof:'#8b4513', door:'#6b3410'}, | |
| {wall:'#a0b8a0', side:'#809880', roof:'#4a6a4a', door:'#3a4a3a'}, | |
| {wall:'#b8a0a0', side:'#988080', roof:'#7a3a3a', door:'#5a2a2a'}, | |
| {wall:'#a0a8c0', side:'#8088a0', roof:'#4a5a7a', door:'#3a4a6a'}, | |
| {wall:'#c8b878', side:'#a89858', roof:'#8a7a40', door:'#6a5a30'}, | |
| ]; | |
| const allPos = getEffectivePositions(); | |
| const idx = Object.keys(allPos).indexOf(id) % hues.length; | |
| const c = hues[idx]; | |
| const baseY = y + h/2; | |
| // 2.5D wall | |
| draw25DBox(x, baseY, w, h, c.wall, c.side, c.roof, dk); | |
| // Pitched roof | |
| ctx.fillStyle = dk ? dim(c.roof, 0.4) : c.roof; | |
| ctx.beginPath(); | |
| ctx.moveTo(x - w/2 - 3, baseY - h); | |
| ctx.lineTo(x, baseY - h - 14); | |
| ctx.lineTo(x + w/2 + 3, baseY - h); | |
| ctx.closePath(); ctx.fill(); | |
| // Roof right side | |
| ctx.fillStyle = dk ? dim(c.roof, 0.3) : dim(c.roof, 0.8); | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w/2 + 3, baseY - h); | |
| ctx.lineTo(x, baseY - h - 14); | |
| ctx.lineTo(x + ISO_DX, baseY - h - 14 - ISO_DY); | |
| ctx.lineTo(x + w/2 + 3 + ISO_DX, baseY - h - ISO_DY); | |
| ctx.closePath(); ctx.fill(); | |
| // Door | |
| ctx.fillStyle = dk ? dim(c.door, 0.4) : c.door; | |
| ctx.fillRect(x - 3, baseY - 10, 6, 10); | |
| ctx.fillStyle = dk ? '#aa9060' : '#d4b070'; | |
| ctx.beginPath(); ctx.arc(x + 2, baseY - 4, 1, 0, 6.28); ctx.fill(); | |
| // Windows | |
| const wc = dk ? 'rgba(255,210,100,0.75)' : 'rgba(180,220,255,0.55)'; | |
| ctx.fillStyle = wc; | |
| ctx.fillRect(x - w/2 + 4, baseY - h + 5, 8, 6); | |
| ctx.fillRect(x + w/2 - 12, baseY - h + 5, 8, 6); | |
| ctx.strokeStyle = dk ? 'rgba(100,80,50,0.4)' : 'rgba(80,70,60,0.3)'; | |
| ctx.lineWidth = 0.5; | |
| ctx.strokeRect(x - w/2 + 4, baseY - h + 5, 8, 6); | |
| ctx.strokeRect(x + w/2 - 12, baseY - h + 5, 8, 6); | |
| // Chimney | |
| ctx.fillStyle = dk ? dim(c.roof, 0.35) : dim(c.roof, 0.8); | |
| ctx.fillRect(x + 8, baseY - h - 10, 5, 8); | |
| } | |
| function drawShop(x, y, dk) { | |
| const w = 48, h = 32; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#d4c4a8', '#b4a488', '#e0d4c0', dk); | |
| // Awning with 2.5D | |
| const awningColors = ['#c44', '#4a8', '#48a', '#a84']; | |
| const ci = Math.floor(x*7 + y*3) % awningColors.length; | |
| const ac = awningColors[ci]; | |
| ctx.fillStyle = dk ? dim(ac, 0.35) : ac; | |
| ctx.beginPath(); | |
| ctx.moveTo(x-w/2-3, baseY-h); | |
| ctx.lineTo(x-w/2-6, baseY-h+12); | |
| ctx.lineTo(x+w/2+6, baseY-h+12); | |
| ctx.lineTo(x+w/2+3, baseY-h); | |
| ctx.closePath(); ctx.fill(); | |
| // Awning side | |
| ctx.fillStyle = dk ? dim(ac, 0.25) : dim(ac, 0.7); | |
| ctx.beginPath(); | |
| ctx.moveTo(x+w/2+3, baseY-h); | |
| ctx.lineTo(x+w/2+6, baseY-h+12); | |
| ctx.lineTo(x+w/2+6+ISO_DX, baseY-h+12-ISO_DY); | |
| ctx.lineTo(x+w/2+3+ISO_DX, baseY-h-ISO_DY); | |
| ctx.closePath(); ctx.fill(); | |
| // Awning stripes | |
| ctx.strokeStyle = dk ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.25)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i < 5; i++) { | |
| const sx = x-w/2 + i*(w/4); | |
| ctx.beginPath(); ctx.moveTo(sx, baseY-h); ctx.lineTo(sx-1.5, baseY-h+12); ctx.stroke(); | |
| } | |
| // Door | |
| ctx.fillStyle = dk ? '#2a2520' : '#5a4a3a'; | |
| ctx.fillRect(x-4, baseY-12, 8, 12); | |
| // Windows | |
| const wc = dk ? 'rgba(255,210,100,0.65)' : 'rgba(200,230,255,0.55)'; | |
| ctx.fillStyle = wc; | |
| ctx.fillRect(x-w/2+4, baseY-h+14, w/2-10, 10); | |
| ctx.fillRect(x+5, baseY-h+14, w/2-10, 10); | |
| } | |
| function drawOffice(x, y, dk) { | |
| const w = 50, h = 40; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#8090a8', '#607088', '#90a0b8', dk); | |
| // Door | |
| ctx.fillStyle = dk ? '#1a1a25' : '#4a5a6a'; | |
| ctx.fillRect(x-4, baseY-12, 8, 12); | |
| // Windows grid | |
| const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(180,220,255,0.5)'; | |
| ctx.fillStyle = wc; | |
| for (let r = 0; r < 3; r++) | |
| for (let c = 0; c < 3; c++) | |
| ctx.fillRect(x-w/2+6+c*15, baseY-h+5+r*10, 9, 6); | |
| // Side windows | |
| const swc = dk ? 'rgba(255,200,80,0.4)' : 'rgba(160,200,240,0.35)'; | |
| ctx.fillStyle = swc; | |
| for (let r = 0; r < 3; r++) { | |
| const wy = baseY - h + 6 + r*10; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+w/2+1, wy); | |
| ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY); | |
| ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+6); | |
| ctx.lineTo(x+w/2+1, wy+6); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| } | |
| function drawPublicBuilding(x, y, dk) { | |
| const w = 46, h = 34; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#7a9a6a', '#5a7a4a', '#8aaa7a', dk); | |
| ctx.fillStyle = dk ? '#1a2018' : '#4a6a3a'; | |
| ctx.fillRect(x-4, baseY-12, 8, 12); | |
| const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(200,240,200,0.5)'; | |
| ctx.fillStyle = wc; | |
| for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, baseY-h+6, 8, 8); | |
| for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, baseY-h+18, 8, 8); | |
| } | |
| function drawPark(x, y, dk) { | |
| // Green area with slight elevation | |
| ctx.fillStyle = dk ? '#1a3018' : '#4a9040'; | |
| ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.fill(); | |
| // Raised edge (2.5D) | |
| ctx.fillStyle = dk ? '#122810' : '#3a7030'; | |
| ctx.beginPath(); ctx.ellipse(x, y+3, 55, 25, 0, 0, Math.PI); ctx.fill(); | |
| ctx.strokeStyle = dk ? '#2a4a25' : '#6ab850'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.stroke(); | |
| // Walking path | |
| ctx.strokeStyle = dk ? '#2a2820' : '#c8c0a8'; | |
| ctx.lineWidth = 3; ctx.setLineDash([6,4]); | |
| ctx.beginPath(); ctx.ellipse(x, y, 32, 14, 0, 0, 6.28); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Pond | |
| ctx.fillStyle = dk ? '#1a2a3a' : '#5a9ac0'; | |
| ctx.beginPath(); ctx.ellipse(x+15, y+4, 12, 6, 0.2, 0, 6.28); ctx.fill(); | |
| ctx.strokeStyle = dk ? '#2a3a4a' : '#80b0d0'; ctx.lineWidth = 1; ctx.stroke(); | |
| // Water shimmer | |
| if (!dk) { | |
| ctx.fillStyle = `rgba(255,255,255,${0.15+Math.sin(animFrame*0.05)*0.1})`; | |
| ctx.beginPath(); ctx.ellipse(x+17, y+2, 4, 2, 0.3, 0, 6.28); ctx.fill(); | |
| } | |
| // Benches (2.5D) | |
| ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30'; | |
| ctx.fillRect(x-22, y+7, 14, 3); | |
| ctx.fillStyle = dk ? '#2a1a08' : '#5a3a18'; | |
| ctx.fillRect(x-22, y+10, 14, 2); // bench shadow/depth | |
| // Trees in park | |
| for (let i = -1; i <= 1; i++) { | |
| const tx = x + i*22; | |
| ctx.fillStyle = dk ? '#3a2a15' : '#6b4226'; ctx.fillRect(tx-2, y-4, 4, 10); | |
| ctx.fillStyle = dk ? '#1a4a18' : '#3a8a30'; ctx.beginPath(); ctx.arc(tx, y-10, 8+Math.abs(i)*2, 0, 6.28); ctx.fill(); | |
| ctx.fillStyle = dk ? '#2a5a28' : '#55aa45'; ctx.beginPath(); ctx.arc(tx-2, y-12, 4, 0, 6.28); ctx.fill(); | |
| } | |
| // Label | |
| ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Willow Park', x+1, y+19); | |
| ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18); | |
| } | |
| function drawTower(x, y, dk) { | |
| const w = 36, h = 56; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#6880a0', '#486078', '#7890b0', dk); | |
| // Antenna | |
| ctx.fillStyle = '#888'; ctx.fillRect(x-1, baseY-h-10, 2, 10); | |
| ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(x, baseY-h-10, 2, 0, 6.28); ctx.fill(); | |
| // Door | |
| ctx.fillStyle = dk ? '#0a0e18' : '#384858'; | |
| ctx.fillRect(x-5, baseY-14, 10, 14); | |
| // Front windows (5 rows x 4 cols) | |
| const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.5)'; | |
| ctx.fillStyle = wc; | |
| for (let r = 0; r < 5; r++) | |
| for (let c = 0; c < 4; c++) | |
| ctx.fillRect(x-w/2+3+c*8.5, baseY-h+5+r*10, 6, 6); | |
| // Side windows | |
| const swc = dk ? 'rgba(255,200,80,0.35)' : 'rgba(160,200,240,0.3)'; | |
| ctx.fillStyle = swc; | |
| for (let r = 0; r < 5; r++) { | |
| const wy = baseY-h+6+r*10; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+w/2+1, wy); | |
| ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY); | |
| ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+5); | |
| ctx.lineTo(x+w/2+1, wy+5); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| } | |
| function drawFactory(x, y, dk) { | |
| const w = 56, h = 34; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#8a7a6a', '#6a5a4a', '#9a8a78', dk); | |
| // Saw-tooth roof | |
| ctx.fillStyle = dk ? '#1a1815' : '#6a5a4a'; | |
| for (let i = 0; i < 3; i++) { | |
| const rx = x - w/2 + i*(w/3); | |
| ctx.beginPath(); | |
| ctx.moveTo(rx, baseY-h); ctx.lineTo(rx+w/6, baseY-h-10); ctx.lineTo(rx+w/3, baseY-h); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // Chimney with smoke | |
| ctx.fillStyle = dk ? '#3a3020' : '#706050'; | |
| ctx.fillRect(x+w/2-10, baseY-h-16, 8, 14); | |
| // 2.5D chimney side | |
| ctx.fillStyle = dk ? '#2a2018' : '#605040'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+w/2-2, baseY-h-16); ctx.lineTo(x+w/2-2+ISO_DX, baseY-h-16-ISO_DY); | |
| ctx.lineTo(x+w/2-2+ISO_DX, baseY-h-2-ISO_DY); ctx.lineTo(x+w/2-2, baseY-h-2); | |
| ctx.closePath(); ctx.fill(); | |
| // Smoke | |
| ctx.fillStyle = `rgba(180,180,180,${dk?0.15:0.25})`; | |
| for (let i = 0; i < 3; i++) { | |
| const sy = baseY-h-20-i*8+Math.sin(animFrame*0.03+i)*3; | |
| ctx.beginPath(); ctx.arc(x+w/2-6+i*3, sy, 4+i*2, 0, 6.28); ctx.fill(); | |
| } | |
| // Loading door | |
| ctx.fillStyle = dk ? '#1a1815' : '#5a4a3a'; | |
| ctx.fillRect(x-8, baseY-16, 16, 16); | |
| // Windows | |
| const wc = dk ? 'rgba(255,180,80,0.5)' : 'rgba(200,220,240,0.4)'; | |
| ctx.fillStyle = wc; | |
| for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*14, baseY-h+6, 10, 8); | |
| } | |
| function drawSchool(x, y, dk) { | |
| const w = 52, h = 36; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#c4a088', '#a48068', '#d4b098', dk); | |
| // Flagpole | |
| ctx.fillStyle = '#888'; ctx.fillRect(x+w/2-6, baseY-h-18, 2, 18); | |
| ctx.fillStyle = '#e94560'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+w/2-4, baseY-h-18); ctx.lineTo(x+w/2+8, baseY-h-14); ctx.lineTo(x+w/2-4, baseY-h-10); | |
| ctx.closePath(); ctx.fill(); | |
| // Door | |
| ctx.fillStyle = dk ? '#1a1218' : '#6a4a3a'; | |
| ctx.fillRect(x-5, baseY-14, 10, 14); | |
| // Windows (2 rows) | |
| const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(200,230,255,0.5)'; | |
| ctx.fillStyle = wc; | |
| for (let r = 0; r < 2; r++) | |
| for (let c = 0; c < 4; c++) | |
| ctx.fillRect(x-w/2+4+c*12, baseY-h+5+r*12, 8, 8); | |
| } | |
| function drawHospital(x, y, dk) { | |
| const w = 50, h = 40; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#c8ccd0', '#a0a4a8', '#d8dce0', dk); | |
| // Red cross on front | |
| ctx.fillStyle = '#e94560'; | |
| ctx.fillRect(x-3, baseY-h+4, 6, 14); | |
| ctx.fillRect(x-7, baseY-h+8, 14, 6); | |
| // Door | |
| ctx.fillStyle = dk ? '#0a1018' : '#4a5a6a'; | |
| ctx.fillRect(x-5, baseY-12, 10, 12); | |
| // Windows | |
| const wc = dk ? 'rgba(200,240,255,0.55)' : 'rgba(200,230,255,0.5)'; | |
| ctx.fillStyle = wc; | |
| for (let r = 0; r < 2; r++) | |
| for (let c = 0; c < 4; c++) | |
| ctx.fillRect(x-w/2+4+c*12, baseY-h+20+r*8, 8, 5); | |
| } | |
| function drawChurch(x, y, dk) { | |
| const w = 40, h = 36; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#c0b8a8', '#a09888', '#d0c8b8', dk); | |
| // Steeple (2.5D) | |
| const stW = 12, stH = 18; | |
| draw25DBox(x, baseY-h, stW, stH, '#908880', '#706860', '#a09890', dk); | |
| // Steeple pointed top | |
| ctx.fillStyle = dk ? '#1a1818' : '#908880'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x-8, baseY-h-stH); ctx.lineTo(x, baseY-h-stH-12); ctx.lineTo(x+8, baseY-h-stH); | |
| ctx.closePath(); ctx.fill(); | |
| // Cross | |
| ctx.fillStyle = dk ? '#888' : '#d4c8a0'; | |
| ctx.fillRect(x-1.5, baseY-h-stH-20, 3, 10); | |
| ctx.fillRect(x-4, baseY-h-stH-18, 8, 3); | |
| // Stained glass (rose window) | |
| ctx.fillStyle = dk ? 'rgba(100,150,255,0.5)' : 'rgba(80,120,200,0.4)'; | |
| ctx.beginPath(); ctx.arc(x, baseY-h-8, 4, 0, 6.28); ctx.fill(); | |
| // Arched door | |
| ctx.fillStyle = dk ? '#1a1518' : '#5a4a3a'; | |
| ctx.fillRect(x-5, baseY-14, 10, 14); | |
| ctx.beginPath(); ctx.arc(x, baseY-14, 5, Math.PI, 0); ctx.fill(); | |
| // Side windows | |
| const wc = dk ? 'rgba(255,200,100,0.5)' : 'rgba(200,180,120,0.45)'; | |
| ctx.fillStyle = wc; | |
| ctx.fillRect(x-w/2+4, baseY-h+6, 6, 10); | |
| ctx.fillRect(x+w/2-10, baseY-h+6, 6, 10); | |
| // Label | |
| ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText("St. Mary's", x+1, baseY+3); | |
| ctx.fillStyle = dk ? '#a0a8c0' : '#fff'; ctx.fillText("St. Mary's", x, baseY+2); | |
| } | |
| function drawCinema(x, y, dk) { | |
| const w = 48, h = 34; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#5a3060', '#3a1840', '#6a4070', dk); | |
| // Marquee sign | |
| ctx.fillStyle = dk ? '#4a2040' : '#8a4080'; | |
| ctx.fillRect(x-w/2+4, baseY-h-8, w-8, 10); | |
| // Marquee lights | |
| ctx.fillStyle = dk ? '#f0c040' : '#ffe880'; | |
| for (let i = 0; i < 8; i++) { | |
| const lx = x-w/2+8+i*5; | |
| const flicker = Math.sin(animFrame*0.1+i*0.8) > 0; | |
| if (flicker || !dk) { | |
| ctx.beginPath(); ctx.arc(lx, baseY-h-3, 1.5, 0, 6.28); ctx.fill(); | |
| } | |
| } | |
| ctx.fillStyle = dk ? '#f0c040' : '#fff'; | |
| ctx.font = 'bold 7px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText('CINEMA', x, baseY-h-2); | |
| // Door | |
| ctx.fillStyle = dk ? '#1a0818' : '#3a1830'; | |
| ctx.fillRect(x-5, baseY-12, 10, 12); | |
| // Poster frames | |
| const wc = dk ? 'rgba(255,200,100,0.4)' : 'rgba(200,180,240,0.5)'; | |
| ctx.fillStyle = wc; | |
| ctx.fillRect(x-w/2+4, baseY-h+8, 12, 16); | |
| ctx.fillRect(x+w/2-16, baseY-h+8, 12, 16); | |
| } | |
| function drawApartment(x, y, dk) { | |
| const w = 38, h = 48; | |
| const baseY = y + h/2; | |
| draw25DBox(x, baseY, w, h, '#a09890', '#807870', '#b0a8a0', dk); | |
| // Door | |
| ctx.fillStyle = dk ? '#1a1520' : '#605850'; | |
| ctx.fillRect(x-4, baseY-10, 8, 10); | |
| // Front windows (4 rows x 3 cols) | |
| const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.45)'; | |
| for (let r = 0; r < 4; r++) | |
| for (let c = 0; c < 3; c++) { | |
| if (dk && ((r*3+c+animFrame) % 7 < 2)) continue; | |
| ctx.fillStyle = wc; | |
| ctx.fillRect(x-w/2+4+c*12, baseY-h+5+r*11, 8, 7); | |
| } | |
| // Side windows | |
| const swc = dk ? 'rgba(255,200,80,0.35)' : 'rgba(160,200,240,0.3)'; | |
| for (let r = 0; r < 4; r++) { | |
| if (dk && ((r+animFrame) % 5 < 2)) continue; | |
| ctx.fillStyle = swc; | |
| const wy = baseY-h+6+r*11; | |
| ctx.beginPath(); | |
| ctx.moveTo(x+w/2+1, wy); ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY); | |
| ctx.lineTo(x+w/2+ISO_DX, wy-ISO_DY+6); ctx.lineTo(x+w/2+1, wy+6); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| } | |
| function drawSquare(x, y, dk) { | |
| // Cobblestone plaza with raised edge | |
| ctx.fillStyle = dk ? '#2a2820' : '#b0a890'; | |
| ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.fill(); | |
| // Raised edge (2.5D) | |
| ctx.fillStyle = dk ? '#1a1810' : '#908870'; | |
| ctx.beginPath(); ctx.ellipse(x, y+3, 50, 30, 0, 0, Math.PI); ctx.fill(); | |
| ctx.strokeStyle = dk ? '#3a3828' : '#c0b898'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.stroke(); | |
| // Cobblestone pattern | |
| ctx.strokeStyle = dk ? 'rgba(60,55,45,0.3)' : 'rgba(160,150,130,0.3)'; | |
| ctx.lineWidth = 0.5; | |
| for (let i = -3; i <= 3; i++) { | |
| ctx.beginPath(); ctx.moveTo(x+i*12, y-20); ctx.lineTo(x+i*12, y+20); ctx.stroke(); | |
| } | |
| // Fountain (2.5D — circular base + column + basin) | |
| ctx.fillStyle = dk ? '#4a5868' : '#788898'; | |
| ctx.beginPath(); ctx.ellipse(x, y+4, 14, 7, 0, 0, 6.28); ctx.fill(); | |
| ctx.fillStyle = dk ? '#1a2a3a' : '#708898'; | |
| ctx.beginPath(); ctx.ellipse(x, y+1, 12, 6, 0, 0, 6.28); ctx.fill(); | |
| // Fountain column | |
| ctx.fillStyle = dk ? '#3a4858' : '#8098a8'; | |
| ctx.fillRect(x-2, y-8, 4, 10); | |
| // Water basin top | |
| ctx.fillStyle = dk ? '#2a3a5a' : '#88b0d0'; | |
| ctx.beginPath(); ctx.ellipse(x, y-8, 7, 3, 0, 0, 6.28); ctx.fill(); | |
| // Water spray | |
| if (!dk || animFrame % 3 < 2) { | |
| ctx.fillStyle = `rgba(100,180,220,${dk?0.3:0.5})`; | |
| const wy = y-14+Math.sin(animFrame*0.06)*2; | |
| ctx.beginPath(); ctx.arc(x, wy, 2, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(x-3, wy+2, 1.5, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(x+3, wy+2, 1.5, 0, 6.28); ctx.fill(); | |
| } | |
| // Benches | |
| ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30'; | |
| ctx.fillRect(x-30, y+12, 12, 3); | |
| ctx.fillRect(x+18, y+12, 12, 3); | |
| // Label | |
| ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Town Square', x+1, y+23); | |
| ctx.fillStyle = dk ? '#a0a8b0' : '#fff'; ctx.fillText('Town Square', x, y+22); | |
| } | |
| function drawSportsField(x, y, dk) { | |
| // Large green field | |
| ctx.fillStyle = dk ? '#1a3018' : '#4a9a38'; | |
| ctx.beginPath(); ctx.ellipse(x, y, 48, 24, 0, 0, 6.28); ctx.fill(); | |
| // Field lines | |
| ctx.strokeStyle = dk ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.ellipse(x, y, 44, 20, 0, 0, 6.28); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(x, y-20); ctx.lineTo(x, y+20); ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(x, y, 8, 0, 6.28); ctx.stroke(); | |
| // Goals | |
| ctx.strokeStyle = dk ? '#555' : '#ddd'; ctx.lineWidth = 2; | |
| ctx.strokeRect(x-46, y-6, 4, 12); | |
| ctx.strokeRect(x+42, y-6, 4, 12); | |
| // Running track | |
| ctx.strokeStyle = dk ? '#3a2828' : '#c87850'; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); ctx.ellipse(x, y, 52, 28, 0, 0, 6.28); ctx.stroke(); | |
| // Bleachers | |
| ctx.fillStyle = dk ? '#2a2828' : '#888'; | |
| ctx.fillRect(x-20, y+26, 40, 5); | |
| // Label | |
| ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Sports Field', x+1, y+33); | |
| ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Sports Field', x, y+32); | |
| } | |
| function dim(hex, f) { | |
| if (hex.startsWith('rgb')) return hex; // already rgb | |
| const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16); | |
| return `rgb(${~~(r*f)},${~~(g*f)},${~~(b*f)})`; | |
| } | |
| // ============================================================ | |
| // COUPLE LINES | |
| // ============================================================ | |
| function drawCoupleLines(W, H) { | |
| const drawn = new Set(); | |
| for (const [id, a] of Object.entries(agents)) { | |
| if (!a.partner_id || drawn.has(id)) continue; | |
| const other = agents[a.partner_id]; | |
| if (!other) continue; | |
| drawn.add(id); drawn.add(a.partner_id); | |
| const p1 = agentPositions[id], p2 = agentPositions[a.partner_id]; | |
| if (!p1 || !p2) continue; | |
| ctx.strokeStyle = 'rgba(233, 30, 144, 0.4)'; | |
| ctx.lineWidth = 1.5; ctx.setLineDash([4,4]); | |
| ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2 - 10; | |
| drawHeart(mx, my + Math.sin(animFrame*0.05)*3, 6, 'rgba(233,30,144,0.7)'); | |
| } | |
| } | |
| function drawHeart(x, y, s, color) { | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y + s*0.4); | |
| ctx.bezierCurveTo(x, y - s*0.2, x - s, y - s*0.5, x - s, y + s*0.1); | |
| ctx.bezierCurveTo(x - s, y + s*0.6, x, y + s, x, y + s*1.2); | |
| ctx.bezierCurveTo(x, y + s, x + s, y + s*0.6, x + s, y + s*0.1); | |
| ctx.bezierCurveTo(x + s, y - s*0.5, x, y - s*0.2, x, y + s*0.4); | |
| ctx.fill(); | |
| } | |
| // ============================================================ | |
| // CONVERSATION BUBBLES | |
| // ============================================================ | |
| function drawConversationBubbles(W, H) { | |
| if (!conversationData.active) return; | |
| for (const conv of conversationData.active) { | |
| if (!conv.turns || conv.turns.length === 0) continue; | |
| const lastTurn = conv.turns[conv.turns.length - 1]; | |
| const speakerId = lastTurn.speaker_id; | |
| const pos = agentPositions[speakerId]; | |
| if (!pos) continue; | |
| const msg = (lastTurn.message || '').slice(0, 40) + (lastTurn.message.length > 40 ? '...' : ''); | |
| ctx.font = '9px Segoe UI'; | |
| const tw = ctx.measureText(msg).width; | |
| const bw = tw + 14, bh = 18; | |
| const bx = pos.x - bw/2, by = pos.y - 48; | |
| ctx.fillStyle = 'rgba(240,192,64,0.9)'; | |
| ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.moveTo(pos.x - 4, by + bh); ctx.lineTo(pos.x, by + bh + 6); ctx.lineTo(pos.x + 4, by + bh); | |
| ctx.fill(); | |
| ctx.fillStyle = '#1a1a2e'; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText(msg, pos.x, by + bh/2); | |
| } | |
| } | |
| // ============================================================ | |
| // AGENT POSITIONS | |
| // ============================================================ | |
| function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) { | |
| const loc = agent.location || 'house_elena'; | |
| const allPos = getEffectivePositions(); | |
| const pos = allPos[loc]; | |
| if (!pos) return; | |
| const atLoc = byLoc[loc] || []; | |
| const localIdx = atLoc.findIndex(a => a.id === id); | |
| const count = atLoc.length; | |
| // For streets, spread agents along the road | |
| if (pos.type === 'street') { | |
| const row = Math.floor(localIdx / 10); | |
| const col = localIdx % 10; | |
| const spread = Math.min(count, 10); | |
| const step = 0.025; | |
| const startX = pos.x - (spread-1)*step/2; | |
| agentTargets[id] = { | |
| x: (startX + col * step) * W, | |
| y: pos.y * H + 16 + row * 18 + (col % 2) * 10 | |
| }; | |
| } else { | |
| // Multi-row arrangement for large crowds | |
| locCrowdCount[loc] = count; // store for drawPerson scale-down | |
| let ox, oy; | |
| if (count <= 10) { | |
| // Small crowd: natural semicircle arc | |
| const baseR = pos.type === 'house' ? 18 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 40 : 28); | |
| const mpr = pos.type === 'house' ? 4 : 6; | |
| const iRow = Math.floor(localIdx / mpr); | |
| const iCol = localIdx % mpr; | |
| const rCnt = Math.min(count - iRow * mpr, mpr); | |
| const rad = baseR + iRow * 18; | |
| const stp = Math.PI / Math.max(rCnt + 1, 2); | |
| const ang = stp * (iCol + 1); | |
| ox = Math.cos(ang) * rad - rad / 3; | |
| oy = Math.sin(ang) * rad * 0.5 + (pos.type === 'house' ? 20 : 28) + iRow * 8; | |
| } else { | |
| // Large crowd: grid layout — fills a rectangle so no dense blob | |
| const isWide = pos.type === 'park' || pos.type === 'square' || pos.type === 'sports'; | |
| const areaW = isWide ? 290 : 210; | |
| const areaH = isWide ? 210 : 160; | |
| const cols = Math.ceil(Math.sqrt(count * 1.3)); | |
| const rows = Math.ceil(count / cols); | |
| const cellW = areaW / cols; | |
| const cellH = areaH / rows; | |
| const col = localIdx % cols; | |
| const iRow = Math.floor(localIdx / cols); | |
| // Deterministic per-agent jitter so grid looks organic, not military | |
| const jx = ((globalIdx * 7 + 3) % 11) - 5; | |
| const jy = ((globalIdx * 11 + 7) % 9) - 4; | |
| ox = -(cols - 1) * cellW / 2 + col * cellW + jx; | |
| oy = 26 + iRow * cellH + jy; | |
| } | |
| agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy }; | |
| } | |
| if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]}; | |
| // ── L-shaped road routing for moving agents ────────────────── | |
| // On destination change, compute a 2-waypoint L-shaped path via the | |
| // road grid so agents walk along streets instead of cutting diagonals. | |
| const prevLoc = agentPrevLocations[id]; | |
| if (agent.state === 'moving' && prevLoc && prevLoc !== loc) { | |
| const prevPos = allPos[prevLoc]; | |
| if (prevPos && agentPositions[id]) { | |
| const srcX = prevPos.x, srcY = prevPos.y; | |
| const dstX = pos.x, dstY = pos.y; | |
| const hRoads = [0.22, 0.28, 0.43, 0.58, 0.72, 0.80]; | |
| const vRoads = [0.08, 0.15, 0.30, 0.50, 0.70, 0.85, 0.92]; | |
| const midY = (srcY + dstY) / 2, midX = (srcX + dstX) / 2; | |
| const bestH = hRoads.reduce((b, r) => Math.abs(midY - r) < Math.abs(midY - b) ? r : b, hRoads[0]); | |
| const bestV = vRoads.reduce((b, r) => Math.abs(midX - r) < Math.abs(midX - b) ? r : b, vRoads[0]); | |
| const lo = (localIdx % 3 - 1) * 5; // lane spread keeps agents from stacking | |
| if (Math.abs(dstX - srcX) >= Math.abs(dstY - srcY)) { | |
| // Primarily horizontal: exit to H-road, slide across, drop to destination | |
| agentWaypoints[id] = [ | |
| { x: srcX * W + lo, y: bestH * H }, | |
| { x: dstX * W + lo, y: bestH * H }, | |
| ]; | |
| } else { | |
| // Primarily vertical: exit to V-road, travel up/down, approach destination | |
| agentWaypoints[id] = [ | |
| { x: bestV * W, y: srcY * H + lo }, | |
| { x: bestV * W, y: dstY * H + lo }, | |
| ]; | |
| } | |
| } | |
| } | |
| agentPrevLocations[id] = loc; | |
| } | |
| // ============================================================ | |
| // PERSON DRAWING | |
| // ============================================================ | |
| function drawPerson(id, agent, globalIdx, W, H) { | |
| const pos = agentPositions[id]; | |
| if (!pos) return; | |
| const ax = pos.x, ay = pos.y; | |
| const color = AGENT_COLORS[globalIdx % AGENT_COLORS.length]; | |
| const isSel = id === selectedAgentId; | |
| const isHov = id === hoveredAgent; | |
| const gender = agent.gender || 'unknown'; | |
| // Shrink agents proportionally when many share a location — sqrt falloff, min 48% | |
| const crowdN = locCrowdCount[agent.location] || 1; | |
| const crowdShrink = Math.max(0.48, Math.sqrt(12 / Math.max(12, crowdN))); | |
| const scale = isSel ? 1.15 : (isHov ? 1.0 : 0.82 * crowdShrink); | |
| const isMoving = agent.state === 'moving'; | |
| const isSleeping = agent.state === 'sleeping'; | |
| // Visual movement = interpolated position is far from target | |
| const tgt = agentTargets[id]; | |
| const movingVisually = tgt && Math.hypot(ax - tgt.x, ay - tgt.y) > 4; | |
| const walkAnim = isMoving || movingVisually; | |
| const walkPhase = animFrame * 0.28; | |
| const bounce = walkAnim ? Math.sin(walkPhase) * 2.5 : 0; | |
| const legSwing = walkAnim ? Math.sin(walkPhase) * 12 : 0; | |
| const armSwing = walkAnim ? Math.sin(walkPhase) * 10 : 0; | |
| const tY = -10 + bounce; // torso top Y — hoisted so profile view can use it | |
| // Facing direction — maintained by animate() from position delta | |
| const facingRight = agentFacingRight[id] !== false; // default right | |
| const movingUp = agentMovingUp[id] === true; | |
| // Skin & hair | |
| const skinTones = ['#f5dbb8','#d4a574','#c68642','#8d5524','#e8c4a0','#f0c090']; | |
| const skin = skinTones[(globalIdx * 7 + 3) % skinTones.length]; | |
| const hairColor = dim(color, 0.45); | |
| // Pants = darker variant of shirt, shoes = very dark | |
| const pantsColor = dim(color, 0.55); | |
| const shoeColor = currentTimeOfDay === 'night' ? '#1a1a1a' : '#2a2010'; | |
| ctx.save(); | |
| ctx.translate(ax, ay); | |
| ctx.scale(scale, scale); | |
| if (isSel) { ctx.shadowColor = color; ctx.shadowBlur = 14; } | |
| // Gold ring for player-controlled agent | |
| if (agent.is_player) { | |
| ctx.beginPath(); | |
| ctx.arc(0, 0, 22, 0, Math.PI * 2); | |
| ctx.strokeStyle = '#f0c040'; | |
| ctx.lineWidth = 2.5; | |
| ctx.globalAlpha = 0.8; | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1.0; | |
| } | |
| // ══════════════════════════════════════════════════════════════ | |
| // SLEEPING VIEW — agent lying on a bed | |
| // ══════════════════════════════════════════════════════════════ | |
| if (isSleeping) { | |
| const bw = 22, bh = 10; | |
| // Bed frame | |
| ctx.fillStyle = '#5c3d1a'; | |
| ctx.beginPath(); ctx.roundRect(-bw/2 - 1, -bh/2 - 1, bw + 2, bh + 2, 2); ctx.fill(); | |
| // Mattress | |
| ctx.fillStyle = '#c9a97a'; | |
| ctx.beginPath(); ctx.roundRect(-bw/2, -bh/2, bw, bh, 1); ctx.fill(); | |
| // Pillow at head-end (right side) | |
| ctx.fillStyle = '#f0e8e0'; | |
| ctx.beginPath(); ctx.roundRect(bw/2 - 9, -bh/2 + 1, 8, bh - 2, 2); ctx.fill(); | |
| // Blanket covering body (left portion) | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); ctx.roundRect(-bw/2 + 1, -bh/2 + 1.5, bw - 11, bh - 3, 1); ctx.fill(); | |
| // Blanket fold highlight | |
| ctx.fillStyle = dim(color, 1.25); | |
| ctx.beginPath(); ctx.roundRect(-bw/2 + 1, -bh/2 + 1.5, bw - 11, 2.5, 1); ctx.fill(); | |
| // Head resting on pillow | |
| ctx.fillStyle = skin; | |
| ctx.beginPath(); ctx.arc(bw/2 - 5, 0, 3.5, 0, 6.28); ctx.fill(); | |
| // Hair | |
| ctx.fillStyle = hairColor; | |
| ctx.beginPath(); ctx.arc(bw/2 - 5, -0.5, 4.2, Math.PI * 0.85, Math.PI * 2.15); ctx.fill(); | |
| // Closed eyes (two small arcs) | |
| ctx.strokeStyle = dim(skin, 0.45); ctx.lineWidth = 0.7; ctx.lineCap = 'round'; | |
| ctx.beginPath(); ctx.arc(bw/2 - 3.5, -0.3, 1.0, Math.PI * 1.1, Math.PI * 0.0); ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(bw/2 - 6.5, -0.3, 1.0, Math.PI * 1.1, Math.PI * 0.0); ctx.stroke(); | |
| // ZZZ bubbles | |
| const zt = animFrame * 0.04; | |
| ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left'; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8'; | |
| ctx.fillText('z', bw/2 - 2 + i * 5, -bh/2 - 4 - i * 5 + Math.sin(zt + i) * 2); | |
| } | |
| ctx.globalAlpha = 1; | |
| if (agent.partner_id) drawHeart(bw/2 - 5, -bh/2 - 14 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); | |
| if (isSel) { | |
| ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); | |
| ctx.beginPath(); ctx.roundRect(-bw/2 - 4, -bh/2 - 4, bw + 8, bh + 8, 4); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; | |
| ctx.restore(); | |
| const fnS = (agent.name || id).split(' ')[0]; | |
| ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnS, ax + 1, ay + 8 * scale + 1); | |
| ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnS, ax, ay + 8 * scale); | |
| return; | |
| } | |
| if (movingUp) { | |
| // ══════════════════════════════════════════════════════════ | |
| // BACK VIEW — agent moving/facing toward top of screen | |
| // Same figure as front view but no face; hair covers back. | |
| // ══════════════════════════════════════════════════════════ | |
| const twB = gender === 'female' ? 7 : 8; | |
| const bwB = gender === 'female' ? 4.5 : 6; | |
| const hxB = 1, hyB = tY - 10, hrB = 6.5; | |
| // Ground shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.22)'; | |
| ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill(); | |
| // SHOES | |
| const lFBX = -2.5 - legSwing * 0.35, lFBY = 12 + bounce + legSwing; | |
| const rFBX = 2.5 + legSwing * 0.35, rFBY = 12 + bounce - legSwing; | |
| ctx.fillStyle = shoeColor; | |
| ctx.beginPath(); ctx.roundRect(lFBX - 3, lFBY - 1, 5, 2.5, 1); ctx.fill(); | |
| ctx.beginPath(); ctx.roundRect(rFBX - 1.5, rFBY - 1, 5, 2.5, 1); ctx.fill(); | |
| // LEGS | |
| ctx.lineWidth = 2.8; ctx.lineCap = 'round'; | |
| ctx.strokeStyle = pantsColor; | |
| ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce); | |
| ctx.lineTo(-1.5 - legSwing * 0.18, 8 + bounce + legSwing * 0.5); | |
| ctx.lineTo(lFBX, lFBY); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); | |
| ctx.lineTo(1.5 + legSwing * 0.18, 8 + bounce - legSwing * 0.5); | |
| ctx.lineTo(rFBX, rFBY); ctx.stroke(); | |
| // TORSO (back — darker gradient, spine crease) | |
| const tGradB = ctx.createLinearGradient(-twB - 1, tY, twB + 1, tY + 13); | |
| tGradB.addColorStop(0.00, dim(color, 0.62)); | |
| tGradB.addColorStop(0.50, dim(color, 0.78)); | |
| tGradB.addColorStop(1.00, dim(color, 0.50)); | |
| ctx.fillStyle = tGradB; | |
| ctx.beginPath(); | |
| ctx.moveTo(-bwB, tY + 13); ctx.lineTo(-twB, tY + 2); | |
| ctx.quadraticCurveTo(-twB, tY - 1, -twB + 3, tY - 1); | |
| ctx.lineTo(twB - 3, tY - 1); | |
| ctx.quadraticCurveTo(twB, tY - 1, twB, tY + 2); | |
| ctx.lineTo(bwB, tY + 13); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = dim(color, 0.38); ctx.lineWidth = 0.8; | |
| ctx.beginPath(); ctx.moveTo(0, tY + 2); ctx.lineTo(0, tY + 12); ctx.stroke(); | |
| if (gender === 'female') { | |
| const sGB = ctx.createLinearGradient(-bwB - 2, tY + 13, bwB + 2, tY + 17); | |
| sGB.addColorStop(0, dim(color, 0.65)); sGB.addColorStop(1, dim(color, 0.50)); | |
| ctx.fillStyle = sGB; | |
| ctx.beginPath(); | |
| ctx.moveTo(-bwB, tY + 13); ctx.lineTo(-bwB - 2.5, tY + 17 + bounce); | |
| ctx.lineTo(bwB + 2.5, tY + 17 + bounce); ctx.lineTo(bwB, tY + 13); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // ARMS (back — slightly dimmed skin) | |
| const sBy = tY + 2; | |
| ctx.strokeStyle = dim(skin, 0.78); ctx.lineWidth = 1.8; ctx.lineCap = 'round'; | |
| ctx.beginPath(); ctx.moveTo(-twB + 1, sBy); | |
| ctx.lineTo(-twB - 3 + armSwing * 0.3, sBy + 5 + armSwing * 0.4); | |
| ctx.lineTo(-twB - 4.5 + armSwing * 0.7, sBy + 9 + armSwing * 0.85); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(twB - 1, sBy); | |
| ctx.lineTo(twB + 3 - armSwing * 0.3, sBy + 5 - armSwing * 0.4); | |
| ctx.lineTo(twB + 4.5 - armSwing * 0.7, sBy + 9 - armSwing * 0.85); ctx.stroke(); | |
| ctx.fillStyle = dim(skin, 0.78); | |
| ctx.beginPath(); ctx.arc(-twB - 4.5 + armSwing * 0.7, sBy + 9 + armSwing * 0.85, 1.1, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(twB + 4.5 - armSwing * 0.7, sBy + 9 - armSwing * 0.85, 1.1, 0, 6.28); ctx.fill(); | |
| // NECK (back) | |
| ctx.fillStyle = dim(skin, 0.72); ctx.fillRect(-1.5, tY - 3.5, 3, 4); | |
| // HEAD (back of head — darker, no face features) | |
| ctx.fillStyle = dim(skin, 0.78); | |
| ctx.beginPath(); ctx.arc(hxB, hyB, hrB, 0, 6.28); ctx.fill(); | |
| // HAIR (back view — covers more of head than front) | |
| ctx.fillStyle = hairColor; | |
| if (gender === 'female') { | |
| ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 1.0, Math.PI * 1.0, Math.PI * 2.0); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(hxB - hrB + 0.5, hyB + 4, 2.5, hrB * 0.95, -0.1, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(hxB + hrB - 0.5, hyB + 4, 2.2, hrB * 0.90, 0.1, 0, 6.28); ctx.fill(); | |
| } else if (gender === 'male') { | |
| ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 0.5, Math.PI * 1.0, Math.PI * 2.0); ctx.fill(); | |
| ctx.fillRect(hxB - hrB - 0.5, hyB - 0.5, 3, hrB * 0.6); | |
| } else { | |
| ctx.beginPath(); ctx.arc(hxB, hyB - 0.5, hrB + 0.8, Math.PI * 1.0, Math.PI * 2.0); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(hxB - hrB + 0.5, hyB + 3, 2.2, hrB * 0.75, -0.1, 0, 6.28); ctx.fill(); | |
| } | |
| if (agent.partner_id) drawHeart(hxB + 1, hyB - hrB - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); | |
| if (isSel) { | |
| ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); | |
| ctx.beginPath(); ctx.arc(hxB, hyB, hrB + 3, 0, 6.28); ctx.stroke(); ctx.setLineDash([]); | |
| } | |
| ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; | |
| ctx.restore(); | |
| const fnB = (agent.name || id).split(' ')[0]; | |
| ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnB, ax + 1, ay + 14 * scale + 1); | |
| ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnB, ax, ay + 14 * scale); | |
| const mVB = agent.mood || 0, mwBL = 16, mxbB = ax - 8, mybB = ay + 14 * scale + 11; | |
| ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxbB, mybB, mwBL, 2.5); | |
| const mfB = (mVB + 1) / 2; | |
| ctx.fillStyle = mfB > 0.6 ? '#4ecca3' : (mfB > 0.3 ? '#f0c040' : '#e94560'); | |
| ctx.fillRect(mxbB, mybB, mwBL * mfB, 2.5); | |
| return; | |
| } | |
| if (walkAnim) { | |
| // ══════════════════════════════════════════════════════════ | |
| // PROFILE VIEW — side-on silhouette while walking | |
| // Positive X = forward (direction of travel). | |
| // Flip the context for left-facing agents. | |
| // ══════════════════════════════════════════════════════════ | |
| if (!facingRight) ctx.scale(-1, 1); | |
| const hr = 6.5; | |
| const hx = 2, hy = tY - 11; | |
| const pStride = Math.sin(walkPhase) * 12; // foot X swing (±12) | |
| // Ground shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.20)'; | |
| ctx.beginPath(); ctx.ellipse(2, 13, 10, 2.5, 0, 0, 6.28); ctx.fill(); | |
| // ── BACK LEG (behind body, drawn first) ─────────────────── | |
| const bKX = -pStride * 0.42, bKY = 8 + bounce; | |
| const bFX = -pStride * 0.75, bFY = 14 + bounce; | |
| ctx.strokeStyle = dim(pantsColor, 0.62); ctx.lineWidth = 2.5; ctx.lineCap = 'round'; | |
| ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); ctx.lineTo(bKX, bKY); ctx.lineTo(bFX, bFY); ctx.stroke(); | |
| ctx.fillStyle = dim(shoeColor, 0.62); | |
| ctx.beginPath(); ctx.roundRect(bFX - 2, bFY - 1, 6.5, 2.5, 1); ctx.fill(); | |
| // ── BACK ARM (behind body) ──────────────────────────────── | |
| const bAS = -Math.sin(walkPhase) * 8; | |
| const bEX = 1.5 + bAS * 0.42, bEY = tY + 7; | |
| const bHX = bEX + bAS * 0.5, bHY = bEY + 4; | |
| ctx.strokeStyle = dim(skin, 0.65); ctx.lineWidth = 1.7; | |
| ctx.beginPath(); ctx.moveTo(2, tY + 2); ctx.lineTo(bEX, bEY); ctx.lineTo(bHX, bHY); ctx.stroke(); | |
| ctx.fillStyle = dim(skin, 0.65); | |
| ctx.beginPath(); ctx.arc(bHX, bHY, 1.0, 0, 6.28); ctx.fill(); | |
| // ── TORSO (narrow side profile, lit-front → dark-back) ─── | |
| const pGrad = ctx.createLinearGradient(-0.5, tY, 5.5, tY); | |
| pGrad.addColorStop(0.00, dim(color, 1.22)); | |
| pGrad.addColorStop(0.45, color); | |
| pGrad.addColorStop(1.00, dim(color, 0.50)); | |
| ctx.fillStyle = pGrad; | |
| ctx.beginPath(); | |
| ctx.moveTo(-0.5, tY + 13); ctx.lineTo(-0.5, tY + 2); | |
| ctx.quadraticCurveTo(-0.5, tY - 1, 1, tY - 1); | |
| ctx.lineTo(3.5, tY - 1); ctx.quadraticCurveTo(5.5, tY - 1, 5.5, tY + 2); | |
| ctx.lineTo(5.5, tY + 13); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = 'rgba(255,255,255,0.11)'; | |
| ctx.beginPath(); ctx.ellipse(0.5, tY + 4, 1.3, 4, 0, 0, 6.28); ctx.fill(); | |
| ctx.strokeStyle = dim(color, 0.42); ctx.lineWidth = 0.8; | |
| ctx.beginPath(); ctx.moveTo(-0.5, tY + 13); ctx.lineTo(5.5, tY + 13); ctx.stroke(); | |
| if (gender === 'female') { | |
| const sG = ctx.createLinearGradient(0, tY + 13, 0, tY + 19); | |
| sG.addColorStop(0, color); sG.addColorStop(1, dim(color, 0.68)); | |
| ctx.fillStyle = sG; | |
| ctx.beginPath(); | |
| ctx.moveTo(-0.5, tY + 13); ctx.lineTo(-2.5, tY + 19 + bounce); | |
| ctx.lineTo(7.5, tY + 19 + bounce); ctx.lineTo(5.5, tY + 13); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // ── FRONT LEG (in front of body) ───────────────────────── | |
| const fKX = pStride * 0.42 + 1.5, fKY = 8 + bounce; | |
| const fFX = pStride * 0.75 + 1.5, fFY = 14 + bounce; | |
| ctx.strokeStyle = pantsColor; ctx.lineWidth = 2.9; | |
| ctx.beginPath(); ctx.moveTo(2, 4 + bounce); ctx.lineTo(fKX, fKY); ctx.lineTo(fFX, fFY); ctx.stroke(); | |
| ctx.fillStyle = shoeColor; | |
| ctx.beginPath(); ctx.roundRect(fFX - 1.5, fFY - 1, 8, 2.5, 1); ctx.fill(); | |
| // ── FRONT ARM ───────────────────────────────────────────── | |
| const fAS = Math.sin(walkPhase) * 8; | |
| const fEX = 2.5 + fAS * 0.42, fEY = tY + 7; | |
| const fHX = fEX + fAS * 0.5, fHY = fEY + 4; | |
| ctx.strokeStyle = skin; ctx.lineWidth = 1.8; | |
| ctx.beginPath(); ctx.moveTo(2.5, tY + 2); ctx.lineTo(fEX, fEY); ctx.lineTo(fHX, fHY); ctx.stroke(); | |
| ctx.fillStyle = skin; | |
| ctx.beginPath(); ctx.arc(fHX, fHY, 1.1, 0, 6.28); ctx.fill(); | |
| // ── NECK ────────────────────────────────────────────────── | |
| ctx.fillStyle = skin; ctx.fillRect(0.5, tY - 4, 2.5, 4); | |
| // ── HEAD (profile) ──────────────────────────────────────── | |
| // Back of head (whole circle, slightly dark) | |
| ctx.fillStyle = dim(skin, 0.80); | |
| ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill(); | |
| // Front face hemisphere (right semicircle, skin-tone) | |
| ctx.fillStyle = skin; | |
| ctx.beginPath(); ctx.arc(hx, hy, hr, -Math.PI * 0.55, Math.PI * 0.55); | |
| ctx.lineTo(hx, hy); ctx.closePath(); ctx.fill(); | |
| // Face highlight (lit from front-upper) | |
| ctx.fillStyle = 'rgba(255,255,255,0.10)'; | |
| ctx.beginPath(); ctx.arc(hx + 2.5, hy - 2, hr * 0.48, 0, 6.28); ctx.fill(); | |
| // Ear (back of head, visible in profile) | |
| ctx.fillStyle = dim(skin, 0.86); | |
| ctx.beginPath(); ctx.ellipse(hx - hr + 1.5, hy + 0.5, 1.8, 2.5, 0.1, 0, 6.28); ctx.fill(); | |
| // ── HAIR (profile) ──────────────────────────────────────── | |
| ctx.fillStyle = hairColor; | |
| ctx.beginPath(); ctx.arc(hx, hy - 0.5, hr + 0.8, Math.PI * 1.08, Math.PI * 0.1); ctx.fill(); | |
| if (gender === 'female') { | |
| ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 3, 2.5, hr * 0.82, 0.15, 0, 6.28); ctx.fill(); | |
| } else { | |
| ctx.fillRect(hx - hr - 0.5, hy - 1.5, 3, hr * 0.50); | |
| } | |
| // ── FACE FEATURES (profile) ─────────────────────────────── | |
| const moodVP = agent.mood || 0; | |
| // Eyebrow (one, angle reacts to mood) | |
| const pBrY = hy - 3.5; | |
| const pSadT = moodVP < 0 ? Math.min(1, -moodVP) * 1.5 : 0; | |
| const pHapR = moodVP > 0 ? moodVP * 0.7 : 0; | |
| ctx.strokeStyle = hairColor; ctx.lineWidth = 1.0; ctx.lineCap = 'round'; | |
| ctx.beginPath(); ctx.moveTo(hx + 1.5, pBrY - pHapR); ctx.lineTo(hx + 5.0, pBrY - pHapR + pSadT); ctx.stroke(); | |
| // Eye (one, oval) | |
| ctx.fillStyle = '#1a1010'; | |
| ctx.beginPath(); ctx.ellipse(hx + 3.5, hy - 0.8, 0.85, 1.1, 0.12, 0, 6.28); ctx.fill(); | |
| ctx.fillStyle = 'rgba(255,255,255,0.55)'; | |
| ctx.beginPath(); ctx.arc(hx + 3.8, hy - 1.1, 0.35, 0, 6.28); ctx.fill(); | |
| // Nose (small bump at front edge) | |
| ctx.fillStyle = dim(skin, 0.73); | |
| ctx.beginPath(); ctx.moveTo(hx + hr - 3, hy - 2); | |
| ctx.quadraticCurveTo(hx + hr + 1.8, hy - 0.5, hx + hr - 1.5, hy + 2); | |
| ctx.closePath(); ctx.fill(); | |
| // Mouth (small profile curve: happy=up ∩, frown=down ∪) | |
| const pMC = Math.max(-2.5, Math.min(2.5, moodVP * 6)); | |
| const pMX0 = hx + hr - 4.2, pMX1 = hx + hr - 1.3, pMY = hy + 3.6; | |
| ctx.strokeStyle = moodVP > 0 ? 'rgba(210,90,90,0.85)' : 'rgba(140,70,80,0.85)'; | |
| ctx.lineWidth = 0.9; | |
| ctx.beginPath(); ctx.moveTo(pMX0, pMY); | |
| ctx.quadraticCurveTo((pMX0 + pMX1) / 2, pMY - pMC, pMX1, pMY); ctx.stroke(); | |
| // ── STATE EFFECTS ───────────────────────────────────────── | |
| if (agent.state === 'in_conversation') { | |
| ctx.fillStyle = 'rgba(240,192,64,0.9)'; | |
| ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill(); | |
| ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI'; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText('...', hr + 11, hy - hr); | |
| } | |
| if (isSleeping) { | |
| const t = animFrame * 0.04; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left'; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8'; | |
| ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| if (agent.partner_id) drawHeart(hx + 1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); | |
| if (isSel) { | |
| ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); | |
| ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke(); ctx.setLineDash([]); | |
| } | |
| ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; | |
| ctx.restore(); | |
| // Name label + mood bar (world coords, shared with front view) | |
| const fnP = (agent.name || id).split(' ')[0]; | |
| ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(fnP, ax + 1, ay + 14 * scale + 1); | |
| ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(fnP, ax, ay + 14 * scale); | |
| const mVP = agent.mood || 0, mwP = 16, mxbP = ax - 8, mybP = ay + 14 * scale + 11; | |
| ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxbP, mybP, mwP, 2.5); | |
| const mfP = (mVP + 1) / 2; | |
| ctx.fillStyle = mfP > 0.6 ? '#4ecca3' : (mfP > 0.3 ? '#f0c040' : '#e94560'); | |
| ctx.fillRect(mxbP, mybP, mwP * mfP, 2.5); | |
| return; | |
| } | |
| // ── Ground shadow (elongated, ISO-angled) ───────────────── | |
| ctx.fillStyle = 'rgba(0,0,0,0.22)'; | |
| ctx.beginPath(); ctx.ellipse(3, 13, 8, 3, 0.2, 0, 6.28); ctx.fill(); | |
| // ── SHOES ──────────────────────────────────────────────── | |
| const lKneeX = walkAnim ? -1.5 - legSwing * 0.18 : -1.5; | |
| const rKneeX = walkAnim ? 1.5 + legSwing * 0.18 : 1.5; | |
| const lFootX = walkAnim ? -2.5 - legSwing * 0.35 : -2.5; | |
| const rFootX = walkAnim ? 2.5 + legSwing * 0.35 : 2.5; | |
| const lFootY = 12 + bounce + legSwing; | |
| const rFootY = 12 + bounce - legSwing; | |
| // Shoe body | |
| ctx.fillStyle = shoeColor; | |
| ctx.beginPath(); ctx.roundRect(lFootX - 3, lFootY - 1, 5, 2.5, 1); ctx.fill(); | |
| ctx.beginPath(); ctx.roundRect(rFootX - 1.5, rFootY - 1, 5, 2.5, 1); ctx.fill(); | |
| // ── LEGS (two-segment: thigh + shin with knee) ─────────── | |
| ctx.lineWidth = walkAnim ? 2.8 : 2.2; | |
| ctx.lineCap = 'round'; | |
| // Left leg | |
| ctx.strokeStyle = gender === 'female' ? dim(pantsColor, 0.85) : pantsColor; | |
| ctx.beginPath(); ctx.moveTo(-1.5, 4 + bounce); | |
| ctx.lineTo(lKneeX, 8 + bounce + legSwing * 0.5); | |
| ctx.lineTo(lFootX, lFootY); ctx.stroke(); | |
| // Right leg | |
| ctx.beginPath(); ctx.moveTo(1.5, 4 + bounce); | |
| ctx.lineTo(rKneeX, 8 + bounce - legSwing * 0.5); | |
| ctx.lineTo(rFootX, rFootY); ctx.stroke(); | |
| // Right leg 2.5D depth (slightly lighter) | |
| ctx.strokeStyle = dim(gender === 'female' ? dim(pantsColor, 0.85) : pantsColor, 1.2); | |
| ctx.lineWidth = 1.2; | |
| ctx.beginPath(); ctx.moveTo(1.5 + 1.5, 4 + bounce); | |
| ctx.lineTo(rKneeX + 1.5, 8 + bounce - legSwing * 0.5); | |
| ctx.lineTo(rFootX + 1.5, rFootY); ctx.stroke(); | |
| // ── TORSO (smooth, rounded, gradient-shaded) ────────────── | |
| const tw = gender === 'female' ? 7 : 8; // shoulder half-width | |
| const bw = gender === 'female' ? 4.5 : 6; // waist half-width | |
| // Helper to build the torso path (reused for fill + highlight) | |
| function torsoPath() { | |
| ctx.beginPath(); | |
| ctx.moveTo(-bw, tY + 13); | |
| ctx.lineTo(-tw, tY + 2); | |
| ctx.quadraticCurveTo(-tw, tY - 1, -tw + 3, tY - 1); // left shoulder round | |
| ctx.lineTo(tw - 3, tY - 1); | |
| ctx.quadraticCurveTo(tw, tY - 1, tw, tY + 2); // right shoulder round | |
| ctx.lineTo(bw, tY + 13); | |
| ctx.closePath(); | |
| } | |
| // Main fill: horizontal gradient — lit left, shadow right (cylindrical form) | |
| const tGrad = ctx.createLinearGradient(-tw - 1, tY, tw + 1, tY + 13); | |
| tGrad.addColorStop(0.00, dim(color, 1.30)); // highlight left shoulder | |
| tGrad.addColorStop(0.22, dim(color, 1.08)); // upper-centre | |
| tGrad.addColorStop(0.52, color); // mid | |
| tGrad.addColorStop(0.78, dim(color, 0.72)); // right shading | |
| tGrad.addColorStop(1.00, dim(color, 0.45)); // right edge deep shadow | |
| ctx.fillStyle = tGrad; | |
| torsoPath(); ctx.fill(); | |
| // Chest highlight — soft radial glow on upper-left | |
| const chGrad = ctx.createRadialGradient(-2.5, tY + 3.5, 0, -2.5, tY + 3.5, 6.5); | |
| chGrad.addColorStop(0, 'rgba(255,255,255,0.20)'); | |
| chGrad.addColorStop(1, 'rgba(255,255,255,0)'); | |
| ctx.fillStyle = chGrad; | |
| torsoPath(); ctx.fill(); | |
| // Subtle right-edge depth line | |
| ctx.strokeStyle = dim(color, 0.35); ctx.lineWidth = 1; ctx.lineCap = 'round'; | |
| ctx.beginPath(); ctx.moveTo(tw, tY + 2); ctx.lineTo(bw, tY + 13); ctx.stroke(); | |
| // Belt / waist crease | |
| ctx.strokeStyle = dim(color, 0.45); ctx.lineWidth = 0.9; | |
| ctx.beginPath(); ctx.moveTo(-bw + 0.5, tY + 13); ctx.lineTo(bw - 0.5, tY + 13); ctx.stroke(); | |
| // Female: skirt flare with gradient | |
| if (gender === 'female') { | |
| const skirtGrad = ctx.createLinearGradient(-bw - 2, tY + 13, bw + 2, tY + 17); | |
| skirtGrad.addColorStop(0, color); | |
| skirtGrad.addColorStop(1, dim(color, 0.72)); | |
| ctx.fillStyle = skirtGrad; | |
| ctx.beginPath(); | |
| ctx.moveTo(-bw, tY + 13); ctx.lineTo(-bw - 2.5, tY + 17 + bounce); | |
| ctx.lineTo(bw + 2.5, tY + 17 + bounce); ctx.lineTo(bw, tY + 13); | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // ── ARMS (two-segment with elbow when walking) ──────────── | |
| const shoulderY = tY + 2; | |
| // Elbow positions | |
| const lElbowX = walkAnim ? -tw - 3 + armSwing * 0.3 : -tw - 3; | |
| const lElbowY = walkAnim ? shoulderY + 5 + armSwing * 0.4 : shoulderY + 5; | |
| const rElbowX = walkAnim ? tw + 3 - armSwing * 0.3 : tw + 3; | |
| const rElbowY = walkAnim ? shoulderY + 5 - armSwing * 0.4 : shoulderY + 5; | |
| // Hand positions | |
| const lHandX = walkAnim ? lElbowX - 1.5 + armSwing * 0.4 : lElbowX - 1; | |
| const lHandY = walkAnim ? lElbowY + 4 + armSwing * 0.45 : lElbowY + 4; | |
| const rHandX = walkAnim ? rElbowX + 1.5 - armSwing * 0.4 : rElbowX + 1; | |
| const rHandY = walkAnim ? rElbowY + 4 - armSwing * 0.45 : rElbowY + 4; | |
| ctx.strokeStyle = skin; ctx.lineWidth = 1.8; ctx.lineCap = 'round'; | |
| // Left arm (upper arm + forearm) | |
| ctx.beginPath(); ctx.moveTo(-tw + 1, shoulderY); | |
| ctx.lineTo(lElbowX, lElbowY); ctx.lineTo(lHandX, lHandY); ctx.stroke(); | |
| // Right arm | |
| ctx.beginPath(); ctx.moveTo(tw - 1, shoulderY); | |
| ctx.lineTo(rElbowX, rElbowY); ctx.lineTo(rHandX, rHandY); ctx.stroke(); | |
| // Hands (small filled circles) | |
| ctx.fillStyle = skin; | |
| ctx.beginPath(); ctx.arc(lHandX, lHandY, 1.1, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(rHandX, rHandY, 1.1, 0, 6.28); ctx.fill(); | |
| // ── NECK ───────────────────────────────────────────────── | |
| ctx.fillStyle = skin; | |
| ctx.fillRect(-1.5, tY - 3.5, 3, 4); | |
| // ── HEAD (2.5D — sphere-like with side shading) ─────────── | |
| const hx = 1, hy = tY - 10; // head center (slightly right for 2.5D) | |
| const hr = 6.5; | |
| // Back-of-head / shading arc | |
| ctx.fillStyle = dim(skin, 0.78); | |
| ctx.beginPath(); ctx.arc(hx + 1.5, hy, hr - 0.5, 0, 6.28); ctx.fill(); | |
| // Main face | |
| ctx.fillStyle = skin; | |
| ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.28); ctx.fill(); | |
| // Right cheek highlight (2.5D lit from upper-left) | |
| ctx.fillStyle = `rgba(255,255,255,0.12)`; | |
| ctx.beginPath(); ctx.arc(hx - 2, hy - 2, hr * 0.55, 0, 6.28); ctx.fill(); | |
| // Ear (right side, 2.5D) | |
| ctx.fillStyle = dim(skin, 0.88); | |
| ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 1, 1.5, 2, 0, 0, 6.28); ctx.fill(); | |
| // ── HAIR ───────────────────────────────────────────────── | |
| ctx.fillStyle = hairColor; | |
| if (gender === 'female') { | |
| // Long hair — top cap + side curtains | |
| ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.1, Math.PI * 0.05); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(hx - hr + 1, hy + 3, 2.2, hr * 0.85, -0.2, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(hx + hr - 0.5, hy + 3, 1.8, hr * 0.7, 0.2, 0, 6.28); ctx.fill(); | |
| } else if (gender === 'male') { | |
| // Short crop | |
| ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.15, Math.PI * -0.1); ctx.fill(); | |
| ctx.fillRect(hx - hr - 0.5, hy - 1, 3, hr * 0.6); // left side | |
| } else { | |
| // Medium / non-binary | |
| ctx.beginPath(); ctx.arc(hx, hy - 1, hr + 0.5, Math.PI * 1.08, Math.PI * 0.0); ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse(hx - hr + 0.5, hy + 2, 2, hr * 0.65, -0.15, 0, 6.28); ctx.fill(); | |
| } | |
| // ── FACE ───────────────────────────────────────────────── | |
| const moodVal = agent.mood || 0; | |
| // Eyebrows — angle and height react to mood | |
| const brBase = hy - 3.4; | |
| const sadTilt = moodVal < 0 ? Math.min(1, -moodVal) * 2.0 : 0; // inner corner rises | |
| const happyRaise = moodVal > 0 ? moodVal * 0.8 : 0; // whole brow lifts | |
| ctx.strokeStyle = hairColor; ctx.lineWidth = 1.0; ctx.lineCap = 'round'; | |
| // Left brow: outer=(hx-3.8), inner=(hx-1.0) | |
| ctx.beginPath(); | |
| ctx.moveTo(hx - 3.8, brBase - happyRaise); | |
| ctx.lineTo(hx - 1.0, brBase - happyRaise - sadTilt); | |
| ctx.stroke(); | |
| // Right brow: inner=(hx+1.0), outer=(hx+3.8) | |
| ctx.beginPath(); | |
| ctx.moveTo(hx + 1.0, brBase - happyRaise - sadTilt); | |
| ctx.lineTo(hx + 3.8, brBase - happyRaise); | |
| ctx.stroke(); | |
| // Eyes | |
| ctx.fillStyle = '#1a1010'; | |
| ctx.beginPath(); ctx.arc(hx - 2.2, hy - 1, 1.1, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(hx + 1.5, hy - 1, 1.1, 0, 6.28); ctx.fill(); | |
| // Eye shine | |
| ctx.fillStyle = 'rgba(255,255,255,0.6)'; | |
| ctx.beginPath(); ctx.arc(hx - 1.8, hy - 1.5, 0.45, 0, 6.28); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(hx + 1.9, hy - 1.5, 0.45, 0, 6.28); ctx.fill(); | |
| // Mouth — small proportional bezier curve | |
| // happy (moodVal>0): control point ABOVE endpoints → ∩ = smile (corners up) | |
| // neutral (≈0): control point at same level → flat line | |
| // unhappy (moodVal<0): control point BELOW endpoints → ∪ = frown (corners down) | |
| const mouthW = 1.9; | |
| const mCurve = Math.max(-5, Math.min(5, moodVal * 9)); | |
| const mouthY = hy + 3.0; | |
| ctx.strokeStyle = moodVal > 0 ? 'rgba(210,90,90,0.88)' : 'rgba(130,70,80,0.85)'; | |
| ctx.lineWidth = 1.0; ctx.lineCap = 'round'; | |
| ctx.beginPath(); | |
| ctx.moveTo(hx - mouthW, mouthY); | |
| ctx.quadraticCurveTo(hx, mouthY - mCurve, hx + mouthW, mouthY); | |
| ctx.stroke(); | |
| ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; | |
| // ── STATE EFFECTS ───────────────────────────────────────── | |
| if (agent.state === 'in_conversation') { | |
| ctx.fillStyle = 'rgba(240,192,64,0.9)'; | |
| ctx.beginPath(); ctx.roundRect(hr + 3, hy - hr - 6, 16, 11, 3); ctx.fill(); | |
| // speech tail | |
| ctx.beginPath(); ctx.moveTo(hr + 3, hy - hr - 2); ctx.lineTo(hr - 1, hy); ctx.lineTo(hr + 7, hy - hr - 2); ctx.fill(); | |
| ctx.fillStyle = '#1a1a2e'; ctx.font = 'bold 6px Segoe UI'; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText('...', hr + 11, hy - hr); | |
| } | |
| if (isSleeping) { | |
| const t = animFrame * 0.04; | |
| ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'left'; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.globalAlpha = 0.3 + i * 0.25; ctx.fillStyle = '#8ab4f8'; | |
| ctx.fillText('z', hr + 2 + i * 4, hy - hr - i * 6 + Math.sin(t + i) * 2); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| if (agent.partner_id) { | |
| drawHeart(1, hy - hr - 10 + Math.sin(animFrame * 0.04) * 2, 3.5, 'rgba(233,30,144,0.8)'); | |
| } | |
| // Selected ring | |
| if (isSel) { | |
| ctx.strokeStyle = color; ctx.lineWidth = 1.5; | |
| ctx.setLineDash([3, 3]); | |
| ctx.beginPath(); ctx.arc(hx, hy, hr + 3, 0, 6.28); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| ctx.restore(); | |
| // ── Name label ─────────────────────────────────────────── | |
| const firstName = (agent.name || id).split(' ')[0]; | |
| ctx.font = `${isSel ? 'bold ' : ''}8px Segoe UI`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'top'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(firstName, ax + 1, ay + 14 * scale + 1); | |
| ctx.fillStyle = isSel ? '#fff' : '#c8c8d8'; ctx.fillText(firstName, ax, ay + 14 * scale); | |
| // ── Mood bar ───────────────────────────────────────────── | |
| const moodV = agent.mood || 0; | |
| const mw = 16, mxb = ax - mw / 2, myb = ay + 14 * scale + 11; | |
| ctx.fillStyle = 'rgba(15,52,96,0.45)'; ctx.fillRect(mxb, myb, mw, 2.5); | |
| const mf = (moodV + 1) / 2; | |
| ctx.fillStyle = mf > 0.6 ? '#4ecca3' : (mf > 0.3 ? '#f0c040' : '#e94560'); | |
| ctx.fillRect(mxb, myb, mw * mf, 2.5); | |
| } | |
| // ============================================================ | |
| // INTERACTION | |
| // ============================================================ | |
| function onCanvasClick(e) { | |
| if (isDragging) return; | |
| if (_blockNextClick) { _blockNextClick = false; return; } | |
| const rect=canvas.getBoundingClientRect(); | |
| const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY; | |
| let clicked=null, minD=24; | |
| for (const [id,pos] of Object.entries(agentPositions)) { | |
| const d=Math.hypot(mx-pos.x,my-pos.y); | |
| if(d<minD){minD=d;clicked=id;} | |
| } | |
| if(clicked){ | |
| selectedAgentId=clicked; | |
| switchTab('agents'); | |
| fetchAgentDetail(clicked); | |
| } else { | |
| selectedAgentId=null; | |
| showDefaultDetail(); | |
| } | |
| } | |
| function onCanvasMouseMove(e) { | |
| const rect=canvas.getBoundingClientRect(); | |
| const mx=(e.clientX-rect.left)/zoom+panX, my=(e.clientY-rect.top)/zoom+panY; | |
| const W=worldW(), H=worldH(); | |
| const tt=document.getElementById('tooltip'); | |
| if (isDragging || rectZoomMode) return; | |
| let foundAgent=null; | |
| for (const [id,pos] of Object.entries(agentPositions)) { | |
| if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;} | |
| } | |
| let foundLoc=null; | |
| if (!foundAgent) { | |
| const allPos = getEffectivePositions(); | |
| for (const [id, pos] of Object.entries(allPos)) { | |
| const lx=pos.x*W, ly=pos.y*H; | |
| const hitR = pos.type === 'house' ? 25 : 35; | |
| if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;} | |
| } | |
| } | |
| if(foundAgent!==hoveredAgent){ | |
| hoveredAgent=foundAgent; | |
| canvas.style.cursor=(foundAgent||foundLoc)?'pointer':'default'; | |
| if(foundAgent&&agents[foundAgent]){ | |
| const a=agents[foundAgent]; | |
| let extra=''; | |
| if(a.partner_id&&agents[a.partner_id]) extra=`<br><span style="color:#e91e90">Partner: ${agents[a.partner_id].name}</span>`; | |
| tt.innerHTML=`<b>${a.name}</b><br><span style="color:#a0a0c0">${a.action||'idle'}</span>${extra}`; | |
| tt.style.display='block'; | |
| } else if (!foundLoc) { | |
| tt.style.display='none'; | |
| } | |
| } | |
| if (!foundAgent && foundLoc && locations[foundLoc]) { | |
| const loc = locations[foundLoc]; | |
| const occ = (loc.occupants||[]); | |
| const occNames = occ.map(o => (typeof o === 'object' ? o.name : (agents[o]?.name || o))).slice(0, 8); | |
| const hasConv = conversationData.active?.some(c => c.location === foundLoc); | |
| tt.innerHTML = `<b>${loc.name||foundLoc}</b> <span style="color:#666">(${loc.zone||''})</span> | |
| ${loc.description ? `<br><span style="color:#888;font-size:10px">${esc(loc.description)}</span>` : ''} | |
| <br><span style="color:#a0a0c0">${occ.length} occupant${occ.length!==1?'s':''}</span> | |
| ${occNames.length > 0 ? `<br><span style="font-size:10px;color:#b0b0c0">${occNames.join(', ')}${occ.length>8?'...':''}</span>` : ''} | |
| ${hasConv ? '<br><span style="color:#f0c040;font-size:10px">Active conversation here</span>' : ''}`; | |
| tt.style.display = 'block'; | |
| canvas.style.cursor = 'pointer'; | |
| } else if (!foundAgent && !foundLoc) { | |
| canvas.style.cursor = 'default'; | |
| if (!hoveredAgent) tt.style.display = 'none'; | |
| } | |
| if(foundAgent||foundLoc){ | |
| tt.style.left=(e.clientX-rect.left+15)+'px'; | |
| tt.style.top=(e.clientY-rect.top+15)+'px'; | |
| } | |
| } | |
| // ============================================================ | |
| // AGENT DETAIL (Agents Tab) | |
| // ============================================================ | |
| function showDefaultDetail() { | |
| const el = document.getElementById('agent-detail'); | |
| const sorted = Object.entries(agents).sort((a,b) => { | |
| const aConv = a[1].state === 'in_conversation' ? 0 : 1; | |
| const bConv = b[1].state === 'in_conversation' ? 0 : 1; | |
| if (aConv !== bConv) return aConv - bConv; | |
| return (a[1].name || '').localeCompare(b[1].name || ''); | |
| }); | |
| const aliveCount = sorted.filter(([,a]) => a.alive !== false).length; | |
| const deadCount = sorted.length - aliveCount; | |
| const mayorInfo = window._mayorData ? `<div style="font-size:10px;color:#f0c040;margin:4px 0">\uD83C\uDFDB\uFE0F Mayor: ${esc(window._mayorData.name)}</div>` : ''; | |
| el.innerHTML = ` | |
| <div class="section-header">Population (${aliveCount} alive${deadCount > 0 ? `, ${deadCount} deceased` : ''})</div> | |
| ${mayorInfo} | |
| ${sorted.map(([aid,a]) => { | |
| const isDead = a.alive === false; | |
| const color = isDead ? '#555' : AGENT_COLORS[getAgentIdx(aid) % AGENT_COLORS.length]; | |
| const stg = a.lifecycle_stage || ''; | |
| const gi = isDead ? '\u2620\uFE0F' : (stg==='infant'||stg==='toddler') ? '\uD83D\uDC76' : (stg==='child'||stg==='teenager') ? '\uD83E\uDDD2' : a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1'; | |
| const stateIcon = isDead ? '' : a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':'')); | |
| const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : ''; | |
| const pregIcon = a.pregnant ? ' \uD83E\uDD30' : ''; | |
| const kidIcon = a.children_count > 0 ? ` \uD83D\uDC76${a.children_count}` : ''; | |
| const mayorBadge = a.is_mayor ? ' \uD83C\uDFDB\uFE0F' : ''; | |
| const ageStr = a.age != null ? ` (${a.age})` : ''; | |
| return ` | |
| <div class="agent-list-item" onclick="selectedAgentId='${aid}';selectAgentFromList('${aid}');" style="${isDead ? 'opacity:0.5' : ''}"> | |
| <span class="agent-dot" style="background:${color}"></span> | |
| <span style="font-size:13px">${gi}</span> | |
| <div class="agent-info"> | |
| <div class="agent-name" style="color:${color}">${a.name}${ageStr}${mayorBadge}${partner}${pregIcon}${kidIcon}${stateIcon}</div> | |
| <div class="agent-action">${isDead ? 'Deceased' : esc(a.action||'idle')}</div> | |
| </div> | |
| </div>`; | |
| }).join('')}`; | |
| } | |
| function selectAgentFromList(agentId) { | |
| fetchAgentDetail(agentId); | |
| send3d('select-agent'); | |
| } | |
| async function fetchAgentDetail(agentId) { | |
| try { | |
| const res=await fetch(`${API_BASE}/agents/${agentId}`); | |
| if(!res.ok) throw 0; | |
| renderAgentDetail(await res.json()); | |
| } catch(e){ | |
| const ag = agents[agentId]; | |
| if (ag) renderAgentDetail({ ...ag, id: agentId, memories: [], relationships: [], recent_memories: [] }); | |
| } | |
| } | |
| function renderAgentDetail(data) { | |
| const needs=data.needs||{}; | |
| const needBars=[ | |
| {name:'Hunger',key:'hunger',color:'orange'}, | |
| {name:'Energy',key:'energy',color:'blue'}, | |
| {name:'Social',key:'social',color:'purple'}, | |
| {name:'Purpose',key:'purpose',color:'green'}, | |
| {name:'Fun',key:'fun',color:'yellow'}, | |
| ]; | |
| const mood=data.mood||0; | |
| const moodPct=((mood+1)/2*100).toFixed(0); | |
| const moodColor=moodPct>60?'green':(moodPct>30?'yellow':'red'); | |
| const moodLabel=mood>0.3?'Happy':(mood>-0.3?'Okay':'Unhappy'); | |
| const gi=data.gender==='female'?'\uD83D\uDC69':data.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1'; | |
| const memories=(data.recent_memories||[]).slice(-6).reverse(); | |
| const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none'); | |
| const locName = data.location ? (typeof data.location === 'object' ? data.location.name : data.location) : '?'; | |
| // Life events | |
| const lifeEvents = (data.life_events || []).slice(-10).reverse(); | |
| const evtIcon = { | |
| origin:'\uD83C\uDFE0', career:'\uD83D\uDCBC', dating:'\uD83D\uDC95', engaged:'\uD83D\uDC8D', | |
| married:'\uD83D\uDC92', pregnant:'\uD83E\uDD30', child_born:'\uD83D\uDC76', | |
| promotion:'\uD83D\uDCC8', graduated:'\uD83C\uDF93', achievement:'\u2B50', | |
| milestone:'\uD83C\uDFC6', new_job:'\uD83D\uDCBC', moved:'\uD83D\uDE9A', | |
| breakup:'\uD83D\uDC94', friendship:'\uD83E\uDD1D', | |
| death:'\u2620\uFE0F', birthday:'\uD83C\uDF82', divorce:'\uD83D\uDC94', | |
| politics:'\uD83C\uDFDB\uFE0F', bereavement:'\uD83D\uDE22', | |
| }; | |
| // Goals — split into short-term and long-term | |
| const goals = data.goals || []; | |
| const activeGoals = goals.filter(g => g.status === 'active'); | |
| const completedGoals = goals.filter(g => g.status === 'completed'); | |
| const shortTermGoals = activeGoals.filter(g => g.term === 'short'); | |
| const longTermGoals = activeGoals.filter(g => g.term !== 'short'); | |
| // Children & pregnancy | |
| const children = data.children || []; | |
| const isPregnant = data.pregnant || false; | |
| const pregnantBadge = isPregnant ? ' \uD83E\uDD30' : ''; | |
| const childrenBadge = children.length > 0 ? ` \uD83D\uDC76${children.length}` : ''; | |
| const isDead = data.alive === false; | |
| const mayorBadge = data.is_mayor ? ' \uD83C\uDFDB\uFE0F Mayor' : ''; | |
| const lifeStageBadge = data.lifecycle_stage ? `<span style="color:#888;font-size:10px;margin-left:6px">${data.lifecycle_stage}</span>` : ''; | |
| const parentNames = (data.parent_names || []); | |
| const deathInfo = isDead ? `<div style="margin:6px 0;padding:6px 8px;background:rgba(233,69,96,0.15);border:1px solid rgba(233,69,96,0.3);border-radius:4px;font-size:11px;color:#e94560"> | |
| \u2620\uFE0F Deceased (Day ${data.death_day}) — ${esc(data.death_cause || 'unknown')}</div>` : ''; | |
| document.getElementById('agent-detail').innerHTML=` | |
| <div style="margin-bottom:6px"> | |
| <span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">← All Agents</span> | |
| </div> | |
| <h2>${gi} ${data.name||'?'}${pregnantBadge}${childrenBadge}</h2> | |
| <p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}${mayorBadge}${lifeStageBadge}</p> | |
| <p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p> | |
| <p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} — ${esc(data.action||'idle')}</p> | |
| ${deathInfo} | |
| ${parentNames.length>0?`<div style="margin:4px 0;font-size:10px;color:#888">\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67 Parents: ${parentNames.map(n=>esc(n)).join(' & ')}</div>`:''} | |
| ${romanceRels.length>0?`<div style="margin:6px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border:1px solid rgba(233,30,144,0.2);border-radius:4px;font-size:11px;color:#e91e90"> | |
| ${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')} | |
| </div>`:''} | |
| ${children.length>0?`<div style="margin:4px 0;padding:4px 8px;background:rgba(78,204,163,0.08);border:1px solid rgba(78,204,163,0.2);border-radius:4px;font-size:11px;color:#4ecca3"> | |
| \uD83D\uDC76 Children: ${children.map(c=>esc(c)).join(', ')} | |
| </div>`:''} | |
| ${!isDead ? ` | |
| <div class="bar-container"> | |
| <div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div> | |
| <div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div> | |
| </div> | |
| ${needBars.map(n=>{ | |
| const v=(needs[n.key]||0)*100; | |
| return `<div class="bar-container"> | |
| <div class="bar-label"><span>${n.name}</span><span>${v.toFixed(0)}%</span></div> | |
| <div class="bar-bg"><div class="bar-fill ${n.color}" style="width:${v}%"></div></div> | |
| </div>`; | |
| }).join('')} | |
| <div style="margin-top:6px"> | |
| <div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div> | |
| </div> | |
| ` : ''} | |
| ${longTermGoals.length>0?` | |
| <div class="section-header">Long-Term Goals</div> | |
| ${longTermGoals.map(g=>{ | |
| const pct=Math.round((g.progress||0)*100); | |
| return `<div style="margin:3px 0;font-size:11px"> | |
| <div style="display:flex;justify-content:space-between;color:#e0e0f0"> | |
| <span>\uD83C\uDFAF ${esc(g.description)}</span><span style="color:#888;font-size:9px">${pct}%</span> | |
| </div> | |
| <div style="height:3px;background:#1a1a3e;border-radius:2px;margin-top:2px"> | |
| <div style="height:100%;width:${pct}%;background:#4ecca3;border-radius:2px"></div> | |
| </div> | |
| </div>`; | |
| }).join('')} | |
| `:''} | |
| ${shortTermGoals.length>0?` | |
| <div class="section-header">This Week's Goals</div> | |
| ${shortTermGoals.map(g=>{ | |
| const pct=Math.round((g.progress||0)*100); | |
| return `<div style="margin:3px 0;font-size:11px"> | |
| <div style="display:flex;justify-content:space-between;color:#e0e0f0"> | |
| <span>\u26A1 ${esc(g.description)}</span><span style="color:#888;font-size:9px">${pct}%</span> | |
| </div> | |
| <div style="height:3px;background:#1a1a3e;border-radius:2px;margin-top:2px"> | |
| <div style="height:100%;width:${pct}%;background:#f0c040;border-radius:2px"></div> | |
| </div> | |
| </div>`; | |
| }).join('')} | |
| `:''} | |
| ${completedGoals.length>0?` | |
| <div style="margin-top:4px"> | |
| ${completedGoals.slice(-3).map(g=>`<div style="margin:2px 0;font-size:10px;color:#555">\u2705 ${esc(g.description)}</div>`).join('')} | |
| </div>`:''} | |
| ${lifeEvents.length>0?` | |
| <div class="section-header">Life History</div> | |
| <div style="max-height:160px;overflow-y:auto"> | |
| ${lifeEvents.map(e=>{ | |
| const icon = evtIcon[e.type] || '\uD83D\uDD39'; | |
| const dayLabel = e.day > 0 ? `Day ${e.day}` : 'Background'; | |
| return `<div style="margin:3px 0;font-size:11px;display:flex;gap:6px;align-items:start"> | |
| <span style="flex-shrink:0">${icon}</span> | |
| <div><span style="color:#555;font-size:9px">${dayLabel}</span><br><span style="color:#c0c0d0">${esc(e.description)}</span></div> | |
| </div>`; | |
| }).join('')} | |
| </div>`:''} | |
| ${(data.relationships||[]).length>0?` | |
| <div class="section-header">Relationships</div> | |
| ${data.relationships.slice(0,8).map(r=>{ | |
| const fam = (r.familiarity||0)*100; | |
| const trust = (r.trust||0)*100; | |
| const sent = (r.sentiment||0)*100; | |
| const rom = (r.romantic_interest||0)*100; | |
| const statusBadge = r.relationship_status && r.relationship_status !== 'none' | |
| ? `<span style="color:#e91e90;font-size:9px"> \u2764 ${r.relationship_status}</span>` : ''; | |
| return ` | |
| <div class="rel-item" onclick="selectedAgentId='${r.agent_id}';fetchAgentDetail('${r.agent_id}');"> | |
| <div><span class="rel-name">${esc(r.name)}</span>${statusBadge} | |
| <span style="color:#555;font-size:9px">(${r.interaction_count||0} talks)</span></div> | |
| <div class="rel-bars"> | |
| <div class="rel-mini-bar" title="Familiarity ${fam.toFixed(0)}%"><div class="rel-mini-fill" style="width:${fam}%;background:#4ecca3"></div></div> | |
| <div class="rel-mini-bar" title="Trust ${trust.toFixed(0)}%"><div class="rel-mini-fill" style="width:${trust}%;background:#4e9eca"></div></div> | |
| <div class="rel-mini-bar" title="Sentiment ${sent.toFixed(0)}%"><div class="rel-mini-fill" style="width:${sent}%;background:#f0c040"></div></div> | |
| ${rom > 0 ? `<div class="rel-mini-bar" title="Romance ${rom.toFixed(0)}%"><div class="rel-mini-fill" style="width:${rom}%;background:#e91e90"></div></div>` : ''} | |
| </div> | |
| </div>`; | |
| }).join('')}`:''} | |
| <div class="section-header">Recent Memories</div> | |
| ${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'} | |
| <div class="section-header">Ask About Memories</div> | |
| <div style="display:flex;gap:4px;margin:4px 0"> | |
| <input type="text" id="memory-query-input" placeholder="e.g. Describe your feelings about..." style="flex:1;background:#0a1628;color:#e0e0e0;border:1px solid #1a3a6e;padding:5px 8px;border-radius:3px;font-size:11px" onkeydown="if(event.key==='Enter')askAgentMemory('${data.id}')"> | |
| <button onclick="askAgentMemory('${data.id}')" style="background:#4ecca3;color:#0a1628;border:none;padding:5px 10px;border-radius:3px;cursor:pointer;font-weight:700;font-size:11px">Ask</button> | |
| </div> | |
| <div id="memory-query-result" style="font-size:11px;color:#c0c0d0;margin:4px 0;max-height:200px;overflow-y:auto"></div> | |
| ${playerToken && playerAgentId && data.id !== playerAgentId ? ` | |
| <div style="margin-top:10px"> | |
| <button class="btn-primary" style="width:100%" onclick="openChat('${data.id}')">Talk to ${esc(data.name||'them')}</button> | |
| </div>` : ''} | |
| `; | |
| } | |
| async function askAgentMemory(agentId) { | |
| const input = document.getElementById('memory-query-input'); | |
| const result = document.getElementById('memory-query-result'); | |
| const question = input.value.trim(); | |
| if (!question) return; | |
| result.innerHTML = '<span style="color:#888">Thinking...</span>'; | |
| try { | |
| const res = await fetch(`${API_BASE}/agents/${agentId}/ask`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({question}), | |
| }); | |
| const data = await res.json(); | |
| result.innerHTML = `<div style="padding:6px 8px;background:rgba(78,204,163,0.06);border:1px solid rgba(78,204,163,0.15);border-radius:4px;margin-top:4px;line-height:1.5">${esc(data.answer || '(no response)')}</div><div style="color:#555;font-size:9px;margin-top:2px">${data.memories_used || 0} memories referenced</div>`; | |
| } catch(e) { | |
| result.innerHTML = `<span style="color:#e94560">Error: ${e.message}</span>`; | |
| } | |
| } | |
| // ============================================================ | |
| // CONVERSATIONS TAB | |
| // ============================================================ | |
| async function fetchConversations() { | |
| try { | |
| const res = await fetch(`${API_BASE}/conversations?include_history=true&limit=15`); | |
| if (!res.ok) return; | |
| conversationData = await res.json(); | |
| renderConversations(); | |
| } catch(e) {} | |
| } | |
| function renderConversations() { | |
| const panel = document.getElementById('conversations-panel'); | |
| const all = [ | |
| ...(conversationData.active||[]).map(c => ({...c, _active: true})), | |
| ...(conversationData.recent||[]).map(c => ({...c, _active: false})), | |
| ]; | |
| if (all.length === 0) { | |
| panel.innerHTML = '<div class="conv-empty">No conversations yet.<br>Agents will start talking when they meet!</div>'; | |
| return; | |
| } | |
| panel.innerHTML = all.map(conv => { | |
| const turns = conv.turns || []; | |
| const badge = conv._active | |
| ? '<span class="conv-badge live">LIVE</span>' | |
| : '<span class="conv-badge ended">ended</span>'; | |
| const names = (conv.participant_names || []).join(' & '); | |
| return ` | |
| <div class="conv-card ${conv._active ? 'active-conv' : ''}"> | |
| <div class="conv-header"> | |
| <span class="conv-topic">${esc(conv.topic || 'Chat')}</span> | |
| ${badge} | |
| </div> | |
| <div class="conv-participants">${esc(names)} — ${esc(conv.location || '')}</div> | |
| ${turns.slice(-6).map(t => ` | |
| <div class="conv-turn"> | |
| <span class="conv-speaker" style="color:${getAgentColor(t.speaker_id)}">${esc(t.speaker_name)}:</span> | |
| <span class="conv-message">${esc(t.message)}</span> | |
| </div> | |
| `).join('')} | |
| </div>`; | |
| }).join(''); | |
| } | |
| function getAgentColor(agentId) { | |
| return AGENT_COLORS[getAgentIdx(agentId) % AGENT_COLORS.length]; | |
| } | |
| // ============================================================ | |
| // EVENT LOG TAB | |
| // ============================================================ | |
| function renderEventLog() { | |
| const c = document.getElementById('event-log-panel'); | |
| c.innerHTML = eventLog.slice(-80).map(line => { | |
| let cls = 'event-line'; | |
| if (line.includes('[PLAN]')) cls += ' plan'; | |
| else if (line.includes('[CONV]')) cls += ' conv'; | |
| else if (line.includes('[ROMANCE]')) cls += ' romance'; | |
| else if (line.includes('[MOVE]')) cls += ' move'; | |
| else if (line.includes('[REFLECT]')) cls += ' reflect'; | |
| else if (line.includes('[EVENT]') || line.includes('[ENTROPY]')) cls += ' event'; | |
| else if (line.startsWith('---') || line.startsWith('\n---')) cls += ' time'; | |
| return `<div class="${cls}">${esc(line)}</div>`; | |
| }).join(''); | |
| c.scrollTop = c.scrollHeight; | |
| } | |
| function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') : ''; } | |
| // ============================================================ | |
| // DATA FETCHING — WebSocket + polling fallback | |
| // ============================================================ | |
| let lastTick = -1; | |
| let ws = null; | |
| let wsRetryTimer = null; | |
| function processStateData(data) { | |
| const clock = data.clock || {}; | |
| currentTimeOfDay = clock.time_of_day || 'morning'; | |
| currentWeather = (data.weather || 'sunny').toLowerCase(); | |
| const dayNum = clock.day || 1; | |
| const dayOfWeek = ((dayNum - 1) % 7) + 1; // 1=Mon … 7=Sun | |
| const isWeekend = dayOfWeek >= 6; | |
| document.getElementById('clock').textContent = `Day ${dayNum}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`; | |
| const badge = document.getElementById('weekend-badge'); | |
| badge.style.display = isWeekend ? 'flex' : 'none'; | |
| document.getElementById('weather-icon').textContent = WEATHER_ICONS[currentWeather] || '\u2600\uFE0F'; | |
| document.getElementById('weather').textContent = currentWeather; | |
| document.getElementById('agent-count').innerHTML = `<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`; | |
| document.getElementById('conv-count').textContent = `${data.active_conversations||0} convos`; | |
| const usage = data.llm_usage || ''; | |
| const cm = usage.match(/calls:\s*(\d+)/i), $m = usage.match(/\$([0-9.]+)/); | |
| document.getElementById('api-calls').textContent = `API: ${cm?cm[1]:'0'}`; | |
| document.getElementById('cost').textContent = $m ? `$${$m[1]}` : '$0.00'; | |
| if (data.llm_provider && data.llm_model) { | |
| // Compact model label: strip long date suffixes and version noise | |
| let label = data.llm_model | |
| .replace(/-\d{8}$/, '') // remove trailing date e.g. -20251001 | |
| .replace(/-instant$/, '') // groq suffix | |
| .replace(/^gemini-/, ''); // "gemini-2.0-flash" → "2.0-flash" | |
| const providerIcon = { nn: '🧠', gemini: '✦', groq: '⚡', claude: '◆', ollama: '🦙' }; | |
| const icon = providerIcon[data.llm_provider] || '⚡'; | |
| // Status: nokey > limited > skipped > idle > active | |
| const status = data.llm_status; | |
| const isNoKey = status === 'nokey'; | |
| const isLimited = status === 'limited'; | |
| const isSkipped = data.llm_skipped === true; | |
| const hasCalls = (data.llm_calls_last_tick || 0) > 0; | |
| let dotColor, statusTip; | |
| if (isNoKey) { dotColor = '#f0a040'; statusTip = 'no API token — set HF_TOKEN env var'; } | |
| else if (isLimited) { dotColor = '#e94560'; statusTip = 'quota / rate limit hit'; } | |
| else if (isSkipped) { dotColor = '#666'; statusTip = 'LLM skipped (fast mode)'; } | |
| else if (hasCalls) { dotColor = '#4ecca3'; statusTip = `${data.llm_calls_last_tick} calls this tick`; } | |
| else { dotColor = '#f0c040'; statusTip = 'idle — no calls needed'; } | |
| const calls = data.llm_calls_last_tick || 0; | |
| const callBadge = calls > 0 ? ` <span style="font-size:10px;color:#4ecca3;opacity:0.85">×${calls}</span>` : ''; | |
| const el = document.getElementById('llm-model'); | |
| el.innerHTML = `${icon} ${label}${callBadge} <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${dotColor};vertical-align:middle;margin-left:2px"></span>`; | |
| el.title = `${data.llm_provider}: ${data.llm_model} — ${statusTip}`; | |
| } | |
| agents = data.agents || {}; | |
| window._mayorData = data.mayor || null; | |
| renderPlayerPanel(); | |
| const tick = clock.total_ticks || 0; | |
| if (tick !== lastTick) fetchSecondaryData(); | |
| lastTick = tick; | |
| if (activeTab === 'agents') { | |
| if (selectedAgentId) fetchAgentDetail(selectedAgentId); | |
| else showDefaultDetail(); | |
| } | |
| } | |
| async function fetchSecondaryData() { | |
| try { | |
| const locRes = await fetch(`${API_BASE}/city/locations`); | |
| if (locRes.ok) locations = await locRes.json(); | |
| } catch(e) {} | |
| try { | |
| const er = await fetch(`${API_BASE}/events`); | |
| if (er.ok) { | |
| const d2 = await er.json(); | |
| eventLog = (d2.events||[]).map(e => e.message||'').filter(m => m.trim()); | |
| checkForNotableEvents(); | |
| if (activeTab === 'events') renderEventLog(); | |
| } | |
| } catch(e) {} | |
| if (activeTab === 'conversations') { | |
| fetchConversations(); | |
| } else { | |
| try { | |
| const cr = await fetch(`${API_BASE}/conversations?include_history=false`); | |
| if (cr.ok) { | |
| const cd = await cr.json(); | |
| conversationData.active = cd.active || []; | |
| } | |
| } catch(e) {} | |
| } | |
| } | |
| function connectWebSocket() { | |
| const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${wsProto}//${window.location.host}/ws/stream`; | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| connected = true; | |
| document.getElementById('status').innerHTML = '<span class="dot green"></span> Live (WS)'; | |
| }; | |
| ws.onmessage = (evt) => { | |
| try { | |
| const msg = JSON.parse(evt.data); | |
| if (msg.type === 'tick' && msg.state) { | |
| processStateData(msg.state); | |
| } else if (msg.type === 'event') { | |
| eventLog.push(msg.message); | |
| if (eventLog.length > 200) eventLog = eventLog.slice(-200); | |
| checkForNotableEvents(); | |
| if (activeTab === 'events') renderEventLog(); | |
| } | |
| } catch(e) {} | |
| }; | |
| ws.onclose = () => { | |
| connected = false; | |
| document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected'; | |
| wsRetryTimer = setTimeout(connectWebSocket, 3000); | |
| }; | |
| ws.onerror = () => { ws.close(); }; | |
| } | |
| async function fetchState() { | |
| try { | |
| const res = await fetch(`${API_BASE}/city`); | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| if (!connected) { | |
| connected = true; | |
| document.getElementById('status').innerHTML = '<span class="dot green"></span> Connected'; | |
| } | |
| processStateData(data); | |
| } catch(e) { | |
| if (connected) { | |
| connected = false; | |
| document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected'; | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // CONTROLS | |
| // ============================================================ | |
| let simPaused = false; | |
| let simSpeed = 1.0; | |
| let llmCallProbability = 0.10; | |
| function send3dSpeed(mult) { | |
| const iframe = document.getElementById('city3d'); | |
| if (iframe?.contentWindow) iframe.contentWindow.postMessage({ type: 'set-speed', multiplier: mult }, '*'); | |
| } | |
| async function togglePause() { | |
| try { | |
| const endpoint = simPaused ? 'resume' : 'pause'; | |
| const res = await fetch(`${API_BASE}/controls/${endpoint}`, { method: 'POST' }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| simPaused = data.paused; | |
| updateControlsUI(); | |
| send3dSpeed(simPaused ? 0 : simSpeed); | |
| return; | |
| } | |
| } catch(e) {} | |
| simPaused = !simPaused; | |
| updateControlsUI(); | |
| send3dSpeed(simPaused ? 0 : simSpeed); | |
| } | |
| async function setSpeed(mult) { | |
| simSpeed = mult; | |
| updateControlsUI(); | |
| send3dSpeed(mult); | |
| try { | |
| await fetch(`${API_BASE}/controls/speed?multiplier=${mult}`, { method: 'POST' }); | |
| } catch(e) {} | |
| } | |
| function updateControlsUI() { | |
| const pauseBtn = document.getElementById('btn-pause'); | |
| pauseBtn.className = 'ctrl-btn' + (simPaused ? ' paused' : ''); | |
| pauseBtn.title = simPaused ? 'Resume' : 'Pause'; | |
| // Highlight the active speed button | |
| const speedBtns = [ | |
| { id: 'btn-slow', min: 2.0, max: Infinity }, | |
| { id: 'btn-1x', min: 0.8, max: 2.0 }, | |
| { id: 'btn-2x', min: 0.35, max: 0.8 }, | |
| { id: 'btn-5x', min: 0.15, max: 0.35 }, | |
| { id: 'btn-10x', min: 0.05, max: 0.15 }, | |
| { id: 'btn-50x', min: 0, max: 0.05 }, | |
| ]; | |
| for (const btn of speedBtns) { | |
| const el = document.getElementById(btn.id); | |
| if (el) el.className = 'ctrl-btn' + (simSpeed >= btn.min && simSpeed < btn.max ? ' active' : ''); | |
| } | |
| // Speed label | |
| let label = 'PAUSED'; | |
| if (!simPaused) { | |
| if (simSpeed >= 2.0) label = '0.3x'; | |
| else if (simSpeed >= 0.8) label = '1x'; | |
| else if (simSpeed >= 0.35) label = '2x'; | |
| else if (simSpeed >= 0.15) label = '5x'; | |
| else if (simSpeed >= 0.05) label = '10x'; | |
| else label = '50x'; | |
| } | |
| document.getElementById('speed-label').textContent = label; | |
| } | |
| async function onLlmProbSlider(val) { | |
| const pct = parseInt(val); | |
| document.getElementById('llm-prob-label').textContent = pct + '%'; | |
| llmCallProbability = pct / 100; | |
| try { | |
| await fetch(`${API_BASE}/controls/llm_probability?value=${llmCallProbability}`, { method: 'POST' }); | |
| } catch(e) {} | |
| } | |
| function updateLlmProbUI(prob) { | |
| llmCallProbability = prob; | |
| const pct = Math.round(prob * 100); | |
| const slider = document.getElementById('llm-prob-slider'); | |
| const label = document.getElementById('llm-prob-label'); | |
| if (slider) slider.value = pct; | |
| if (label) label.textContent = pct + '%'; | |
| } | |
| async function fetchControls() { | |
| try { | |
| const res = await fetch(`${API_BASE}/controls`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| simPaused = data.paused; | |
| simSpeed = data.speed; | |
| if (data.llm_call_probability !== undefined) updateLlmProbUI(data.llm_call_probability); | |
| updateControlsUI(); | |
| } | |
| } catch(e) {} | |
| } | |
| // ============================================================ | |
| // TOAST NOTIFICATIONS | |
| // ============================================================ | |
| let lastEventCount = 0; | |
| function showToast(message, type='info') { | |
| const container = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast ' + type; | |
| toast.textContent = message; | |
| container.appendChild(toast); | |
| setTimeout(() => toast.remove(), 5000); | |
| while (container.children.length > 5) container.firstChild.remove(); | |
| } | |
| function checkForNotableEvents() { | |
| if (eventLog.length <= lastEventCount) return; | |
| const newEvents = eventLog.slice(lastEventCount); | |
| lastEventCount = eventLog.length; | |
| for (const msg of newEvents) { | |
| if (msg.includes('[ROMANCE]')) showToast(msg.replace(/\s*\[ROMANCE\]\s*/, ''), 'romance'); | |
| else if (msg.includes('[EVENT]') && !msg.includes('Weather')) showToast(msg.replace(/\s*\[EVENT\]\s*/, ''), 'event'); | |
| else if (msg.includes('[GOSSIP]')) showToast(msg.replace(/\s*\[GOSSIP\]\s*/, ''), 'gossip'); | |
| else if (msg.includes('[CONV]') && msg.includes('starts talking')) showToast(msg.replace(/\s*\[CONV\]\s*/, ''), 'conv'); | |
| } | |
| } | |
| // ============================================================ | |
| // PLAYER / AUTH | |
| // ============================================================ | |
| let playerToken = localStorage.getItem('soci_token') || null; | |
| let playerUsername = localStorage.getItem('soci_username') || null; | |
| let playerAgentId = localStorage.getItem('soci_agent_id') || null; | |
| let chatTargetId = null; | |
| let chatHistory = []; | |
| let authMode = 'login'; // 'login' or 'register' | |
| function closeLoginModal() { | |
| document.getElementById('login-modal').style.display = 'none'; | |
| } | |
| function toggleAuthMode() { | |
| authMode = authMode === 'login' ? 'register' : 'login'; | |
| document.getElementById('auth-title').textContent = authMode === 'login' ? 'Welcome to Soci City' : 'Create Account'; | |
| document.getElementById('auth-btn').textContent = authMode === 'login' ? 'Login' : 'Register'; | |
| document.getElementById('auth-switch-text').innerHTML = authMode === 'login' | |
| ? 'No account? <a onclick="toggleAuthMode()">Register</a>' | |
| : 'Have an account? <a onclick="toggleAuthMode()">Login</a>'; | |
| document.getElementById('auth-error').style.display = 'none'; | |
| } | |
| async function authSubmit() { | |
| const username = document.getElementById('auth-username').value.trim(); | |
| const password = document.getElementById('auth-password').value; | |
| const errEl = document.getElementById('auth-error'); | |
| if (!username || !password) { errEl.textContent = 'Please fill in both fields'; errEl.style.display = 'block'; return; } | |
| const endpoint = authMode === 'login' ? '/api/auth/login' : '/api/auth/register'; | |
| try { | |
| const res = await fetch(window.location.origin + endpoint, { | |
| method: 'POST', headers: {'Content-Type':'application/json'}, | |
| body: JSON.stringify({username, password}) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { errEl.textContent = data.detail || 'Error'; errEl.style.display = 'block'; return; } | |
| playerToken = data.token; | |
| playerUsername = data.username; | |
| playerAgentId = data.agent_id || null; | |
| localStorage.setItem('soci_token', playerToken); | |
| localStorage.setItem('soci_username', playerUsername); | |
| if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId); | |
| closeLoginModal(); | |
| renderPlayerPanel(); | |
| } catch(e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; } | |
| } | |
| async function authLogout() { | |
| if (playerToken) { | |
| try { await fetch(window.location.origin + '/api/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+playerToken}}); } catch(e){} | |
| } | |
| playerToken = playerUsername = playerAgentId = null; | |
| localStorage.removeItem('soci_token'); localStorage.removeItem('soci_username'); localStorage.removeItem('soci_agent_id'); | |
| document.getElementById('player-panel').style.display = 'none'; | |
| document.getElementById('chat-panel') && (document.getElementById('chat-panel').style.display = 'none'); | |
| } | |
| function renderPlayerPanel() { | |
| const panel = document.getElementById('player-panel'); | |
| if (!playerToken) { panel.style.display = 'none'; return; } | |
| panel.style.display = 'block'; | |
| const agent = playerAgentId ? agents[playerAgentId] : null; | |
| document.getElementById('pp-name').textContent = agent ? agent.name : (playerUsername || 'You'); | |
| const locName = agent ? (locations[agent.location]?.name || agent.location || '') : ''; | |
| document.getElementById('pp-loc').textContent = locName ? `@ ${locName}` : ''; | |
| // Populate move dropdown from known locations (only rebuild if empty) | |
| const sel = document.getElementById('pp-move-select'); | |
| const locKeys = Object.keys(locations); | |
| if (locKeys.length > 0 && sel.options.length === 0) { | |
| sel.innerHTML = locKeys.map(lid => { | |
| const ln = locations[lid]?.name || lid; | |
| const selected = agent && agent.location === lid ? ' selected' : ''; | |
| return `<option value="${lid}"${selected}>${ln}</option>`; | |
| }).join(''); | |
| } | |
| } | |
| async function playerMove() { | |
| if (!playerToken || !playerAgentId) return; | |
| const location = document.getElementById('pp-move-select').value; | |
| try { | |
| const res = await fetch(window.location.origin + '/api/player/move', { | |
| method:'POST', headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify({token: playerToken, location}) | |
| }); | |
| if (res.ok) renderPlayerPanel(); | |
| } catch(e){} | |
| } | |
| async function openProfileEditor() { | |
| if (!playerAgentId) return; | |
| // Fetch full agent detail to get all fields (WS summary may not have everything) | |
| try { | |
| const res = await fetch(`${API_BASE}/agents/${playerAgentId}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| document.getElementById('pe-name').value = data.name || ''; | |
| document.getElementById('pe-age').value = data.age || 30; | |
| document.getElementById('pe-occupation').value = data.occupation || ''; | |
| document.getElementById('pe-gender').value = data.gender || 'unknown'; | |
| document.getElementById('pe-background').value = data.background || ''; | |
| const pers = data.personality || {}; | |
| const extVal = pers.extraversion || 5; | |
| const agrVal = pers.agreeableness || 7; | |
| const opnVal = pers.openness || 6; | |
| document.getElementById('pe-extraversion').value = extVal; | |
| document.getElementById('pe-extra-val').textContent = extVal; | |
| document.getElementById('pe-agreeableness').value = agrVal; | |
| document.getElementById('pe-agree-val').textContent = agrVal; | |
| document.getElementById('pe-openness').value = opnVal; | |
| document.getElementById('pe-open-val').textContent = opnVal; | |
| } | |
| } catch(e) { | |
| // Fallback to WS data | |
| const agent = agents[playerAgentId]; | |
| if (agent) { | |
| document.getElementById('pe-name').value = agent.name || ''; | |
| document.getElementById('pe-age').value = agent.age || 30; | |
| document.getElementById('pe-occupation').value = agent.occupation || ''; | |
| document.getElementById('pe-gender').value = agent.gender || 'unknown'; | |
| } | |
| } | |
| document.getElementById('profile-modal').style.display = 'flex'; | |
| } | |
| async function saveProfile() { | |
| if (!playerToken) return; | |
| const body = { | |
| token: playerToken, | |
| name: document.getElementById('pe-name').value.trim(), | |
| age: parseInt(document.getElementById('pe-age').value)||30, | |
| gender: document.getElementById('pe-gender').value, | |
| occupation: document.getElementById('pe-occupation').value.trim(), | |
| background: document.getElementById('pe-background').value.trim(), | |
| extraversion: parseInt(document.getElementById('pe-extraversion').value), | |
| agreeableness: parseInt(document.getElementById('pe-agreeableness').value), | |
| openness: parseInt(document.getElementById('pe-openness').value), | |
| }; | |
| try { | |
| const res = await fetch(window.location.origin + '/api/player/update', { | |
| method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) | |
| }); | |
| if (res.ok) { document.getElementById('profile-modal').style.display='none'; renderPlayerPanel(); } | |
| } catch(e){} | |
| } | |
| function openPlansModal() { | |
| if (!playerAgentId) return; | |
| const agent = agents[playerAgentId]; | |
| const plans = agent?.daily_plan || []; | |
| const listEl = document.getElementById('plans-list'); | |
| listEl.innerHTML = plans.length > 0 | |
| ? plans.map((p,i) => `<div style="padding:3px 0;border-bottom:1px solid #1a3a6e">${i+1}. ${esc(p)}</div>`).join('') | |
| : '<div style="color:#555">No plans yet.</div>'; | |
| document.getElementById('plans-modal').style.display = 'flex'; | |
| } | |
| async function addPlan() { | |
| if (!playerToken) return; | |
| const input = document.getElementById('plans-input'); | |
| const item = input.value.trim(); | |
| if (!item) return; | |
| try { | |
| const res = await fetch(window.location.origin + '/api/player/plan', { | |
| method:'POST', headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify({token: playerToken, plan_item: item}) | |
| }); | |
| if (res.ok) { input.value = ''; openPlansModal(); } | |
| } catch(e){} | |
| } | |
| // ── StockEx Integration ────────────────────────────────────────── | |
| let stockexMarketData = {}; | |
| async function openStockExModal() { | |
| if (!playerToken) return; | |
| document.getElementById('stockex-modal').style.display = 'flex'; | |
| document.getElementById('sx-result').innerHTML = ''; | |
| // Load market data and symbols | |
| try { | |
| const res = await fetch(`${API_BASE}/stockex/market`); | |
| if (res.ok) { | |
| stockexMarketData = await res.json(); | |
| const sel = document.getElementById('sx-symbol'); | |
| const symbols = Object.keys(stockexMarketData).sort(); | |
| sel.innerHTML = symbols.map(s => `<option value="${s}">${s}</option>`).join(''); | |
| sel.value = 'EXAE'; // Default | |
| updateStockExPrice(); | |
| } | |
| } catch(e) { | |
| document.getElementById('sx-market-info').textContent = 'Could not load market data'; | |
| } | |
| loadStockExPortfolio(); | |
| } | |
| function updateStockExPrice() { | |
| const sym = document.getElementById('sx-symbol').value; | |
| const mkt = stockexMarketData[sym]; | |
| if (mkt) { | |
| const bid = mkt.bid_price || 0; | |
| const ask = mkt.ask_price || 0; | |
| const mid = mkt.mid || ((bid + ask) / 2); | |
| document.getElementById('sx-price').value = mid.toFixed(2); | |
| document.getElementById('sx-market-info').textContent = | |
| `Bid: ${bid.toFixed(2)} | Ask: ${ask.toFixed(2)} | Mid: ${mid.toFixed(2)}`; | |
| } | |
| } | |
| async function loadStockExPortfolio() { | |
| const mid = document.getElementById('sx-member').value.trim().toUpperCase(); | |
| if (!mid) return; | |
| try { | |
| const res = await fetch(`${API_BASE}/stockex/portfolio/${mid}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| const cap = data.capital || 0; | |
| const pnl = data.pnl || 0; | |
| const pnlColor = pnl >= 0 ? '#4ecca3' : '#e74c3c'; | |
| let html = `<div style="margin-bottom:4px"><b>${data.member_id}</b> Capital: <b>${cap.toFixed(2)}</b> | P&L: <span style="color:${pnlColor}">${pnl.toFixed(2)}</span></div>`; | |
| const holdings = data.holdings || []; | |
| if (holdings.length > 0) { | |
| html += '<div style="display:flex;flex-wrap:wrap;gap:4px">'; | |
| for (const h of holdings) { | |
| const pnlH = h.unrealized_pnl || 0; | |
| const c = pnlH >= 0 ? '#4ecca3' : '#e74c3c'; | |
| html += `<span style="padding:1px 4px;background:#1a1a3e;border-radius:3px;font-size:10px">${h.symbol}: ${h.quantity} <span style="color:${c}">(${pnlH > 0 ? '+' : ''}${pnlH.toFixed(0)})</span></span>`; | |
| } | |
| html += '</div>'; | |
| } | |
| document.getElementById('stockex-portfolio').innerHTML = html; | |
| document.getElementById('stockex-status').textContent = `${holdings.length} positions | Obligation: ${data.obligation || 20}/day`; | |
| } else { | |
| document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Member not found</span>'; | |
| } | |
| } catch(e) { | |
| document.getElementById('stockex-portfolio').innerHTML = '<span style="color:#e74c3c">Could not load portfolio</span>'; | |
| } | |
| } | |
| async function submitStockExOrder() { | |
| if (!playerToken) return; | |
| const resultEl = document.getElementById('sx-result'); | |
| const body = { | |
| token: playerToken, | |
| member_id: document.getElementById('sx-member').value.trim().toUpperCase(), | |
| symbol: document.getElementById('sx-symbol').value, | |
| side: document.getElementById('sx-side').value, | |
| quantity: parseInt(document.getElementById('sx-qty').value) || 0, | |
| price: parseFloat(document.getElementById('sx-price').value) || 0, | |
| }; | |
| if (body.quantity <= 0 || body.price <= 0) { | |
| resultEl.innerHTML = '<span style="color:#e74c3c">Invalid quantity or price</span>'; | |
| return; | |
| } | |
| resultEl.innerHTML = '<span style="color:#888">Placing order...</span>'; | |
| try { | |
| const res = await fetch(`${API_BASE}/stockex/order`, { | |
| method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| resultEl.innerHTML = `<span style="color:#4ecca3">${esc(data.detail)} — ${data.cl_ord_id}</span>`; | |
| loadStockExPortfolio(); // Refresh | |
| } else { | |
| resultEl.innerHTML = `<span style="color:#e74c3c">${esc(data.detail || data.error || 'Order failed')}</span>`; | |
| } | |
| } catch(e) { | |
| resultEl.innerHTML = '<span style="color:#e74c3c">Connection error</span>'; | |
| } | |
| } | |
| // Update price when symbol changes | |
| document.getElementById('sx-symbol')?.addEventListener('change', updateStockExPrice); | |
| document.getElementById('sx-member')?.addEventListener('change', loadStockExPortfolio); | |
| // Chat with an NPC | |
| function openChat(targetId) { | |
| chatTargetId = targetId; | |
| chatHistory = []; | |
| const target = agents[targetId]; | |
| let chatPanel = document.getElementById('chat-panel'); | |
| if (!chatPanel) { | |
| chatPanel = document.createElement('div'); | |
| chatPanel.id = 'chat-panel'; | |
| document.getElementById('agent-detail').after(chatPanel); | |
| } | |
| chatPanel.style.display = 'block'; | |
| chatPanel.innerHTML = ` | |
| <div class="chat-header"> | |
| <span>Talking to ${esc(target?.name || targetId)}</span> | |
| <span style="cursor:pointer;color:#888" onclick="closeChat()">✕ Close</span> | |
| </div> | |
| <div id="chat-messages"></div> | |
| <div id="chat-input-row"> | |
| <input id="chat-input" type="text" placeholder="Say something..." onkeydown="if(event.key==='Enter')sendChat()"> | |
| <button id="chat-send" onclick="sendChat()">Send</button> | |
| </div>`; | |
| } | |
| function closeChat() { | |
| chatTargetId = null; | |
| const p = document.getElementById('chat-panel'); | |
| if (p) p.style.display = 'none'; | |
| } | |
| function appendChatMsg(cls, speaker, text) { | |
| const el = document.getElementById('chat-messages'); | |
| if (!el) return; | |
| const div = document.createElement('div'); | |
| div.className = cls; | |
| div.innerHTML = `<b>${esc(speaker)}:</b> ${esc(text)}`; | |
| el.appendChild(div); | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| async function sendChat() { | |
| if (!playerToken || !playerAgentId || !chatTargetId) return; | |
| const input = document.getElementById('chat-input'); | |
| const message = input.value.trim(); | |
| if (!message) return; | |
| input.value = ''; | |
| input.disabled = true; | |
| document.getElementById('chat-send').disabled = true; | |
| appendChatMsg('chat-msg-you', playerUsername || 'You', message); | |
| appendChatMsg('chat-msg-thinking', agents[chatTargetId]?.name || '...', '...'); | |
| try { | |
| const res = await fetch(window.location.origin + '/api/player/talk', { | |
| method:'POST', headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify({token: playerToken, target_id: chatTargetId, message}) | |
| }); | |
| const el = document.getElementById('chat-messages'); | |
| if (el && el.lastChild) el.lastChild.remove(); // remove thinking... | |
| if (res.ok) { | |
| const data = await res.json(); | |
| appendChatMsg('chat-msg-npc', agents[chatTargetId]?.name || chatTargetId, data.reply || '(no reply)'); | |
| } else { | |
| appendChatMsg('chat-msg-thinking', 'System', 'Could not get a response right now.'); | |
| } | |
| } catch(e) { | |
| const el = document.getElementById('chat-messages'); | |
| if (el && el.lastChild) el.lastChild.remove(); | |
| appendChatMsg('chat-msg-thinking', 'System', 'Connection error.'); | |
| } | |
| input.disabled = false; | |
| document.getElementById('chat-send').disabled = false; | |
| input.focus(); | |
| } | |
| async function checkSession() { | |
| if (!playerToken) { document.getElementById('login-modal').style.display = 'flex'; return; } | |
| try { | |
| const res = await fetch(window.location.origin + '/api/auth/me', {headers:{'Authorization':'Bearer '+playerToken}}); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| playerUsername = data.username; | |
| playerAgentId = data.agent_id; | |
| if (playerAgentId) localStorage.setItem('soci_agent_id', playerAgentId); | |
| renderPlayerPanel(); | |
| } else { | |
| // Token expired | |
| playerToken = null; localStorage.removeItem('soci_token'); | |
| document.getElementById('login-modal').style.display = 'flex'; | |
| } | |
| } catch(e) { renderPlayerPanel(); } | |
| } | |
| // ============================================================ | |
| // LLM PROVIDER SWITCHER | |
| // ============================================================ | |
| let _llmPopupOpen = false; | |
| document.getElementById('llm-model').addEventListener('click', async (e) => { | |
| e.stopPropagation(); | |
| const popup = document.getElementById('llm-popup'); | |
| if (_llmPopupOpen) { popup.style.display = 'none'; _llmPopupOpen = false; return; } | |
| // Position below the clicked element | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| popup.style.left = rect.left + 'px'; | |
| popup.style.top = (rect.bottom + 6) + 'px'; | |
| // Fetch available providers + quota info | |
| try { | |
| const [res, quotaRes] = await Promise.all([ | |
| fetch(`${API_BASE}/llm/providers`), | |
| fetch(`${API_BASE}/llm/quota`).catch(() => null), | |
| ]); | |
| const data = await res.json(); | |
| const quota = quotaRes && quotaRes.ok ? await quotaRes.json() : null; | |
| const existing = popup.querySelectorAll('.llm-opt,.llm-quota-panel'); | |
| existing.forEach(el => el.remove()); | |
| // Estimate remaining runtime based on RPM (the real bottleneck, not probability). | |
| // With 50 agents, even low probability saturates the RPM rate limiter. | |
| function estimateRuntime(q) { | |
| if (!q || q.remaining <= 0) return 'exhausted'; | |
| const callsPerHour = q.max_calls_per_hour || (q.rpm || 4) * 60; | |
| if (callsPerHour <= 0) return '∞'; | |
| const hours = q.remaining / callsPerHour; | |
| if (hours >= 48) return `~${Math.round(hours / 24)}d`; | |
| if (hours >= 1) return `~${hours.toFixed(1)}h`; | |
| return `~${Math.round(hours * 60)}min`; | |
| } | |
| const isRateLimited = (id) => id === 'gemini' || id === 'groq'; | |
| for (const p of data.providers) { | |
| const isActive = p.id === data.current && (p.model === data.current_model || (!p.model && p.id !== 'hf')); | |
| const row = document.createElement('div'); | |
| row.className = 'llm-opt' + (isActive ? ' active' : ''); | |
| // Quota badge for rate-limited providers | |
| let badge = ''; | |
| const pQuota = (quota && quota.providers && quota.providers[p.id]) || null; | |
| if (isRateLimited(p.id) && pQuota) { | |
| const isLimited = pQuota.status === 'limited'; | |
| const rpm = pQuota.rpm || 4; | |
| if (isLimited) { | |
| badge = `<span style="font-size:10px;color:#e94560;margin-left:auto">exhausted</span>`; | |
| } else { | |
| const rt = estimateRuntime({remaining: pQuota.daily_limit, rpm, max_calls_per_hour: rpm * 60}); | |
| badge = `<span style="font-size:10px;color:#8899aa;margin-left:auto">${pQuota.daily_limit}/day · ${rt}</span>`; | |
| } | |
| } | |
| row.innerHTML = `<span class="llm-check">${isActive ? '✔' : ''}</span> | |
| <span style="font-size:15px">${p.icon}</span> | |
| <span>${p.label}</span>${badge}`; | |
| if (isRateLimited(p.id) && !isActive) { | |
| // Rate-limited: show quota panel on click instead of switching immediately | |
| row.addEventListener('click', (ev) => { | |
| ev.stopPropagation(); | |
| // Remove any existing quota panel | |
| popup.querySelectorAll('.llm-quota-panel').forEach(el => el.remove()); | |
| const panel = document.createElement('div'); | |
| panel.className = 'llm-quota-panel'; | |
| panel.style.cssText = 'padding:8px 14px;background:#0a1628;border-top:1px solid #0f3460;border-bottom:1px solid #0f3460;font-size:11px;color:#c8c8d8'; | |
| // IMPORTANT: stop clicks inside panel from closing the popup | |
| panel.addEventListener('click', (e) => e.stopPropagation()); | |
| panel.addEventListener('mousedown', (e) => e.stopPropagation()); | |
| // Use per-provider quota data | |
| const pq = (quota && quota.providers && quota.providers[p.id]) || null; | |
| const lim = pq ? (pq.daily_limit || 0) : 0; | |
| const rpm = (pq && pq.rpm) || 4; | |
| const maxPerHour = (pq && pq.max_calls_per_hour) || (rpm * 60); | |
| // Runtime based on full daily quota (counter is unreliable across restarts) | |
| const pqForCalc = {remaining: lim, rpm: rpm, max_calls_per_hour: maxPerHour}; | |
| const isExhausted = pq && pq.status === 'limited'; | |
| if (isExhausted) { | |
| panel.innerHTML = `<div style="color:#e94560;margin-bottom:6px">Quota exhausted. Resets daily (10:00 AM).</div>`; | |
| row.after(panel); | |
| return; | |
| } | |
| const runtime = estimateRuntime(pqForCalc); | |
| const numAgents = quota.num_agents || 50; | |
| // Default probability for rate-limited providers (not inherited from NN's 100%) | |
| const defaultProb = 10; | |
| // Calculate usage stats for a given probability | |
| function usageStats(probPct) { | |
| const prob = probPct / 100; | |
| // ~40% of agents are idle (not busy/sleeping) and eligible for LLM each tick | |
| const idleFraction = 0.4; | |
| const eligibleAgents = Math.round(numAgents * idleFraction); | |
| // Expected LLM attempts per tick: eligible agents × prob (action) + prob (social) | |
| const attemptsPerTick = eligibleAgents * prob + prob; | |
| // Budget caps at 2 calls/tick for rate-limited providers | |
| const callsPerTick = Math.min(attemptsPerTick, 2); | |
| // Ticks per hour (4s tick delay at 1x speed) | |
| const ticksH = 900; | |
| const callsPerHour = Math.min(callsPerTick * ticksH, rpm * 60); | |
| // What % of agent decisions go through LLM vs routine | |
| const llmPct = Math.min(prob * 100, 100); | |
| return { callsPerHour: Math.round(callsPerHour), llmPct: Math.round(llmPct) }; | |
| } | |
| const initStats = usageStats(defaultProb); | |
| panel.innerHTML = | |
| `<div style="color:#4ecca3;font-weight:600;margin-bottom:4px">${p.icon} ${p.label}</div>` + | |
| `<div style="margin-bottom:4px">Daily limit: <b>${lim}</b> requests · ${rpm} req/min</div>` + | |
| `<div style="display:flex;align-items:center;gap:8px;margin-top:6px">` + | |
| `<label style="font-size:11px;color:#8899aa">Probability:</label>` + | |
| `<input type="range" min="1" max="100" value="${defaultProb}" style="flex:1;accent-color:#4ecca3" class="popup-prob-slider">` + | |
| `<span class="popup-prob-val" style="font-size:12px;color:#4ecca3;min-width:32px">${defaultProb}%</span>` + | |
| `</div>` + | |
| `<div class="popup-usage-info" style="font-size:10px;color:#8899aa;margin:4px 0 8px 0;line-height:1.5">` + | |
| `<span style="color:#c8c8d8">${initStats.llmPct}%</span> of decisions via LLM · ` + | |
| `<span style="color:#c8c8d8">~${initStats.callsPerHour}</span> calls/hour` + | |
| `</div>` + | |
| `<button class="popup-switch-btn" style="width:100%;padding:6px;border:none;border-radius:4px;` + | |
| `background:#4ecca3;color:#0a0a23;font-weight:600;cursor:pointer;font-size:12px">` + | |
| `Switch to ${p.label} at ${defaultProb}%</button>`; | |
| row.after(panel); | |
| // Wire up slider | |
| const slider = panel.querySelector('.popup-prob-slider'); | |
| const valLabel = panel.querySelector('.popup-prob-val'); | |
| const usageInfo = panel.querySelector('.popup-usage-info'); | |
| const btn = panel.querySelector('.popup-switch-btn'); | |
| slider.addEventListener('input', (e) => { | |
| e.stopPropagation(); | |
| const pv = parseInt(slider.value); | |
| valLabel.textContent = pv + '%'; | |
| const stats = usageStats(pv); | |
| usageInfo.innerHTML = | |
| `<span style="color:#c8c8d8">${stats.llmPct}%</span> of decisions via LLM · ` + | |
| `<span style="color:#c8c8d8">~${stats.callsPerHour}</span> calls/hour`; | |
| btn.textContent = `Switch to ${p.label} at ${pv}%`; | |
| }); | |
| btn.addEventListener('click', async (ev2) => { | |
| ev2.stopPropagation(); | |
| const probVal = parseInt(slider.value) / 100; | |
| popup.style.display = 'none'; _llmPopupOpen = false; | |
| try { | |
| // 1. Set probability first | |
| await fetch(`${API_BASE}/controls/llm_probability?value=${probVal}`, {method: 'POST'}); | |
| // 2. Then switch provider | |
| const body = {provider: p.id}; | |
| if (p.model) body.model = p.model; | |
| const r = await fetch(`${API_BASE}/llm/provider`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; } | |
| // 3. Sync the top probability slider | |
| updateLlmProbUI(probVal); | |
| showToast(`Switched to ${p.label} at ${Math.round(probVal*100)}% · ${runtime}`, 'conv'); | |
| } catch (err) { showToast('LLM switch error', 'event'); } | |
| }); | |
| }); | |
| } else if (!isActive) { | |
| // Non-rate-limited (NN, Claude, Ollama): switch immediately, set probability to 100% | |
| row.addEventListener('click', async () => { | |
| popup.style.display = 'none'; _llmPopupOpen = false; | |
| try { | |
| // Set probability to 100% for local/unlimited providers | |
| const autoProb = (p.id === 'nn') ? 1.0 : 0.10; | |
| await fetch(`${API_BASE}/controls/llm_probability?value=${autoProb}`, {method: 'POST'}); | |
| const body = {provider: p.id}; | |
| if (p.model) body.model = p.model; | |
| const r = await fetch(`${API_BASE}/llm/provider`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!r.ok) { const err = await r.json(); showToast(`LLM switch failed: ${err.detail}`, 'event'); return; } | |
| updateLlmProbUI(autoProb); | |
| showToast(`Switched to ${p.label} at ${Math.round(autoProb*100)}%`, 'conv'); | |
| } catch (err) { showToast('LLM switch error', 'event'); } | |
| }); | |
| } | |
| popup.appendChild(row); | |
| } | |
| // Test button | |
| const sep = document.createElement('div'); | |
| sep.style.cssText = 'border-top:1px solid #0f3460;margin:4px 0'; | |
| popup.appendChild(sep); | |
| const testRow = document.createElement('div'); | |
| testRow.className = 'llm-opt'; | |
| testRow.innerHTML = `<span class="llm-check"></span><span style="font-size:15px">🔬</span><span>Test current LLM…</span>`; | |
| testRow.addEventListener('click', async (ev) => { | |
| ev.stopPropagation(); | |
| popup.style.display = 'none'; _llmPopupOpen = false; | |
| showToast('Testing LLM…', 'event'); | |
| try { | |
| const r = await fetch(`${API_BASE}/llm/test`); | |
| const d = await r.json(); | |
| if (d.ok) { | |
| showToast(`✔ LLM OK — "${d.raw.slice(0,60)}"`, 'conv'); | |
| } else { | |
| const msg = (d.error || d.raw || 'empty response — model may not be on HF serverless API').slice(0, 120); | |
| showToast(`✘ ${d.model}: ${msg}`, 'event'); | |
| } | |
| } catch (err) { showToast('Test request failed', 'event'); } | |
| }); | |
| popup.appendChild(testRow); | |
| popup.style.display = 'block'; | |
| _llmPopupOpen = true; | |
| } catch { showToast('Could not fetch providers', 'event'); } | |
| }); | |
| document.addEventListener('click', () => { | |
| if (_llmPopupOpen) { | |
| document.getElementById('llm-popup').style.display = 'none'; | |
| _llmPopupOpen = false; | |
| } | |
| }); | |
| // ============================================================ | |
| // 3D IFRAME BRIDGE | |
| // ============================================================ | |
| function send3d(cmd) { | |
| const f = document.getElementById('city3d'); | |
| if (f?.contentWindow) f.contentWindow.postMessage({ type: cmd }, '*'); | |
| } | |
| window.addEventListener('message', (e) => { | |
| if (e.data?.type === 'agent-select' && e.data.agentId) { | |
| selectedAgentId = e.data.agentId; | |
| switchTab('agents'); | |
| // In demo mode (no server), show inline detail from iframe state | |
| const ag = agents[e.data.agentId]; | |
| if (ag) { | |
| renderAgentDetail({ ...ag, id: e.data.agentId, memories: [], relationships: [] }); | |
| } else { | |
| fetchAgentDetail(e.data.agentId); | |
| } | |
| } | |
| if (e.data?.type === 'state-update' && e.data.state) { | |
| processStateData(e.data.state); | |
| } | |
| }); | |
| // ============================================================ | |
| // INIT | |
| // ============================================================ | |
| showDefaultDetail(); | |
| fetchState(); | |
| fetchControls(); | |
| connectWebSocket(); | |
| setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL); | |
| checkSession(); | |
| </script> | |
| </body> | |
| </html> | |